fix(security): narrow the env-dump-to-grep aws deny to a real dump - #8251
Conversation
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified against the repo: the shared-pattern mechanism, consumer counts, and the remaining First-Principles-Verdict: CONCERNS The PR's own root cause — independently authored tier twins that drift — survives unfixed for the echo intent, where the untoggleable tier is already the weaker one. What this change shipsIntent: stop the AWS env-dump deny refusing commands that cannot print a credential — a FIX.
WatchThe description's stated cause — "the two tiers had independently authored, unanchored regexes" — still holds for the echo-a-secret-variable intent: 3 catalog rules ( Subtractions
[FIRST-PRINCIPLES-REVIEWED] 810a3b1 |
Design Review (Fable 5) — ✅ PASSDesign-level review of The diff, description, and tests are all consistent: the narrowing is anchored on attacker-invariant properties (word-bounded dump verb, credential-printing selector), the quoted-separator bypass class and the Design-Verdict: PASS Root cause fixed at the right layer: one shared, anchored regex per intent across both tiers, with the over-block/bypass trade argued and pinned by test. [DESIGN-REVIEWED] 810a3b1 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've validated the single candidate against the diff and the surrounding regex engine. Assessment of Candidate 1 (env-dump-to-file-then-grep no longer denied): The claim is factually correct that the old pattern
(c) "observable wrong outcome" therefore fails the 80+ bar: what changed is the disappearance of a trivially-bypassable accidental match, not a genuine regression in an enforced boundary. The candidate's own confidence is "low" and its Fix clause concedes it is intended design. It does not survive falsification. No other grounded defect surfaced under Step 2: the shared-rule wiring routes both tiers through No findings. [OPUS-REVIEWED] 810a3b1 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
b0924fd to
20b1029
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
ce6284e to
a646792
Compare
|
/ai-review override fable a646792: intentional design — the partial-prefix grep ( |
Human judgment recorded@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
/ai-review override gpt a646792: intentional design — the |
|
AI-review override not recorded: keep the reason to 500 characters or fewer. |
|
/ai-review override gpt a646792: intentional design — the shell-expansion/redirection exfil path was never matched by the env-dump-to-grep rule (old or new); it is covered by the unchanged keystone |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
a646792 to
3468f41
Compare
|
/ai-review override fable 3468f41: intentional design — the deny selector is deliberately the bare AWS/AWS_ prefix plus the SECRET|SESSION|SECURITY|ACCESS names, not an allowlist inversion; a partial or unknown AWS_* name is not denied because secret values are caught by the entropy/AKIA/ASIA output redactor. Same diff as the already-overridden a646792, rebased onto main after #8317. |
|
/ai-review override gpt 3468f41: intentional design — the shell-expansion/redirection exfil path was never matched by the env-dump-to-grep rule (old or new); it is covered by the unchanged keystone |
Human judgment recorded@bolichen97 marked the fable AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
3468f41 to
9729cff
Compare
|
AI-review override not recorded: |
9729cff to
a8f51b5
Compare
`.*env.*grep.*AWS.*` and `.*printenv.*AWS.*` matched on substrings, so `grep -rn AWS_REGION src/environment/`, `pyenv | grep AWS_SECRET` and `printenv AWS_REGION` were refused as credential exfiltration. None of them can print a secret. Both rules now require the dump verb to be a whole word and the selected name to be one whose selection prints a credential (the bare `AWS`/`AWS_` prefix, a secret-bearing word, or a truncation of one that ends the operand, since grep selects by substring). The narrowing stops there: the gaps between the dump, the pipe, the filter and the selector are plain `.*`, because a statement-scoped span has to treat `;` and `&` as separators and a regex cannot tell one from the identical character inside a quoted argument -- `env | sed 's/;/x/' | grep AWS_SECRET` is an ordinary dump that such a span would allow. `environ` and `typeset` are named in the verb list for the same fail-closed reason: the old substring match caught `/proc/<pid>/environ` and `typeset` only by accident, and both are real dumps. The always-on keystone shares one regex per intent with the catalog rule AND evaluates it through the same `_deny_matcher`, so the tier that cannot be switched off can be neither weaker nor slower: it applies no length cap, and an ordered-existence pattern under a backtracking engine costs seconds on a few thousand characters where the linear fragment matcher costs milliseconds.
4de22c9 to
810a3b1
Compare
Owner ruling: the narrowing stands, and the blocking finding is fixable without giving it upThe repository owner has decided the narrowing should land. The GPT lane's remedy — "revert both narrowed patterns until equivalent shell forms are denied" — is declined, because reverting reinstates the false positives this PR exists to remove. The lane's blocking finding is nevertheless partly right, and there is a fix that satisfies both. Measured against Now allowed that Now allowed that For the record on the rest of the lane's framing: The fixRequire a pipe or a redirect between the dump verb and the filter — one character in - + r".*\|.*"
+ + r".*[|>].*"Verified over 20 cases against the shipped pattern, zero mismatches. It keeps all three intended allowances, recovers both denials Its cost is an over-block: a redirect of an environment dump earlier in a line makes a later unrelated Please add a test per recovered form, keep the golden fixture in step, and re-run the two The two non-blocking findings
|
Problem / Motivation
The env-dump-to-grep AWS deny fires on commands that never touch a credential. Its regex —
.*env.*grep.*AWS.*on the catalog rule and(?:env|printenv|export\s+-p|set)\s*(?:\|.*)?(?:grep|awk|sed)\s+.*AWS_on the always-on keystone — matches unanchored substrings anywhere in the command text. Observed refusals ("Blocked: command reads AWS credentials from environment variables"):grep -rn AWS_REGION src/environment/—envinsideenvironmentpyenv | grep AWS_SECRET,virtualenv versions | grep AWS_SECRET,dotenv | grep AWS_SECRETAWS_REGION,AWS_PROFILEorAWS_SDK_LOAD_CONFIGprintenv AWS_REGION,printenv AWS_ROLE_ARNThe keystone tier is not toggleable and runs before the rule tiers, so disabling the catalog rule from Settings → Security does not clear the block; it only changes which tier refuses.
Why it matters
An agent investigating anything AWS-adjacent (a sandbox launcher's env contract, a CDK stack, region config) hits this several times per session and has to reword commands around a phantom threat. The refusal text also tells the agent it read credentials when it did not, which derails the investigation.
What changed
Symptom → the two tiers had independently authored, unanchored regexes → one regex per intent, shared by both tiers, evaluated on the same matcher:
_ENV_DUMP_GREP_AWS_PATTERNfor the piped form and_PRINTENV_AWS_SECRET_PATTERNfor the direct one. Neither can drift, and the tier that cannot be switched off can no longer end up the weaker of the two — it was: the keystone's hand-written printenv regex covered three full names while the catalog covered every secret-bearing prefix.The narrowing is two anchors, and only two, chosen because they are the two an attacker cannot rewrite around:
Selector. The bare
AWS/AWS_prefix, a secret-bearing word (SECRET,SESSION,SECURITY,ACCESS), or a truncation of one that ends the operand.grepmatches by substring, so selectingAWS_SprintsAWS_SECRET_ACCESS_KEY's value exactly as selecting the whole word does; the truncations are derived from_AWS_SECRET_WORDS, not listed. Requiring the operand to END at the truncation is what keepsAWS_SDK_LOAD_CONFIG,AWS_SHARED_CREDENTIALS_FILEandAWS_STS_REGIONAL_ENDPOINTSallowed. The boundary classes admit digits, so selectingAWS1— which no secret-bearing name contains — is allowed.printenvdiverges here on purpose: it resolves EXACT names, soprintenv AWS_Sprints nothing and only whole words are denied.Command word. The verb must both begin and end a word, so
unset,offset,pyenv,dotenv,src/environmentandsettings.pypiped into a grep are not dumps. A.or/before it is deliberately allowed —/usr/bin/env,/bin/printenvand/proc/self/environare the same dumps under a path — and a quoted or substituted command word ('env',$(which env)) is still the dump. Two spellings are therefore named in the verb list rather than left to luck:/proc/<pid>/environis the process environment under a path, andtypesetwith no operand prints every variable with its value. The old substring matcher caught both only by accident (environcontainsenv,typesetcontainsset), so bounding the verb without naming them drops two real dumps.The narrowing stops there. The gaps between the dump, the pipe, the filter and the selector are plain
.*— ordered existence within one line, with no statement or pipeline-stage scoping. A statement-scoped span has to treat;and&as separators, and a regex cannot tell a separator from the identical character inside a quoted argument:env | sed 's/;/x/' | grep AWS_SECRET_ACCESS_KEY,env | grep -E 'a&b|AWS_SECRET'andenv FOO='a;b' | grep AWS_SECRETare ordinary credential dumps whose only unusual feature is a quoted separator, and a span that stops there fails open on all three. Guessing the other way costs an over-block instead, which is the direction this rule has to fail. A|between the dump and the filter is still required, which is what keepsenvas a wrapper (env FOO=1 cmd),set -e; grep AWS_ file.txtandcat .env; grep AWS_ config.pyout.Cost is shared, not just the regex text. The keystone tier applies no length cap, and an ordered-existence pattern under Python's backtracking engine is superlinear in the number of candidate pipes and filter words — measured at 5,088 ms on 2,200 characters through a raw
re.search, against 0.19 ms through the linear fragment matcher the catalog tier already uses. So_check_env_credential_accessevaluates the two shared rules through the same_deny_matcher, and no compiled duplicate is left in the raw list behind it. Measured after the change: 0.19 ms / 1.41 ms / 11.36 ms at 2.2 KB / 17.6 KB / 140.8 KB — linear, and faster than main's keystone (1,011 ms at 5.2 KB).The keystone names the two catalog RULES, not a second copy of their patterns.
_ENV_CRED_SHARED_RULE_IDSholds the two ids and_ENV_CRED_SHARED_RULESresolves them fromBUILTIN_DENIED_RULES— never from the user's effective set, so opting the catalog rule out does not retire the always-on block. Resolution is eager and without a default, so a renamed id fails loudly at import rather than silently shrinking the tuple and retiring the block. That is what makes "one regex per intent" structural: there is no parallel pattern constant here that could be edited alone.Deliberately out of scope: a dump REDIRECTED to a file and read back with no pipe (
env > f, then a grep off). Correlating the sink with the reader needs a backreference the RE2-style engine these built-ins are authored for does not have, and blocking only thegrepspelling would be no control at all —awk,sedand a plaincatof the same file read it just as well and were already unmatched on main.redact_credentials(AKIA/ASIA plus high-entropy detection) is what stands between that shape and a chat surface.Measured before/after
Every number below comes from running the corpus through the real matchers on both tiers, with the
origin/mainpattern strings and the previous revision's pattern strings restored for their columns.The 14 shapes the previous revision opened are the two blocking findings' shapes: eight quoted-separator pipelines and four
/proc/<pid>/environreads, plustypeset | grep AWS_SECRETandtypeset | grep AWS_.The residual over-block is what refusing to guess at statement boundaries costs, and it is pinned by test (
RESIDUAL_OVER_BLOCK) rather than left to a comment: a later statement's filter is attributed to the dump (env | head -5; grep -r AWS_ src/,env | wc -l && grep AWS_SECRET f), a later pipeline stage's text is read as the filter's operand (env | grep PATH | echo AWS_SECRET), andenvas another tool's subcommand counts as a dump (conda env list | grep aws). Every one of those was refused on main as well. Anchoring the verb to a command position would reclaim the last one and would also dropsudo -E /usr/bin/env | grep AWS_SECRET, since any wrapper prefix defeats that anchor — so it is not one.Disposition of the review findings
Opus 4.8, BLOCKING on
a8f51b5b— "narrowingcredential-exfil-env-grep-awsdrops the/proc/<pid>/environenv-dump spelling with no compensating rule." Confirmed and fixed. Reproduced against the real matchers:strings /proc/self/environ | grep AWS_SECRET,cat /proc/self/environ | tr '\0' '\n' | grep AWS_SECRET,tr '\0' '\n' < /proc/self/environ | grep AWS_SECRETandxargs -0 -n1 < /proc/1234/environ | grep AWS_SECRETwere all denied on main (the old pattern matched via theenvsubstring insideenviron) and all allowed ata8f51b5b, on both tiers.environis now a named dump verb, so all four are denied again on both tiers and are in the parametrized denied set. Taken as the alternation half of the lane's own suggested fix rather than a new rule: the shape is a dump piped into a selecting filter, which is exactly what this rule already expresses, and a second rule would be a second thing to keep in step with the keystone.grep -a AWS_SECRET /proc/self/environ— filter before dump, no pipe — was allowed on main too and stays allowed; it is the same redirect-shaped residual named above, not a regression.GPT 5.6, BLOCKING on
a8f51b5b— "quote-blind spans reopen credential exfiltration:env | sed 's/;/x/' | grep AWS_SECRET_ACCESS_KEY. Fix: make separators quote-aware or revert the narrowing hunk." Confirmed and fixed, and the class is wider than the one shape cited. Reproduced against the real matchers: eight shapes were denied on main and allowed ata8f51b5b, on both tiers — the citedsed 's/;/x/', plusenv | grep -E 'a;b|AWS_SECRET',env | grep -E 'a&b|AWS_SECRET',env | awk -F';' '{print}' | grep AWS_,env | tr ';' '\n' | grep AWS_SECRET,env | sed "s/&/x/" | grep AWS_SECRET,env -u 'A;B' | grep AWS_SECRETandenv FOO='a;b' | grep AWS_SECRET. The last two show the guard was not repairable in place: a;inside the DUMP's own argument list defeats it just as one inside an intermediate stage does, so every span that excluded a separator character was bypassable, not only the one named. "Quote-aware" is not expressible in a regex without counting quote state, so the fix is the other branch of the lane's own suggestion applied to the unsound part only: the three spans are gone and the gaps are plain.*. The two anchors that carry the actual narrowing — the word-bounded verb and the credential-printing selector — are untouched, so the 23 measured false positives stay fixed while all eight shapes are denied again. What it costs is the five statement-scoped over-blocks in the table above, each of which main refused too.GPT 5.6, FINDING (earlier head) — "
(?![A-Za-z_])treats digits as boundaries." Fixed: both boundary classes are[A-Za-z0-9_], pinned bytest_selector_boundaries_admit_digitsasserting on the constant so the two cannot drift.AWS1andAWS_1are allowed; no secret-bearing name contains either substring.GPT 5.6, FINDING (earlier head) — "
[^;&\n]*attributes later pipeline text to the filter." Moot: there is no filter-argument span left to attribute anything.env | grep PATH | echo AWS_SECRETis now pinned as a deliberate over-block instead, for the reason above — the span that would exempt it is the same span that exempted eight real dumps.First Principles Review, CONCERNS subtraction — "delete the keystone's hand-written direct-printenv regex and reuse
_PRINTENV_AWS_SECRET_PATTERN." Taken. The two had already diverged in the dangerous direction — the always-on tier covered three full names while the disableable one covered every secret-bearing prefix — so the keystone now reads the shared constants, andtest_catalog_rule_and_keystone_share_one_regexpins both pairs.First Principles Review, remaining watch — the echo-of-secret family has the same two-tier drift (keystone lacks
ACCESS), and ~60 other unanchored.*-joined rules remain. Accepted and deferred, as the lane itself frames them. Both are separate rule families with their own regressions to measure, and the discipline these two rules needed is exactly what makes folding in a third unwise here. They are named in the pattern harvest so the next pass has the technique and the hazard written down.Tests
test/test_security.py::TestEnvDumpGrepAwsNarrowing, asserting on the production_DenyMatcher, the keystone andis_deniedrather than onre.search:test_catalog_rule_and_keystone_share_one_regex— each rule's pattern is the shared constant, each id is in_ENV_CRED_SHARED_RULE_IDS, and the resolved tuple is the same length as the id tuple, so a rename cannot quietly drop one.test_keystone_tier_evaluates_the_shared_rules_on_the_deny_matcher— the always-on tier routes through_deny_matcher, and no compiled duplicate remains in the raw list to reintroduce the superlinear cost behind the shared one. Pinned as shape, not as a duration.test_catalog_rule_is_published_not_silently_disabled×2 — not_disabled, not_bounded, more than one fragment,is_safe_user_regextrue. A rule the matcher silently disabled is otherwise indistinguishable from one that was narrowed, and the fragment count is what makes both tiers linear.test_credential_dumps_are_denied_on_both_tiers×53 — adds the eight quoted-separator pipelines, the four/proc/<pid>/environreads and the twotypesetdumps alongside the absolute-path, quoted and substituted dump spellings, the truncated selectors, the|&and2>&1pipeline spellings and the quoted filter words.test_the_residual_over_block_is_pinned_not_assumed×6 — the over-block is asserted, so reclaiming it later has to argue with the quoted-separator dumps rather than delete a comment.test_every_truncation_of_a_secret_word_is_denied— parametrized over every prefix DERIVED from_AWS_SECRET_WORDS, so adding a word extends the pinned set automatically;test_a_non_secret_initial_is_not_a_truncationis its complement.test_benign_commands_pass_both_tiers×29 — the reported false positives plusAWS_SDK_LOAD_CONFIG,AWS_SHARED_CREDENTIALS_FILE,AWS_STS_REGIONAL_ENDPOINTS, the digit-terminated selectors,ls src/environment,pyenv,dotenv,offset,envused as a wrapper, andset -e; grep AWS_ file.txtso the pipe requirement cannot be dropped silently.test_printenv_of_a_secret_is_denied/test_printenv_of_a_non_secret_passes— assert the keystone too, since it shares the regex;printenv AWS_Sis pinned as allowed becauseprintenvresolves exact names.test_full_gate_denies/test_full_gate_allows— every case throughis_denied, so a benign shape one rule stops refusing while a sibling still refuses it fails the suite.Local:
test_security.py,test_denied_commands_security.py,test_security_posture.py,test_recovery_card_parity.py— 2196 passed / 1 skipped;test_hooks.py,test_fire_tool_hooks.py,test_script_hooks.py,test_credential_scrub_sync.py,test_review_fixes.py— 305 passed. Full suite once at-n 4 --dist loadgroup: 84,744 passed / 71 failed, every failure in this host's documented environmental classes (no git push credentials, noghauth, no user-namespace sandbox, no docker) and none in a module this diff touches. black (touched files only;security.py's baseline entry left intact), isort, flake8,mypy --platform linux(1,282 files clean), docs-lint, brand and harness-parity gates clean.scrub-lintfails ontest/test_atomic_write_named_duplicates.py, which is not in this diff and fails identically onorigin/main.Dependency Auditfails withnpm audit timed out after 120s for website/package-lock.jsonon every attempt so far, with no JavaScript in this diff; PRs #7669 and #8355 red the same job in the same window. An npm-registry timeout, re-run rather than fixed.CodeQLflagged 2 highpy/clear-text-logging-sensitive-dataalerts on the first push of this revision, both landing on_DenyMatcher's two pre-existinglogger.warning(..., pattern)lines. Those lines log a deny REGEX, never a credential value, and only for a pattern the matcher refuses — which a built-in never is. The alerts were newly attributed because passing a_AWS_SECRET_*-named module constant straight into_deny_matchergave the query a flow it could follow end to end. Resolving through the catalog rule instead — the same call shapeis_deniedhas always used — clears them without a suppression comment, and is the better spelling anyway for the reason above.Manual verification
N/A — unit coverage sufficient: the change is two regexes, the constants they are built from, and which matcher the always-on tier calls. The tests exercise the production matcher and gate entry point rather than
re.search.Related Issues
Follow-up to the deny-rule toggle work in #7705 (this keystone was one of the always-on layers the toggle could not reach). No linked issue: the defect was reported in-session as repeated refusals, not filed.
Pattern harvest
Rule candidate: review-prompt
Pattern: a deny regex may only be narrowed on properties an attacker cannot rewrite, and shell quoting makes every separator character such a property. Excluding
;or&from a span reads like "stop at the statement boundary" and actually means "stop wherever the attacker puts that character in quotes" —env | sed 's/;/x/' | grep AWS_SECRET,env | grep -E 'a&b|AWS_SECRET'andenv FOO='a;b' | grep AWS_SECRETare three ordinary dumps that walk through, and the guard cannot be repaired in place because quote state is not regex-expressible. When the choice is between an over-block and a bypass, a deny rule takes the over-block and pins it by test. Four corollaries, each an actual defect in this PR's own revisions:/proc/<pid>/environandtypesetare real environment dumps that containenvandset. Enumerate what the loose pattern was covering by luck before tightening it, or the tightening is a coverage cut wearing a precision costume.re.search. Share the RULE — one tier naming the other's rule id has no parallel constant to drift from, and it keeps the cost decision in one place too.*is refused by_redos_prone, and a refused built-in is disabled, not rejected — the rule silently stops matching. Any deny-pattern edit needs a published-not-disabled assertion, because that failure has no other symptom.The same two-tier drift and unanchored-substring causes remain in the echo-of-secret family and ~60 sibling rules; the technique above is what a follow-up should apply to them.
Checklist