Repository navigation
security(ci): the Kubescape guard's conditional screen is a second shell reader, and it keeps drifting from the first #3579
Description
🤖 Generated by the Agentic Engineer
Evidence
scanCandidate (the screen for conditional steps) and the unconditional path read the same shell with two different, independently-maintained readers. Every bypass found in the Kubescape scan guard this week has been the same defect: the screen reads the shell less faithfully than the path it is supposed to mirror. Five of them, all on #3546, each found only after the previous one was fixed:
| # | Bypass | The asymmetry |
|---|---|---|
| 1 | env ksail workload scan … |
prefix form recognised by the unconditional path, not the screen |
| 2 | bash -c '<scan>', eval '<scan>' |
undecidableShellString applied on one path only |
| 3 | line ending in an even backslash run | screen used strings.HasSuffix(…, "\\"); the other path uses continuesLine parity |
| 4 | k\ + sail workload scan … |
screen rejoined with a space; bash inserts nothing |
| 5 | true;ksail workload scan … |
screen's splitter kept true;ksail as one word |
Four of the five were found by review, not by us, and each round found another spelling. That is the signature of enumerating a blacklist rather than closing a class: no round has converged, and there is no reason to think #5 is the last.
The problem
The screen is deliberately loose — its own doc says a false positive costs a diagnosable refusal while a false negative reopens the hole — and that is the right disposition. But "loose" was implemented as a second reader of the same language, and a second reader drifts from the first one token at a time. Every fix above closed exactly one spelling and left the asymmetry that produced it.
The security consequence is unchanged across all five: a conditional step running a reduced scan that overwrites the SARIF while an unconditional nsa,mitre invocation satisfies the validator.
What to do
Stop maintaining two readers. The screen should reach its verdict from the same tokenisation and line-joining the unconditional path already performs, and keep its looseness in the decision (what counts as a candidate) rather than in the parsing. Concretely: have scanCandidate consume the unconditional parser's view of the scalar and then apply its own permissive membership test, so a spelling the unconditional path can read is never invisible to the screen.
Acceptance criteria:
- The screen performs no shell parsing of its own — no private line-joining, no private tokenisation.
- All five regression cases above still refuse, unmodified.
- The existing controls still accept: quoted prose (
echo 'ksail workload scan …'), a conditionalbash -c 'echo hello', and an odd-backslash continuation. - A property test or a shared fixture list drives both paths from one table, so a spelling added for one is automatically asserted against the other — this is the part that stops Enable GitHub OAuth for Grafana, and disable CloudFlare OAuth and Grafana NoAuth #6 existing.
Why this is filed rather than fixed in #3546
#3546 is closing the five known spellings and is nearly done; folding a parser unification into it would mix a behaviour-preserving restructure with five behaviour changes in one diff, against this repo's own rule. This is the follow-up that makes the sixth spelling impossible instead of waiting for review to find it.
Metadata
Metadata
Assignees
Labels
Type
Fields
Projects
- StatusShow more project fields✅ Done
Activity
fix(ci): refuse conditional steps the scan guard cannot prove scan-free
fix(ci): judge the command word, and stop the inversion over-refusing
fix(ci): judge every command position, keep the key within one comman…
fix(ci): treat an execution wrapper's operand as a command position
devantler commentedon Sep 24, 2026
Spike #3564 settled how to do this: back the guard with
mvdan.cc/sh/v3/syntax, so the unconditional path and the conditional screen read the same parse tree. A prototype is on branchclaude/spike-kubescape-parser-3564(4e0c98a, based onmainat35031dbb). It meets this issue's first criterion:scanCandidateno longer lexes the shell itself. It is evidence to start from, not ready to merge.It changes behaviour, so it lands as a fix: it closes the three spellings in #4163, and accepts one false refusal it no longer makes (
IP=$(kubectl …)in the DR rebuild summary step). Every other verdict on the 283run:scalars in the platform's workflows is unchanged, both unconditional and conditional.Still needed before it merges:
&& … || true#4163's three spellings; a!-negated scan (refused today, but no test pins it); the conditional screen's heredoc re-parse and its per-line and per-statement grouping (removing either letsbash <<'EOF'or… | xargs ksailthrough in a conditional step); a--frame\+newline+worksplit in a conditional step;${{ … }}insiderun:; and theIP=$(…)andARGS=( … )acceptances.collapseQuotedNewlinesandcompoundToken, which only their own unit tests still call.run:scalar that fails to parse is refused (0 of today's 283 do), and${{ … }}is replaced by${GITHUB_EXPRESSION}before parsing.Success signal from the Spike: no new shell-reading findings in the first two review rounds of the delivery PR. #4164 builds on this.
Generated by Claude Code
chore: agent-claim #3579 nonce=9202c5ad1f1934dd2e0ee688a4dd4385
chore: agent-claim #3579 nonce=f976ceb566028d09d210b2f2d36f4696