mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(capy): the launch environment is sourced from a 0600 file and no skill fence may print the environment (GSTA-104) During GSTA-96's unattended /ship on #3425, the Step 14.5 docs-audit child re-typed the GSTACK_LAUNCH assignments as `export $(...)`; the substitution came back empty, the bare `export` printed the whole environment, and three API key values landed in its tool output. - bin/gstack-capy-install writes the assignments to <verdict>.env (mode 0600, idempotent PATH prepend) and prints GSTACK_LAUNCH_FILE: <path>. - ship Step 14.5 prompt, document-release's spawned note, autoplan/DRIVER.md and gstack-skill-start's capy-asks block say: source the file; never export $(...), env, printenv or bare set in a tool fence. - test/launch-env-echo-ratchet.test.ts greps every generated SKILL.md, sections, DRIVER.md, the Capy skill and bin/ shell helpers, with negative controls under test/fixtures/launch-env-echo/. - lib/redact-engine.ts: env.value (HIGH) finds the value of any *_KEY, *_TOKEN, *_SECRET, *_PASSWORD variable in the writer's environment, so a leaked value cannot reach a PR body, receipt, report or Drive file. - scripts/lib/shard-engine.ts scrubs the Capy launch markers from shard environments (GSTA-99's finding). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): DRIVER.md back to 159 lines; shard-engine recorded non-behavioral at HARNESS_VERSION 6; verdict-count test lists .json only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register test/fixtures/launch-env-echo as a free fixture Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(redact): env-value fixtures stay out of pregate's env.kv net A credential-named assignment with an entropic literal on one line is what the repo's own pregate secrets scan refuses in added lines, so the fixture values move into plainly named constants and the scanned text is built from them. The scan under test still sees the same values through processEnv. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): the late-completion remote-caller case gives its second attempt a 2s budget (run 40263984171) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): env.value trims the value, keeps a prefix value's longer match, hides the preview and skips PUBLIC names /ship Step 9.4 review fixes for the GSTA-104 env-value scan: - a value whose environment copy ends in a newline (a key read from a file) is searched for trimmed, so its leaked form still matches (the length check already trimmed; the search used the raw value); - when one credential value is a prefix of another, the dedup key carries the span length, so the longer value is redacted whole in either order instead of leaving its tail in the text and dropping its finding; - the env.value preview is a fixed mask instead of the first characters of a live credential; - framework-public names (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) are not scanned, since those values are committed on purpose; - the header and the processEnv docstring state the name rule (KEY, TOKEN, SECRET, PASSWORD, PASSWD, any case) and that process.env is the engine's one ambient input. Step 7 coverage extensions and a regression test for each fix in test/redact-env-value.test.ts; docs/capy-install.md names the full rule. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * refactor(capy-install): write_launch_file quotes through shq; header names the launch file Review finding: _sq duplicated shq() from the same file. The launch file's assignments now go through shq, and the header comment describes the GSTACK_LAUNCH_PATH and GSTACK_LAUNCH_FILE lines beside GSTACK_LAUNCH. Step 7 coverage: the verdict test sources the launch file and runs --check; the single-quote path test sources the file too; a new case makes the launch-file directory unwritable and checks the run says so, prints no GSTACK_LAUNCH_FILE line, leaves no temp file and keeps its exit code and trailer (linux, non-root). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(preload): strip the Capy launch markers so a direct bun test matches the shard sandbox Red Team finding: the launch-marker scrub lived only in createShardSandbox, so a direct `bun test <file>` run under an unattended Capy session inherited GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL and GSTACK_INSTALL_VERDICT (GSTA-99's symptom). The preload now deletes the same four names, and test/shard-engine.test.ts imports CAPY_LAUNCH_MARKERS instead of re-typing them. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ratchet): the launch-env echo scan catches backtick and $VAR exports, typeset -p, env flags and a set in any segment Review findings 11 and 12: export-subst also trips on `export \`...\`` and an unquoted `export $VAR` (both splat a NAME=value file; `export "$name"` is one word and stays allowed); the export rule adds `typeset -p`; env trips with only flags (`env -0`) and as `/usr/bin/env`; set is judged per segment and with redirections, allowed only when piped straight into an anchored grep filter. Quoted strings are blanked offset-preserving so a `#` inside quotes no longer hides the rest of the line. The negative control covers each new form on both sides. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 14.5 names where the launch-file placeholder comes from The /document-release prompt line read `. <file>` with no word on what fills it. It now says the placeholder is the GSTACK_LAUNCH_FILE: path the parent's own install check printed. Worded to stay under the ship skill's pinned parity size (313,279 of 313,281 union bytes). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): env.value admits *_PASS, *_PAT, *_APIKEY and *_PWD, and skips path values and gstack's lock token /ship Step 11 adversarial findings. The name rule also covers DB_PASS, GITHUB_PAT, *_APIKEY and *_PWD (the shell's own PWD and OLDPWD stay out). A credential-named variable whose value is a filesystem path (SSL_KEY=/etc/ssl/ private/x.key) names where the credential lives, so the path is no finding, and GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file) is not a secret. Without these, an ordinary mention of that path or token in a PR body, receipt or CSO report was a HIGH fail-closed blocker. The adversarial pass also recommended an entropy floor so dev defaults such as DB_PASSWORD=postgres stop matching prose; declined here on purpose, because the scan's promise is to catch a leaked value whatever its shape, and a weak real password is still a credential. Recorded as an open decision in the ship report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-install): a failed launch-file write removes only its own temp file The failure path ran rm -f "$LAUNCH_FILE".tmp.*, which also deleted the temp file a concurrent installer run (two threads on one machine, same verdict path) was about to mv, so that run lost its launch file too. write_launch_file now cleans up its own $tmp and returns 1; the caller only reports. The file's comment no longer embeds the state-root path (a newline in GSTACK_STATE_ROOT could have ended the comment). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the launch markers come from one list, and the echo ratchet scans setup and scripts/*.sh The preload imports CAPY_LAUNCH_MARKERS instead of re-typing the four names, and the ratchet asserts the installer exports every name on that list, so a fifth marker cannot be added to one place only. The shell scan also covers ./setup and the scripts/*.sh install scripts callers run first. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): the Unreleased entry states the review-fixed env.value rules, the ratchet forms and the one test-budget change Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): the env.value path exemption asks whether the file exists instead of matching a shape Second review cycle: a path-shaped rule also matched a base64 secret that starts with "/" and holds another "/" (about 1 in 150 AWS-shaped keys), which then went unscanned. The exemption now fires only when the value names a file that exists on the scanning machine (SSL_KEY=/etc/ssl/private/x.key). The module header names that stat as the engine's one filesystem read. NON_SECRET_ENV_NAMES is exported so the test can prove bin/gstack-brain-lock.sh still exports every name in it; the PWD/OLDPWD fixtures hold plain words so only the name rule keeps them out. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the installer's own-temp-file cleanup is exercised, and the ratchet coverage test names setup and scripts An mv shim first on PATH refuses only the launch-file target, so the temp file exists when the move fails: the run removes that file alone (a sibling from a concurrent run survives), says so, prints no GSTACK_LAUNCH_FILE line and keeps the earlier file, exit code and trailer. The ratchet coverage test asserts setup, scripts/build.sh and scripts/sandbox-doctor.sh are scanned. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): env.value has no path exemption and matches the value in normalized form Second adversarial pass: the existence check let the scanned agent exempt any "/"- or "~/"-prefixed secret by creating a path of that name, a silent bypass of the one guard this branch adds, so the exemption is gone; a path-valued SSL_KEY that must appear in a text goes through the allowlist. The engine is back to no I/O. The value is now normalized the way the text is before indexOf, so a credential NFKC rewrites (fullwidth digits) still finds its leaked ASCII form. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-install): the launch file is written only on a passing run; the ratchet checks the marker list both ways A gstack: fail run no longer writes or announces a sourceable file whose PATH prepend points at an unverified Bun. The ratchet also asserts every variable the launch file exports is a CAPY_LAUNCH_MARKERS entry or GSTACK_STATE_ROOT, so a sixth export cannot reach fixture children unstripped. The Capy skill's prose no longer offers re-typing the assignments as an alternative to sourcing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-install): the launch file is part of the ok verdict Third adversarial pass: a passing run whose launch file could not be written still said gstack: ok with exit 0 and printed only the GSTACK_LAUNCH line, leaving a shell-evaluating caller with the re-typing path the branch exists to remove. The file is now written before the verdict is recorded; a write failure on a passing run turns the run into "gstack: fail launch-file — could not write the launch file <path>; fix: … (LAUNCH_FILE_UNWRITABLE)" with exit 1 and an ok:false verdict, and every failing run removes the launch file an earlier passing run left beside the verdict. The two failure tests assert the new contract, the doctor-FAIL tests assert no launch file is announced or left, and the docs, Capy skill and CHANGELOG say the same. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): the env.value allowlist and placeholder rules see the normalized value too An allowlisted value in NFC did not suppress a match on its NFKC-composed form, and a value that is a placeholder only after normalization was still searched for. Fixture nits from the third review cycle: fixed directory string instead of process.cwd(), the fullwidth assertion guards its own vacuity, a zero-width-padded value under the floor is pinned, and the ratchet comment names the one kept export and the PATH case line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-install): LAUNCH_FILE_UNWRITABLE is a registered result code; a kept profile verdict keeps its launch file Fourth review cycle. The new trailer code now has its row in lib/result-codes-capy.ts (the owner module is at its size cap) and its docs/troubleshooting.md anchor, so the install-side code scan resolves it. A failing profile check (--for review-voice) that keeps a fuller ok verdict for this HEAD no longer deletes that verdict's launch file: the fail branch removes the file only when the run rewrites the verdict, and setup_failed removes it too. The docs say "a failing run that rewrites the verdict", and that an unwritable directory keeps both the old file and the old verdict. A new test runs a passing install, then a failing profile check, and asserts the file and the ok verdict both survive. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(redact): the normalized allowlist and placeholder rules each have an assertion that fails without them Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(capy-install): a setup failure after a passing install removes the earlier launch file Fifth review cycle: setup_failed's cleanup had no assertion. The setup failure test now installs once (launch file present), commits a new HEAD so setup runs again, fails that setup, and asserts the launch file is gone and the verdict says ok:false before the next run sets up again and ends ok. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: state-root, contributor reference and capy-install follow the launch-file contract (Step 14.5 audit) The /document-release audit (docs-3449-1792992068) corrected three authored docs: docs/state-root.md shows the GSTACK_LAUNCH_FILE line and says to apply it with ". <that file>" instead of prefixing or exporting the assignments; docs/CONTRIBUTOR_REFERENCE.md names scan()'s one ambient input instead of calling it pure; docs/capy-install.md's bun-shim bullet and exit table name the launch file and the fail launch-file trailer. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
818 lines
33 KiB
TypeScript
818 lines
33 KiB
TypeScript
/**
|
||
* 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,
|
||
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> = {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
""": '"',
|
||
"'": "'",
|
||
"'": "'",
|
||
};
|
||
|
||
/**
|
||
* 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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|