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..9abcc4dc1b --- /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, protect parent-owned producer and protocol metadata, and preserve the original five-field performance budget contract.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r4-followup_2026-09-11-19-24.json b/common/changes/@rushstack/rush-reporter/review-r4-followup_2026-09-11-19-24.json new file mode 100644 index 0000000000..371b72f2de --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r4-followup_2026-09-11-19-24.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Bound individual telemetry diagnostic codes without truncation and exclude secret diagnostic codes and categories from aggregates.", + "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..b9e8fad22c 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -901,6 +901,14 @@ export interface IReporterSelectionInput { readonly isTTY: boolean; } +// @beta +export interface IReporterTelemetryLimits { + readonly maxTelemetryDiagnosticCategories: number; + readonly maxTelemetryDiagnosticCodes: number; + readonly maxTelemetryProducerVersionLength: number; + readonly maxTelemetryProducerVersions: number; +} + // @beta export interface IResolveExitStatusFromEventsOptions { readonly cancelled?: boolean; @@ -1340,7 +1348,7 @@ export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; // @beta -export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets; +export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets & IReporterTelemetryLimits; // @beta export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f..1f20256279 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -327,7 +327,7 @@ export { parseReporterExtensionEventName } from './producers/ReporterExtensionEventName'; -export type { IReporterPerformanceBudgets } from './perf/PerformanceBudgets'; +export type { IReporterPerformanceBudgets, IReporterTelemetryLimits } from './perf/PerformanceBudgets'; export { REPORTER_PERFORMANCE_BUDGETS, computeWallTimeRegressionPercent, diff --git a/libraries/reporter/src/perf/PerformanceBudgets.ts b/libraries/reporter/src/perf/PerformanceBudgets.ts index ab9a4c4647..5cecd3ce4d 100644 --- a/libraries/reporter/src/perf/PerformanceBudgets.ts +++ b/libraries/reporter/src/perf/PerformanceBudgets.ts @@ -47,6 +47,38 @@ export interface IReporterPerformanceBudgets { readonly maxAiDetailedDiagnostics: number; } +/** + * The capacity limits applied when reporter events are projected into telemetry + * aggregates. + * + * @beta + */ +export interface IReporterTelemetryLimits { + /** + * 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; +} + /** * One mebibyte, in bytes. */ @@ -63,12 +95,16 @@ const BYTES_PER_KIB: number = 1024; * * @beta */ -export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets = { +export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets & IReporterTelemetryLimits = { maxWallTimeRegressionPercent: 3, 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..37351db131 100644 --- a/libraries/reporter/src/telemetry/TelemetryAggregate.ts +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -47,12 +47,20 @@ export interface ITelemetryAggregate { readonly operationStatusCounts: { readonly [status: string]: number }; /** - * The distinct diagnostic codes emitted, sorted. + * The distinct diagnostic codes admitted by telemetry, sorted. + * + * @remarks + * Codes from effectively public diagnostics and registered codes from + * local-sensitive envelopes are eligible. Secret envelopes contribute no + * values. Codes over 256 characters are omitted, not truncated, and the + * number retained is bounded by the reporter telemetry budgets. */ readonly diagnosticCodes: readonly string[]; /** - * The number of diagnostics emitted in each category. + * The number of eligible diagnostics in each category. Local-sensitive + * diagnostics contribute only their registered code's category; secret + * envelopes never contribute. */ readonly diagnosticCategoryCounts: { readonly [category: string]: number }; @@ -67,7 +75,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..74c13e9d0d 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -5,16 +5,110 @@ 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 MAX_TELEMETRY_DIAGNOSTIC_CODE_LENGTH: number = 256; +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`. The fallback for + * non-public diagnostics keeps only a registered code and its registry category + * when the envelope is `local-sensitive`, never parameters, remediation, or + * templates. Secret envelopes contribute no values. Diagnostic codes over 256 + * characters are omitted before registry lookup or syntax validation, never + * truncated. It ignores all other values from non-public events, messages, raw + * external output, and command arguments entirely. * * @beta */ @@ -27,14 +121,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 +142,48 @@ 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') { + // Only registered schema fields are allowlisted for local-sensitive diagnostics. + const payload: { code?: unknown; category?: unknown } = isRecord(event.payload) ? event.payload : {}; + const code: string | undefined = + typeof payload.code === 'string' && payload.code.length <= MAX_TELEMETRY_DIAGNOSTIC_CODE_LENGTH + ? payload.code + : undefined; + const registeredDefinition: IRushDiagnosticCodeDefinition | undefined = + code === undefined ? undefined : REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS.get(code); + if (isEffectivelyPublicEnvelope) { + if (code !== undefined && isValidRushDiagnosticCode(code)) { + this._recordDiagnosticCode(code, registeredDefinition !== undefined); + } + if (typeof payload.category === 'string') { + this._recordDiagnosticCategory( + KNOWN_DIAGNOSTIC_CATEGORIES.has(payload.category) ? payload.category : OTHER_DIAGNOSTIC_CATEGORY + ); + } + } else if (event.privacy === 'local-sensitive' && registeredDefinition !== undefined) { + this._recordDiagnosticCode(registeredDefinition.code, true); + this._recordDiagnosticCategory(registeredDefinition.category); + } + return; + } + + if (!isEffectivelyPublicEnvelope) { + return; + } switch (event.type) { case 'commandStarted': { @@ -114,21 +248,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 +264,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 +282,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 +308,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..39dc38a9ac 100644 --- a/libraries/reporter/src/test/Performance.test.ts +++ b/libraries/reporter/src/test/Performance.test.ts @@ -13,6 +13,7 @@ import { type IReporter, type IReporterEmitEventInput, type IReporterEventEnvelope, + type IReporterPerformanceBudgets, type ReporterEventType, type ReporterJsonValue } from '../index'; @@ -118,6 +119,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', () => { @@ -128,6 +133,19 @@ describe('reporter performance budgets', () => { expect(() => computeWallTimeRegressionPercent(0, 10)).toThrow(); }); + it('accepts the original five-field performance budget contract', () => { + const legacyBudgets: IReporterPerformanceBudgets = { + maxWallTimeRegressionPercent: 5, + maxAdditionalPeakMemoryBytes: 64 * 1024 * 1024, + maxInteractiveRefreshHz: 10, + maxAiOutputBytes: 64 * 1024, + maxAiDetailedDiagnostics: 20 + }; + + expect(isWithinWallTimeBudget(1000, 1050, legacyBudgets)).toBe(true); + expect(isWithinMemoryBudget(64 * 1024 * 1024, legacyBudgets)).toBe(true); + }); + it('evaluates additional peak memory against the 32 MiB budget', () => { expect(isWithinMemoryBudget(31 * 1024 * 1024)).toBe(true); expect(isWithinMemoryBudget(32 * 1024 * 1024)).toBe(true); diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index 423b8a78cf..2d2a79f7ef 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,575 @@ 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_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); + expect(Object.keys(aggregate.diagnosticCategoryCounts)).toEqual(['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.each([false, true])( + 'does not change any aggregate field for a secret diagnostic (child: %s)', + async (isChild: boolean) => { + 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 before: ITelemetryAggregate = telemetry.buildAggregate(); + + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope( + 1, + 'secret', + { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + parameters: { token: { value: 'private-token', privacy: 'secret' } } + }, + { + source: { packageName: '@private/secret-plugin', packageVersion: '1.0.0-private' }, + protocolVersion: { major: 99, minor: 1 }, + parentSessionId: isChild ? 'sess' : undefined + } + ) + ); + await manager.flushAsync(); + + expect(recording.reported).toHaveLength(1); + expect(telemetry.buildAggregate()).toEqual(before); + } + ); + + 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_FUTURE_PUBLIC_CODE', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { + configuration: 1, + operation: 1, + other: 1 + } + }); + expect(Object.keys(telemetry.buildAggregate().diagnosticCategoryCounts)).toEqual([ + 'configuration', + 'operation', + 'other' + ]); + }); + + it.each([255, 256, 257])( + 'omits public diagnostic codes over the 256-character budget without truncation (length: %s)', + async (length: number) => { + const code: string = 'RUSH_BOUNDARY_'.padEnd(length, 'A'); + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.ingestForeignEnvelope(foreignDiagnosticEnvelope(1, 'public', { code, category: 'operation' })); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.diagnosticCodes).toEqual(length <= 256 ? [code] : []); + expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); + expect(aggregate.producerVersions).toEqual(['@foreign/reporter-plugin@1.0.0']); + } + ); + + it('bounds diagnostic dimensions deterministically under cardinality flooding', async () => { + const maximumCodeLength: number = 256; + const publicCodes: string[] = []; + const oversizedPublicCodes: string[] = []; + for ( + let index: number = 0; + index < REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes * 3; + index++ + ) { + publicCodes.push(`RUSH_FOREIGN_CODE${String(index).padStart(3, '0')}`.padEnd(maximumCodeLength, 'A')); + oversizedPublicCodes.push(`RUSH_A_FOREIGN${index}_${'A'.repeat(8 * 1024)}`); + } + 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}` + })), + ...oversizedPublicCodes.map((code: string) => ({ + privacy: 'public' as const, + code, + category: 'future-public-category' + })), + ...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: 'public', code: 'RUSH_CONFIG_INVALID_JSON', category: 'configuration' }, + { + 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(forward.diagnosticCodes.every((code: string): boolean => code.length <= maximumCodeLength)).toBe( + true + ); + expect(reverse).toEqual(forward); + expect(forward.diagnosticCodes).toHaveLength(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes); + expect(forward.diagnosticCodes).toEqual( + [ + 'RUSH_CONFIG_INVALID_JSON', + 'RUSH_DEPENDENCY_TOOL_FAILED', + ...publicCodes.slice(0, REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes - 2) + ].sort() + ); + expect(forward.diagnosticCodes).not.toContain('RUSH_OPERATION_FAILED'); + expect(forward.diagnosticCodes).not.toContain(hostilePrivateCodes[0]); + expect(forward.diagnosticCategoryCounts).toEqual({ + other: publicCodes.length + oversizedPublicCodes.length, + configuration: 1, + 'dependency-tool': 1 + }); + expect(Object.keys(forward.diagnosticCategoryCounts)).toHaveLength(3); + expect(Buffer.byteLength(JSON.stringify(forward))).toBeLessThanOrEqual( + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes * (maximumCodeLength + 3) + 1024 + ); + }); + + 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_OPERATION_FAILED'], + diagnosticCategoryCounts: { 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.each(['public', 'local-sensitive', 'secret'] as const)( + 'restricts mixed-privacy lifecycle diagnostics to the local-sensitive allowlist (floor: %s)', + async (privacy: IReporterEventEnvelope['privacy']) => { + 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: { + floorValue: { value: 'safe', privacy }, + token: { value: SECRET, privacy: 'secret' } + } + }) + ); + await manager.flushAsync(); + + expect(recording.reported[0].privacy).toBe(privacy); + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.protocolVersion).toBeUndefined(); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.diagnosticCodes).toEqual( + privacy === 'local-sensitive' ? ['RUSH_OPERATION_FAILED'] : [] + ); + expect(aggregate.diagnosticCategoryCounts).toEqual( + privacy === 'local-sensitive' ? { 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_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 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';