mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.94.1.0 fix(redact): a lookup call's file-name or key argument is not a secret (GST-1064) (#3893)
* fix(redact): a name-shaped literal inside a call is not a secret (GST-1064) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-1064: passphrases passed to a call still report; only file names and short snake_case keys are names) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: only a call's first argument is a lookup name (GST-1064: getenv defaults and fallbacks are still checked; file names need a known extension) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: only a lookup call's first argument is a name (GST-1064: make_password, hashpw, grouping parens and later statements still report) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: env.kv's lookup-call first-argument exemption (GST-1064 docs audit) 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>
This commit is contained in:
1 parent
4ec1722ecb
commit
466689bbbf
8 files changed
+86
-4
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [1.94.1.0] - 2026-11-03
|
||||
|
||||
### Fixed
|
||||
- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. The first argument of a lookup call (`get`, `getenv`, `read`, `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name or a snake_case key of two or three words now counts as a name. Any other call still reports a password-shaped literal (`make_password("orange_monkey_pass")`, `bcrypt.hashpw(...)`), and so do a later argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`), a real key in quotes or in a `.env` line, and a kebab passphrase (`decrypt("prod-signing-key")`). The entropy bar is unchanged.
|
||||
|
||||
## [1.94.0.0] - 2026-11-03
|
||||
|
||||
- CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: <label> carried run=<id>/<attempt>` and is recorded as an explicit CI-sourced evidence row (`gstack-evidence record-ci`), never as a local run. Dirty trees, fork runs, stale runs and a newer red or running run never carry.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.94.0.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.94.1.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -1137,6 +1137,13 @@ those, each with its scope and its reason:
|
||||
end the outer string (GST-738). The pre-push hook sees only added lines,
|
||||
so it scans Python and Dart lines without this exemption and may still warn
|
||||
(MEDIUM, non-blocking) on them.
|
||||
- The first argument of a lookup call (`get`, `getenv`, `read`, `load`,
|
||||
`loads`, `open`, `pop`, `fetch`) that is a lowercase file name
|
||||
(`files.get("key.json")`) or a snake_case key of two or three words
|
||||
(`resp.json().get("access_token")`) is a name, not a credential (GST-1064).
|
||||
Any other call's literal (`make_password("orange_monkey_pass")`), a later
|
||||
argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`) and a
|
||||
kebab passphrase still report.
|
||||
|
||||
The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted
|
||||
finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line
|
||||
|
||||
+15
-1
@@ -661,13 +661,27 @@ const SCHEMA_FIELD_VALUE = /^[a-z][a-z0-9]*(?:[_.-][a-z][a-z0-9]*)+$/;
|
||||
function isSchemaFieldIdentifier(span: string, match: RegExpExecArray): boolean {
|
||||
return SCHEMA_FIELD_NAME.test(match[0]) && SCHEMA_FIELD_VALUE.test(span);
|
||||
}
|
||||
/**
|
||||
* GST-1064: the first argument of a lookup call (`get`, `getenv`, `read`,
|
||||
* `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name
|
||||
* (`files.get("key.json")`) or a snake_case key of two or three words
|
||||
* (`resp.json().get("access_token")`) names what the call reads. Eight distinct
|
||||
* letters already reach 3.0 bits, so entropy alone cannot tell it from a
|
||||
* secret. Any other callee (`make_password("orange_monkey_pass")`), a grouping
|
||||
* paren, a later argument or a fallback (`os.getenv("X", "orange_monkey_pass")`)
|
||||
* is a value and is still checked, and so is a first argument with a digit, an
|
||||
* uppercase letter, kebab words or four or more snake_case words.
|
||||
*/
|
||||
const LOOKUP_ARGUMENT =
|
||||
/(?<![\w$])(get|getenv|read|load|loads|open|pop|fetch)\([ \t]*(["'])(?:[a-z]+(?:_[a-z]+){1,2}|[a-z]+(?:[_-][a-z]+)*\.(?:json|ya?ml|toml|ini|cfg|conf|env|pem|crt|cer|key|txt|csv|xml|cookie))\2/g;
|
||||
function isCallExpression(span: string, match: RegExpExecArray): boolean {
|
||||
if (!CALL_SHAPED_VALUE.test(span)) return false;
|
||||
const { start } = spanBounds(match);
|
||||
if (match.input[start - 1] === '"' || match.input[start - 1] === "'") return false;
|
||||
const call = match.input.slice(start + span.indexOf("(")).split("\n", 1)[0];
|
||||
if (!call.includes(")") && call.trim() !== "(") return false;
|
||||
return !carriesSecretLiteral(call);
|
||||
const line = match.input.slice(start).split("\n", 1)[0];
|
||||
return !carriesSecretLiteral(line.replace(LOOKUP_ARGUMENT, "$1("));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.94.0",
|
||||
"version": "1.94.1",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -54,6 +54,15 @@ describe('pregate secrets: source context and diff integrity', () => {
|
||||
expect([...parseAddedLines(diff)]).toEqual([['a.ts', ['one', '++two']], ['b.env', ['three']]]);
|
||||
});
|
||||
|
||||
test('a Python call that reads key.json is code; the same key name holding a literal key still fails (GST-1064)', () => {
|
||||
expect(repo({ 'attach.py': 'import json\nkey = json.loads(files.get("key.json", ""))\n' }).secrets().status).toBe('pass');
|
||||
const fake = 'Zx9Q' + 'w8Er7Ty6Ui5Op4As3Df2';
|
||||
const r = repo({ 'attach.py': `key = json.loads(files.get("key.json")) or "${fake}"\n`, '.env': `KEY=${fake}\n` }).secrets();
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.lines.join('\n')).toContain('attach.py');
|
||||
expect(r.lines.join('\n')).toContain('.env');
|
||||
});
|
||||
|
||||
test('a TS object property that reads a credential at runtime is code, not a secret (#3211)', () => {
|
||||
const f = repo({ 'app.ts': 'const cfg = {\n token: credentials.token,\n apiKey: settings.apiKey,\n};\n' });
|
||||
expect(f.secrets().status).toBe('pass');
|
||||
|
||||
@@ -106,6 +106,53 @@ describe("env.kv: a function call assigned to a credential name is code (#2899)"
|
||||
);
|
||||
});
|
||||
|
||||
describe("env.kv: a name-shaped literal inside a call is a file or dict key, not a secret (GST-1064)", () => {
|
||||
const FAKE_KEY = "Zx9Qw8Er7Ty6Ui5Op4As3Df2";
|
||||
const FAKE_STRIPE_TAIL = "4eC39HqLyjWDarjtT1zdp7dc";
|
||||
table(
|
||||
"env.kv",
|
||||
"MEDIUM",
|
||||
[
|
||||
String.raw`key = json.loads(files.get("key.json", ""))`,
|
||||
String.raw`token = resp.json().get("access_token")`,
|
||||
String.raw` session = store.load('session.cookie')`,
|
||||
String.raw`secret = vault.read("client_secret")`,
|
||||
],
|
||||
[
|
||||
String.raw`key = "${FAKE_KEY}"`,
|
||||
String.raw`KEY=${FAKE_KEY}`,
|
||||
String.raw`key = json.loads("sk_live_${FAKE_STRIPE_TAIL}")`,
|
||||
String.raw`token = resp.json().get("access_token", "${FAKE_KEY}")`,
|
||||
String.raw`password = os.getenv("DB_PASSWORD", "hunter2.Zx9Qw8")`,
|
||||
String.raw`key = json.loads(files.get("key.json")) or "${FAKE_KEY}"`,
|
||||
String.raw`password = os.getenv("DB_PASSWORD", "correct-horse-battery-staple")`,
|
||||
String.raw`api_key = decrypt("prod-signing-key")`,
|
||||
String.raw`token = decrypt("correct_horse_battery_staple")`,
|
||||
String.raw`key = decrypt("qwhzxkvbnmplor")`,
|
||||
String.raw`SECRET_KEY = os.getenv("SECRET_KEY", "orange_monkey_pass")`,
|
||||
String.raw`password = os.environ.get("DB_PASSWORD", "purple_walrus")`,
|
||||
String.raw`token = resp.json().get("access_token", "orange_monkey_pass")`,
|
||||
String.raw`key = derive_secret("tigers.horse")`,
|
||||
String.raw`password = os.getenv("DB_PASSWORD", "qwhzxkvbnmplor.txt")`,
|
||||
String.raw`password = make_password("orange_monkey_pass")`,
|
||||
String.raw`password = bcrypt.hashpw("orange_monkey_pass", salt)`,
|
||||
String.raw`token = jwt.encode("purple_walrus_pie", payload)`,
|
||||
String.raw`api_key = settings.Secret("correct_horse_battery")`,
|
||||
String.raw`password = hash_password("purple_walrus")`,
|
||||
String.raw`password=hash_password("purple_walrus")`,
|
||||
String.raw`+password = hash_password( "purple_walrus" )`,
|
||||
String.raw`password = hash_password("purple_walrus").encode()`,
|
||||
String.raw`password = hash_password("purple_walrus") # seed user`,
|
||||
String.raw`password = config.get("db_password") or ("orange_monkey_pass")`,
|
||||
String.raw`password = config.get("db_password", ("orange_monkey_pass"))`,
|
||||
String.raw`password = login(user, ("purple_walrus"))`,
|
||||
String.raw`password = load_cfg("app.yaml"); login("purple_walrus")`,
|
||||
String.raw`password = login(user, str("purple_walrus"))`,
|
||||
String.raw`password = encrypt(str("orange_monkey_pass"))`,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => {
|
||||
table(
|
||||
"internal.hostname",
|
||||
|
||||
Reference in new issue
Block a user