fix(telemetry): redact error_message spans before they leave the machine (#1947)

error_message was uploaded with only quote/newline escaping — stack traces
and failed-API errors can embed credentials, private paths, and hostnames,
and the sync path strips only _repo_slug/_branch.

New lib/redact-engine.ts export redactFindingSpans(): replaces EVERY
finding's span with <REDACTED-{id}> regardless of tier (applyRedactions is
the interactive PII-only path and exits nonzero on credential findings, so
it can't serve machine egress). Returns null when a span can't be located —
callers drop the whole payload rather than risk a leak.

gstack-telemetry-log pipes error_message through it at LOG time, so the
local JSONL at rest is clean too; surrounding text survives for crash
triage. FAIL CLOSED: bun missing, engine error, or non-JSON-string output
all null the field. Tests pin: embedded ghp_ token → <REDACTED-github.pat>
with context intact; redactor unavailable → null; raw bytes on disk never
contain the token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-11 20:38:06 -07:00
co-authored by Claude Fable 5
parent b3085f137e
commit c8f078b482
3 changed files with 83 additions and 1 deletions
+35
View File
@@ -201,6 +201,41 @@ describe('gstack-telemetry-log', () => {
expect(event.error_message).toContain('not found');
});
test('redacts credential spans in error_message before they touch disk (#1947)', () => {
setConfig('telemetry', 'anonymous');
const token = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
run(
`${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome error --error-message 'push failed: auth ${token} rejected by remote' --session-id red-1`,
);
const lines = readJsonl();
expect(lines).toHaveLength(1);
const event = JSON.parse(lines[0]);
// The span is masked, the surrounding triage context survives.
expect(event.error_message).toContain('<REDACTED-github.pat>');
expect(event.error_message).toContain('push failed');
expect(event.error_message).not.toContain(token);
// Raw bytes on disk never contain the token either.
expect(lines[0]).not.toContain(token);
});
test('fails closed: error_message becomes null when the redactor is unavailable (#1947)', () => {
setConfig('telemetry', 'anonymous');
const token = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
// PATH without bun: the redaction snippet cannot run, so the whole
// message must drop — never raw passthrough.
run(
`${BIN}/gstack-telemetry-log --skill qa --duration 10 --outcome error --error-message 'auth ${token} rejected' --session-id red-2`,
{ PATH: '/usr/bin:/bin' },
);
const lines = readJsonl();
expect(lines).toHaveLength(1);
const event = JSON.parse(lines[0]);
expect(event.error_message).toBeNull();
expect(lines[0]).not.toContain(token);
});
test('creates analytics directory if missing', () => {
// Remove analytics dir
const analyticsDir = path.join(tmpDir, 'analytics');