diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json new file mode 100644 index 0000000000..d854555746 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Prevent non-public reporter events from contributing unvalidated or unbounded values to telemetry aggregates, and protect parent-owned producer and protocol metadata.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2da..70d8580951 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -852,6 +852,10 @@ export interface IReporterPerformanceBudgets { readonly maxAiDetailedDiagnostics: number; readonly maxAiOutputBytes: number; readonly maxInteractiveRefreshHz: number; + readonly maxTelemetryDiagnosticCategories: number; + readonly maxTelemetryDiagnosticCodes: number; + readonly maxTelemetryProducerVersionLength: number; + readonly maxTelemetryProducerVersions: number; readonly maxWallTimeRegressionPercent: number; } diff --git a/libraries/reporter/src/perf/PerformanceBudgets.ts b/libraries/reporter/src/perf/PerformanceBudgets.ts index ab9a4c4647..ad35b62e78 100644 --- a/libraries/reporter/src/perf/PerformanceBudgets.ts +++ b/libraries/reporter/src/perf/PerformanceBudgets.ts @@ -45,6 +45,30 @@ export interface IReporterPerformanceBudgets { * before summarizing the remainder. Defaults to `20`. */ readonly maxAiDetailedDiagnostics: number; + + /** + * The maximum number of distinct diagnostic codes retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryDiagnosticCodes: number; + + /** + * The maximum number of diagnostic category buckets retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryDiagnosticCategories: number; + + /** + * The maximum number of distinct producer versions retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryProducerVersions: number; + + /** + * The maximum character length of one `packageName@packageVersion` telemetry + * entry. Longer entries are omitted. Defaults to `256`. + */ + readonly maxTelemetryProducerVersionLength: number; } /** @@ -68,7 +92,11 @@ export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets = { maxAdditionalPeakMemoryBytes: 32 * BYTES_PER_MIB, maxInteractiveRefreshHz: 10, maxAiOutputBytes: 64 * BYTES_PER_KIB, - maxAiDetailedDiagnostics: 20 + maxAiDetailedDiagnostics: 20, + maxTelemetryDiagnosticCodes: 20, + maxTelemetryDiagnosticCategories: 20, + maxTelemetryProducerVersions: 20, + maxTelemetryProducerVersionLength: 256 }; /** diff --git a/libraries/reporter/src/telemetry/TelemetryAggregate.ts b/libraries/reporter/src/telemetry/TelemetryAggregate.ts index d54fa16977..12ab291610 100644 --- a/libraries/reporter/src/telemetry/TelemetryAggregate.ts +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -67,7 +67,14 @@ export interface ITelemetryAggregate { readonly protocolVersion?: IReporterProtocolVersion; /** - * The distinct `packageName@packageVersion` producers observed, sorted. + * The distinct `packageName@packageVersion` producers observed on effectively + * public envelopes, sorted. + * + * @remarks + * The list is bounded by the reporter telemetry budgets. Parent-session + * producers are retained before child-session producers, remaining entries + * are selected lexicographically, and entries over the per-entry length + * budget are omitted. Package namespace text does not confer priority. */ readonly producerVersions: readonly string[]; } diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts index 48b45f5098..7a6d2230ef 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -5,16 +5,107 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; import type { IReporter } from '../manager/IReporter'; import type { IOperationStatusChangedPayload } from '../lifecycle/LifecycleEvents'; +import { + isValidRushDiagnosticCode, + RUSH_DIAGNOSTIC_CODE_DEFINITIONS, + type IRushDiagnosticCodeDefinition +} from '../diagnostics/RushDiagnosticCodeRegistry'; +import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate'; +const OTHER_DIAGNOSTIC_CATEGORY: 'other' = 'other'; +const REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition): readonly [string, IRushDiagnosticCodeDefinition] => [ + definition.code, + definition + ] + ) +); +const KNOWN_DIAGNOSTIC_CATEGORIES: ReadonlySet = new Set( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition): string => definition.category + ) +); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isDiagnosticPayloadEffectivelyPublic(payload: unknown): boolean { + if (!isRecord(payload)) { + return false; + } + const parameters: unknown = payload.parameters; + if (parameters === undefined) { + return true; + } + if (!isRecord(parameters)) { + return false; + } + for (const parameter of Object.values(parameters)) { + if (!isRecord(parameter) || parameter.privacy !== 'public') { + return false; + } + } + return true; +} + +function comparePrioritizedCandidates( + left: readonly [value: string, preferred: boolean], + right: readonly [value: string, preferred: boolean] +): number { + if (left[1] !== right[1]) { + return left[1] ? -1 : 1; + } + return left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0; +} + +function recordBoundedPrioritizedValue( + values: Map, + value: string, + preferred: boolean, + maximumCount: number +): void { + const existingPriority: boolean | undefined = values.get(value); + if (existingPriority !== undefined) { + if (preferred && !existingPriority) { + values.set(value, true); + } + return; + } + + if (values.size < maximumCount) { + values.set(value, preferred); + return; + } + + let worstCandidate: readonly [value: string, preferred: boolean] | undefined; + for (const candidate of values) { + if (worstCandidate === undefined || comparePrioritizedCandidates(candidate, worstCandidate) > 0) { + worstCandidate = candidate; + } + } + + const newCandidate: readonly [value: string, preferred: boolean] = [value, preferred]; + if (worstCandidate !== undefined && comparePrioritizedCandidates(newCandidate, worstCandidate) < 0) { + values.delete(worstCandidate[0]); + values.set(value, preferred); + } +} + /** * Consumes canonical events and produces the allowlisted telemetry aggregate. * * @remarks * The subscriber runs before reporter filtering, so it observes every event. It - * extracts only allowlisted values: from a diagnostic it keeps the code and - * category but never the parameters, remediation, or templates; it ignores - * messages, raw external output, and command arguments entirely. + * projects envelope metadata and lifecycle values only from effectively public + * events. A diagnostic containing any non-public parameter is treated as + * non-public even when its envelope floor is `public`. From a non-public + * diagnostic it keeps only a registered code and that code's registry category, + * never parameters, remediation, or templates. It ignores all other values from + * non-public events, messages, raw external output, and command arguments + * entirely. * * @beta */ @@ -27,14 +118,14 @@ export class TelemetrySubscriber { private _protocolVersion: IReporterProtocolVersion | undefined; private readonly _operationStatuses: Map; private readonly _diagnosticCategoryCounts: { [category: string]: number }; - private readonly _diagnosticCodes: Set; - private readonly _producerVersions: Set; + private readonly _diagnosticCodes: Map; + private readonly _producerVersions: Map; public constructor() { this._operationStatuses = new Map(); this._diagnosticCategoryCounts = {}; - this._diagnosticCodes = new Set(); - this._producerVersions = new Set(); + this._diagnosticCodes = new Map(); + this._producerVersions = new Map(); } /** @@ -48,8 +139,47 @@ export class TelemetrySubscriber { * Ingests one event, extracting only allowlisted values. */ public ingest(event: IReporterEventEnvelope): void { - this._protocolVersion = event.protocolVersion; - this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`); + const isEffectivelyPublicEnvelope: boolean = + event.privacy === 'public' && + (event.type !== 'diagnosticEmitted' || isDiagnosticPayloadEffectivelyPublic(event.payload)); + if (isEffectivelyPublicEnvelope) { + if (event.parentSessionId === undefined) { + this._protocolVersion = event.protocolVersion; + } + this._recordProducerVersion( + event.source.packageName, + event.source.packageVersion, + event.parentSessionId === undefined + ); + } + + if (event.type === 'diagnosticEmitted') { + // Code and category are public schema fields even when classified + // parameters make the diagnostic envelope non-public. + const payload: { code?: unknown; category?: unknown } = isRecord(event.payload) ? event.payload : {}; + const registeredDefinition: IRushDiagnosticCodeDefinition | undefined = + typeof payload.code === 'string' + ? REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS.get(payload.code) + : undefined; + if (isEffectivelyPublicEnvelope) { + if (typeof payload.code === 'string' && isValidRushDiagnosticCode(payload.code)) { + this._recordDiagnosticCode(payload.code, registeredDefinition !== undefined); + } + if (typeof payload.category === 'string') { + this._recordDiagnosticCategory( + KNOWN_DIAGNOSTIC_CATEGORIES.has(payload.category) ? payload.category : OTHER_DIAGNOSTIC_CATEGORY + ); + } + } else if (registeredDefinition !== undefined) { + this._recordDiagnosticCode(registeredDefinition.code, true); + this._recordDiagnosticCategory(registeredDefinition.category); + } + return; + } + + if (!isEffectivelyPublicEnvelope) { + return; + } switch (event.type) { case 'commandStarted': { @@ -114,21 +244,6 @@ export class TelemetrySubscriber { this._operationStatuses.set(payload.operationId, payload.status); break; } - case 'diagnosticEmitted': { - // Keeps only the code and category, never parameters, remediation, or templates. - const payload: { code?: string; category?: string } = event.payload as { - code?: string; - category?: string; - }; - if (payload.code !== undefined) { - this._diagnosticCodes.add(payload.code); - } - if (payload.category !== undefined) { - this._diagnosticCategoryCounts[payload.category] = - (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; - } - break; - } default: { // Messages, raw external output, artifacts, and extension events are not // telemetry. @@ -145,6 +260,10 @@ export class TelemetrySubscriber { for (const status of this._operationStatuses.values()) { operationStatusCounts[status] = (operationStatusCounts[status] ?? 0) + 1; } + const diagnosticCategoryCounts: { [category: string]: number } = {}; + for (const category of Object.keys(this._diagnosticCategoryCounts).sort()) { + diagnosticCategoryCounts[category] = this._diagnosticCategoryCounts[category]; + } const aggregate: { commandName?: string; @@ -159,9 +278,9 @@ export class TelemetrySubscriber { producerVersions: string[]; } = { operationStatusCounts, - diagnosticCodes: [...this._diagnosticCodes].sort(), - diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, - producerVersions: [...this._producerVersions].sort() + diagnosticCodes: [...this._diagnosticCodes.keys()].sort(), + diagnosticCategoryCounts, + producerVersions: [...this._producerVersions.keys()].sort() }; if (this._commandName !== undefined) { @@ -185,6 +304,46 @@ export class TelemetrySubscriber { return aggregate; } + + private _recordDiagnosticCode(code: string, registered: boolean): void { + recordBoundedPrioritizedValue( + this._diagnosticCodes, + code, + registered, + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes + ); + } + + private _recordDiagnosticCategory(category: string): void { + const existingCount: number | undefined = this._diagnosticCategoryCounts[category]; + if (existingCount !== undefined) { + this._diagnosticCategoryCounts[category] = existingCount + 1; + return; + } + if ( + Object.keys(this._diagnosticCategoryCounts).length < + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories + ) { + this._diagnosticCategoryCounts[category] = 1; + } + } + + private _recordProducerVersion( + packageName: string, + packageVersion: string, + isParentSessionProducer: boolean + ): void { + const producerVersion: string = `${packageName}@${packageVersion}`; + if (producerVersion.length > REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength) { + return; + } + recordBoundedPrioritizedValue( + this._producerVersions, + producerVersion, + isParentSessionProducer, + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions + ); + } } /** diff --git a/libraries/reporter/src/test/Performance.test.ts b/libraries/reporter/src/test/Performance.test.ts index 47c50adf89..0898117580 100644 --- a/libraries/reporter/src/test/Performance.test.ts +++ b/libraries/reporter/src/test/Performance.test.ts @@ -118,6 +118,10 @@ describe('reporter performance budgets', () => { expect(REPORTER_PERFORMANCE_BUDGETS.maxInteractiveRefreshHz).toBe(10); expect(REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes).toBe(64 * 1024); expect(REPORTER_PERFORMANCE_BUDGETS.maxAiDetailedDiagnostics).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength).toBe(256); }); it('evaluates wall-time regression against the 3 percent budget', () => { diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 423b8a78cf..b271623c4a 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -5,6 +5,7 @@ import { TelemetrySubscriber, createTelemetryReporter, createBeforeLogAdapter, + REPORTER_PERFORMANCE_BUDGETS, TELEMETRY_AGGREGATE_KEYS, LifecycleEmitter, ReporterManager, @@ -51,6 +52,46 @@ function rawInput(type: string, payload: unknown): IReporterEmitEventInput['privacy']; + readonly source?: IReporterEventSource; + readonly protocolVersion?: IReporterEventEnvelope['protocolVersion']; + readonly parentSessionId?: string; +} + +function foreignEnvelope( + sequence: number, + type: IReporterEventEnvelope['type'], + payload: unknown, + options: IForeignEnvelopeOptions = {} +): IReporterEventEnvelope { + return { + protocolVersion: options.protocolVersion ?? { major: 1, minor: 0 }, + eventId: `foreign_${sequence}`, + sessionId: 'foreign-session', + parentSessionId: options.parentSessionId, + sequence, + timestamp: '2026-08-28T00:00:00.000Z', + source: options.source ?? { + packageName: '@foreign/reporter-plugin', + packageVersion: '1.0.0' + }, + privacy: options.privacy ?? 'public', + required: true, + type, + payload + }; +} + +function foreignDiagnosticEnvelope( + sequence: number, + privacy: IReporterEventEnvelope['privacy'], + payload: unknown, + options: Omit = {} +): IReporterEventEnvelope { + return foreignEnvelope(sequence, 'diagnosticEmitted', payload, { ...options, privacy }); +} + describe('TelemetrySubscriber', () => { it('produces an allowlisted aggregate from the event stream before reporter filtering', async () => { const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); @@ -110,6 +151,504 @@ describe('TelemetrySubscriber', () => { } }); + it('does not collect producer identities from local-sensitive or secret extension events', async () => { + const LOCAL_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/local-reporter-plugin', + packageVersion: '1.2.3-private' + }; + const SECRET_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/secret-reporter-plugin', + packageVersion: '4.5.6-secret' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('extension', { name: 'private.local.event', privateField: 'local-private-value' }), + source: LOCAL_PRIVATE_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event', secretField: 'secret-private-value' }), + source: SECRET_PRIVATE_SOURCE, + privacy: 'secret' + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + const serialized: string = JSON.stringify(aggregate); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.protocolVersion).toBeUndefined(); + for (const forbidden of [ + LOCAL_PRIVATE_SOURCE.packageName, + LOCAL_PRIVATE_SOURCE.packageVersion, + SECRET_PRIVATE_SOURCE.packageName, + SECRET_PRIVATE_SOURCE.packageVersion, + 'local-private-value', + 'secret-private-value' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('rejects hostile non-public diagnostic fields from foreign envelopes', async () => { + const TOKEN_CODE: string = 'ghp_super_secret_token'; + const TOKEN_CATEGORY: string = 'token=super-secret-value'; + const PATH_CODE: string = '/home/user/private/.npmrc'; + const PATH_CATEGORY: string = 'C:\\Users\\private\\rush.json'; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(1, 'local-sensitive', { + code: PATH_CODE, + category: TOKEN_CATEGORY + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(2, 'secret', { + code: TOKEN_CODE, + category: PATH_CATEGORY + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(3, 'local-sensitive', { + code: 'RUSH_OPERATION_FAILED', + category: 'network-auth' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(4, 'secret', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'configuration' + }) + ); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.diagnosticCodes).toEqual(['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ + 'dependency-tool': 1, + operation: 1 + }); + expect(Object.keys(aggregate.diagnosticCategoryCounts)).toEqual(['dependency-tool', 'operation']); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [TOKEN_CODE, TOKEN_CATEGORY, PATH_CODE, PATH_CATEGORY]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('preserves allowlisted diagnostics across mixed privacy ordering', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(1, 'secret', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(2, 'public', { + code: 'RUSH_OPERATION_FAILED', + category: 'operation' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(3, 'local-sensitive', { + code: 'RUSH_CONFIG_INVALID_JSON', + category: 'configuration' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(4, 'secret', { + code: 'RUSH_NOT_REGISTERED_PRIVATE', + category: 'future-private-category' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(5, 'public', { + code: 'RUSH_FUTURE_PUBLIC_CODE', + category: 'future-public-category' + }) + ); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + diagnosticCodes: [ + 'RUSH_CONFIG_INVALID_JSON', + 'RUSH_DEPENDENCY_TOOL_FAILED', + 'RUSH_FUTURE_PUBLIC_CODE', + 'RUSH_OPERATION_FAILED' + ], + diagnosticCategoryCounts: { + configuration: 1, + 'dependency-tool': 1, + operation: 1, + other: 1 + } + }); + expect(Object.keys(telemetry.buildAggregate().diagnosticCategoryCounts)).toEqual([ + 'configuration', + 'dependency-tool', + 'operation', + 'other' + ]); + }); + + it('bounds diagnostic dimensions deterministically under cardinality flooding', async () => { + const publicCodes: string[] = []; + for ( + let index: number = 0; + index < REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes * 3; + index++ + ) { + publicCodes.push(`RUSH_FOREIGN_CODE${String(index).padStart(3, '0')}`); + } + const hostilePrivateCodes: string[] = publicCodes.map((code: string): string => `${code}_PRIVATE`); + const payloads: Array<{ + privacy: IReporterEventEnvelope['privacy']; + code: string; + category: string; + }> = [ + ...publicCodes.map((code: string, index: number) => ({ + privacy: 'public' as const, + code, + category: `/private/category/${index}` + })), + ...hostilePrivateCodes.map((code: string, index: number) => ({ + privacy: index % 2 === 0 ? ('local-sensitive' as const) : ('secret' as const), + code, + category: `token-${index}` + })), + { privacy: 'secret', code: 'RUSH_OPERATION_FAILED', category: 'operation' }, + { + privacy: 'local-sensitive', + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool' + } + ]; + + async function aggregatePayloads(orderedPayloads: typeof payloads): Promise { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + orderedPayloads.forEach((payload, index: number) => { + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(index + 1, payload.privacy, { + code: payload.code, + category: payload.category + }) + ); + }); + await manager.flushAsync(); + return telemetry.buildAggregate(); + } + + const forward: ITelemetryAggregate = await aggregatePayloads(payloads); + const reverse: ITelemetryAggregate = await aggregatePayloads([...payloads].reverse()); + expect(reverse.diagnosticCodes).toEqual(forward.diagnosticCodes); + expect(forward.diagnosticCodes).toHaveLength(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes); + expect(forward.diagnosticCodes).toContain('RUSH_OPERATION_FAILED'); + expect(forward.diagnosticCodes).toContain('RUSH_DEPENDENCY_TOOL_FAILED'); + expect(forward.diagnosticCodes).not.toContain(hostilePrivateCodes[0]); + expect(forward.diagnosticCategoryCounts).toEqual({ + other: publicCodes.length, + operation: 1, + 'dependency-tool': 1 + }); + expect(Object.keys(forward.diagnosticCategoryCounts)).toHaveLength(3); + }); + + it('keeps protocol root-owned while attributing safe child diagnostics', async () => { + const CHILD_PUBLIC_SOURCE: IReporterEventSource = { + packageName: '@rushstack/heft', + packageVersion: '1.2.19' + }; + const CHILD_SECRET_SOURCE: IReporterEventSource = { + packageName: '@private/child-plugin', + packageVersion: '9.9.9-secret' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope( + 1, + 'public', + { code: 'RUSH_OPERATION_FAILED', category: 'operation' }, + { + source: CHILD_PUBLIC_SOURCE, + protocolVersion: { major: 99, minor: 1 }, + parentSessionId: 'sess' + } + ) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope( + 2, + 'secret', + { code: 'RUSH_DEPENDENCY_TOOL_FAILED', category: 'configuration' }, + { + source: CHILD_SECRET_SOURCE, + protocolVersion: { major: 100, minor: 0 }, + parentSessionId: 'sess' + } + ) + ); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + protocolVersion: { major: 1, minor: 0 }, + producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/heft@1.2.19'], + diagnosticCodes: ['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { 'dependency-tool': 1, operation: 1 } + }); + }); + + it('protects parent producers from root-first and root-last spoofed-prefix floods', async () => { + const childSources: IReporterEventSource[] = []; + for ( + let index: number = 0; + index < REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions * 3; + index++ + ) { + childSources.push({ + packageName: + index % 2 === 0 + ? `@microsoft/spoof-${String(index).padStart(3, '0')}` + : `@rushstack/spoof-${String(index).padStart(3, '0')}`, + packageVersion: '1.0.0' + }); + } + const parentSources: IReporterEventSource[] = [ + { packageName: 'zz-parent-owned-a', packageVersion: '1.0.0' }, + { packageName: 'zz-parent-owned-b', packageVersion: '2.0.0' } + ]; + const oversizedChildSource: IReporterEventSource = { + packageName: `@microsoft/${'x'.repeat(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength)}`, + packageVersion: '1.0.0' + }; + const oversizedParentSource: IReporterEventSource = { + packageName: 'z'.repeat(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength), + packageVersion: '1.0.0' + }; + + async function aggregateSources( + rootFirst: boolean, + reverseChildren: boolean + ): Promise { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + const emitParentSources = (): void => { + for (const source of [...parentSources, oversizedParentSource]) { + manager.emit({ + ...rawInput('extension', { name: 'parent.public.event' }), + source + }); + } + }; + const emitChildSources = (): void => { + const orderedChildren: IReporterEventSource[] = [ + ...(reverseChildren ? [...childSources].reverse() : childSources), + oversizedChildSource + ]; + orderedChildren.forEach((source: IReporterEventSource, index: number) => { + manager.ingestForeignEnvelope( + foreignEnvelope( + index + 1, + 'extension', + { name: 'foreign.public.event' }, + { + source, + parentSessionId: 'sess', + protocolVersion: { major: 50 + index, minor: 0 } + } + ) + ); + }); + }; + + if (rootFirst) { + emitParentSources(); + emitChildSources(); + } else { + emitChildSources(); + emitParentSources(); + } + await manager.flushAsync(); + return telemetry.buildAggregate(); + } + + const rootFirstForward: ITelemetryAggregate = await aggregateSources(true, false); + const rootFirstReverse: ITelemetryAggregate = await aggregateSources(true, true); + const rootLastForward: ITelemetryAggregate = await aggregateSources(false, false); + const rootLastReverse: ITelemetryAggregate = await aggregateSources(false, true); + expect(rootFirstReverse.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootLastForward.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootLastReverse.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootFirstForward.producerVersions).toHaveLength( + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions + ); + for (const source of parentSources) { + expect(rootFirstForward.producerVersions).toContain(`${source.packageName}@${source.packageVersion}`); + } + expect(rootFirstForward.producerVersions).not.toContain( + `${oversizedChildSource.packageName}@${oversizedChildSource.packageVersion}` + ); + expect(rootFirstForward.producerVersions).not.toContain( + `${oversizedParentSource.packageName}@${oversizedParentSource.packageVersion}` + ); + expect( + rootFirstForward.producerVersions.filter((producerVersion: string): boolean => + producerVersion.startsWith('@microsoft/spoof-') + ) + ).toHaveLength(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions - parentSources.length); + for (const producerVersion of rootFirstForward.producerVersions) { + expect(producerVersion.length).toBeLessThanOrEqual( + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength + ); + } + expect(rootFirstForward.protocolVersion).toEqual({ major: 1, minor: 0 }); + }); + + it('does not admit producer metadata for lifecycle diagnostics with mixed privacy', async () => { + const SECRET: string = 'mixed-privacy-secret'; + const MIXED_SOURCE: IReporterEventSource = { + packageName: '@private/mixed-diagnostic-plugin', + packageVersion: '1.0.0-private' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const recording: RecordingReporter = new RecordingReporter(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + manager.addReporter(recording); + await manager.initializeAsync(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: MIXED_SOURCE, + protocolVersion: { major: 7, minor: 0 } + }); + + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + publicValue: { value: 'safe', privacy: 'public' }, + token: { value: SECRET, privacy: 'secret' } + } + }) + ); + await manager.flushAsync(); + + expect(recording.reported[0].privacy).toBe('public'); + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.protocolVersion).toBeUndefined(); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); + expect(JSON.stringify(aggregate)).not.toContain(SECRET); + expect(JSON.stringify(aggregate)).not.toContain(MIXED_SOURCE.packageName); + }); + + it('projects public envelopes while preserving allowlisted diagnostic fields deterministically', async () => { + const PUBLIC_EXTENSION_SOURCE: IReporterEventSource = { + packageName: '@rushstack/public-reporter-plugin', + packageVersion: '1.2.3' + }; + const PRIVATE_FIRST_PARTY_SOURCE: IReporterEventSource = { + packageName: '@microsoft/internal-build-plugin', + packageVersion: '9.8.7-private' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('commandResult', { + commandName: 'private-command', + succeeded: false, + exitCode: 97 + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive', + protocolVersion: { major: 7, minor: 0 } + }); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('diagnosticEmitted', { code: 'RUSH_OPERATION_FAILED', category: 'operation' })); + manager.emit({ + ...rawInput('operationStatusChanged', { + operationId: 'private-operation', + status: 'failure' + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('diagnosticEmitted', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + parameters: { + token: { value: 'private-secret-value', privacy: 'secret' } + } + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret' + }); + manager.emit(rawInput('operationStatusChanged', { operationId: 'public-operation', status: 'success' })); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event' }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret', + protocolVersion: { major: 99, minor: 0 } + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 1 }, + diagnosticCodes: ['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1, 'dependency-tool': 1 }, + protocolVersion: { major: 1, minor: 0 }, + producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/public-reporter-plugin@1.2.3'] + }); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [ + PRIVATE_FIRST_PARTY_SOURCE.packageName, + PRIVATE_FIRST_PARTY_SOURCE.packageVersion, + 'private-command', + 'private-secret-value', + 'private-operation' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + it('never leaks messages, paths, arguments, remediation, raw output, or secret values', async () => { const SECRET: string = 'sk-super-secret-value'; const LOG_PATH: string = '/home/user/secret/install.log';