Skip to content

Commit 87c9724

Browse files
committed
Fix reporter telemetry privacy projection
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e
1 parent a88b242 commit 87c9724

4 files changed

Lines changed: 169 additions & 21 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/rush-reporter",
5+
"comment": "Prevent local-sensitive and secret reporter events from contributing producer identities or other values to telemetry aggregates.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/rush-reporter",
10+
"email": "TheLarkInn@users.noreply.github.com"
11+
}

libraries/reporter/src/telemetry/TelemetryAggregate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export interface ITelemetryAggregate {
6767
readonly protocolVersion?: IReporterProtocolVersion;
6868

6969
/**
70-
* The distinct `packageName@packageVersion` producers observed, sorted.
70+
* The distinct `packageName@packageVersion` producers observed on public envelopes, sorted.
7171
*/
7272
readonly producerVersions: readonly string[];
7373
}

libraries/reporter/src/telemetry/TelemetrySubscriber.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate'
1212
*
1313
* @remarks
1414
* The subscriber runs before reporter filtering, so it observes every event. It
15-
* extracts only allowlisted values: from a diagnostic it keeps the code and
16-
* category but never the parameters, remediation, or templates; it ignores
17-
* messages, raw external output, and command arguments entirely.
15+
* projects envelope metadata and lifecycle values only from public events. From
16+
* a local-sensitive diagnostic it may keep the explicitly public code and
17+
* category, but never parameters, remediation, or templates. It ignores secret
18+
* events, messages, raw external output, and command arguments entirely.
1819
*
1920
* @beta
2021
*/
@@ -48,8 +49,34 @@ export class TelemetrySubscriber {
4849
* Ingests one event, extracting only allowlisted values.
4950
*/
5051
public ingest(event: IReporterEventEnvelope<unknown>): void {
51-
this._protocolVersion = event.protocolVersion;
52-
this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`);
52+
const isPublicEnvelope: boolean = event.privacy === 'public';
53+
if (isPublicEnvelope) {
54+
this._protocolVersion = event.protocolVersion;
55+
this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`);
56+
}
57+
58+
if (event.type === 'diagnosticEmitted') {
59+
if (event.privacy !== 'secret') {
60+
// Code and category are public schema fields even when classified
61+
// parameters make the diagnostic envelope local-sensitive.
62+
const payload: { code?: string; category?: string } = event.payload as {
63+
code?: string;
64+
category?: string;
65+
};
66+
if (payload.code !== undefined) {
67+
this._diagnosticCodes.add(payload.code);
68+
}
69+
if (payload.category !== undefined) {
70+
this._diagnosticCategoryCounts[payload.category] =
71+
(this._diagnosticCategoryCounts[payload.category] ?? 0) + 1;
72+
}
73+
}
74+
return;
75+
}
76+
77+
if (!isPublicEnvelope) {
78+
return;
79+
}
5380

