Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions plugin/scripts/guard-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5812,28 +5812,41 @@ function cursorHooksPath(homedir2) {
function redacted(kind, hint) {
return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`;
}
var AWS_ACCESS_KEY_ID = /\b((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b/g;
var LEAD2 = String.raw`(?:\b|(?<=\\[nrtfb])|(?<=\\u[0-9A-Fa-f]{4})|(?<=\x1b\[[0-9;:?]{0,32}[@-~])|(?<=\\(?:u001[bB]|x1[bB]|033)\[[0-9;:?]{0,32}[@-~])|(?<=\x1b\([0-9A-Za-z])|(?<=\\(?:u001[bB]|x1[bB]|033)\([0-9A-Za-z]))`;
function led(source, flags) {
return new RegExp(`${LEAD2}${source}`, flags);
}
var AWS_ACCESS_KEY_ID = led(
String.raw`((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b`,
"g"
);
var AWS_SECRET_ACCESS_KEY = new RegExp(
`((?:aws[_-]?)?secret[_-]?access[_-]?key)(["']?\\s*[=:]\\s*["']?)((?![0-9a-f]{40}(?![A-Za-z0-9/+=]))[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])`,
"gi"
);
var OPENAI_KEY = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b/g;
var GITHUB_TOKEN = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g;
var SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g;
var BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi;
var CONNECTION_STRING = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)/gi;
var JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
var OPENAI_KEY = led(String.raw`sk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b`, "g");
var GITHUB_TOKEN = led(
String.raw`(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|github_pat_[A-Za-z0-9_]{22,255}\b)`,
"g"
);
var SLACK_TOKEN = led(String.raw`xox[baprs]-[A-Za-z0-9-]{10,255}\b`, "g");
var BEARER_TOKEN = led(String.raw`Bearer\s+([A-Za-z0-9._~+/-]{8,})=*`, "gi");
var CONNECTION_STRING = led(
String.raw`(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)`,
"gi"
);
var JWT = led(String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`, "g");
var PEM_PRIVATE_KEY = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]{0,8192}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g;
var BASIC_AUTH_URL = /\b(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])/gi;
var BASIC_AUTH_URL = led(String.raw`(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])`, "gi");
var ENV_SECRET_ASSIGNMENT = (() => {
const sentinel2 = String.fromCharCode(57344);
const key = "([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN|CREDENTIALS?)[A-Z0-9_]*)";
const value = `(?:"([^"${sentinel2}]{4,})"|'([^'${sentinel2}]{4,})'|([^\\s"',;${sentinel2}]{4,}))`;
return new RegExp(`\\b${key}(["']?\\s*[=:]\\s*)${value}`, "gi");
return new RegExp(`${LEAD2}${key}(["']?\\s*[=:]\\s*)${value}`, "gi");
})();
var EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b/g;
var SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g;
var CREDIT_CARD = /\b\d{13,19}\b|\b\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b/g;
var EMAIL = led(String.raw`[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b`, "g");
var SSN = led(String.raw`(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b`, "g");
var CREDIT_CARD = led(String.raw`(?:\d{13,19}\b|\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b)`, "g");
var PATTERNS = [
{
id: "aws-access-key-id",
Expand Down
37 changes: 25 additions & 12 deletions plugin/scripts/guard-scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8605,28 +8605,41 @@ function redactSecrets(input) {
function redacted(kind, hint) {
return hint ? `[REDACTED:${kind}:${hint}]` : `[REDACTED:${kind}]`;
}
var AWS_ACCESS_KEY_ID = /\b((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b/g;
var LEAD2 = String.raw`(?:\b|(?<=\\[nrtfb])|(?<=\\u[0-9A-Fa-f]{4})|(?<=\x1b\[[0-9;:?]{0,32}[@-~])|(?<=\\(?:u001[bB]|x1[bB]|033)\[[0-9;:?]{0,32}[@-~])|(?<=\x1b\([0-9A-Za-z])|(?<=\\(?:u001[bB]|x1[bB]|033)\([0-9A-Za-z]))`;
function led(source, flags) {
return new RegExp(`${LEAD2}${source}`, flags);
}
var AWS_ACCESS_KEY_ID = led(
String.raw`((?:AKIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA|ASCA|ASIA)[A-Z0-9]{16})\b`,
"g"
);
var AWS_SECRET_ACCESS_KEY = new RegExp(
`((?:aws[_-]?)?secret[_-]?access[_-]?key)(["']?\\s*[=:]\\s*["']?)((?![0-9a-f]{40}(?![A-Za-z0-9/+=]))[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])`,
"gi"
);
var OPENAI_KEY = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b/g;
var GITHUB_TOKEN = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g;
var SLACK_TOKEN = /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g;
var BEARER_TOKEN = /\bBearer\s+([A-Za-z0-9._~+/-]{8,})=*/gi;
var CONNECTION_STRING = /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)/gi;
var JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
var OPENAI_KEY = led(String.raw`sk-(?:proj-)?[A-Za-z0-9_-]{20,64}\b`, "g");
var GITHUB_TOKEN = led(
String.raw`(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b|github_pat_[A-Za-z0-9_]{22,255}\b)`,
"g"
);
var SLACK_TOKEN = led(String.raw`xox[baprs]-[A-Za-z0-9-]{10,255}\b`, "g");
var BEARER_TOKEN = led(String.raw`Bearer\s+([A-Za-z0-9._~+/-]{8,})=*`, "gi");
var CONNECTION_STRING = led(
String.raw`(postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s/@:]+:[^\s]*@([^\s/:@]+)`,
"gi"
);
var JWT = led(String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`, "g");
var PEM_PRIVATE_KEY = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]{0,8192}?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g;
var BASIC_AUTH_URL = /\b(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])/gi;
var BASIC_AUTH_URL = led(String.raw`(https?|ftp|wss?):\/\/[^\s/@:]+:[^\s]*@(?=[^\s/:@])`, "gi");
var ENV_SECRET_ASSIGNMENT = (() => {
const sentinel2 = String.fromCharCode(57344);
const key = "([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN|CREDENTIALS?)[A-Z0-9_]*)";
const value = `(?:"([^"${sentinel2}]{4,})"|'([^'${sentinel2}]{4,})'|([^\\s"',;${sentinel2}]{4,}))`;
return new RegExp(`\\b${key}(["']?\\s*[=:]\\s*)${value}`, "gi");
return new RegExp(`${LEAD2}${key}(["']?\\s*[=:]\\s*)${value}`, "gi");
})();
var EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b/g;
var SSN = /\b(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g;
var CREDIT_CARD = /\b\d{13,19}\b|\b\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b/g;
var EMAIL = led(String.raw`[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}\b`, "g");
var SSN = led(String.raw`(?!000|666|9\d\d)\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b`, "g");
var CREDIT_CARD = led(String.raw`(?:\d{13,19}\b|\d{3,6}([ -])\d{3,6}(?:\1\d{3,6}){1,3}\b)`, "g");
var PATTERNS = [
{
id: "aws-access-key-id",
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/scan-leak-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { compileCatalog } from "../core/rules.js";
import { aggregateScan, redactForReport, type ScanCorpus } from "../core/scan-report.js";
import type { GuardRule } from "../core/types.js";
import { session, toolUse, turn } from "./scan-fixtures.js";
import { ESCAPE_BOUNDARY_FIXTURES } from "./scrub-corpus.js";

const META: ReportMeta = { version: "0.1.0", generatedAt: new Date("2026-10-01T09:00:00.000Z") };

Expand Down Expand Up @@ -290,6 +291,16 @@ describe("end to end through the HTML and JSON surfaces", () => {
});
});

describe("a secret after an escape sequence or a control code", () => {
// The report keeps the secret's own label: the format pass catches it, not only the
// generic fallback that masks a random-looking run together with its neighbours.
it.each(ESCAPE_BOUNDARY_FIXTURES)("$name", ({ input, secret, kind }) => {
const out = redactForReport(input);
expect(out).not.toContain(secret);
expect(out).toContain(`[REDACTED:secret:${kind}`);
});
});

describe("kept readable on purpose", () => {
it.each([
["a branch pushed as a plain operand", "git push origin acme-billing-fix"],
Expand Down
71 changes: 71 additions & 0 deletions src/__tests__/scrub-corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,75 @@ export const INTERACTION_FIXTURES: readonly InteractionFixture[] = [
},
];

/**
* Secrets that sit right after an escape sequence or a terminal control code. A word
* boundary cannot see the start of the secret there: the character before it is the
* `n` of an escaped newline, the `m` of a colour code, or a hex digit of `\uXXXX`.
*
* Each entry is `[description, secret, placeholder kind]`. The GitHub token and the
* JWT are assembled at runtime; the AWS key is AWS's own documentation example.
*/
export const ESCAPE_BOUNDARY_SECRETS: ReadonlyArray<readonly [string, string, string]> = [
["a GitHub token", `${["gh", "p_"].join("")}${"R2l0SHViVG9rZW4".padEnd(36, "q")}`, "github"],
["an AWS access key", "AKIAIOSFODNN7EXAMPLE", "aws"],
[
"a JWT",
`${["ey", "J"].join("")}hbGciOiJIUzI1NiJ9.${["ey", "J"].join("")}zdWIiOiIxMjMifQ.c2lnbmF0dXJlLXZhbHVl`,
"jwt",
],
];

/** Each position where the start of a secret is hidden from a word boundary. */
export const ESCAPE_BOUNDARY_CONTEXTS: ReadonlyArray<
readonly [string, (secret: string) => string]
> = [
// What a JSON string holds for a two-line output: a backslash and an `n`, not a newline.
["line 2 of a JSON-escaped output", (s) => `{"output":"line one\\n${s}\\nline three"}`],
["after an escaped tab", (s) => `name\\t${s}`],
["after an escaped CRLF", (s) => `first\\r\\n${s}`],
["after a \\uXXXX escape", (s) => `key\\u003d${s}`],
["after a raw ANSI colour code", (s) => `\x1b[32m${s}\x1b[0m`],
["after an escaped ANSI colour code", (s) => `\\u001b[1;32m${s}\\u001b[0m`],
["after an ANSI code escaped for a shell", (s) => `\\x1b[31m${s}`],
["in a JSON argument after a newline", (s) => JSON.stringify({ query: `find\n${s}` })],
["after an erase-line code, as a spinner prints", (s) => `\r\x1b[K${s}`],
["after an erase-whole-line code", (s) => `\x1b[2K${s}`],
["after an erase and a cursor move", (s) => `\x1b[2K\x1b[1G${s}`],
["after an escaped erase-line code", (s) => JSON.stringify({ o: `progress\r\x1b[K${s}` })],
["after a colon-form colour code", (s) => `\x1b[38:5:208m${s}`],
["after a character-set switch", (s) => `\x1b(B${s}`],
["after a JSON-escaped form feed", (s) => JSON.stringify({ o: `a\f${s}` })],
["after a JSON-escaped backspace", (s) => JSON.stringify({ o: `a\b${s}` })],
];

/** A secret in an escape-boundary position: it must be redacted once, as `kind`. */
export interface EscapeBoundaryFixture {
readonly name: string;
readonly input: string;
readonly secret: string;
readonly kind: string;
}

/** Every secret in every escape-boundary context. */
export const ESCAPE_BOUNDARY_FIXTURES: readonly EscapeBoundaryFixture[] =
ESCAPE_BOUNDARY_SECRETS.flatMap(([what, secret, kind]) =>
ESCAPE_BOUNDARY_CONTEXTS.map(([where, wrap]) => ({
name: `${what} ${where}`,
input: wrap(secret),
secret,
kind,
})),
);

/**
* A secret glued to a word is still not a secret on its own: the escape-aware start
* only adds positions after an escape or a control code, never inside a word.
*/
export const GLUED_TO_A_WORD: readonly string[] = [
"xAKIAIOSFODNN7EXAMPLE",
`prefix${["gh", "p_"].join("")}${"R2l0SHViVG9rZW4".padEnd(36, "q")}`,
];

/**
* Every input, flattened. Order is stable so a failing
* case is identified the same way on every run.
Expand All @@ -272,6 +341,8 @@ export const CORPUS: readonly string[] = [
...POSITIVE_FIXTURES.map((f) => f.input),
...NEGATIVE_FIXTURES.map((f) => f.input),
...INTERACTION_FIXTURES.map((f) => f.input),
...ESCAPE_BOUNDARY_FIXTURES.map((f) => f.input),
...GLUED_TO_A_WORD,
// Engine-level edge cases. They carry no catalog expectation of their own, but
// the two scrubbers must still agree on them exactly.
"",
Expand Down
37 changes: 36 additions & 1 deletion src/__tests__/scrub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@

import { describe, expect, it } from "vitest";
import { PATTERN_IDS, scrubText } from "../core/scrub.js";
import { INTERACTION_FIXTURES, NEGATIVE_FIXTURES, POSITIVE_FIXTURES } from "./scrub-corpus.js";
import {
ESCAPE_BOUNDARY_FIXTURES,
GLUED_TO_A_WORD,
INTERACTION_FIXTURES,
NEGATIVE_FIXTURES,
POSITIVE_FIXTURES,
} from "./scrub-corpus.js";

const SENTINEL = String.fromCharCode(0xe000);

Expand Down Expand Up @@ -87,6 +93,35 @@ describe("correctness — benign strings survive untouched", () => {
});
});

describe("correctness — a secret after an escape sequence or a control code", () => {
// In JSON-escaped text or coloured terminal output the character before a secret is a
// letter or a hex digit (`\n`, `ESC[32m`, `\u001b`), so a plain word boundary does not
// hold there. Each case must still be redacted, once, with its own placeholder.
it.each(ESCAPE_BOUNDARY_FIXTURES)("$name", ({ input, secret, kind }) => {
const result = scrubText(input);
expect(result.text).not.toContain(secret);
expect(result.text).toContain(`[REDACTED:secret:${kind}`);
expect(result.total).toBe(1);
});

it.each(GLUED_TO_A_WORD)("a secret glued to a word stays as it is: %s", (input) => {
expect(scrubText(input)).toEqual({ text: input, redactions: {}, total: 0 });
});

it("stays linear on long runs of escapes and unterminated control codes", () => {
const inputs = [
"\\n".repeat(50_000),
"\x1b[".repeat(20_000),
`\x1b[${"1;".repeat(50_000)}`,
"\\u001b[".repeat(20_000),
"\\u00".repeat(40_000),
];
const start = performance.now();
for (const input of inputs) scrubText(input);
expect(performance.now() - start).toBeLessThan(2_000);
});
});

describe("engine semantics carried over from the source of truth", () => {
it("an empty string short-circuits", () => {
expect(scrubText("")).toEqual({ text: "", redactions: {}, total: 0 });
Expand Down
Loading
Loading