mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(upgrade): accept a Git Bash checkout whose toplevel Git spells C:/… bin/gstack-upgrade compared `git rev-parse --show-toplevel` (C:/x from Git for Windows) with `pwd -P` (/c/x in Git Bash) as strings, so every Windows Git Bash install was refused as "not a gstack checkout", and the doctor's recovery line points at this command. Compare the two through gstack_path_same (bin/gstack-path-spelling.sh); a tree nested inside another repository is still refused. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): a catalog field named <schema word>_key is not a credential (#3208) env.kv accepted every separated name ending in _key, so a catalog entry `"input_key": "expected_media_rate_bits_per_second_ingress"` failed pregate's never-downgradable secrets check as MEDIUM env.kv [secret]. The camelCase twins (cacheKey, sortKey, partitionKey, idempotencyKey) were already rejected for having no credential prefix; the snake/kebab/dotted forms of those data-model words now are too. ALL-CAPS env names (INPUT_KEY=) and credential-word names (signing_key, stripe_key, input_secret) still fire. Reported by @brentyates-swx. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(cso): build the git-hardening fixture repo once per file Each test built its fixture repo with five /usr/bin/git spawns inside its own 5 s budget. On macOS that binary is the Xcode shim, and on a cold runner those spawns cost far more than the code under test: in run 38800592772 the first test timed out at 5 s before readGitMetadata got past its first lstat, and afterEach then tore the fixture down under it. Build the repo once in beforeAll, under the same 30 s bound each fixture spawn already declares, and give every test a fresh copy. The assertions and the code under test are unchanged, and each test keeps its full 5 s for the code under test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(browse): rule 3 treats wizard steps as writes and reads back sets before saving (#3216) On a non-local target a wizard or dialog step (Next, Step N, a menu item that opens a dialog) now counts as mutating until its handler or network effect shows otherwise, and its Cancel proves nothing. Before any Save or Submit, every programmatic set is read back and the flow stops on a mismatch. The cookbook's silent-failures list adds the skip-path precondition probe. Redundant clauses in rules 2, 3, 7, 8, 9, 10 and READY are tightened so the shared setup grows by 3 bytes and every browsing skill stays under its parity cap. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-design-review): keep the Completion Summary out of the regenerated report (#3219) The design review saved its Completion Summary inside the terminal GSTACK REVIEW REPORT section, which the next plan review deletes from its heading to the next "## " heading. The summary, deferred list and unresolved decisions now go in their own "## Design Review Completion Summary" section above the report, and the template says everything under the report heading is regenerated by the next review. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): exempt a schema field only when its name and value both say so (#3208) Review (GPT-6.1 Sol outside voice): the name-only exemption also silenced real secrets, e.g. Azure's cosmosdb_primary_key and any longer name such as api_cache_key. Now the whole name must be one data-model word plus _key (primary/secondary dropped) and the value two or more lowercase identifier words; an entropic value, a longer name or a credential name still fires in .env, YAML, JSON and TOML. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(upgrade): the Git shim delegates with the unshimmed PATH A PATH-searching git wrapper found the shim again and recursed until the test timed out. The shim now restores the host PATH before running git. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): secrets scans each file with its type and never reads an emptied diff as clean (#3211) addedLines() flattened every file into one added.txt, so gstack-redact never saw a .ts path and the #3048 source-expression exemption never ran: TS properties such as `token: credentials.token,` failed the gate as MEDIUM env.kv. Two false negatives predate this wave: the `+++` header filter dropped added content lines that begin with "++", and `git diff` honoured diff.external/textconv, so an external driver that prints nothing made a branch carrying a real AWS key pass with "no added lines". Added lines are now parsed hunk-aware per file (the pre-push hook's rules), with --no-ext-diff --no-textconv, and each file is scanned as <tmp>/<i>/<basename> so its type survives; findings name the file. Reported by @brentyates-swx. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-devex-review): keep the DX Scorecard out of the regenerated report (#3219) The DX Scorecard had the same placement gap as the design Completion Summary: with no rule, it could land inside GSTACK REVIEW REPORT, which the next plan review deletes. It now goes in its own "## DX Review Scorecard" section above the report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.109.0 fix: wave 5 — pregate secrets false negatives (#3211), Windows gstack-upgrade, env.kv schema keys (#3208), browse rule 3 (#3216), review summaries (#3219) 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> Co-authored-by: Scott Gigante <scott@gethubflow.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
730 lines
37 KiB
TypeScript
730 lines
37 KiB
TypeScript
/**
|
||
* Unit tests for lib/redact-engine.ts + lib/redact-patterns.ts.
|
||
*
|
||
* One positive test per pattern, plus FP-filters, validators (Luhn/entropy/
|
||
* RFC1918), email allowlist, no-promotion visibility semantics, tool-fence
|
||
* degrade, normalization (zero-width / homoglyph / entity), oversize fail-closed,
|
||
* and pure-function purity.
|
||
*/
|
||
import { describe, test, expect } from "bun:test";
|
||
import {
|
||
scan,
|
||
exitCodeFor,
|
||
maskPreview,
|
||
normalizeWithMap,
|
||
redactFindingSpans,
|
||
type RepoVisibility,
|
||
} from "../lib/redact-engine";
|
||
import {
|
||
PATTERNS,
|
||
luhnValid,
|
||
shannonEntropy,
|
||
isPublicIPv4,
|
||
isPlaceholderSpan,
|
||
URL_PASSWORD_PLACEHOLDER_WORDS,
|
||
} from "../lib/redact-patterns";
|
||
|
||
function ids(text: string, vis: RepoVisibility = "private"): string[] {
|
||
return scan(text, { repoVisibility: vis }).findings.map((f) => f.id);
|
||
}
|
||
|
||
describe("HIGH credential patterns", () => {
|
||
const cases: Array<[string, string]> = [
|
||
["aws.access_key", "key = AKIA1234567890ABCDEF"],
|
||
["aws.secret_key", "aws_secret_access_key = AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCd"],
|
||
["github.pat", "token ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz"],
|
||
["github.oauth", "gho_" + "1234567890abcdefghijklmnopqrstuvwxyz"],
|
||
["github.server", "ghs_1234567890abcdefghijklmnopqrstuvwxyz"],
|
||
["github.fine_grained", "github_pat_" + "A".repeat(82)],
|
||
["anthropic.key", "sk-ant-" + "api03-abcdefghij1234567890XYZ"],
|
||
["openai.key", "sk-proj-" + "a".repeat(40)],
|
||
["sendgrid.key", "SG." + "a".repeat(22) + "." + "b".repeat(43)],
|
||
["stripe.secret", "sk_live_" + "a".repeat(30)],
|
||
["slack.token", "xox" + "b-1234567890-abcdefghijklmnop"],
|
||
["slack.webhook", "https://hooks.slack.com/services/T00000000/B11111111/" + "a".repeat(24)],
|
||
["discord.webhook", "https://discord.com/api/webhooks/123456789012345678/" + "a".repeat(60)],
|
||
["pem.private_key", "-----BEGIN RSA PRIVATE KEY-----"],
|
||
// #1946 coverage-gap additions
|
||
["gitlab.token", "remote: glpat-" + "Ab12Cd34Ef56Gh78Ij90"],
|
||
["gitlab.token", "trigger glptt-" + "a1b2c3d4e5f6a7b8c9d0e1f2"],
|
||
["gitlab.token", "deploy gldt-" + "Zy98Xw76Vu54Ts32Rq10"],
|
||
["groq.key", "gsk_" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKlMn"],
|
||
["tavily.key", "tvly-" + "AbCdEfGhIjKlMnOpQrStUvWx"],
|
||
["tavily.key", "tvly-dev-" + "AbCdEfGhIjKlMnOpQrStUvWx"],
|
||
["notion.token", "ntn_" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh"],
|
||
["notion.token", "secret_" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh"],
|
||
["huggingface.token", "hf_" + "AbCdEfGhIjKlMnOpQrStUvWxYz012345"],
|
||
["npm.token", "npm_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"],
|
||
["digitalocean.token", "dop_v1_" + "0123456789abcdef".repeat(4)],
|
||
[
|
||
"gcp.service_account",
|
||
'{"private_key_id": "abc123", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIE..."}',
|
||
],
|
||
["google.oauth_client_secret", 'client_secret: "GOCSPX-' + "Ab3xQ9zLmNp2RtVw7YkD1sHf" + '"'],
|
||
["telegram.bot_token", "TELEGRAM_TOKEN=8326208591:AA" + "HdqRy9Lm2ZpXvKb4NcQw8TuEr6YoP1sVg"],
|
||
];
|
||
for (const [id, text] of cases) {
|
||
test(`flags ${id}`, () => {
|
||
expect(ids(text)).toContain(id);
|
||
});
|
||
}
|
||
|
||
// #1868 — modern OpenAI keys use base64url bodies (with - and _). The old
|
||
// [A-Za-z0-9]{32,} regex stopped at the first separator and missed them all,
|
||
// failing a HIGH credential OPEN through the redaction gate.
|
||
test("openai.key flags modern sk-proj-/sk-svcacct-/sk-admin- shapes (#1868)", () => {
|
||
const missed = [
|
||
"sk-proj-Ab12_Cd34-Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv",
|
||
"sk-svcacct-abc_def-ghijklmnopqrstuvwxyz0123456789ABCDEF",
|
||
"sk-admin-AAAA_BBBB-CCCC_DDDD-EEEE_FFFF-GGGG_HHHH1234",
|
||
];
|
||
for (const key of missed) {
|
||
expect(ids(`OPENAI_API_KEY=${key}`)).toContain("openai.key");
|
||
}
|
||
// legacy contiguous shape still flags
|
||
expect(ids("sk-proj-" + "a".repeat(40))).toContain("openai.key");
|
||
});
|
||
|
||
test("openai.key does not over-match prose / malformed sk- strings (#1868 calibration)", () => {
|
||
// HIGH tier BLOCKS, so false positives on prose are costly. None of these
|
||
// should flag as openai.key.
|
||
const benign = [
|
||
"the sk-learning-rate-schedule-was-tuned-carefully", // hyphenated prose
|
||
"sk--double-dash-typo-not-a-real-key",
|
||
"use sk-proj for the project prefix in docs", // no body
|
||
"sk-short", // too short, no prefix
|
||
];
|
||
for (const text of benign) {
|
||
expect(ids(text)).not.toContain("openai.key");
|
||
}
|
||
});
|
||
|
||
test("twilio.auth_token needs an SID nearby", () => {
|
||
const sid = "AC" + "a".repeat(32);
|
||
const tok = "b".repeat(32);
|
||
expect(ids(`account ${sid} token ${tok}`)).toContain("twilio.auth_token");
|
||
// bare 32-hex with no SID nearby should NOT flag as twilio
|
||
expect(ids(`random ${tok} here`)).not.toContain("twilio.auth_token");
|
||
});
|
||
|
||
test("db.url_with_password flags real password, skips placeholder/env-var", () => {
|
||
expect(ids("postgres://user:s3cretP@ss@db.example.com/app")).toContain("db.url_with_password");
|
||
expect(ids("postgres://user:${DB_PASSWORD}@host/app")).not.toContain("db.url_with_password");
|
||
// Literal PASSWORD placeholder (URL-format doc comments).
|
||
expect(ids("postgresql://USER:PASSWORD@host/db")).not.toContain("db.url_with_password");
|
||
// JS template interpolations are code, not credentials — the
|
||
// uppercase-only placeholder form blocked a push over
|
||
// `postgresql://${dbUser}:${dbPass}@...` in a bash->TS port.
|
||
// eslint-disable-next-line no-template-curly-in-string
|
||
expect(ids("postgresql://${dbUser}:${dbPass}@${dbHost}:5432/db")).not.toContain("db.url_with_password");
|
||
// Assembled at runtime so this file's own diff never contains a
|
||
// credential-shaped literal (the prepush guard scans exact pushed bytes).
|
||
expect(ids("postgres://admin:" + "hun" + "ter2@db.internal/app")).toContain("db.url_with_password");
|
||
// Bare $UPPER_SNAKE is shell convention → suppressed; bare $lowercase is
|
||
// NOT an interpolation form — a real password starting with `$` must
|
||
// still block (both-braces-optional would have let it through).
|
||
expect(ids("postgres://user:$DB_PASSWORD@host/app")).not.toContain("db.url_with_password");
|
||
expect(ids("postgres://admin:$" + "hun" + "ter2@db.internal/app")).toContain("db.url_with_password");
|
||
// Mismatched brace is not an interpolation either (assembled at runtime
|
||
// so this file's own pushed bytes carry no blockable URL shape).
|
||
expect(ids("postgres://admin:${" + "dbPass@db.internal/app")).toContain("db.url_with_password");
|
||
// A fully-braced interpolation is code whatever it contains — the DSN
|
||
// builder's `${encodeURIComponent(dbPass)}` call site must not scan as a
|
||
// pushed secret.
|
||
expect(ids("postgresql://user:${encodeURIComponent(dbPass)}@host:5432/db")).not.toContain("db.url_with_password");
|
||
// A LOWERCASE literal 'password'/'pass' at the URL-password position is a
|
||
// real (terrible) credential, not a doc placeholder — only the ALL-CAPS
|
||
// doc convention (USER:PASSWORD) is suppressed. Assembled at runtime so
|
||
// this file's own bytes never carry a live credential shape.
|
||
expect(ids("postgres://admin:" + "pass" + "word@10.0.0.5/app")).toContain("db.url_with_password");
|
||
expect(ids("https://root:" + "pa" + "ss@127.0.0.1/")).toContain("creds.basic_auth_url");
|
||
// Structural placeholders still suppress at the URL position.
|
||
expect(ids("postgres://user:<your-password>@host/db")).not.toContain("db.url_with_password");
|
||
// An ALL-CAPS password that is NOT an exact placeholder token is a real
|
||
// secret and must block — the pre-fix shape rule (/^[A-Z][A-Z0-9_]*$/) waved
|
||
// every all-caps password through. Substring of a placeholder word (SECRET)
|
||
// must not rescue it. Assembled at runtime so this file's own pushed bytes
|
||
// carry no live DSN shape.
|
||
expect(ids("postgres://admin:" + "PROD2026" + "SECRET@db-prod.internal/app")).toContain("db.url_with_password");
|
||
expect(ids("postgres://admin:" + "ADMIN" + "123@host/db")).toContain("db.url_with_password");
|
||
});
|
||
|
||
// Every curated placeholder word must suppress at the URL-password position.
|
||
// The fix replaced a shape rule with a hand-curated EXACT set, so a typo or a
|
||
// dropped entry (CHANGEME -> CHANGME) would silently start blocking a legit
|
||
// doc placeholder with zero failure elsewhere. Loop the real exported set so
|
||
// the test can't drift from the source list.
|
||
test("db.url_with_password suppresses every curated placeholder word", () => {
|
||
for (const word of URL_PASSWORD_PLACEHOLDER_WORDS) {
|
||
expect(ids(`postgres://user:${word}@host/db`)).not.toContain("db.url_with_password");
|
||
}
|
||
// Guard the set stays a non-trivial curated list (catches an accidental clear).
|
||
expect(URL_PASSWORD_PLACEHOLDER_WORDS.size).toBeGreaterThanOrEqual(8);
|
||
// And a real secret that merely CONTAINS a placeholder word still blocks.
|
||
expect(ids("postgres://user:" + "MY" + "SECRETPASS@host/db")).toContain("db.url_with_password");
|
||
});
|
||
|
||
test("all HIGH patterns block (exit 3)", () => {
|
||
const r = scan("AKIA1234567890ABCDEF", { repoVisibility: "private" });
|
||
expect(exitCodeFor(r)).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
|
||
test("stripe.publishable is MEDIUM not HIGH", () => {
|
||
const f = scan("pk_live_" + "a".repeat(30), { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "stripe.publishable",
|
||
);
|
||
expect(f?.tier).toBe("MEDIUM");
|
||
});
|
||
test("google.api_key is MEDIUM", () => {
|
||
const f = scan("AIza" + "a".repeat(35), { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "google.api_key",
|
||
);
|
||
expect(f?.tier).toBe("MEDIUM");
|
||
});
|
||
test("jwt is MEDIUM", () => {
|
||
const jwt = "eyJhbGciOiJ.eyJzdWIiOiI." + "x".repeat(20);
|
||
const f = scan(jwt, { repoVisibility: "private" }).findings.find((x) => x.id === "jwt");
|
||
expect(f?.tier).toBe("MEDIUM");
|
||
});
|
||
test("env.kv fires on high-entropy, skips placeholder", () => {
|
||
expect(ids("API_TOKEN=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
|
||
expect(ids("API_KEY=changeme")).not.toContain("env.kv");
|
||
expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv");
|
||
});
|
||
|
||
// #1946 gap 3: the uppercase-`=`-only shape made lowercase and YAML/JSON
|
||
// colon assignments invisible — the exact config shapes people actually
|
||
// push. Each closed detection fail-open gets a pinned case.
|
||
test("env.kv fires on lowercase = assignment (#1946)", () => {
|
||
expect(ids("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
|
||
});
|
||
test("env.kv fires on YAML colon assignment (#1946)", () => {
|
||
expect(ids("password: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
|
||
});
|
||
test("env.kv fires on quoted JSON key colon assignment (#1946)", () => {
|
||
expect(ids('"apiKey": "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv");
|
||
});
|
||
test("env.kv colon/lowercase forms stay entropy-gated and placeholder-safe", () => {
|
||
expect(ids("password: changeme")).not.toContain("env.kv");
|
||
expect(ids("apiKey: YOUR_API_KEY_HERE")).not.toContain("env.kv");
|
||
expect(ids("api_key=${MY_VAR}")).not.toContain("env.kv");
|
||
});
|
||
// T1 calibration: the zero-or-more-prefix net matched ANY identifier ending
|
||
// in a suffix, so ordinary code (`cacheKey: <entropic id>`) hit a MEDIUM
|
||
// confirm prompt. Name shape must be credential-semantic to count.
|
||
test("env.kv ignores non-credential names ending in a suffix (entropic values)", () => {
|
||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||
expect(ids(`cacheKey: ${v}`)).not.toContain("env.kv");
|
||
expect(ids(`sortKey: ${v}`)).not.toContain("env.kv");
|
||
expect(ids(`partitionKey: ${v}`)).not.toContain("env.kv");
|
||
expect(ids(`hotkey: ${v}`)).not.toContain("env.kv");
|
||
expect(ids(`monkey: ${v}`)).not.toContain("env.kv");
|
||
expect(ids(`idempotencyKey: ${v}`)).not.toContain("env.kv");
|
||
});
|
||
test("env.kv still fires on every credential-shaped name form", () => {
|
||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||
expect(ids(`api_key=${v}`)).toContain("env.kv"); // (i) separator
|
||
expect(ids(`API_KEY=${v}`)).toContain("env.kv"); // (i) + ALL-CAPS
|
||
expect(ids(`x-access-key: ${v}`)).toContain("env.kv"); // (i) dash separator
|
||
expect(ids(`key: ${v}`)).toContain("env.kv"); // (ii) bare suffix
|
||
expect(ids(`APIKEY=${v}`)).toContain("env.kv"); // (iii) ALL-CAPS compound
|
||
expect(ids(`apiKey: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||
expect(ids(`authToken: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||
expect(ids(`clientSecret: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||
});
|
||
// #3208: a catalog entry whose whole name is a data-model word plus `_key`
|
||
// and whose value is a lowercase identifier names a field, not a credential.
|
||
// Either side alone is not enough: an entropic value, a longer name or a
|
||
// credential name still fires, in every config format.
|
||
test("env.kv skips schema field identifiers only when name and value both say so (#3208)", () => {
|
||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||
expect(ids('{\n "input_key": "expected_media_rate_bits_per_second_ingress",\n "unit": "bps"\n}')).not.toContain("env.kv");
|
||
for (const line of ["input_key: expected_media_rate_bits_per_second_ingress", '"output_key": "bits.per.second.egress"', "cache-key: user-profile-v2", "sort_key = created_at_desc"])
|
||
expect(ids(line)).not.toContain("env.kv");
|
||
for (const line of [
|
||
`input_key: ${v}`, `INPUT_KEY=${v}`, `input_key: expectedMediaRateBitsPerSecond`, "input_key: abcdefghijkmnop",
|
||
`cosmosdb_primary_key=${v}`, `cosmosdb_primary_key: ${v}`, `{\n "cosmosdb_primary_key": "${v}"\n}`, `cosmosdb_primary_key = "${v}"`,
|
||
`primary_key: ${v}`, `api_cache_key: ${v}`, "api_key: expected_media_rate_bits_per_second_ingress", "cosmosdb_primary_key: expected_media_rate_bits_per_second",
|
||
])
|
||
expect(ids(line)).toContain("env.kv");
|
||
});
|
||
// #3048 — in a TS/JSX file an unquoted value after `:` is a type or an
|
||
// expression, and a JSX brace of names is an expression. Decided by file
|
||
// context: the same shapes in .env/YAML (or with no path) are literals.
|
||
test("env.kv skips TS/JSX source syntax by file context, never by value shape (#3048)", () => {
|
||
const kv = (text: string, sourcePath?: string) =>
|
||
scan(text, { repoVisibility: "private", ...(sourcePath ? { sourcePath } : {}) }).findings.some((f) => f.id === "env.kv");
|
||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||
const signature = "export async function issueServerCoachItem(\n db: Pool,\n session: SessionState,\n item: IssuedCoachItem,\n): Promise<void> {}";
|
||
for (const [code, file] of [
|
||
[signature, "src/coach.ts"],
|
||
["interface Props {\n session: SessionState;\n}", "src/props.ts"],
|
||
["key={turn.requestId + turn.role + index}", "src/Turn.tsx"],
|
||
["apiKey={settings.apiKey}", "src/Settings.jsx"],
|
||
] as const) expect({ code, kv: kv(code, file) }).toEqual({ code, kv: false });
|
||
for (const [literal, file] of [
|
||
["API_KEY=VelvetRiverOrbitSunset;", ".env"],
|
||
["password: {RiverStoneAmberCedar}", "config/app.yml"],
|
||
[signature, undefined],
|
||
[" session: SessionState,", "deploy/values.yaml"],
|
||
[`session: "${v}",`, "src/a.ts"], [`key={${v}}`, "src/A.tsx"], [`API_KEY={${v}}`, "src/A.tsx"],
|
||
[`key={settings.apiKey + "${v}"}`, "src/A.tsx"], [`key="${v}"`, "src/A.tsx"], [`api_key: ${v}`, ".env"],
|
||
] as const) expect({ literal, file, kv: kv(literal, file) }).toEqual({ literal, file, kv: true });
|
||
const jwt = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxMjM0NTY3ODkwIn0", "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"].join(".");
|
||
expect(scan(`token: ${jwt},`, { repoVisibility: "private", sourcePath: "src/auth.ts" }).findings.map((f) => f.id)).toContain("jwt");
|
||
});
|
||
// #2912 — a line that READS a secret from the environment holds no secret;
|
||
// it must not fire (and so must not be masked or withhold a /cso source file).
|
||
test("env.kv skips exact environment reads (#2912)", () => {
|
||
for (const line of [
|
||
'api_key=os.environ["AGENTOPS_API_KEY"]',
|
||
' api_key=os.environ["AGENTOPS_API_KEY"]',
|
||
"LIVEKIT_API_KEY = os.getenv('LIVEKIT_API_KEY')",
|
||
'token = os.environ.get("GH_TOKEN")',
|
||
"API_KEY=process.env.OPENAI_API_KEY",
|
||
"SECRET_KEY=getenv(\"APP_SECRET_KEY\")",
|
||
]) {
|
||
expect(ids(line)).not.toContain("env.kv");
|
||
expect(redactFindingSpans(`x=1\n${line}\n`, {})).toBe(`x=1\n${line}\n`);
|
||
}
|
||
});
|
||
test("env.kv still fires on real high-entropy keys beside or instead of an env read (#2912 negative controls)", () => {
|
||
const v = "Zq8vR2mN5tYb7Lc3Wd9K";
|
||
expect(ids(`SECRET_KEY = os.getenv('DJANGO_SECRET', '${v}')`)).toContain("env.kv");
|
||
expect(ids(`API_KEY=process.env.OPENAI_API_KEY||"${v}"`)).toContain("env.kv");
|
||
expect(ids(`API_KEY=os.environ["X"]+"${v}"`)).toContain("env.kv");
|
||
expect(ids(`API_KEY=os.environ_${v}`)).toContain("env.kv");
|
||
expect(ids(`API_KEY=getenv${v}`)).toContain("env.kv");
|
||
expect(ids(`API_KEY="${v}"`)).toContain("env.kv");
|
||
});
|
||
test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => {
|
||
const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" })
|
||
.findings.find((x) => x.id === "env.kv");
|
||
expect(f?.tier).toBe("MEDIUM");
|
||
});
|
||
|
||
// #1946 — Bearer is the most FP-prone shape in the wave: docs and examples
|
||
// are full of "Authorization: Bearer <token>". MEDIUM + header proximity +
|
||
// the env.kv entropy recipe keep it calibrated.
|
||
test("auth.bearer fires on a high-entropy token in header context", () => {
|
||
const text = "curl -H 'Authorization: Bearer 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq'";
|
||
const f = scan(text, { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "auth.bearer",
|
||
);
|
||
expect(f).toBeDefined();
|
||
expect(f?.tier).toBe("MEDIUM");
|
||
});
|
||
test("auth.bearer skips placeholders and env interpolations", () => {
|
||
expect(ids("Authorization: Bearer YOUR_TOKEN_HERE_PLACEHOLDER")).not.toContain("auth.bearer");
|
||
expect(ids("Authorization: Bearer ${ACCESS_TOKEN_FROM_ENV}")).not.toContain("auth.bearer");
|
||
});
|
||
test("auth.bearer requires header context (bare 'Bearer x' prose doesn't fire)", () => {
|
||
expect(ids("the Bearer 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq walked in")).not.toContain(
|
||
"auth.bearer",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("#1946 pattern negatives (placeholders never fire)", () => {
|
||
test("short or placeholder shapes don't trip the new HIGH patterns", () => {
|
||
expect(ids("glpat-xxxx")).not.toContain("gitlab.token");
|
||
expect(ids("hf_token")).not.toContain("huggingface.token");
|
||
expect(ids("gsk_key")).not.toContain("groq.key");
|
||
expect(ids("tvly-key")).not.toContain("tavily.key");
|
||
expect(ids("ntn_token")).not.toContain("notion.token");
|
||
// `secret_` is an ordinary word; only the length makes it a credential.
|
||
expect(ids("secret_value")).not.toContain("notion.token");
|
||
expect(ids("npm_install")).not.toContain("npm.token");
|
||
expect(ids("dop_v1_short")).not.toContain("digitalocean.token");
|
||
// pem header WITHOUT the GCP JSON shape stays pem.private_key only.
|
||
expect(ids("-----BEGIN PRIVATE KEY-----")).not.toContain("gcp.service_account");
|
||
});
|
||
});
|
||
|
||
describe("google.oauth_client_secret / telegram.bot_token negatives", () => {
|
||
test("undersized and placeholder shapes never fire", () => {
|
||
// Length floor keeps short repo fixtures quiet (e.g. the 19-char body in
|
||
// openclaw's extensions/google/oauth.test.ts).
|
||
expect(ids("GOCSPX-FakeSecretValue123")).not.toContain("google.oauth_client_secret");
|
||
expect(ids("GOCSPX-short")).not.toContain("google.oauth_client_secret");
|
||
// Placeholder suppression on an otherwise correctly-sized body.
|
||
expect(ids("GOCSPX-example" + "a".repeat(17))).not.toContain("google.oauth_client_secret");
|
||
expect(ids("1234567890:AAexample" + "a".repeat(26))).not.toContain("telegram.bot_token");
|
||
// A plain number pair must not read as a bot token.
|
||
expect(ids("1234567890:1234567890")).not.toContain("telegram.bot_token");
|
||
// The AIza key stays MEDIUM (google.api_key); it is not promoted here.
|
||
expect(ids("AIza" + "a".repeat(35))).not.toContain("google.oauth_client_secret");
|
||
});
|
||
});
|
||
|
||
describe("PII patterns", () => {
|
||
test("email flags + is autoRedactable", () => {
|
||
const f = scan("ping alice@corp.io please", { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "pii.email",
|
||
);
|
||
expect(f).toBeTruthy();
|
||
expect(f?.autoRedactable).toBe(true);
|
||
});
|
||
test("email allowlist: example.com, noreply, self, repo-public", () => {
|
||
expect(ids("see user@example.com")).not.toContain("pii.email");
|
||
expect(ids("from noreply@github.com")).not.toContain("pii.email");
|
||
expect(
|
||
scan("me@garry.dev", { repoVisibility: "private", selfEmail: "me@garry.dev" }).findings,
|
||
).toHaveLength(0);
|
||
expect(
|
||
scan("bob@acme.co", { repoVisibility: "private", repoPublicEmails: ["bob@acme.co"] }).findings,
|
||
).toHaveLength(0);
|
||
});
|
||
// A git SSH remote's `git@host` is a transport user@host, not a person's
|
||
// address. Suppressed by URL SHAPE rather than by allowlisting the `git`
|
||
// local part: a bare `git@` entry would also silently hide a real address
|
||
// at a domain that merely starts with "git".
|
||
test("ssh git remotes are not flagged as emails", () => {
|
||
expect(ids("set :repo_url, 'git@github.com:acme/widgets.git'")).not.toContain(
|
||
"pii.email",
|
||
);
|
||
expect(ids("git clone git@gitlab.com:acme/widgets.git")).not.toContain("pii.email");
|
||
expect(ids("git@bitbucket.org:acme/widgets.git")).not.toContain("pii.email");
|
||
expect(ids("git@ssh.dev.azure.com:v3/acme/widgets/widgets")).not.toContain("pii.email");
|
||
expect(ids("ssh -T git@github.com")).not.toContain("pii.email");
|
||
// General case: any host in <user>@<host>:<path>.git position.
|
||
expect(ids("git@git.acme-internal.net:infra/tools.git")).not.toContain("pii.email");
|
||
expect(ids("ssh://git@scm.acme-internal.net/infra/tools.git")).not.toContain("pii.email");
|
||
});
|
||
test("a real address is still flagged, including at a git host", () => {
|
||
expect(ids("ping alex@github.com about the issue")).toContain("pii.email");
|
||
// A domain that merely STARTS WITH "git" is not a git host — this is the
|
||
// case a bare `git@` local-part allowlist would have wrongly suppressed.
|
||
expect(ids("contact git@gitmail.com for access")).toContain("pii.email");
|
||
});
|
||
test("phone E.164 flags, skips compact timestamps", () => {
|
||
expect(ids("call +14155550123 now")).toContain("pii.phone.e164");
|
||
expect(ids("backup stamp 20260727202423 ran late")).not.toContain("pii.phone.e164");
|
||
});
|
||
test("ssn flags valid, skips 000 octet", () => {
|
||
expect(ids("ssn 123-45-6789")).toContain("pii.ssn");
|
||
expect(ids("000-12-3456")).not.toContain("pii.ssn");
|
||
});
|
||
test("credit card needs Luhn", () => {
|
||
expect(ids("card 4111111111111111")).toContain("pii.cc");
|
||
expect(ids("num 4111111111111112")).not.toContain("pii.cc");
|
||
});
|
||
test("public IP flagged, RFC1918 skipped", () => {
|
||
expect(ids("connect 8.8.8.8")).toContain("pii.ip_public");
|
||
expect(ids("local 192.168.1.5")).not.toContain("pii.ip_public");
|
||
expect(ids("local 10.0.0.1")).not.toContain("pii.ip_public");
|
||
});
|
||
|
||
// Digit-only UUIDs are the standard test-fixture shape, and their digit runs
|
||
// collide with both the card pattern (a 13-19 digit slice passes Luhn often
|
||
// enough to matter) and the phone pattern (hyphen groups read as national
|
||
// formatting). Observed live: 14 of 21 MEDIUM findings on one ordinary branch
|
||
// were exactly this, all from test files — the volume that makes people stop
|
||
// reading MEDIUM output at all.
|
||
test("digit-only UUID fixtures are not cards or phones", () => {
|
||
expect(ids("owner_user_id: '00000000-0000-0000-0000-000000000000'")).not.toContain("pii.cc");
|
||
expect(ids("const OWNER = '11111111-1111-1111-1111-111111111111'")).not.toContain(
|
||
"pii.phone.e164",
|
||
);
|
||
expect(ids("const TEAM = '22222222-2222-2222-2222-222222222222'")).not.toContain(
|
||
"pii.phone.e164",
|
||
);
|
||
// Hex UUIDs never matched these digit patterns; pinned so the suppression
|
||
// is not silently widened to something that swallows real numbers.
|
||
expect(ids("id 'a1b2c3d4-1111-2222-3333-444455556666'")).not.toContain("pii.cc");
|
||
});
|
||
|
||
test("UUID suppression requires TOTAL containment", () => {
|
||
// Real card sitting next to a UUID still reports — suppression is the
|
||
// exception and may only fire when the whole match is UUID interior.
|
||
expect(ids("00000000-0000-0000-0000-000000000000 4111111111111111")).toContain("pii.cc");
|
||
// And the plain cases are untouched.
|
||
expect(ids("card 4111-1111-1111-1111")).toContain("pii.cc");
|
||
expect(ids("reach me on +1 415 555 2671")).toContain("pii.phone.e164");
|
||
});
|
||
});
|
||
|
||
describe("internal + legal patterns", () => {
|
||
test("internal hostname", () => {
|
||
expect(ids("db1.corp internal host")).toContain("internal.hostname");
|
||
});
|
||
test("localhost url with path", () => {
|
||
expect(ids("hit http://localhost:8080/admin/secrets")).toContain("internal.url_private");
|
||
});
|
||
test("NDA marker", () => {
|
||
expect(ids("This is CONFIDENTIAL material")).toContain("legal.nda_marker");
|
||
});
|
||
test("named criticism needs a capitalized full name nearby", () => {
|
||
expect(ids("John Smith is incompetent at this")).toContain("legal.named_criticism");
|
||
expect(ids("the build is incompet019ently configured".replace("019", ""))).not.toContain(
|
||
"legal.named_criticism",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("LOW patterns surface only", () => {
|
||
test("user path is LOW", () => {
|
||
const f = scan("/Users/bob/secret/config", { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "internal.user_path",
|
||
);
|
||
expect(f?.tier).toBe("LOW");
|
||
});
|
||
test("TODO marker is LOW", () => {
|
||
const f = scan("TODO(alice) fix later", { repoVisibility: "private" }).findings.find(
|
||
(x) => x.id === "hygiene.todo",
|
||
);
|
||
expect(f?.tier).toBe("LOW");
|
||
});
|
||
test("gstack-shortcut(dec-*) debt marker survives redaction untouched", () => {
|
||
// The shortcut ledger convention (AskUserQuestion Format / /retro Step 11.5)
|
||
// ships in commits and PR bodies BY DESIGN — the ledger is the point. It
|
||
// must not be caught by hygiene.todo's TODO(owner) shape or anything else.
|
||
const marker =
|
||
"// gstack-shortcut(dec-a1b2c3): global lock, upgrade when throughput matters";
|
||
const result = scan(marker, { repoVisibility: "public" });
|
||
expect(result.findings).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe("placeholder suppression (per-span)", () => {
|
||
test("AWS docs EXAMPLE key not flagged", () => {
|
||
expect(ids("AKIAIOSFODNN7EXAMPLE")).not.toContain("aws.access_key");
|
||
});
|
||
test("your_ prefix not flagged", () => {
|
||
expect(isPlaceholderSpan("your_api_key")).toBe(true);
|
||
});
|
||
test("a real secret on a line that ALSO contains EXAMPLE still flags", () => {
|
||
// line-based suppression would wrongly skip this; per-span must catch it.
|
||
expect(ids("# EXAMPLE usage\nkey AKIA1234567890ABCDEF")).toContain("aws.access_key");
|
||
});
|
||
});
|
||
|
||
describe("no visibility-based tier promotion (TENSION-2-followup)", () => {
|
||
test("email stays MEDIUM on both private and public", () => {
|
||
const priv = scan("x@corp.io", { repoVisibility: "private" }).findings[0];
|
||
const pub = scan("x@corp.io", { repoVisibility: "public" }).findings[0];
|
||
expect(priv.tier).toBe("MEDIUM");
|
||
expect(pub.tier).toBe("MEDIUM");
|
||
expect(pub.severity).toBe("MEDIUM"); // NOT promoted to HIGH
|
||
expect(pub.repoVisibility).toBe("public"); // recorded for sterner wording
|
||
});
|
||
test("demoted credential patterns stay MEDIUM on public", () => {
|
||
const pub = scan("pk_live_" + "a".repeat(30), { repoVisibility: "public" }).findings[0];
|
||
expect(pub.severity).toBe("MEDIUM");
|
||
});
|
||
test("unknown visibility treated as public for wording, still no promotion", () => {
|
||
const r = scan("x@corp.io", { repoVisibility: "unknown" });
|
||
expect(r.findings[0].severity).toBe("MEDIUM");
|
||
});
|
||
});
|
||
|
||
describe("tool-attributed fence WARN-degrade (TENSION-3)", () => {
|
||
test("placeholder-shaped credential in tool fence → WARN", () => {
|
||
const text = "```codex-review\nfound your_aws_key AKIAIOSFODNN7EXAMPLE in code\n```";
|
||
const r = scan(text, { repoVisibility: "private" });
|
||
// the EXAMPLE key is suppressed as placeholder; verify a non-credential note doesn't block
|
||
expect(r.counts.HIGH).toBe(0);
|
||
});
|
||
test("live-format credential in tool fence STILL blocks", () => {
|
||
const text = "```codex-review\nleaked AKIA1234567890ABCDEF here\n```";
|
||
const r = scan(text, { repoVisibility: "private" });
|
||
expect(r.counts.HIGH).toBe(1); // not degraded — live format
|
||
});
|
||
test("AKIA outside any fence blocks", () => {
|
||
expect(exitCodeFor(scan("AKIA1234567890ABCDEF", {}))).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe("normalization", () => {
|
||
test("zero-width chars inside a key are stripped before matching", () => {
|
||
const zwsp = "";
|
||
const broken = "AKIA1234567890" + zwsp + "ABCDEF";
|
||
expect(ids(broken)).toContain("aws.access_key");
|
||
});
|
||
test("HTML entity decode", () => {
|
||
const { normalized } = normalizeWithMap("a & b");
|
||
expect(normalized).toBe("a & b");
|
||
});
|
||
test("offset map points back into original", () => {
|
||
const input = "xyz";
|
||
const { normalized, map } = normalizeWithMap(input);
|
||
expect(normalized).toBe("xyz");
|
||
// 'z' is at normalized index 2, original index 3
|
||
expect(map[2]).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe("oversize fails CLOSED", () => {
|
||
test("input over the byte cap returns a single blocking HIGH finding", () => {
|
||
const big = "a".repeat(2000);
|
||
const r = scan(big, { maxBytes: 1000 });
|
||
expect(r.oversize).toBe(true);
|
||
expect(r.counts.HIGH).toBe(1);
|
||
expect(r.findings[0].id).toBe("engine.input_too_large");
|
||
expect(exitCodeFor(r)).toBe(3);
|
||
});
|
||
|
||
// #1824: a malformed --max-bytes used to reach the engine as NaN. `byteLen >
|
||
// NaN` is always false, silently disabling the fail-closed guard. The engine
|
||
// guardrail must fall back to the default cap for any non-finite / <= 0 value.
|
||
test("NaN maxBytes falls back to the default cap (does NOT disable the guard)", () => {
|
||
const big = "a".repeat(2 * 1024 * 1024); // > 1 MiB default cap
|
||
const r = scan(big, { maxBytes: NaN });
|
||
expect(r.oversize).toBe(true);
|
||
expect(r.findings[0].id).toBe("engine.input_too_large");
|
||
expect(exitCodeFor(r)).toBe(3);
|
||
});
|
||
|
||
test("negative / zero maxBytes falls back to the default cap", () => {
|
||
// negative would make `byteLen > -5` always true (block everything);
|
||
// the guardrail normalizes it to the default instead.
|
||
const small = "ok";
|
||
expect(scan(small, { maxBytes: -5 }).oversize).toBeFalsy();
|
||
expect(scan(small, { maxBytes: 0 }).oversize).toBeFalsy();
|
||
const big = "a".repeat(2 * 1024 * 1024);
|
||
expect(scan(big, { maxBytes: -5 }).oversize).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("validators", () => {
|
||
test("luhn", () => {
|
||
expect(luhnValid("4111111111111111")).toBe(true);
|
||
expect(luhnValid("4111111111111112")).toBe(false);
|
||
});
|
||
test("entropy", () => {
|
||
expect(shannonEntropy("aaaaaaaa")).toBeLessThan(1);
|
||
expect(shannonEntropy("8Fk2pQ9vXz4wL7mN")).toBeGreaterThan(3);
|
||
});
|
||
test("isPublicIPv4", () => {
|
||
expect(isPublicIPv4("8.8.8.8")).toBe(true);
|
||
expect(isPublicIPv4("10.1.2.3")).toBe(false);
|
||
expect(isPublicIPv4("172.16.5.5")).toBe(false);
|
||
expect(isPublicIPv4("999.1.1.1")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("masking + purity", () => {
|
||
test("preview never leaks more than 4 leading chars", () => {
|
||
expect(maskPreview("AKIA1234567890ABCDEF")).toBe("AKIA********…");
|
||
expect(maskPreview("abc")).toBe("abc");
|
||
});
|
||
test("scan is pure — same input twice yields identical findings", () => {
|
||
const a = scan("AKIA1234567890ABCDEF x@corp.io", { repoVisibility: "public" });
|
||
const b = scan("AKIA1234567890ABCDEF x@corp.io", { repoVisibility: "public" });
|
||
expect(a).toEqual(b);
|
||
});
|
||
});
|
||
|
||
describe("redactFindingSpans — machine-egress masking (#1947)", () => {
|
||
test("clean input passes through unchanged", () => {
|
||
const text = "push failed: remote rejected the branch";
|
||
expect(redactFindingSpans(text, { repoVisibility: "private" })).toBe(text);
|
||
});
|
||
|
||
test("a single finding's span becomes <REDACTED-{id}>, context survives", () => {
|
||
const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
|
||
const out = redactFindingSpans(`auth ${token} rejected`, { repoVisibility: "private" });
|
||
expect(out).toBe("auth <REDACTED-github.pat> rejected");
|
||
});
|
||
|
||
test("multiple findings are all replaced (right-to-left splice keeps offsets valid)", () => {
|
||
const pat = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
|
||
const aws = "AKIA1234567890ABCDEF";
|
||
const out = redactFindingSpans(`first ${aws} then ${pat} end`, {
|
||
repoVisibility: "private",
|
||
});
|
||
expect(out).toBe("first <REDACTED-aws.access_key> then <REDACTED-github.pat> end");
|
||
});
|
||
|
||
test("masks an anchored env.kv value rather than withholding the whole payload", () => {
|
||
const secret = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq";
|
||
const out = redactFindingSpans(`API_KEY=${secret}`, { repoVisibility: "private" });
|
||
expect(out).toBe("API_KEY=<REDACTED-env.kv>");
|
||
});
|
||
|
||
test("line/col at boundaries: line start, after blank lines, first char, last unterminated line", () => {
|
||
const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
|
||
const at = (text: string) => {
|
||
const f = scan(text, { repoVisibility: "private" }).findings.find((x) => x.id === "github.pat");
|
||
expect(f).toBeDefined();
|
||
return [f!.line, f!.col];
|
||
};
|
||
expect(at(`a\nb\n${token} x`)).toEqual([3, 1]);
|
||
expect(at(`a\n\n\n ${token}`)).toEqual([4, 3]);
|
||
expect(at(token)).toEqual([1, 1]);
|
||
expect(at(`one\r\ntwo ${token}`)).toEqual([2, 5]);
|
||
expect(redactFindingSpans(`a\nb\n${token} x`, { repoVisibility: "private" })).toBe("a\nb\n<REDACTED-github.pat> x");
|
||
});
|
||
|
||
test("multiline input redacts a finding past the first line (original span map)", () => {
|
||
const token = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
|
||
const out = redactFindingSpans(`line one\nline two has ${token}\nline three`, {
|
||
repoVisibility: "private",
|
||
});
|
||
expect(out).toBe("line one\nline two has <REDACTED-github.pat>\nline three");
|
||
});
|
||
|
||
// Pre-landing review CRITICAL: pem.private_key and gcp.service_account
|
||
// capture only the HEADER, not the key material — a span splice would
|
||
// redact the marker and forward the key body. Marker-only patterns must
|
||
// drop the whole payload.
|
||
test("PEM private key → null (header-only span must not forward the key body)", () => {
|
||
const msg =
|
||
"deploy failed: -----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----";
|
||
expect(redactFindingSpans(msg, { repoVisibility: "private" })).toBeNull();
|
||
});
|
||
|
||
test("GCP service-account JSON → null (key body follows the captured marker)", () => {
|
||
const msg =
|
||
'config dump: {"private_key_id": "abc123", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBg..."}';
|
||
expect(redactFindingSpans(msg, { repoVisibility: "private" })).toBeNull();
|
||
});
|
||
|
||
// Pre-landing review: overlapping spans (a Bearer token that is also a
|
||
// JWT) must coalesce — independent splices apply stale offsets and can
|
||
// leave trailing secret bytes or mangled markers.
|
||
test("overlapping spans (Bearer JWT fires auth.bearer + jwt) never leak and produce clean markers", () => {
|
||
const jwt = "eyJ" + "a".repeat(20) + ".eyJ" + "b".repeat(20) + "." + "c".repeat(20);
|
||
const out = redactFindingSpans(`Authorization: Bearer ${jwt}`, { repoVisibility: "private" });
|
||
expect(out).not.toBeNull();
|
||
expect(out!).not.toContain("eyJ");
|
||
expect(out!).not.toContain("aaaa");
|
||
expect(out!).not.toContain("cccc");
|
||
// One coalesced, well-formed marker — no truncated fragments.
|
||
expect(out!).toMatch(/^Authorization: Bearer <REDACTED-[a-z._+]+>$/);
|
||
});
|
||
});
|
||
|
||
describe("taxonomy integrity", () => {
|
||
test("every pattern has a unique id", () => {
|
||
const set = new Set(PATTERNS.map((p) => p.id));
|
||
expect(set.size).toBe(PATTERNS.length);
|
||
});
|
||
test("autoRedactable patterns have a redactToken", () => {
|
||
for (const p of PATTERNS) {
|
||
if (p.autoRedactable) expect(p.redactToken).toBeTruthy();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("#3048: gstack-redact --from-file passes the file's path as scan context", () => {
|
||
test("the same line is source syntax in a .ts file and a literal in YAML", () => {
|
||
const fs = require("node:fs") as typeof import("node:fs");
|
||
const os = require("node:os") as typeof import("node:os");
|
||
const path = require("node:path") as typeof import("node:path");
|
||
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-from-file-"));
|
||
try {
|
||
const kv = (name: string) => {
|
||
fs.writeFileSync(path.join(dir, name), " session: SessionState,\n");
|
||
const r = spawnSync("bun", [path.join(import.meta.dir, "..", "bin", "gstack-redact"), "--from-file", path.join(dir, name), "--json"], { encoding: "utf8", timeout: 30_000 });
|
||
return (JSON.parse(r.stdout).findings as Array<{ id: string }>).some((f) => f.id === "env.kv");
|
||
};
|
||
expect(kv("coach.ts")).toBe(false);
|
||
expect(kv("values.yaml")).toBe(true);
|
||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||
});
|
||
});
|