5481
switch (event.type) {
5582
case 'commandStarted': {
@@ -114,21 +141,6 @@ export class TelemetrySubscriber {
114141
this._operationStatuses.set(payload.operationId, payload.status);
115142
break;
116143
}
117-
case 'diagnosticEmitted': {
118-
// Keeps only the code and category, never parameters, remediation, or templates.
119-
const payload: { code?: string; category?: string } = event.payload as {
120-
code?: string;
121-
category?: string;
122-
};
123-
if (payload.code !== undefined) {
124-
this._diagnosticCodes.add(payload.code);
125-
}
126-
if (payload.category !== undefined) {
127-
this._diagnosticCategoryCounts[payload.category] =
128-
(this._diagnosticCategoryCounts[payload.category] ?? 0) + 1;
129-
}
130-
break;
131-
}
132144
default: {
133145
// Messages, raw external output, artifacts, and extension events are not
134146
// telemetry.

libraries/reporter/src/test/Telemetry.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,131 @@ describe('TelemetrySubscriber', () => {
110110
}
111111
});
112112

113+
it('does not collect producer identities from local-sensitive or secret extension events', async () => {
114+
const LOCAL_PRIVATE_SOURCE: IReporterEventSource = {
115+
packageName: '@private/local-reporter-plugin',
116+
packageVersion: '1.2.3-private'
117+
};
118+
const SECRET_PRIVATE_SOURCE: IReporterEventSource = {
119+
packageName: '@private/secret-reporter-plugin',
120+
packageVersion: '4.5.6-secret'
121+
};
122+
const telemetry: TelemetrySubscriber = new TelemetrySubscriber();
123+
const manager: ReporterManager = new ReporterManager();
124+
manager.addReporter(createTelemetryReporter(telemetry));
125+
await manager.initializeAsync();
126+
127+
manager.emit({
128+
...rawInput('extension', { name: 'private.local.event', privateField: 'local-private-value' }),
129+
source: LOCAL_PRIVATE_SOURCE,
130+
privacy: 'local-sensitive'
131+
});
132+
manager.emit({
133+
...rawInput('extension', { name: 'private.secret.event', secretField: 'secret-private-value' }),
134+
source: SECRET_PRIVATE_SOURCE,
135+
privacy: 'secret'
136+
});
137+
await manager.flushAsync();
138+
139+
const aggregate: ITelemetryAggregate = telemetry.buildAggregate();
140+
const serialized: string = JSON.stringify(aggregate);
141+
expect(aggregate.producerVersions).toEqual([]);
142+
expect(aggregate.protocolVersion).toBeUndefined();
143+
for (const forbidden of [
144+
LOCAL_PRIVATE_SOURCE.packageName,
145+
LOCAL_PRIVATE_SOURCE.packageVersion,
146+
SECRET_PRIVATE_SOURCE.packageName,
147+
SECRET_PRIVATE_SOURCE.packageVersion,
148+
'local-private-value',
149+
'secret-private-value'
150+
]) {
151+
expect(serialized).not.toContain(forbidden);
152+
}
153+
});
154+
155+
it('projects only public envelopes while aggregating public producers deterministically', async () => {
156+
const PUBLIC_EXTENSION_SOURCE: IReporterEventSource = {
157+
packageName: '@rushstack/public-reporter-plugin',
158+
packageVersion: '1.2.3'
159+
};
160+
const PRIVATE_FIRST_PARTY_SOURCE: IReporterEventSource = {
161+
packageName: '@microsoft/internal-build-plugin',
162+
packageVersion: '9.8.7-private'
163+
};
164+
const telemetry: TelemetrySubscriber = new TelemetrySubscriber();
165+
const manager: ReporterManager = new ReporterManager();
166+
manager.addReporter(createTelemetryReporter(telemetry));
167+
await manager.initializeAsync();
168+
169+
manager.emit({
170+
...rawInput('commandResult', {
171+
commandName: 'private-command',
172+
succeeded: false,
173+
exitCode: 97
174+
}),
175+
source: PRIVATE_FIRST_PARTY_SOURCE,
176+
privacy: 'local-sensitive',
177+
protocolVersion: { major: 7, minor: 0 }
178+
});
179+
manager.emit({
180+
...rawInput('extension', { name: 'public.plugin.event' }),
181+
source: PUBLIC_EXTENSION_SOURCE
182+
});
183+
manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }));
184+
manager.emit({
185+
...rawInput('extension', { name: 'public.plugin.event' }),
186+
source: PUBLIC_EXTENSION_SOURCE
187+
});
188+
manager.emit(rawInput('diagnosticEmitted', { code: 'RUSH_OPERATION_FAILED', category: 'operation' }));
189+
manager.emit({
190+
...rawInput('operationStatusChanged', {
191+
operationId: 'private-operation',
192+
status: 'failure'
193+
}),
194+
source: PRIVATE_FIRST_PARTY_SOURCE,
195+
privacy: 'local-sensitive'
196+
});
197+
manager.emit({
198+
...rawInput('diagnosticEmitted', {
199+
code: 'PRIVATE_INTERNAL_DIAGNOSTIC',
200+
category: 'private-category'
201+
}),
202+
source: PRIVATE_FIRST_PARTY_SOURCE,
203+
privacy: 'secret'
204+
});
205+
manager.emit(rawInput('operationStatusChanged', { operationId: 'public-operation', status: 'success' }));
206+
manager.emit({
207+
...rawInput('extension', { name: 'private.secret.event' }),
208+
source: PRIVATE_FIRST_PARTY_SOURCE,
209+
privacy: 'secret',
210+
protocolVersion: { major: 99, minor: 0 }
211+
});
212+
await manager.flushAsync();
213+
214+
const aggregate: ITelemetryAggregate = telemetry.buildAggregate();
215+
expect(aggregate).toMatchObject({
216+
commandName: 'build',
217+
result: 'succeeded',
218+
exitCode: 0,
219+
operationStatusCounts: { success: 1 },
220+
diagnosticCodes: ['RUSH_OPERATION_FAILED'],
221+
diagnosticCategoryCounts: { operation: 1 },
222+
protocolVersion: { major: 1, minor: 0 },
223+
producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/public-reporter-plugin@1.2.3']
224+
});
225+
const serialized: string = JSON.stringify(aggregate);
226+
for (const forbidden of [
227+
PRIVATE_FIRST_PARTY_SOURCE.packageName,
228+
PRIVATE_FIRST_PARTY_SOURCE.packageVersion,
229+
'private-command',
230+
'PRIVATE_INTERNAL_DIAGNOSTIC',
231+
'private-category',
232+
'private-operation'
233+
]) {
234+
expect(serialized).not.toContain(forbidden);
235+
}
236+
});
237+
113238
it('never leaks messages, paths, arguments, remediation, raw output, or secret values', async () => {
114239
const SECRET: string = 'sk-super-secret-value';
115240
const LOG_PATH: string = '/home/user/secret/install.log';

0 commit comments

Comments
 (0)