Files
gstack/test/redact-engine.test.ts
T
Garry TanandClaude Fable 5 c86e6472eb v1.67.1.0 fix: external-contributor security sweep — 6 findings hardened, regression-pinned (#2605)
* fix(redact): block real all-caps URL passwords, not just shape-match

urlPasswordIsPlaceholder skipped any password matching /^[A-Z][A-Z0-9_]*$/,
so a real DSN like postgres://admin:PROD2026SECRET@db-prod.internal/app slipped
the HIGH pre-push block. Replace the shape rule with an anchored, exact-match
set of doc-convention placeholder tokens (PASSWORD, PASS, CHANGEME, ...),
compared case-sensitively and never as a substring (PROD2026SECRET must not
match SECRET). The USER:PASSWORD doc convention still suppresses; real all-caps
and lowercase passwords block. Regression cases pinned both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): write self-contained .gstack/.gitignore unconditionally

ensureStateDir only appended .gstack/ to the project .gitignore when that file
already existed, skipped silently on ENOENT, and swallowed other append
failures. With BROWSE_PERSIST_STATE=1, session-state.json (live cookies +
localStorage/sessionStorage tokens) and browse-network.log / browse-audit.jsonl
(request headers) then sat git-add-able under <git-root>/.gstack/. Write a
self-contained <stateDir>/.gitignore containing "*" unconditionally, before
return, so the state dir's contents can never be committed regardless of the
project .gitignore. The project-.gitignore append is kept as redundant safety.

The no-import-side-effects guard is relaxed to allow exactly this lone
.gitignore guard file (still fails on browse.json / session-state.json / logs /
listener binds) — the guard is written eagerly by ensureStateDir at import and
is not leaked state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): restore Bun.spawn exited/drain/OOM-cap contract on Node polyfill

The v1.65 fork-port squash silently dropped the `exited` promise, eager
stdout/stderr drain, and 16MB GSTACK_SPAWN_MAX_BUFFER cap that v1.64 added
(#2571), plus the five tests pinning them. On the Windows Node fallback,
`await proc.exited` then resolved to undefined immediately — cookie-import,
isBrowserRunning, and browser-skill children all read stdout before the child
produced it, a silent failure. Re-land the block (keeping v1.65's windowsHide
comment improvements) and re-add the pinning tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): compile the private-API touch bridge out of Release builds

PR #2264 claimed DebugBridgeTouch.m (KIF-derived in-process touch synthesis
using private UIKit/IOKit symbols: _touchesEvent, IOHIDEventCreateDigitizer*,
_AXSSetAutomationEnabled) was "compiled out in Release," but the body was gated
only by TARGET_OS_IOS, so a Release iOS build carried the private symbols (App
Store rejection risk). The safety half of the fix (closed PR #2269) never
landed. Gate the body on `#if TARGET_OS_IOS && DEBUG` and add the cSettings
DEBUG define to the DebugBridgeTouch target so `#if DEBUG` is true in debug and
false in release (mirrors the Core/UI swiftSettings). A free static tripwire
pins both halves; the nm/strings symbol proof needs an iOS-SDK build and belongs
in the device/periodic tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(egress): state truncation/deletion of the ledger are out of scope

gstack-egress verify catches in-place edits, reordering, and mid-chain deletion
(the hash chain breaks) but not tail-truncation, whole-file re-fabrication, or
deletion — a same-user local actor who owns the ledger defeats those and verify
still exits 0. That matches the stated threat model (forensic observability, not
an exfiltration control). Document it in the header threat model and the usage
text rather than adding a count-sidecar, which would false-positive on every
legitimate rotation and barely raise the bar. Head-anchoring stays the tracked
rotation TODO in lib/egress-receipt.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ship): scope the App Store Connect key to one app and disclose it at exit

The release flow minted a non-expiring APP_MANAGER key with allAppsVisible:true
(standing authority over every app on the team) and was told never to mention
any credential to the user, so the durable key never reached their revocation
checklist. Scope the key to the app being released via the apps relationship
(allAppsVisible:false + an explicit apps association — required, since a
no-app key can see nothing and uploads fail), and disclose the key once in the
closing report with its ASC revocation path. Carve the exit disclosure as the
explicit exception to the mid-run no-credential-talk rule so the
one-authorization-moment contract still holds. Edited the .tmpl source and
regenerated the section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harden(browse): constant-time bearer-token comparison in validateAuth

The loopback auth check compared the Authorization header with `===`, whose
byte-by-byte early exit leaks the token prefix through response timing. Use
crypto.timingSafeEqual with a length gate (the length is not secret). Behavior
is unchanged for valid/invalid tokens; auth tests unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin the security-property regression guards from pre-landing review

