Files
gstack/lib/redact-engine.ts
T
64fb6ecaf8 v1.92.8.0 fix(redact): env.kv reads Python, Dart and JSX source syntax as code (#3630) (#3742)
* fix(redact): env.kv reads Python and Dart type names and variable reads as code (#3630)

Extends the #3048 source-syntax exemption to .dart/.py/.pyi and JSX braces
joined by ?? || &&, with a Dart/Python-only triple-quote guard. pregate scans
changed Python/Dart files whole and keeps added-line findings, and the
allowlist names a pattern entry that missed its finding.

Co-authored-by: Adam Balazs <4034736+adam-balazs@users.noreply.github.com>
Co-authored-by: Hamish Barney <1049017+hbarney@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): triple-quote spans skip comments and one-line strings; prepush and large-file fallback scan Python/Dart without the exemption (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): untracked Python/Dart files keep the source exemption; allowlist rows name file lines for whole-file scans (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): a whole-file Python/Dart scan falls back to added lines when the working copy differs from the diff; count added lines, not file lines (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): one string-span pass for Python/Dart (block comments, raw strings, continuations); a literal fallback after a name still reports (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): a name whose expression continues on the next line still reports; allowlist miss rows only for patterns written from the name; clear the span cache after each scan (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): a trailing comment is not part of a name's expression (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): Python keyword form still reports; the pre-push hook scans Python/Dart without the exemption (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Adam Balazs <4034736+adam-balazs@users.noreply.github.com>
Co-authored-by: Hamish Barney <1049017+hbarney@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 01:50:36 +00:00

820 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* redact-engine — pure scanning + auto-redaction over the shared taxonomy.
*
* No I/O. Deterministic given its one ambient input: `scan()` reads the
* credential-named variables of `process.env` by value unless `processEnv` is
* passed (GSTA-104). The CLI shim (`bin/gstack-redact`), the pre-push hook
* (`bin/gstack-redact-prepush`), and tests all import from here.
*
* Key behaviors (locked in /plan-eng-review + two Codex passes):
* - Normalization BEFORE matching (NFKC + strip zero-width + decode a small
* set of HTML entities) so Unicode-confusable / zero-width evasion fails.
* Findings map back to ORIGINAL offsets via an index map.
* - ReDoS safety: a hard input-size cap that fails CLOSED (oversize input
* returns a single synthetic HIGH "input too large to scan safely" finding,
* so callers block rather than skip). Patterns are linear-time (lint-tested).
* - NO visibility-based tier mutation. `repoVisibility` is recorded on each
* finding (drives sterner AUQ wording in the skill) but never promotes a
* MEDIUM to HIGH. (TENSION-2-followup.)
* - Placeholder suppression is per-matched-span.
* - Tool-attributed fences (``` ```codex-review ``` / ``` ```greptile ```)
* degrade credential findings to a non-blocking WARN — UNLESS the span is a
* live-format credential the doc-example heuristic can't excuse. No nonce,
* no trust exemption (the marker scheme was dropped as theater).
*/
import {
PATTERNS,
PATTERNS_BY_ID,
clearStringSpanCache,
isPlaceholderSpan,
type RedactPattern,
type Tier,
type Category,
} from "./redact-patterns";
export type RepoVisibility = "public" | "private" | "unknown";
/** A WARN is a finding that does not block but is surfaced (tool-fence degrade). */
export type Severity = Tier | "WARN";
export interface Finding {
id: string;
tier: Tier;
/** Effective severity after tool-fence degrade. HIGH/MEDIUM/LOW or WARN. */
severity: Severity;
category: Category;
description: string;
/** 1-based line in the ORIGINAL (un-normalized) text. */
line: number;
/** 1-based column in the ORIGINAL text. */
col: number;
/** Safe-masked preview (never more than 4 leading chars of the secret). */
preview: string;
/** Whether this finding offers one-keystroke auto-redact (PII subset). */
autoRedactable: boolean;
/** Repo visibility at scan time — drives sterner AUQ wording, not the tier. */
repoVisibility: RepoVisibility;
/** True when degraded to WARN because it sat in a tool-attributed fence. */
toolFenceDegraded?: boolean;
}
export interface ScanOptions {
repoVisibility?: RepoVisibility;
/** Extra allowlist entries (exact strings) that suppress a matched span. */
allowlist?: string[];
/** The invoking user's own email (from `git config user.email`) — allowlisted. */
selfEmail?: string;
/**
* Emails already public in the repo (git log authors, package.json, CODEOWNERS).
* Suppressed for `pii.email` since they're not a new leak.
*/
repoPublicEmails?: string[];
/** Hard byte cap. Oversize input fails CLOSED. Default 1 MiB. */
maxBytes?: number;
/**
* Path of the file the input came from, when known (the pre-push hook's
* `+++ b/` path, `--from-file`). Lets a pattern tell source syntax from a
* literal: in a TypeScript/JSX file `session: SessionState,` is a type.
*/
sourcePath?: string;
/**
* The environment whose credential-named variables (a name whose last
* `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD, PASS, PAT or
* APIKEY, or that ends in _PWD, any case, carrying no PUBLIC word; see
* `credentialEnvValues`) are scanned for BY VALUE: wherever one of those
* values appears in the text it is an `env.value` HIGH finding, whatever its
* shape, so a value that reached the text (a dumped environment in a tool
* output, GSTA-96) never reaches a PR body, receipt, report or Drive file.
* Defaults to `process.env`; pass `{}` to scan without one.
*/
processEnv?: Record<string, string | undefined>;
}
export interface ScanResult {
findings: Finding[];
counts: { HIGH: number; MEDIUM: number; LOW: number; WARN: number };
repoVisibility: RepoVisibility;
/** True when the input-size cap tripped (caller should BLOCK). */
oversize: boolean;
}
const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
const EMAIL_ALLOW_DOMAINS = [/@example\.(com|org|net)$/i, /@example\.[a-z]{2,}$/i];
const EMAIL_ALLOW_LOCALPARTS = [/^noreply@/i, /^no-reply@/i, /^donotreply@/i];
/**
* Hosts whose `git@<host>` is a git transport endpoint, never a person's
* mailbox. Matched EXACTLY — a domain that merely starts with "git"
* (gitmail.com) is a normal domain and must keep firing.
*/
const SSH_GIT_HOSTS = new Set([
"github.com",
"gitlab.com",
"bitbucket.org",
"ssh.dev.azure.com",
]);
// ── Normalization ─────────────────────────────────────────────────────────────
const ZERO_WIDTH = /[​‌‍⁠]/g;
const HTML_ENTITIES: Record<string, string> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
};
/**
* Normalize text for matching while producing an index map back to the original.
* Returns the normalized string and a function mapping a normalized offset to
* the corresponding original offset.
*
* Strategy: walk the original char-by-char, applying NFKC per char, dropping
* zero-width chars, and expanding a small fixed set of HTML entities. Each
* emitted normalized char records the original offset it came from. This keeps
* the map exact for the transformations we apply (which are all local).
*/
export function normalizeWithMap(input: string): {
normalized: string;
map: number[];
} {
return normalizeOriginal(input);
}
function normalizeOriginal(input: string, spanEnds?: number[]): { normalized: string; map: number[] } {
const out: string[] = [];
const map: number[] = [];
let i = 0;
while (i < input.length) {
// HTML entity expansion (fixed small set; longest first).
let matchedEntity = false;
for (const ent in HTML_ENTITIES) {
if (input.startsWith(ent, i)) {
const rep = HTML_ENTITIES[ent];
for (const ch of rep) {
out.push(ch);
map.push(i);
spanEnds?.push(i + ent.length);
}
i += ent.length;
matchedEntity = true;
break;
}
}
if (matchedEntity) continue;
const ch = input[i];
if (ZERO_WIDTH.test(ch)) {
ZERO_WIDTH.lastIndex = 0;
i += 1;
continue;
}
ZERO_WIDTH.lastIndex = 0;
const norm = ch.normalize("NFKC");
for (let j = 0; j < norm.length; j++) {
out.push(norm[j]);
map.push(i);
spanEnds?.push(i + 1);
}
i += 1;
}
// Sentinel so an offset == length maps to the original length.
map.push(input.length);
return { normalized: out.join(""), map };
}
// ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
/** Start offset of every line, built once per scan and only when a finding needs it. */
function lineStarts(original: string): number[] {
const starts = [0];
for (let i = 0; i < original.length; i++) if (original[i] === "\n") starts.push(i + 1);
return starts;
}
/**
* Binary search over lineStarts: O(log lines) per finding. The previous walk
* from offset 0 per finding made a match-dense input (a pasted log full of
* emails and IPs) cost O(findings x bytes) — seconds for a few hundred KiB.
*/
function lineColAt(starts: number[], original: string, offset: number): { line: number; col: number } {
const at = Math.min(Math.max(0, offset), original.length);
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid] <= at) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, col: at - starts[lo] + 1 };
}
// ── Safe preview masking ──────────────────────────────────────────────────────
/** Show ≤4 leading chars, mask the rest. Never reconstructable. */
export function maskPreview(span: string): string {
const visible = span.slice(0, 4);
const masked = span.length > 4 ? "*".repeat(Math.min(span.length - 4, 8)) : "";
return `${visible}${masked}${span.length > 12 ? "…" : ""}`;
}
// ── Tool-attributed fence detection ───────────────────────────────────────────
const TOOL_FENCE_INFO = /^```(codex-review|greptile|eval|codex|tool-output)\b/;
/**
* Returns a sorted list of [start, end) offset ranges (in normalized text) that
* sit inside a tool-attributed fenced code block. Credential findings inside
* these ranges degrade to WARN (unless the doc-example heuristic says the span
* is live-format and must still block).
*/
function toolFenceRanges(normalized: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const lines = normalized.split("\n");
let offset = 0;
let inFence = false;
let fenceStart = 0;
for (const ln of lines) {
const isFenceMarker = ln.startsWith("```");
if (isFenceMarker) {
if (!inFence && TOOL_FENCE_INFO.test(ln)) {
inFence = true;
fenceStart = offset + ln.length + 1; // content starts after this line
} else if (inFence) {
ranges.push([fenceStart, offset]); // up to start of closing fence
inFence = false;
}
}
offset += ln.length + 1; // +1 for the \n
}
if (inFence) ranges.push([fenceStart, normalized.length]); // unterminated → still degrade its own body
return ranges;
}
function inRanges(offset: number, ranges: Array<[number, number]>): boolean {
for (const [s, e] of ranges) if (offset >= s && offset < e) return true;
return false;
}
/**
* Doc-example heuristic: a credential span inside a tool fence still BLOCKS if
* it looks like a LIVE credential (not an obvious placeholder/example). We only
* downgrade-to-WARN spans that are clearly illustrative.
*/
function isObviousDocExample(span: string): boolean {
return isPlaceholderSpan(span);
}
// ── Proximity check ───────────────────────────────────────────────────────────
function hasNear(
normalized: string,
matchStart: number,
matchEnd: number,
nearRegex: RegExp,
window: number,
): boolean {
const from = Math.max(0, matchStart - window);
const to = Math.min(normalized.length, matchEnd + window);
const slice = normalized.slice(from, to);
const re = new RegExp(nearRegex.source, nearRegex.flags.replace(/g/g, ""));
return re.test(slice);
}
// ── Email allowlist ───────────────────────────────────────────────────────────
/**
* True when the matched "email" is really the user@host of a git SSH remote.
*
* `pii.email` matches the `git@github.com` inside
* `git@github.com:org/repo.git` — a transport identity, not PII. This keys on
* the surrounding URL SHAPE, not on the `git` local part: allowlisting `git@`
* outright would also suppress a genuine address at a domain that merely
* begins with "git" (e.g. git@gitmail.com), turning a false positive into a
* false negative.
*
* Two accepted shapes:
* - scp-like `<user>@<host>:<path>` where the path ends in `.git`, for any
* host — this covers self-hosted remotes.
* - `git@<known-host>` for the major forges, whose bare form appears in docs
* and in `ssh -T git@github.com` connectivity checks with no path at all.
*/
/** Lookahead window for the scp-path suffix check — long enough for any real
* remote path, bounded so a pathological unbroken line can't grow the scan. */
const SSH_REMOTE_PATH_LOOKAHEAD_CHARS = 512;
function isSshGitRemote(email: string, text: string, spanStart: number): boolean {
const at = email.lastIndexOf("@");
if (at < 0) return false;
const local = email.slice(0, at).toLowerCase();
const host = email.slice(at + 1).toLowerCase();
// Major forges: `git@host`, with or without a trailing path.
if (local === "git" && SSH_GIT_HOSTS.has(host)) return true;
// Any host in `<user>@<host>:<path>.git` position. The path stops at
// whitespace or a quote so a trailing delimiter never defeats the suffix.
const rest = text.slice(
spanStart + email.length,
spanStart + email.length + SSH_REMOTE_PATH_LOOKAHEAD_CHARS,
);
const scp = /^:(?!\/)([^\s'"`<>]*)/.exec(rest);
if (scp && /\.git\/?$/.test(scp[1])) return true;
// ssh:// URL form: ssh://<user>@<host>/<path>.git
const before = text.slice(Math.max(0, spanStart - 16), spanStart);
if (/(?:git\+)?ssh:\/\/$/i.test(before)) {
const slash = /^\/([^\s'"`<>]*)/.exec(rest);
if (slash && /\.git\/?$/.test(slash[1])) return true;
}
return false;
}
function emailAllowed(
email: string,
opts: ScanOptions,
text: string,
spanStart: number,
): boolean {
const lower = email.toLowerCase();
if (opts.selfEmail && lower === opts.selfEmail.toLowerCase()) return true;
if (opts.repoPublicEmails?.some((e) => e.toLowerCase() === lower)) return true;
if (EMAIL_ALLOW_DOMAINS.some((re) => re.test(email))) return true;
if (EMAIL_ALLOW_LOCALPARTS.some((re) => re.test(email))) return true;
if (isSshGitRemote(email, text, spanStart)) return true;
return false;
}
// ── The scan ──────────────────────────────────────────────────────────────────
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
return scanInternal(input, opts);
}
type OriginalSpan = { start: number; end: number };
function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, OriginalSpan>): ScanResult {
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
let starts: number[] | null = null; // line index, built on the first finding
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
// maxBytes` always false and silently disable the fail-closed oversize guard.
// Guardrail: any non-finite or non-positive value falls back to the default
// cap. The CLI is the layer that rejects bad args; this is belt-and-suspenders
// so the engine never silently runs uncapped.
const maxBytes =
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
? (opts.maxBytes as number)
: DEFAULT_MAX_BYTES;
// Fail CLOSED on oversize input. Check byte length BEFORE heavy work.
const byteLen = Buffer.byteLength(input, "utf8");
if (byteLen > maxBytes) {
const finding: Finding = {
id: "engine.input_too_large",
tier: "HIGH",
severity: "HIGH",
category: "secret",
description: `Input too large to scan safely (${byteLen} > ${maxBytes} bytes) — blocking fail-closed`,
line: 1,
col: 1,
preview: "",
autoRedactable: false,
repoVisibility,
};
return {
findings: [finding],
counts: { HIGH: 1, MEDIUM: 0, LOW: 0, WARN: 0 },
repoVisibility,
oversize: true,
};
}
const spanEnds: number[] | undefined = spans ? [] : undefined;
const { normalized, map } = normalizeOriginal(input, spanEnds);
const fenceRanges = toolFenceRanges(normalized);
const allow = new Set(opts.allowlist ?? []);
const findings: Finding[] = [];
// Dedup by (id, original-offset) so overlapping global matches don't double-count.
const seen = new Set<string>();
for (const pat of PATTERNS) {
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
let m: RegExpExecArray | null;
while ((m = re.exec(normalized)) !== null) {
// Guard against zero-width matches looping forever.
if (m.index === re.lastIndex) re.lastIndex++;
const span = m[1] ?? m[0];
const normOffset = m.indices?.[1]?.[0] ?? m.index;
// Per-span placeholder suppression.
if (isPlaceholderSpan(span)) continue;
if (allow.has(span)) continue;
// Pattern-specific validators (Luhn, entropy, RFC1918, etc).
if (pat.validate && !pat.validate(span, m, opts)) continue;
// Proximity requirement.
if (
pat.nearRegex &&
!hasNear(normalized, m.index, m.index + m[0].length, pat.nearRegex, pat.nearWindow ?? 100)
) {
continue;
}
// Email allowlist (layered on top of the pattern).
if (pat.id === "pii.email" && emailAllowed(span, opts, normalized, normOffset))
continue;
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
const key = `${pat.id}:${origOffset}`;
if (seen.has(key)) continue;
seen.add(key);
starts ??= lineStarts(input);
const { line, col } = lineColAt(starts, input, origOffset);
// Tool-fence degrade: only credential-category, only obvious doc examples.
let severity: Severity = pat.tier;
let toolFenceDegraded = false;
if (
pat.category === "secret" &&
inRanges(normOffset, fenceRanges) &&
isObviousDocExample(span)
) {
severity = "WARN";
toolFenceDegraded = true;
}
const finding: Finding = {
id: pat.id,
tier: pat.tier,
severity,
category: pat.category,
description: pat.description,
line,
col,
preview: maskPreview(span),
autoRedactable: !!pat.autoRedactable,
repoVisibility,
...(toolFenceDegraded ? { toolFenceDegraded } : {}),
};
findings.push(finding);
if (spans) {
const end = spanEnds?.[normOffset + span.length - 1];
if (end !== undefined && end > origOffset) spans.set(finding, { start: origOffset, end });
}
}
}
clearStringSpanCache();
// Environment values, by exact value: a credential that is already in the
// process environment needs no shape to be recognized.
for (const { name, value: raw } of credentialEnvValues(opts.processEnv ?? process.env)) {
// The text was normalized before matching; the value takes the same form,
// and the allowlist and placeholder rules see both forms.
const value = normalizeOriginal(raw).normalized;
if (allow.has(raw) || allow.has(value) || value.length < MIN_ENV_VALUE_LENGTH || isPlaceholderSpan(value)) continue;
let from = 0;
let normOffset: number;
while ((normOffset = normalized.indexOf(value, from)) !== -1) {
from = normOffset + value.length;
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
// Keyed on the span, not the offset alone: when one credential's value is
// a prefix of another's, both findings exist and their spans coalesce.
const key = `env.value:${origOffset}:${value.length}`;
if (seen.has(key)) continue;
seen.add(key);
starts ??= lineStarts(input);
const { line, col } = lineColAt(starts, input, origOffset);
const finding: Finding = {
id: "env.value",
tier: "HIGH",
severity: "HIGH",
category: "secret",
description: `Value of the ${name} environment variable`,
line,
col,
// A live credential: the name already says what leaked; show no bytes.
preview: "********",
autoRedactable: false,
repoVisibility,
};
findings.push(finding);
if (spans) {
const end = spanEnds?.[normOffset + value.length - 1];
if (end !== undefined && end > origOffset) spans.set(finding, { start: origOffset, end });
}
}
}
// Stable order: by line, then col, then id.
findings.sort((a, b) => a.line - b.line || a.col - b.col || a.id.localeCompare(b.id));
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 };
for (const f of findings) counts[f.severity] += 1;
return { findings, counts, repoVisibility, oversize: false };
}
const CREDENTIAL_ENV_NAME = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PAT|APIKEY)$|_PWD$/i;
/** Framework-public keys (NEXT_PUBLIC_*_KEY, VITE_PUBLIC_*, *_PUBLIC_KEY) are committed on purpose. */
const PUBLIC_ENV_NAME = /(^|_)PUBLIC(_|$)/i;
/** gstack's own credential-named variables whose values are not secrets (bin/gstack-brain-lock.sh sets the lock token). */
export const NON_SECRET_ENV_NAMES: ReadonlySet<string> = new Set(["GSTACK_BRAIN_LOCK_TOKEN"]);
const MIN_ENV_VALUE_LENGTH = 8;
/**
* The (name, value) pairs of `env` worth scanning for by value: credential-named
* variables (the last `_`-separated word is KEY, TOKEN, SECRET, PASSWORD, PASSWD,
* PASS, PAT or APIKEY, or the name ends in _PWD; names carrying a PUBLIC word and
* gstack's own non-secret tokens are exempt) whose trimmed value is long enough to
* be a credential and is not a placeholder. A short or placeholder value would
* match ordinary prose. There is no path exemption on purpose: a shape rule
* exempted base64 secrets that start with `/`, and an existence check let the
* scanned agent exempt a value by creating a path of that name; a path-valued
* SSL_KEY that must appear in a text goes through `allowlist` where a caller
* passes one (`gstack-redact`, the pre-push hook), else the variable is set
* empty for that one command. The value is
* trimmed, so a key read from a file with a trailing newline still matches its
* leaked form; it is matched against the normalized text in its own normalized
* form, so a value NFKC rewrites still finds its leaked bytes.
*/
export function credentialEnvValues(env: Record<string, string | undefined>): Array<{ name: string; value: string }> {
const out: Array<{ name: string; value: string }> = [];
for (const [name, raw] of Object.entries(env)) {
if (!CREDENTIAL_ENV_NAME.test(name) || PUBLIC_ENV_NAME.test(name) || NON_SECRET_ENV_NAMES.has(name)) continue;
const value = raw?.trim() ?? "";
if (value.length < MIN_ENV_VALUE_LENGTH) continue;
if (isPlaceholderSpan(value)) continue;
out.push({ name, value });
}
return out;
}
function withFlags(flags: string): string {
let f = flags;
if (!f.includes("g")) f += "g";
if (!f.includes("m")) f += "m";
if (!f.includes("d")) f += "d";
return f;
}
// ── Auto-redaction ────────────────────────────────────────────────────────────
export interface RedactResult {
body: string;
/** ASCII unified-diff preview of the substitutions. */
diff: string;
/** Findings that could NOT be auto-redacted (structural-corruption guard). */
skipped: Finding[];
}
/**
* Substitute redact tokens for the given finding ids, right-to-left so offsets
* stay valid. Refuses to redact a span that sits inside a structural token
* (markdown link target, JSON string value) — those fall back to `skipped` so
* the skill drops the user to manual edit rather than silently mangling output.
*/
export function applyRedactions(
input: string,
findingIds: string[],
opts: ScanOptions = {},
): RedactResult {
const ids = new Set(findingIds);
const spans = new Map<Finding, OriginalSpan>();
const { findings } = scanInternal(input, opts, spans);
const targets = findings
.filter((f) => ids.has(f.id) && f.autoRedactable)
.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }))
.filter((t) => t.start >= 0);
// Right-to-left so earlier offsets remain valid after splicing.
targets.sort((a, b) => b.start - a.start);
const skipped: Finding[] = [];
const diffLines: string[] = [];
let body = input;
for (const t of targets) {
const pat = PATTERNS_BY_ID[t.f.id];
const token = pat?.redactToken ?? "<REDACTED>";
if (inStructuralToken(body, t.start, t.end)) {
skipped.push(t.f);
continue;
}
const before = lineContaining(body, t.start);
body = body.slice(0, t.start) + token + body.slice(t.end);
const after = lineContaining(body, t.start);
diffLines.push(`- ${before}`);
diffLines.push(`+ ${after}`);
}
return { body, diff: diffLines.reverse().join("\n"), skipped };
}
/**
* Patterns whose regex captures only a MARKER, not the secret payload itself
* (the PEM header line; the GCP JSON key prefix). Span replacement on these
* would redact the header and forward the key body — so redactFindingSpans
* drops the whole payload instead.
*/
const MARKER_ONLY_PATTERN_IDS = new Set(["pem.private_key", "gcp.service_account"]);
/**
* 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 — caller must drop the whole payload — when:
* - any finding's span cannot be located, or
* - any finding matched a marker-only pattern (PEM / GCP service-account
* JSON): their regexes capture the header, not the key material, so a
* span splice would leak the body that follows the marker.
* Overlapping spans (e.g. a Bearer token that is also a JWT) are coalesced
* before splicing so stale offsets never leave partial secret bytes behind.
* (Contrast applyRedactions, which is the interactive, autoRedactable-only,
* structure-preserving path.)
*/
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
const merged = mergedFindingSpans(input, opts);
return merged && spliceEdits(input, merged.map((m) => ({ start: m.start, end: m.end, text: marker(m.ids) })));
}
/** One replacement on the original text: [start, end) becomes `text`. */
export interface RedactionEdit {
start: number;
end: number;
text: string;
}
/**
* redactFindingSpans for a stream declared as JSON (a scanner report: one
* document, several top-level values, or NDJSON), keeping it parseable. A span
* inside a string keeps the in-string marker, widened to whole escape
* sequences; a span inside a number replaces that whole number token with the
* quoted marker, so `{"max_memory_bytes":1145427776}` stays valid JSON
* (#3185). A span that touches more than one token, a structural character,
* whitespace, a literal or a string's quotes returns null (withhold). A
* stream that does not tokenize as JSON gets the plain splice. Returns the
* edits (sorted, disjoint, on the original offsets) so a caller can map
* interleaved runs onto the result.
*/
export function redactJsonFindingSpans(input: string, opts: ScanOptions = {}): { body: string; edits: RedactionEdit[] } | null {
const merged = mergedFindingSpans(input, opts);
if (!merged) return null;
const tokens = jsonTokens(input);
const spans: Array<{ start: number; end: number; ids: string[]; quote: boolean }> = [];
for (const m of merged) {
if (!tokens) {
spans.push({ ...m, quote: false });
continue;
}
let lo = 0, hi = tokens.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tokens[mid].end <= m.start) lo = mid + 1; else hi = mid;
}
const t = tokens[lo];
if (!t || t.start > m.start || t.end < m.end) return null;
let span: { start: number; end: number; quote: boolean };
if (t.kind === "number") span = { start: t.start, end: t.end, quote: true };
else if (t.kind === "string" && m.start > t.start && m.end < t.end) {
span = { start: m.start, end: m.end, quote: false };
for (const [s, e] of t.escapes) {
if (s < span.start && span.start < e) span.start = s;
if (s < span.end && span.end < e) span.end = e;
}
} else return null;
const last = spans[spans.length - 1];
if (last && span.start < last.end) {
last.end = Math.max(last.end, span.end);
for (const id of m.ids) if (!last.ids.includes(id)) last.ids.push(id);
} else spans.push({ ...span, ids: [...m.ids] });
}
const edits = spans.map((x) => ({ start: x.start, end: x.end, text: x.quote ? `"${marker(x.ids)}"` : marker(x.ids) }));
return { body: spliceEdits(input, edits), edits };
}
const marker = (ids: string[]) => `<REDACTED-${ids.join("+")}>`;
function spliceEdits(input: string, edits: RedactionEdit[]): string {
// Right-to-left so earlier offsets remain valid after splicing.
let body = input;
for (let i = edits.length - 1; i >= 0; i--) body = body.slice(0, edits[i].start) + edits[i].text + body.slice(edits[i].end);
return body;
}
/** Every finding's span, coalesced; null when one cannot be located or is marker-only. */
function mergedFindingSpans(input: string, opts: ScanOptions): Array<{ start: number; end: number; ids: string[] }> | null {
const spans = new Map<Finding, OriginalSpan>();
const { findings } = scanInternal(input, opts, spans);
if (findings.some((f) => MARKER_ONLY_PATTERN_IDS.has(f.id))) return null;
const targets = findings.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }));
if (targets.some((t) => t.start < 0)) return null;
// Coalesce overlapping/touching ranges — splicing two intersecting spans
// independently applies a stale end offset to already-modified text and
// can leave trailing secret bytes in place.
targets.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number; ids: string[] }> = [];
for (const t of targets) {
const last = merged[merged.length - 1];
if (last && t.start <= last.end) {
last.end = Math.max(last.end, t.end);
if (!last.ids.includes(t.f.id)) last.ids.push(t.f.id);
} else {
merged.push({ start: t.start, end: t.end, ids: [t.f.id] });
}
}
return merged;
}
type JsonToken = { kind: "string" | "number" | "other"; start: number; end: number; escapes: Array<[number, number]> };
const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
const JSON_LITERAL = /true|false|null/y;
/** Lexical JSON tokens of one or more top-level values (NDJSON included); null when the text is not JSON. */
function jsonTokens(text: string): JsonToken[] | null {
const tokens: JsonToken[] = [];
for (let i = 0; i < text.length;) {
const c = text[i];
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
if ("{}[]:,".includes(c)) { tokens.push({ kind: "other", start: i, end: i + 1, escapes: [] }); i++; continue; }
if (c === '"') {
const escapes: Array<[number, number]> = [];
let j = i + 1;
for (;;) {
if (j >= text.length) return null;
if (text[j] === '"') break;
if (text[j] === "\\") {
const end = j + (text[j + 1] === "u" ? 6 : 2);
if (end > text.length) return null;
escapes.push([j, end]);
j = end;
} else j++;
}
tokens.push({ kind: "string", start: i, end: j + 1, escapes });
i = j + 1;
continue;
}
let matched = false;
for (const [kind, pattern] of [["number", JSON_NUMBER], ["other", JSON_LITERAL]] as const) {
pattern.lastIndex = i;
if (!pattern.test(text)) continue;
tokens.push({ kind, start: i, end: pattern.lastIndex, escapes: [] });
i = pattern.lastIndex;
matched = true;
break;
}
if (!matched) return null;
}
return tokens;
}
function inStructuralToken(body: string, start: number, end: number): boolean {
// Markdown link target: [text](...span...). The span may sit anywhere inside
// the parenthesized target (e.g. an email embedded in a URL). Walk backward
// from the span: if we reach `](` before hitting `)`/whitespace, and forward
// we reach `)` before whitespace, the span is inside a link target.
for (let i = start - 1; i >= 0; i--) {
const ch = body[i];
if (ch === ")" || ch === "\n" || ch === " " || ch === "\t") break;
if (ch === "(" && i > 0 && body[i - 1] === "]") {
for (let j = end; j < body.length; j++) {
const c = body[j];
if (c === " " || c === "\t" || c === "\n") break;
if (c === ")") return true;
}
break;
}
}
// JSON string value: "key": "...span..." — span is inside a quoted value.
const before = body.slice(Math.max(0, start - 80), start);
const after = body.slice(end, Math.min(body.length, end + 4));
if (/:\s*"$/.test(before) && /^"/.test(after)) return true;
return false;
}
function lineContaining(body: string, offset: number): string {
const start = body.lastIndexOf("\n", offset - 1) + 1;
let end = body.indexOf("\n", offset);
if (end === -1) end = body.length;
return body.slice(start, end);
}
// ── Exit-code helper for the CLI shim ─────────────────────────────────────────
/** 0 clean, 2 MEDIUM present (no HIGH), 3 HIGH present. WARN does not gate. */
export function exitCodeFor(result: ScanResult): 0 | 2 | 3 {
if (result.counts.HIGH > 0) return 3;
if (result.counts.MEDIUM > 0) return 2;
return 0;
}