mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
fix(redact): env.value has no path exemption and matches the value in normalized form
Second adversarial pass: the existence check let the scanned agent exempt any "/"- or "~/"-prefixed secret by creating a path of that name, a silent bypass of the one guard this branch adds, so the exemption is gone; a path-valued SSL_KEY that must appear in a text goes through the allowlist. The engine is back to no I/O. The value is now normalized the way the text is before indexOf, so a credential NFKC rewrites (fullwidth digits) still finds its leaked ASCII form. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
93b793c6ef
commit
7765db84dc
4 files changed
+45
-47
No files matched your search
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { NON_SECRET_ENV_NAMES, credentialEnvValues, redactFindingSpans, scan } from "../lib/redact-engine";
|
||||
|
||||
@@ -28,6 +27,7 @@ const monkeyValue = "banana-banana-banana";
|
||||
const tokenizerValue = "tokenizer-value-here";
|
||||
const lockOwner = ["12345", "6789", "17"].join("-");
|
||||
const slashSecret = "/Qm9vZ2llV29vZ2ll/abc123XYZ+=";
|
||||
const sslKeyPath = "/etc/ssl/private/x.key";
|
||||
|
||||
const env = {
|
||||
ANTHROPIC_API_KEY: anthropicValue,
|
||||
@@ -146,31 +146,28 @@ describe("env-value scan", () => {
|
||||
|
||||
// (1) the name rule also admits *_PASS, *_PAT, *_APIKEY and *_PWD, while the shell's own PWD and
|
||||
// OLDPWD stay out by NAME (their fixture values are plain words, so only the name rule keeps them
|
||||
// out); (2) a credential-named variable whose value names an EXISTING file (SSL_KEY=/etc/ssl/...)
|
||||
// says where the credential lives, so that path is no finding, while a path-shaped value that
|
||||
// names no file, a base64 secret starting with "/" included, is still scanned; (3) gstack's own
|
||||
// out); (2) there is no path exemption: a path-valued SSL_KEY and a base64 secret starting with
|
||||
// "/" are both scanned, and an existing directory as a value changes nothing (an existence check
|
||||
// let the scanned agent exempt a value by creating a path of that name); (3) gstack's own
|
||||
// GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file) is not a secret, and
|
||||
// bin/gstack-brain-lock.sh still exports every name in that set. fails_when: PWD is admitted (every
|
||||
// mention of the cwd becomes HIGH), the path rule becomes a shape rule (the slash secret is skipped),
|
||||
// the existence check goes (the real file's path becomes HIGH), or the non-secret set drifts.
|
||||
test("more credential suffixes, path values that exist, and gstack's own lock token", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gsta104-"));
|
||||
const keyFile = path.join(dir, "x.key");
|
||||
fs.writeFileSync(keyFile, "pem");
|
||||
try {
|
||||
const names = credentialEnvValues({
|
||||
DB_PASS: dbValue, GITHUB_PAT: ghValue, STRIPE_APIKEY: lowerCaseValue, MYSQL_PWD: dbPasswdValue,
|
||||
PWD: "workspace-dir-name", OLDPWD: "previous-dir-name",
|
||||
SSL_KEY: keyFile, DIR_KEY: dir, MISSING_KEY: path.join(dir, "absent", "x.key"), SLASH_KEY: slashSecret,
|
||||
GSTACK_BRAIN_LOCK_TOKEN: lockOwner,
|
||||
}).map(e => e.name).sort();
|
||||
expect(names).toEqual(["DB_PASS", "GITHUB_PAT", "MISSING_KEY", "MYSQL_PWD", "SLASH_KEY", "STRIPE_APIKEY"]);
|
||||
expect(scan(`b64 ${slashSecret}`, { processEnv: { SLASH_KEY: slashSecret } }).findings.map(f => f.id)).toEqual(["env.value"]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
// bin/gstack-brain-lock.sh still exports every name in that set; (4) a value NFKC rewrites
|
||||
// (fullwidth digits) is matched in its normalized form, so its leaked ASCII form is found.
|
||||
// fails_when: PWD is admitted (every mention of the cwd becomes HIGH), a shape or existence rule
|
||||
// returns (the slash secret or the directory value is skipped), the non-secret set drifts, or the
|
||||
// value is matched raw against the normalized text.
|
||||
test("more credential suffixes, no path exemption, gstack's own lock token and a normalized value", () => {
|
||||
const names = credentialEnvValues({
|
||||
DB_PASS: dbValue, GITHUB_PAT: ghValue, STRIPE_APIKEY: lowerCaseValue, MYSQL_PWD: dbPasswdValue,
|
||||
PWD: "workspace-dir-name", OLDPWD: "previous-dir-name",
|
||||
SSL_KEY: sslKeyPath, DIR_KEY: process.cwd(), SLASH_KEY: slashSecret,
|
||||
GSTACK_BRAIN_LOCK_TOKEN: lockOwner,
|
||||
}).map(e => e.name).sort();
|
||||
expect(names).toEqual(["DB_PASS", "DIR_KEY", "GITHUB_PAT", "MYSQL_PWD", "SLASH_KEY", "SSL_KEY", "STRIPE_APIKEY"]);
|
||||
expect(scan(`b64 ${slashSecret}`, { processEnv: { SLASH_KEY: slashSecret } }).findings.map(f => f.id)).toEqual(["env.value"]);
|
||||
const lock = fs.readFileSync(path.join(import.meta.dir, "..", "bin", "gstack-brain-lock.sh"), "utf8");
|
||||
for (const name of NON_SECRET_ENV_NAMES) expect(lock, name).toContain(`export ${name}`);
|
||||
const fullwidth = anthropicValue.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 0xfee0));
|
||||
expect(scan(`leak: ${anthropicValue} end`, { processEnv: { WIDE_KEY: fullwidth } }).findings.map(f => f.id)).toEqual(["env.value"]);
|
||||
});
|
||||
|
||||
test("the default environment is process.env", () => {
|
||||
|
||||
Reference in new issue
Block a user