The pre-landing review found the fixes were correct but three regression guards
were missing — each pins a property whose silent revert would keep behavior
identical while reopening the hole:
- validateAuth: a static tripwire asserting crypto.timingSafeEqual + the
  got.length===want.length gate + the null-header guard (a revert to `===`
  keeps accept/reject green but restores the timing side-channel).
- redact: a table-driven loop over the exported URL_PASSWORD_PLACEHOLDER_WORDS
  so a typo or dropped entry can't silently start blocking a doc placeholder;
  plus a substring-can't-rescue-a-real-secret assertion.
- config: assert the self-contained .gitignore is written even when git already
  ignores .gstack/, proving the write precedes the isIgnoredByGit early return.
- bun-polyfill: cover the 128+signal exit branch (POSIX only).

URL_PASSWORD_PLACEHOLDER_WORDS is exported so the table test can't drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.66.2.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync egress-verify scope and layered iOS Release guard into user docs

ARCHITECTURE.md and README.md now carry the same gstack-egress verify
scope disclosure the CLI ships (edits/reordering/mid-chain deletion
detected; tail-truncation and ledger deletion out of scope for a
forensic log). docs/howto-ios-testing-with-gstack.md documents the
second Release-build guard: DebugBridgeTouch.m compiles out behind
#if TARGET_OS_IOS && DEBUG via the cSettings DEBUG define.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ios-qa): call the DebugBridge targets SwiftPM targets, not Swift targets

DebugBridgeTouch is Objective-C (the same sentence says so); "Swift
targets" was the wrong word. Cross-model doc review catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): describe the all-caps DSN examples without a scannable URL shape

The v1.66.2.0 entry quoted its own headline fix as three literal
postgres://user:PASSWORD@host examples — which the branch's stricter HIGH
gate now correctly flags, failing CI's quality scan on this very PR (the
local pre-push hook passed because the installed gstack still runs the old
engine). Rewrite the three mentions: the reproduce command uses a
fully-braced shell interpolation (suppressed in the diff scan by design,
expands to the real all-caps password at runtime, still exits 3 — verified),
and the table row + Fixed bullet name the password token without the URL
shape. Gate scan on the amended diff: 0 high.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(evals): pre-seed one-time preamble markers for PTY smokes

Root cause of the documented intermittent scope-gate-question-NOT-observed
failure (test/skill-e2e-plan-mode-no-op.test.ts, also PR #2593 rounds 3/11):
on a fresh runner every one-time preamble marker is missing, so each PTY
child runs first-run feature discovery before the behavior under test, and
touching .feature-prompted-model-overlay under ~/.claude/skills/gstack/
trips Claude Code's sensitive-file permission prompt — the run stalls on
that dialog (classified outcome=asked) and the scope gate never renders.
Dev machines never reproduce it because the operator's markers exist.

Seed ~/.gstack one-time markers (.activated, .first-loop-tip-shown,
.telemetry-prompted, .proactive-prompted, .completeness-intro-seen,
.plan-tune-nudge-shown) and both .feature-prompted-* markers (via the
gstack root symlink into the checkout) in the PTY-smoke registration step,
so no first-run prompt can preempt the assertion under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-version release as v1.67.1.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: restore main's dependency manifest clobbered by the merge resolution

The v1.67.0.0 merge resolved the package.json conflict wholesale --ours,
which kept this branch's version stamp but erased main's dependency work
(playwright 1.58->1.62 + its patchedDependencies entry, transformers 4.1->4.2,
cross-spawn added, puppeteer-core removed — which is also why main dropped the
basic-ftp pin test: the pinned package left the tree with it — marked/socks
bumps, adm-zip override) while bun.lock auto-merged to main's side. Every CI
job that runs `bun install --frozen-lockfile` failed on the mismatch
(check-freshness, quality, free-tests, gate, windows x2).

Take main's package.json + bun.lock verbatim, re-stamp the version through
gstack-version-bump (1.67.1.0). bun.lock is now byte-identical to main's;
frozen install verified locally; full free suite green for the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:50:37 -07:00

601 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"],
["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
});
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("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);
});
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");
});
});
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 &amp; 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("fails closed (null) when a span cannot be relocated — never raw passthrough", () => {
// env.kv's span (the value) starts well past the regex match start (the
// var name), so locateSpan's rewind-2 re-exec misses it. The contract is
// null → caller drops the whole payload. The one thing that must never
// happen is the secret surviving in the output.
const secret = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJq";
const out = redactFindingSpans(`API_KEY=${secret}`, { repoVisibility: "private" });
if (out !== null) {
// If locateSpan ever learns to find context-prefixed spans, masking
// must actually mask.
expect(out).not.toContain(secret);
} else {
expect(out).toBeNull();
}
});
test("multiline input redacts a finding past the first line (locateSpan line/col path)", () => {
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();
}
});
});