diff --git a/.bumpy/fix-sensitive-number-redaction.md b/.bumpy/fix-sensitive-number-redaction.md new file mode 100644 index 000000000..275e520a2 --- /dev/null +++ b/.bumpy/fix-sensitive-number-redaction.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Register numeric @sensitive values for leak scanning and redaction diff --git a/packages/varlock/src/lib/formatting.ts b/packages/varlock/src/lib/formatting.ts index 8f9c60049..f173b02b9 100644 --- a/packages/varlock/src/lib/formatting.ts +++ b/packages/varlock/src/lib/formatting.ts @@ -135,8 +135,13 @@ export function getItemSummary(item: ConfigItem) { ])); let valAsStr = formattedValue(item.resolvedValue, false); - if (isSensitive && item.resolvedValue && _.isString(item.resolvedValue)) { - valAsStr = redactString(item.resolvedValue)!; + if ( + isSensitive + && item.resolvedValue !== undefined + && item.resolvedValue !== null + && (_.isString(item.resolvedValue) || (_.isNumber(item.resolvedValue) && Number.isFinite(item.resolvedValue))) + ) { + valAsStr = redactString(String(item.resolvedValue))!; } // build inline indicators to append after the value diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts index 6814f1ff0..00a50c06b 100644 --- a/packages/varlock/src/runtime/env.ts +++ b/packages/varlock/src/runtime/env.ts @@ -40,10 +40,16 @@ function getRedactionState(): RedactionState { /** collect every redactable string within a (possibly composite) sensitive value - * for arrays/objects each string element registers individually, so leaking a single - * element (not just the whole serialized value) is still caught */ + * element (not just the whole serialized value) is still caught. + * Finite numbers are included (e.g. `@type=number @sensitive` PINs) because they are + * injected into process.env as strings and must still be scanned/redacted. + * Booleans are intentionally skipped: registering "true"/"false" would false-positive + * almost every response body. */ function collectSensitiveStrings(value: any, collected: Array = []): Array { if (isString(value) && value) { collected.push(value as string); + } else if (typeof value === 'number' && Number.isFinite(value)) { + collected.push(String(value)); } else if (Array.isArray(value)) { for (const el of value) collectSensitiveStrings(el, collected); } else if (value && typeof value === 'object') { @@ -58,7 +64,8 @@ export function resetRedactionMap(graph: SerializedEnvGraph) { state.sensitiveSecretsMap = {}; for (const itemKey in graph.config) { const item = graph.config[itemKey]; - if (!item.isSensitive || !item.value) continue; + // Use nullish check so sensitive numeric `0` is still registered (!value would skip it) + if (!item.isSensitive || item.value === undefined || item.value === null) continue; const sensitiveStrings = collectSensitiveStrings(item.value); // the flat serialized form also registers (e.g. a JSON-encoded element may not // match its raw form once escaped) diff --git a/packages/varlock/src/runtime/test/scan-for-leaks.test.ts b/packages/varlock/src/runtime/test/scan-for-leaks.test.ts index a5bf9c1be..aa80c146e 100644 --- a/packages/varlock/src/runtime/test/scan-for-leaks.test.ts +++ b/packages/varlock/src/runtime/test/scan-for-leaks.test.ts @@ -91,6 +91,40 @@ describe('scanForLeaks', () => { }); }); +describe('scanForLeaks with numeric sensitive values', () => { + it('detects a leaked numeric secret (e.g. @type=number @sensitive)', () => { + resetRedactionMap({ + config: { + PIN: { isSensitive: true, value: 48291736 }, + }, + } as unknown as SerializedEnvGraph); + + expect(() => scanForLeaks('payload 48291736 end')) + .toThrow(/DETECTED LEAKED SENSITIVE CONFIG/); + expect(redactSensitiveConfig('pin=48291736')).not.toContain('48291736'); + }); + + it('registers sensitive numeric zero (must not skip via falsy check)', () => { + resetRedactionMap({ + config: { + ZERO_TOKEN: { isSensitive: true, value: 0 }, + }, + } as unknown as SerializedEnvGraph); + + expect(() => scanForLeaks('code=0;')).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/); + }); + + it('detects numeric secrets nested in arrays/objects', () => { + resetRedactionMap({ + config: { + CREDS: { isSensitive: true, value: { pin: 991122, label: 'x' } }, + }, + } as unknown as SerializedEnvGraph); + + expect(() => scanForLeaks('pin=991122')).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/); + }); +}); + describe('scanForLeaks with per-item preventLeaks opt-out', () => { const LEAKY_VALUE = 'allowed-to-leave-67890'; const NORMAL_VALUE = 'still-protected-12345';