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
+22
View File
@@ -427,6 +427,28 @@ export function applyRedactions(
return { body, diff: diffLines.reverse().join("\n"), skipped };
}
/**
* Replace EVERY finding's span with `<REDACTED-{id}>`, regardless of tier or
* autoRedactable. For machine egress surfaces (telemetry error_message,
* #1947) where structure preservation doesn't matter and fail-closed beats
* fidelity. Returns null when any finding's span cannot be located — the
* caller must then drop the whole payload rather than risk leaking an
* unlocated secret. (Contrast applyRedactions, which is the interactive,
* autoRedactable-only, structure-preserving path.)
*/
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
const { findings } = scan(input, opts);
const targets = findings.map((f) => ({ f, ...locateSpan(input, f) }));
if (targets.some((t) => t.start < 0)) return null;
// Right-to-left so earlier offsets remain valid after splicing.
targets.sort((a, b) => b.start - a.start);
let body = input;
for (const t of targets) {
body = body.slice(0, t.start) + `<REDACTED-${t.f.id}>` + body.slice(t.end);
}
return body;
}
function locateSpan(input: string, f: Finding): { start: number; end: number } {
// Re-derive the offset from line/col on the original text.
let offset = 0;