fix(security): gate the quadratic sensitive-path scan behind a linear pre-filter - #8349
fix(security): gate the quadratic sensitive-path scan behind a linear pre-filter#8349chenmingwei23 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix with a verdict-neutral pre-filter proven by differential tests, drift-proofed by single-source construction, alternatives measured and rejected on evidence. [DESIGN-REVIEWED] 501439c |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of Verification complete. All counted claims check out: First-Principles-Verdict: PASS A reported crash, chased to its cause, fixed with the module's own existing device, verdict-neutrality pinned by differential tests — every item earns its place. What this change shipsIntent: stop long clean commands from wedging the event loop until the stall watchdog kills the gateway. FIX.
No undeclared items; nothing moved, relabeled, or defaulted differently. Consumer counts: Subtractions
[FIRST-PRINCIPLES-REVIEWED] 501439c |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've independently verified the load-bearing properties of this change:
Both call sites (pass 1, pass 1b) were converted consistently; other No findings. [OPUS-REVIEWED] 501439c Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/security.py:7487 -- “needs to strip exactly this prefix” contradicts the implementation, which preserves it on eleven tails -> Fix: say the pre-filter keeps this prefix. (origin: validation) False positive or not applicable? A repository writer can comment: |
`is_sensitive_bash_command()` was quadratic in command length and runs synchronously on the event loop, so a long cron-built command wedged the loop past the stall watchdog and the gateway dump-then-exited every couple of hours. On a clean command -- the common case, and the only one that runs to completion, since a match returns early -- 16.03s at 37KB and 65.11s at 75KB, 4x per doubling. Any `//` in the command tripled it again, because pass 1b re-scans one collapsed variant per separator spelling and a URL's doubled slash produces three. Two complementary changes, neither able to move a verdict: 1. Branch (1)'s redirect alternative was `.*[<>|]\s*`. That leading `.*` is redundant under `.search`, which already retries at every offset, and it was the dominant quadratic term -- unlike the three verb alternatives beside it, which only enter where a verb matches, it entered at every offset unconditionally. Removed; verified match-set identical. 2. Pass 1 and pass 1b now run behind a cheap superset pre-filter, the device this module already uses for credential redaction. It is the pattern's own branch list with exactly ONE substitution: branch (1) replaced by its bare tail. Both patterns come from one `_build_sensitive_patterns()` call and are cached together, so the pre-filter cannot describe a stale pattern or a stale `Path.home()`. The pre-filter keeps the token anchor on its eleven anchored tails. Dropping it would also have been a sound superset but made the PRE-FILTER quadratic: the Windows tails contain `win_gsep`, a starred group, and a 10KB UNC-style backslash run backtracked for 25.4s -- introducing the very crash this prevents, on a different subject. Found by the GPT review lane. 37KB 16.03s -> 0.385s, 75KB 65.11s -> 0.752s, 10KB backslash run 25.4s -> 0.024s, all linear. Also deletes `_build_sensitive_regex`, a wrapper with no `src/` consumers, per review. Fixes #8338
f829eb4 to
501439c
Compare
|
GPT's blocking finding was correct, reproduced, and is fixed in Reproduced. Dropping
So the severity call was right too: 25.4s is past Applied the proposed remedy as given: keep It also simplifies the superset argument, which is worth stating because the previous one was doing more work than it needed to. The pre-filter is now the pattern's own branch list with exactly ONE substitution: branch (1), Re-verified the superset over 46 cases plus every separator-collapsed variant of each, using the real builder output rather than a reconstructed pattern. Five Windows spellings that were only in my scratch corpus are now in the in-repo one: the home-anchored New guard: Two other review items in the same push: Design Review's residual (a subject the pre-filter admits but the pattern rejects still ran the quadratic scan) is closed by removing the redundant Unrelated red: |
|
Closing as superseded by #8282, which was opened about 2.5 hours before I filed #8338 and which I did not find because I searched existing issues but not open PRs. #8282 covers strictly more of this defect:
The one mechanism unique to this PR is the superset pre-filter, and with the verb-anchored branch restructured the quadratic term it was gating is gone, so it would be an optimisation on an already-linear path rather than a fix. The findings worth keeping are posted on #8282 instead: a measured lead that its 20 KB ceiling may still admit a loop-wedging subject (an unanchored path search over a backslash run measured 91s at 19 KB in a model of that code path), a regression guard for that shape, and the case for linting this defect class repo-wide. Issue #8338 is closed as a duplicate. No hard feelings toward the work -- the review lanes on this branch were genuinely useful: GPT caught that my pre-filter had itself introduced a 25s stall on a backslash run, which is exactly the lead now sitting on #8282. |
1. What is the problem?
is_sensitive_bash_command()is quadratic in command length, and it runssynchronously on the asyncio event loop.
All four alternatives of the sensitive-path matcher's verb/redirect-anchored
branch carried a leading
.*(_READ_CMDS.*,_WRITE_CMDS.*,_SCRIPT_OPEN.*,.*[<>|]\s*). The eleven verb-independent branches had theirsremoved for exactly this reason -- the comment there still says "Do NOT
reintroduce
.*here" -- but this branch was not touched, so the engine retriedfrom every start offset. Measured on
main, on a clean command (the commoncase, and the only one that runs to completion, since a match returns early):
is_sensitive_bash_command4x per doubling across eight points. Profiled,
re.searchis 94% of the gate and99.5% of that is this one matcher, so the tokenizing passes are not involved.
A second, independent multiplier compounds it. Pass 1b re-scans one
separator-collapsed variant per spelling, and
_SEPARATOR_RUN_REmatches anydoubled separator -- including the
//in an ordinary URL. Same command, samelength, single slash instead of double: 4.58s against 16.06s at 37 KB.
2. Why this issue matters to the user
The gate sits on the always-enforced deny path in
_resolve_permission, so it isevaluated on every tool call on every surface, and it is evaluated inline on the
event loop. A single oversized command therefore does not fail one tool call -- it
freezes the whole gateway, and then kills it.
LoopStallWatchdogarmsfaulthandler.dump_traceback_later(..., exit=True), so a slow-but-finite regexbecomes a process exit and a systemd restart:
LOOP_STALL_EXIT_AFTER_DEFAULT(25s) at about 45 KB of commandLOOP_STALL_EXIT_AFTER_MANAGED_DEFAULT(90s) at about 87 KBReported as a gateway restarting roughly every two hours, the cadence matching two
cron jobs whose agent turns emit long
curlpipelines with multi-KB JSON bodies --i.e. commands that are both long and full of URLs, which is the worst case for both
multipliers at once. Every restart kills the in-flight cron turn, and any dashboard
or Slack session live at the time freezes for the watchdog budget and then dies with
the process.
Command text is agent-authored under the influence of untrusted external content,
so this is reachable deliberately and not only by accident.
3. How our fix solves it
Chaining from the symptom back to the root cause:
gateway exits every ~2h <- watchdog sees the loop silent for its whole budget
<- the gate holds the loop for 60s+ inside one call <- the matcher is retried
from every start offset <- branch (1) carries a leading
.\*-- and, on top ofthat, the same scan is paid four times because a URL's
//produces threecollapsed variants.
Two complementary changes, because they bound different cases and neither alone is
enough.
(a) Remove the redundant
.*from the redirect alternative (.*[<>|]\s*->[<>|]\s*). This is the same redundancy the earlier round removed from the othereleven branches, and the same argument: the pattern is only ever used via
.search, which already retries at every offset, so.*[<>|]\s*Pmatches asubject exactly when
[<>|]\s*Pdoes -- a match of the former from any offsetimplies the metacharacter sits at some offset the search also visits.
This one was the dominant term. Unlike the three verb alternatives beside it,
which only enter where a verb actually matches, it entered at every offset
unconditionally. The three verb alternatives keep their
.*, since there it spansa meaningful gap between the verb and the path; they cost O(verb occurrences x n)
and measure negligible even on a subject that is nothing but
catinvocations(0.015s at 53 KB).
(b) Run pass 1 and pass 1b behind a cheap superset pre-filter, the device this
module already uses for credential redaction (
_might_contain_credential) and withthe same contract: it may match where the pattern would not, but it must never fail
to match where the pattern would, so only the cost can move, never the verdict.
The pre-filter is not a hand-written literal list. It is the pattern's own branch
list, assembled in the same function from the same locals, with exactly one
substitution: branch (1),
verb_or_redirect_anchor + sensitive_path, is replaced bya bare
sensitive_path. Dropping a required prefix can only admit moresubjects, so that alternative matches wherever branch (1) did. Every other
alternative -- the eleven token-anchored tails and the two anchor-free ones -- is
passed through character for character, so it matches exactly where the pattern's
own branch does. No case is left for the pre-filter to miss.
The token anchor is deliberately kept on those eleven, and that detail is
load-bearing. Dropping it would also have been a sound superset by the same
"required prefix" argument -- and the first version of this PR did drop it -- but it
lets the engine enter the WINDOWS tails at every offset, and those tails contain
win_gsep, a starred group. A UNC-style backslash run then backtracks through it:25.4s is past the 25s watchdog, so that version had introduced the very crash this
PR exists to prevent, on a different subject -- the same DoS family already recorded
on
win_gsepitself, where admitting a separator run into the patterns measured 33son 6,000 backslashes. Found by the GPT review lane; the anchored form is cheaper on
every other subject measured too, so nothing is traded for it.
Both patterns are returned from one
_build_sensitive_patterns()call and cachedtogether by
_build_sensitive_cache(), so the pre-filter cannot come to describe astale pattern or a stale
Path.home()._names_fenced_path()is the single placethe two are paired and is now the only caller of
_get_sensitive_re(), so no callsite can reach the expensive scan without the linear check in front of it. The
pre-filter is applied to each collapsed subject rather than inferred from the
original, since collapsing rewrites the text.
Why both are needed: (b) removes the common case -- a command naming no fenced
path is not scanned even once, which is what the reported crash was made of -- while
(a) bounds the worst case. A subject the pre-filter admits but the pattern
rejects reaches the scan by design (a fenced path glued to a name character, with no
verb or redirect anywhere:
abc~/.aws/credentials), and with the.*still presentthat path was itself 4x per doubling. Design Review raised exactly this residual;
(a) closes it.
Results, all linear now:
The pre-filter's own cost is 0.021s at 75 KB. Beyond about 49 KB an adversarial
near-miss is refused outright by the pre-existing traversal-analysis budget in pass
4 (
"requires more traversal analysis than this gate performs (4096 units)"), whichis fail-closed -- so the window is bounded at both ends.
One thing I deliberately did not do: moving the gate off the event loop
looks like the obvious fix and is not a sufficient one. Measured with a 50 ms async
heartbeat against the real gate on the 37 KB subject: inline, the worst heartbeat gap
is 16.21s; via
asyncio.to_thread, 4.08s. CPython'sreholds the GIL for longstretches, so a thread hop is a ~4x mitigation that moves the crash to a larger
command rather than removing it. Worth doing as defence in depth, separately.
4. What tests we did
New tests live in
test/test_security_regex_linearity.pyrather than a new file,because that file already covers the earlier round of this same defect -- the change
that removed
.*from the other eleven branches. This is that defect one branchover, so it belongs in the same differential.
test_prefilter_never_misses_what_the_pattern_matches-- the pre-filter's safetycontract over a 25-case corpus spanning every branch family: POSIX and
Windows-native spellings, the
%APPDATA%/%LOCALAPPDATA%/!APPDATA!aliases, the home-anchored
AppData\Roamingform the alias branch does notcover, the variable-leaf
%USERPROFILE%\.kiro\crew\%F%shape, a quotedC:\Usersleaf, a UNC anchor,
$KIRO_HOMEand%KIRO_HOME%, both anchor-free bare leaves,an upper-case spelling, and the two shapes only branch (1) matches. Those two
are what prove replacing branch (1) with its bare tail is safe.
test_a_backslash_run_does_not_blow_up_the_prefilter-- the pre-filter runs onevery command, so it has to be safe on hostile input, which the first version of
it was not. A 10 KB backslash run through the real gate, bounded absolutely (2s
against a 0.024s fixed path) rather than by a doubling ratio, following the
existing
test_long_nonshell_line_does_not_blow_upbeside it. The structural half-- that the eleven anchors are still present at all -- is asserted in the anchor
test, so dropping them again fails loudly instead of showing up as a crash dump.
test_removing_the_redirect_wildcard_is_match_set_identical-- the differentialfor change (a). It reconstructs the pre-change pattern by putting the
.*backand asserts the two agree on every case in both corpora plus negatives, rather
than asking the reader to trust the argument.
test_redirect_glued_paths_are_still_blocked-- all six shapes that alternativeexists for and no other branch matches:
>~/path,>>,2>,<~/path,|~/path, and a pipedtee.test_gate_verdicts_unchanged_by_the_prefilter-- the corpus end to end throughthe real gate.
test_clean_command_does_not_reach_the_quadratic_patternandtest_a_url_no_longer_multiplies_the_expensive_scan-- the cost fix, asserted bycounting scans of the expensive pattern via a proxy: exactly 0 where it used to
be 4. Deliberately not a wall-clock ratio: ReDoS doubling-ratio test asserts linearity the matcher does not have: is_denied is O(length x occurrences) #3080, flaky: ReDoS linearity test asserts a wall-clock ratio and fails on shared runners #4108, Flaky: ReDoS doubling-ratio test fails on unrelated frontend PRs (3.13x and 3.31x against a 3.0 bound) #3938 and Windows shard 2: the ReDoS doubling-ratio assertion is perturbed by in-process CPU bursts (1 in 145) #2811 are
four separate flakes caused by timing assertions on this very module, and a count
is exact on every runner.
test_a_fenced_command_still_reaches_the_pattern-- the pre-filter admits a realcandidate instead of deciding for itself.
test_prefilter_is_built_from_the_same_home_as_the_pattern-- withPath.home()patched to a home under neither
Usersnorhome, so only the resolved literalcan match; this is the case where a pre-filter built from a second reading of the
constants would disagree.
test_sensitive_anchor_has_no_leading_wildcardrewritten: it asserted asource-text count of the anchor spelling, which this change necessarily breaks
because the anchor is now written once and applied to a tuple. It now asserts
against the compiled patterns -- no
.*anchor, the anchor present exactly 11times in the pattern and 11 times in the pre-filter, 14 pre-filter
alternatives, the pre-filter free of
.*, and the redirect alternative presentbut without its
.*. Strictly stronger than what it replaced.Gate: 2523 passed / 1 skipped across
test_security_regex_linearity,test_security,test_hooks,test_mcp_cron_security,test_governance_self_protection,test_security_alt_traversal,test_llm_helpers_tool_input_offload,test_connections_tool_aliases,test_exfil_gate_opt_out; and 2290 passed across all 20 test files thatexercise this gate.
flake8,isort,blackandmypyclean on the changedfiles. (
blackreportssecurity.pyas needing reformatting on pristinemaintoo in my environment -- a target-version mismatch, verified as a pre-existing
baseline; its diff does not touch any region this PR changes.)
Separately from the test suite, the superset property was re-verified over 46 cases
plus every separator-collapsed variant of each, driven from the real builder output
rather than a reconstructed pattern.
The spec moved with the code:
docs/system-specs/modules/security.mdrecords bothchanges, the measurements, the URL multiplier, the residual and its bound, and the
verdict-neutrality guarantee.
5. Any other suggestions on the work
Three review items are already applied in this branch rather than deferred, because
two of them were load-bearing:
run. Reproduced at 25.4s on 10 KB, fixed by keeping the token anchor on the
eleven anchored tails, as the finding proposed. Measurements above.
open. Correct, and closed by removing branch (1)'s redundant
.*-- which turnedout to be the better half of the fix, since it bounds the worst case rather than
just the common one.
_build_sensitive_regextogo, since it had no
src/consumers. Deleted; its one caller now uses_build_sensitive_patterns()[0].Two follow-ups remain, neither blocking:
is_sensitive_path,is_sensitive_bash_commandandis_deniedas synchronousscanners in this chain and closed after moving only the
tool_inputscan to athread;
_resolve_permissionstill calls all three inline, a few lines above itsown
await asyncio.to_thread(...). Given the GIL measurement above this isdefence in depth rather than a fix, but the asymmetry inside one function is
worth closing.
inside the permission gate specifically, cancelling the offending task (denying
the tool call) preserves the loop; dump-then-exit is the right last resort for a
genuinely dead loop, not for a slow one.
A note on the original report, since it is quoted in the issue: it diagnosed
"catastrophic backtracking" and expected commands of a few KB to cross the 90s
budget. The blowup is quadratic, not exponential, and a few KB is 0.03-0.2s --
the real threshold is tens of KB. That distinction is why this PR removes a
redundant quantifier and gates the rest, rather than reaching for a linear-time
engine or a timeout.
Pattern harvest
Rule candidate: lint over
re.compilepattern sources undersrc/Pattern: an alternative that begins with
.*in a pattern only ever used via.search.searchalready retries at every start offset, so a leading.*on analternative matches nothing extra while making the scan quadratic in the longest
line. It is a one-token mistake with no upside and a gateway-killing downside.
This is generalizable, and the evidence that a comment is not enough is this
module's own history: the earlier round removed this construct from eleven branches
and left the warning "Do NOT reintroduce
.*here" directly above the twelfthinstance, which survived that round and is what this PR removes. Same file, same
defect, second time.
The check needs no execution: for a compiled pattern reached only through
.search, flag any top-level alternative whose source starts with.*.test_sensitive_anchor_has_no_leading_wildcardnow asserts exactly this on thecompiled patterns for this one matcher -- promoting it to a repo-wide lint over
every
re.compileinsrc/is the harvest, and would have caught this instancewithout anyone measuring a crash first.
Fixes #8338