Skip to content

security(ci): the Kubescape guard's conditional screen is a second shell reader, and it keeps drifting from the first #3579

Description

@devantler

🤖 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 conditional bash -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.

Activity

  1. moved this to 📥 Backlog in 🌊 Project Boardon Sep 4, 2026
  2. added a commit that references this issue on Sep 4, 2026
    123f307
  3. moved this from 📥 Backlog to 👀 In Review in 🌊 Project Boardon Sep 4, 2026
  4. added 3 commits that reference this issue on Sep 4, 2026
    d629894
    4a330bb
    c7718e5
  5. devantler commented on Sep 24, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer (interactive Claude Code session)

    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 branch claude/spike-kubescape-parser-3564 (4e0c98a, based on main at 35031dbb). It meets this issue's first criterion: scanCandidate no 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 283 run: scalars in the platform's workflows is unchanged, both unconditional and conditional.

    Still needed before it merges:

    • Tests for what the parse tree now carries: fix(ci): the Kubescape guard accepts a reduced scan in a command substitution or behind && … || 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 lets bash <<'EOF' or … | xargs ksail through in a conditional step); a --frame\+newline+work split in a conditional step; ${{ … }} inside run:; and the IP=$(…) and ARGS=( … ) acceptances.
    • This issue's own criterion that one shared fixture table drives both paths.
    • A decision on collapseQuotedNewlines and compoundToken, which only their own unit tests still call.
    • Documenting two policies in the guard: an unconditional 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

  6. self-assigned this
    on Oct 3, 2026
  7. added 2 commits that reference this issue on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Fields

No fields configured for Refactor.

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions