Skip to content

Commit 187c339

Browse files
bugerclaude
andauthored
fix: sanitize env vars in telemetry to prevent API key leakage (#285)
* fix: sanitize env vars in telemetry to prevent API key leakage The telemetry system was capturing full template context including all environment variables, which leaked sensitive API keys like GOOGLE_API_KEY, JIRA_AUTH, ZENDESK_AUTH in trace files. Added sanitizeContextForTelemetry() that redacts env vars matching sensitive patterns (api_key, secret, token, password, auth, etc.) before capturing in OTEL spans or fallback NDJSON traces. Affected files: - src/telemetry/state-capture.ts: Added sanitization function and applied to captureCheckInputContext() - src/providers/command-check-provider.ts: Applied to fallback NDJSON - src/providers/ai-check-provider.ts: Applied to fallback NDJSON Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: use sanitizedContext consistently in captureCheckInputContext Changed individual attribute captures (pr, outputs, env_keys) to use sanitizedContext instead of original context to avoid inconsistent sanitization where the full context was sanitized but individual attributes were not. Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 05f0ddb commit 187c339

File tree

4 files changed

+180
-11
lines changed

4 files changed

+180
-11
lines changed

src/providers/ai-check-provider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
captureCheckInputContext,
1313
captureCheckOutput,
1414
captureProviderCall,
15+
sanitizeContextForTelemetry,
1516
} from '../telemetry/state-capture';
1617
import { CustomToolsSSEServer } from './mcp-custom-sse-server';
1718
import { CustomToolDefinition } from '../types/config';
@@ -951,7 +952,8 @@ export class AICheckProvider extends CheckProvider {
951952
// Fallback NDJSON for input context (non-OTEL environments)
952953
try {
953954
const checkId = (config as any).checkName || (config as any).id || 'unknown';
954-
const ctxJson = JSON.stringify(templateContext);
955+
// Sanitize context to avoid leaking API keys in traces
956+
const ctxJson = JSON.stringify(sanitizeContextForTelemetry(templateContext));
955957
const { emitNdjsonSpanWithEvents } = require('../telemetry/fallback-ndjson');
956958
emitNdjsonSpanWithEvents(
957959
'visor.check',

src/providers/command-check-provider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
captureCheckInputContext,
1919
captureCheckOutput,
2020
captureTransformJS,
21+
sanitizeContextForTelemetry,
2122
} from '../telemetry/state-capture';
2223

2324
/**
@@ -155,7 +156,8 @@ export class CommandCheckProvider extends CheckProvider {
155156
// Fallback NDJSON for input context (non-OTEL environments)
156157
try {
157158
const checkId = (config as any).checkName || (config as any).id || 'unknown';
158-
const ctxJson = JSON.stringify(templateContext);
159+
// Sanitize context to avoid leaking API keys in traces
160+
const ctxJson = JSON.stringify(sanitizeContextForTelemetry(templateContext));
159161
const { emitNdjsonSpanWithEvents } = require('../telemetry/fallback-ndjson');
160162
// Emit both start and completion markers together for deterministic E2E assertions
161163
emitNdjsonSpanWithEvents(

src/telemetry/state-capture.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,53 @@ import { Span } from './lazy-otel';
1010
const MAX_ATTRIBUTE_LENGTH = 10000; // Truncate large values
1111
const MAX_ARRAY_ITEMS = 100; // Limit array size in attributes
1212

13+
// Patterns that indicate sensitive environment variables (case-insensitive)
14+
const SENSITIVE_ENV_PATTERNS = [
15+
/api[_-]?key/i,
16+
/secret/i,
17+
/token/i,
18+
/password/i,
19+
/auth/i,
20+
/credential/i,
21+
/private[_-]?key/i,
22+
/^sk-/i, // OpenAI-style keys
23+
/^AIza/i, // Google API keys
24+
];
25+
26+
/**
27+
* Check if an environment variable name is sensitive
28+
*/
29+
function isSensitiveEnvVar(name: string): boolean {
30+
return SENSITIVE_ENV_PATTERNS.some(pattern => pattern.test(name));
31+
}
32+
33+
/**
34+
* Sanitize context for telemetry by redacting sensitive environment variables.
35+
* Returns a new object with env values redacted (keys preserved).
36+
*/
37+
export function sanitizeContextForTelemetry(
38+
context: Record<string, unknown>
39+
): Record<string, unknown> {
40+
if (!context || typeof context !== 'object') return context;
41+
42+
const sanitized = { ...context };
43+
44+
// Sanitize env object if present
45+
if (sanitized.env && typeof sanitized.env === 'object') {
46+
const sanitizedEnv: Record<string, string> = {};
47+
for (const [key, value] of Object.entries(sanitized.env as Record<string, unknown>)) {
48+
if (isSensitiveEnvVar(key)) {
49+
sanitizedEnv[key] = '[REDACTED]';
50+
} else {
51+
sanitizedEnv[key] = String(value);
52+
}
53+
}
54+
sanitized.env = sanitizedEnv;
55+
}
56+
57+
return sanitized;
58+
}
59+
1360
/**
1461
* Safely serialize a value for OTEL span attributes.
1562
* Handles truncation, circular refs, and type conversions.
@@ -46,23 +93,30 @@ function safeSerialize(value: unknown, maxLength = MAX_ATTRIBUTE_LENGTH): string
4693
*/
4794
export function captureCheckInputContext(span: Span, context: Record<string, unknown>): void {
4895
try {
96+
// Sanitize context to redact sensitive env vars before capturing
97+
const sanitizedContext = sanitizeContextForTelemetry(context);
98+
4999
// Capture key context variables
50-
const keys = Object.keys(context);
100+
const keys = Object.keys(sanitizedContext);
51101
span.setAttribute('visor.check.input.keys', keys.join(','));
52102
span.setAttribute('visor.check.input.count', keys.length);
53103

54-
// Capture full context as JSON (with size limit)
55-
span.setAttribute('visor.check.input.context', safeSerialize(context));
104+
// Capture full context as JSON (with size limit) - now sanitized
105+
span.setAttribute('visor.check.input.context', safeSerialize(sanitizedContext));
56106

57107
// Capture specific important variables separately for easy querying
58-
if (context.pr) {
59-
span.setAttribute('visor.check.input.pr', safeSerialize(context.pr, 1000));
108+
// Use sanitizedContext consistently to avoid leaking sensitive data
109+
if (sanitizedContext.pr) {
110+
span.setAttribute('visor.check.input.pr', safeSerialize(sanitizedContext.pr, 1000));
60111
}
61-
if (context.outputs) {
62-
span.setAttribute('visor.check.input.outputs', safeSerialize(context.outputs, 5000));
112+
if (sanitizedContext.outputs) {
113+
span.setAttribute('visor.check.input.outputs', safeSerialize(sanitizedContext.outputs, 5000));
63114
}
64-
if (context.env) {
65-
span.setAttribute('visor.check.input.env_keys', Object.keys(context.env as object).join(','));
115+
if (sanitizedContext.env) {
116+
span.setAttribute(
117+
'visor.check.input.env_keys',
118+
Object.keys(sanitizedContext.env as object).join(',')
119+
);
66120
}
67121
} catch (err) {
68122
try {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { sanitizeContextForTelemetry } from '../../src/telemetry/state-capture';
2+
3+
describe('sanitizeContextForTelemetry', () => {
4+
it('should redact sensitive env vars by pattern', () => {
5+
const context = {
6+
pr: { number: 1 },
7+
env: {
8+
PATH: '/usr/bin',
9+
HOME: '/home/user',
10+
GOOGLE_API_KEY: 'AIzaSyBqWp9Ent9-31GQOI8oAUPW6eGY5PMQHQM',
11+
OPENAI_API_KEY: 'sk-abc123',
12+
JIRA_AUTH: 'base64token',
13+
ZENDESK_AUTH: 'base64token',
14+
MY_SECRET: 'supersecret',
15+
DB_PASSWORD: 'hunter2',
16+
AWS_SECRET_ACCESS_KEY: 'aws-secret',
17+
PRIVATE_KEY: 'private-key-content',
18+
GITHUB_TOKEN: 'ghp_xxx',
19+
API_KEY: 'some-api-key',
20+
NODE_ENV: 'test',
21+
},
22+
};
23+
24+
const sanitized = sanitizeContextForTelemetry(context);
25+
26+
// Non-sensitive vars should be preserved
27+
expect((sanitized.env as any).PATH).toBe('/usr/bin');
28+
expect((sanitized.env as any).HOME).toBe('/home/user');
29+
expect((sanitized.env as any).NODE_ENV).toBe('test');
30+
31+
// Sensitive vars should be redacted
32+
expect((sanitized.env as any).GOOGLE_API_KEY).toBe('[REDACTED]');
33+
expect((sanitized.env as any).OPENAI_API_KEY).toBe('[REDACTED]');
34+
expect((sanitized.env as any).JIRA_AUTH).toBe('[REDACTED]');
35+
expect((sanitized.env as any).ZENDESK_AUTH).toBe('[REDACTED]');
36+
expect((sanitized.env as any).MY_SECRET).toBe('[REDACTED]');
37+
expect((sanitized.env as any).DB_PASSWORD).toBe('[REDACTED]');
38+
expect((sanitized.env as any).AWS_SECRET_ACCESS_KEY).toBe('[REDACTED]');
39+
expect((sanitized.env as any).PRIVATE_KEY).toBe('[REDACTED]');
40+
expect((sanitized.env as any).GITHUB_TOKEN).toBe('[REDACTED]');
41+
expect((sanitized.env as any).API_KEY).toBe('[REDACTED]');
42+
});
43+
44+
it('should preserve other context fields unchanged', () => {
45+
const context = {
46+
pr: { number: 123, title: 'Test PR' },
47+
files: [{ name: 'test.ts' }],
48+
outputs: { check1: 'result' },
49+
env: {
50+
PATH: '/usr/bin',
51+
API_KEY: 'secret',
52+
},
53+
};
54+
55+
const sanitized = sanitizeContextForTelemetry(context);
56+
57+
expect(sanitized.pr).toEqual({ number: 123, title: 'Test PR' });
58+
expect(sanitized.files).toEqual([{ name: 'test.ts' }]);
59+
expect(sanitized.outputs).toEqual({ check1: 'result' });
60+
});
61+
62+
it('should handle context without env', () => {
63+
const context = {
64+
pr: { number: 1 },
65+
};
66+
67+
const sanitized = sanitizeContextForTelemetry(context);
68+
69+
expect(sanitized.pr).toEqual({ number: 1 });
70+
expect(sanitized.env).toBeUndefined();
71+
});
72+
73+
it('should handle null/undefined context', () => {
74+
expect(sanitizeContextForTelemetry(null as any)).toBe(null);
75+
expect(sanitizeContextForTelemetry(undefined as any)).toBe(undefined);
76+
});
77+
78+
it('should not mutate original context', () => {
79+
const context = {
80+
env: {
81+
API_KEY: 'secret',
82+
PATH: '/usr/bin',
83+
},
84+
};
85+
86+
sanitizeContextForTelemetry(context);
87+
88+
// Original should be unchanged
89+
expect(context.env.API_KEY).toBe('secret');
90+
});
91+
92+
it('should detect case-insensitive patterns', () => {
93+
const context = {
94+
env: {
95+
api_key: 'lower',
96+
API_KEY: 'upper',
97+
Api_Key: 'mixed',
98+
apiKey: 'camel',
99+
APIKEY: 'no separator',
100+
},
101+
};
102+
103+
const sanitized = sanitizeContextForTelemetry(context);
104+
105+
expect((sanitized.env as any).api_key).toBe('[REDACTED]');
106+
expect((sanitized.env as any).API_KEY).toBe('[REDACTED]');
107+
expect((sanitized.env as any).Api_Key).toBe('[REDACTED]');
108+
expect((sanitized.env as any).apiKey).toBe('[REDACTED]');
109+
expect((sanitized.env as any).APIKEY).toBe('[REDACTED]');
110+
});
111+
});

0 commit comments

Comments
 (0)