diff --git a/CHANGELOG.d/1087-actions-transport-only-poll-bound.md b/CHANGELOG.d/1087-actions-transport-only-poll-bound.md new file mode 100644 index 00000000..c5a093cf --- /dev/null +++ b/CHANGELOG.d/1087-actions-transport-only-poll-bound.md @@ -0,0 +1,3 @@ +# Security + +- Add the packaged `github-actions-transport-only-poll-bound` detector for GitHub Actions review/control-plane polling loops whose retry budget covers only transport failures while the successful-no-result path can wait indefinitely. The regression corpus pins the verified `ContextualWisdomLab/.github` vulnerable predecessor `5c561a65cca3b925d533e4b40c5c3ac00f16524e` and protected wall-clock repair `e29302c05eade7da7b0bdbb453e53980bc9d577b`. Historical-name safety suppression requires the compared deadline/attempt state to be causally initialized before the same loop, but the safety variable names themselves are identifier-agnostic; unset or late state remains vulnerable, while an unconditional successful-path termination before the sleep/back edge is finite within the reviewed shell grammar. The companion `github-actions-transport-failure-budget-poll-bound` detects the same causal failure when shell retry identifiers are renamed by requiring the failure counter, retry limit, failing `gh api` branch, increment, threshold exit, and repeatable healthy-transport sleep path to be linked in one reviewed loop. `github-actions-poll-bound-state-reset` retains a HIGH finding when an apparent deadline/total-attempt bound is neutralized by a reviewed state mutation that prevents convergence. Safety evidence is loop-local: a bounded helper loop cannot donate a deadline/attempt bound to a later poll. Negative boundaries also cover explicit same-job timeouts, stable monotonic total bounds, comments/quoted commands, split steps, sibling jobs, and non-workflow paths. Shell-error termination depends on the selected GitHub Actions shell and fail-fast flags; the withdrawn `break 0` companion is not retained because a default Linux Actions shell uses fail-fast Bash semantics, so a shell-agnostic HIGH rule would create blocker-class false positives. Explicit non-errexit invalid-break behavior remains an unclaimed boundary until shell selection/state is modeled. See issue #1087. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b9757a95..03e6bb2b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,7 +1,7 @@ # AppGuardrail Requirements, Detection, and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-09-03 | Requirement / security class | Detector/control boundary | Evidence maturity | |---|---|---| @@ -19,6 +19,7 @@ | every retained issue claim mapped to executable detector obligation | issue-detection audit | PR #911 active-PR | | authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | +| GitHub Actions transport-only polling bound | `github-actions-transport-only-poll-bound`, identifier-agnostic `github-actions-transport-failure-budget-poll-bound`, mutable-safety companion `github-actions-poll-bound-state-reset`, unreachable-exit companion `github-actions-poll-bound-unreachable-exit` | Issue #1087 / active PR #1088; verified causal owner wall-clock repair is protected `ContextualWisdomLab/.github@e29302c05eade7da7b0bdbb453e53980bc9d577b`; stronger event-driven runner-release owner work remains Proposed in `.github` PR #1706 | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | ## Promotion rules @@ -47,10 +48,28 @@ For stored webhook/callback SSRF, trace separately: Current protected-branch evidence keeps those controls distinct: PR #924 supplies the fail-closed webhook storage boundary, and PR #910 supplies the packaged `python-stored-ssrf-webhook-url` detector plus focused regression corpus. Neither control expands the detector beyond its declared source/sink and flow contract. +## GitHub Actions polling-bound traceability contract + +Issue #1087 records a verified control-plane availability defect from `ContextualWisdomLab/.github`. At vulnerable protected predecessor `5c561a65cca3b925d533e4b40c5c3ac00f16524e`, the required OpenCode verdict step used a transport-failure counter around `gh api` calls but had no total bound for the path where every API call succeeded and no verdict appeared. Protected repair `e29302c05eade7da7b0bdbb453e53980bc9d577b` adds a 10,800-second wall-clock deadline checked on every loop iteration and fails closed when it expires. `.github` PR #1706 separately owns the stronger Proposed event-driven/one-shot runner-release architecture; its open writer branch and temporary source-fix machinery are not protected production evidence. + +The AppGuardrail obligation is the reusable causal pattern, not the workflow, issue title, or historical shell identifier spelling. `github-actions-transport-only-poll-bound` now requires the historical `max_poll_transport_failures` budget to participate in the same executable transport-failure data flow: a zeroed failure counter is incremented on the failing `gh api` branch, compared with that positive budget, and reaches a nonzero threshold exit. A merely declared setting is not transport-bound evidence. Safety semantics remain identifier-agnostic: a deadline or total-attempt guard suppresses only when the exact captured state is initialized before that same loop and consumed by the same fail-closed in-loop guard. `github-actions-transport-failure-budget-poll-bound` closes the identifier-renaming false-negative boundary by capturing arbitrary shell identifiers when the same counter/limit/failure/increment/threshold/exit relationship is present while the healthy-transport/no-result path can still sleep and repeat. The companion excludes the historical `max_poll_transport_failures` form so one incident is not emitted twice. + +Syntactically present safety state is not automatically a real bound. `github-actions-poll-bound-state-reset` retains HIGH evidence only for reviewed non-convergent mutations such as refreshing a deadline from the current clock, resetting a total-attempt counter, or growing a total limit alongside its counter. State mutations that tighten a deadline or otherwise move monotonically toward termination are safe lookalikes and must remain negative. `github-actions-poll-bound-unreachable-exit` separately preserves HIGH evidence when an apparent total deadline or total-attempt guard contains an unconditional `continue` before the nonzero fail-closed exit, so the textual exit is unreachable and cannot establish finiteness. Its reviewed positive path is confined to the same conventional literal-shell polling loop and does not fire when the owning job has a positive `timeout-minutes` or when the fail-closed exit is directly reachable. + +The temporary `github-actions-poll-invalid-break-zero` companion is not retained. A current-head review established that GitHub's default Linux Actions shell is fail-fast Bash, so a shell-agnostic HIGH rule for `break 0` creates a blocker-class false positive: the invalid command can terminate the step instead of reaching the loop back edge. Explicit non-errexit invalid-break behavior is an unclaimed false-negative boundary until the detector models the selected Actions shell and fail-fast state. Removing an unsound detector is not evidence that the main #1087 control-flow family covers that shell-specific behavior. + +The family is deliberately bounded to conventional two-space GitHub Actions job syntax and literal block shell steps. Evidence split across jobs, across run steps, or after the loop is negative; quoted `gh api`/`sleep` strings and shell comments are not executable evidence. Safety evidence is local to the same candidate job and loop: an explicit positive timeout on that job is a bound, and a loop deadline/attempt guard is a bound only when its state is initialized before the loop, remains convergent for the loop lifetime, is reachable on the relevant path, and the same state is consumed by an in-loop nonzero-exit guard. Uninitialized variables, state first assigned after the guard, and a fail-closed exit made unreachable by an earlier unconditional control transfer do not establish safety. A bounded helper loop cannot donate deadline/attempt evidence to a later vulnerable poll. A comparison that only logs without terminating is not a safety guard, and unrelated numeric variables are not a transport-failure budget unless the failure branch links the counter, limit, increment, threshold, and nonzero exit. + +The large regular-expression rules use only cheap `while` / `sleep` prefilters. Executable command evidence remains governed by the detector grammar `gh[ \t]+api`, so repeated spaces or a tab between `gh` and `api` cannot disappear behind a literal `gh api` fast-path filter. Explicit bounded adjacency windows remain declared false-negative boundaries rather than implied structural coverage. + +Regression evidence lives in the production `_scan_file` suites for poll bounds, renamed aliases, late initialization, current-review causal safety, mixed safety identifiers, command whitespace, mutable bound state, unreachable fail-closed exits, the causal historical-budget negative/positive pair introduced in `df4ff1c3f724764b1d047b2cb95ef491c096356c`, and the pinned answer-free source fixtures under `tests/fixtures/security_corpus/github_actions_transport_only_poll_{vulnerable,fixed}.yml`. Production repair `a467678c7e3b4bedca8092eec28072fdd0aae90a` enforces the historical budget data flow, and `5d87c36251391e5f0254eb915ca7f3068a22b21c` keeps sibling-job and post-loop-safety scope regressions positive under that causal contract. Exact-head workflow success remains a separate promotion requirement; queued or startup-failed runs are not source GREEN. + +Current false-negative boundaries include cross-file/composite-action polling, non-shell control flow, dynamically generated workflows, quoted/nonstandard job-key syntax, noncanonical YAML indentation, declaration/control-flow or multiple-helper shapes outside the reviewed companion grammar, materially different loop/retry frameworks, shell-error behavior whose fail-fast state is not modeled, and relationships beyond bounded adjacency windows. Those limits constitute a concrete Gap for a future structural GitHub Actions + shell control-flow/state analyzer; PR #1088 does not claim that analyzer exists. The detector family remains active-PR evidence until its unchanged exact head passes required checks and ordinary protected integration; the already-protected `.github` wall-clock repair is prevention/control-plane evidence only and does not itself satisfy AppGuardrail scanner coverage. + ## Standards/research Existing repository docs/doctoring/security evidence remain the bibliography/source-of-truth for standards such as SARIF, CycloneDX, GitHub security interfaces, and applicable OWASP/CWE classes. Material new detector classes should add authoritative standard/CWE/OWASP references and APA 7 citations in doctoring where research/standards materially drive implementation. ## Change rule -Every new issue-class detector or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. \ No newline at end of file +Every new issue-class detector or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..1f0dd7c0 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,48 @@ +# Product / Technical Gap Baseline + +**Status:** Active commercial-readiness baseline +**Last reviewed:** 2026-09-03 + +This document records buyer-visible and control-plane gaps that are supported by current AppGuardrail code, tests, review evidence, and canonical-owner boundaries. It is not a substitute for live pull-request checks or protected-branch evidence. + +## Product boundary + +AppGuardrail owns reusable static-analysis and SARIF evidence. It may detect unsafe GitHub Actions control flow, but `.github` remains the canonical owner of organization-wide CI, review, runner, security, and release behavior. AppGuardrail must not copy or mutate that owner source to make a leaf detector pass. + +The current GitHub Actions polling family covers a bounded conventional grammar: two-space Actions jobs, literal shell `run` blocks, executable `gh api` polling, transport-failure budgets, total deadline/attempt guards, selected state-reset patterns, and reviewed unreachable fail-closed exits. It does not claim general shell control-flow or data-flow analysis. + +## Current implemented evidence + +Issue #1087 is backed by the protected `.github` vulnerable predecessor `5c561a65cca3b925d533e4b40c5c3ac00f16524e` and protected wall-clock repair `e29302c05eade7da7b0bdbb453e53980bc9d577b`. + +PR #1088 carries four HIGH/CWE-400 detector identities: + +- `github-actions-transport-only-poll-bound` for the historical transport-budget incident shape; +- `github-actions-transport-failure-budget-poll-bound` for renamed failure-counter/limit shapes; +- `github-actions-poll-bound-state-reset` for reviewed non-convergent mutation of apparent total bounds; +- `github-actions-poll-bound-unreachable-exit` for reviewed guards whose fail-closed exit is unreachable. + +The historical detector now requires causal transport-budget evidence rather than a matching setting name alone. Test-first commit `df4ff1c3f724764b1d047b2cb95ef491c096356c` pins both the negative unused-setting case and the positive counter-flow case. Production commit `a467678c7e3b4bedca8092eec28072fdd0aae90a` requires a zeroed counter, failing executable `gh api` path, counter increment, comparison with `max_poll_transport_failures`, and nonzero threshold exit in the same reviewed polling flow. Commit `5d87c36251391e5f0254eb915ca7f3068a22b21c` repairs older scope/timeout fixtures so positive historical tests use that same causal incident contract instead of relying on the configuration name alone. + +Current review exposed two additional precision contracts, both pinned first as production `_scan_file` regressions in `d50f49ccea1cbf2aecc6da268850fddfc80db3b6`: a strict `-gt` total deadline/attempt guard is still finite, and a statically positive owning-job `timeout-minutes: ${{ 20 }}` expression is still a hard bound. Zero, negative, and dynamic timeout expressions remain unproved and must not suppress a finding. These contracts are RED until production matching semantics are repaired and the unchanged head obtains exact-head test evidence. + +## Commercial and technical gaps + +| Gap | Current evidence | Acceptance | +|---|---|---| +| Strict finite-bound comparator precision | Current polling safety exclusions recognize `-ge` but review identified false HIGH findings for equivalent finite `-gt` deadline/attempt guards. RED regressions in `d50f49ccea1cbf2aecc6da268850fddfc80db3b6` cover historical and identifier-agnostic transport forms plus both total-attempt declaration orders. | Recognize forward `-gt` as well as `-ge` for the same causally initialized wall-clock and total-attempt state, without accepting reversed or non-expiring comparisons. Keep all existing positive vulnerable regressions. | +| Static positive timeout-expression precision | Current job-local timeout exclusions recognize positive integer literals but not the statically positive Actions expression `${{ 20 }}`. RED regressions in `d50f49ccea1cbf2aecc6da268850fddfc80db3b6` cover both primary transport detectors, state-reset, and unreachable-exit companions; zero, negative, and dynamic expressions remain positive-vulnerability controls. | All four polling detectors suppress only for an owning-job timeout that is provably positive at scan time, including a positive integer constant expression. Do not suppress zero, negative, empty, dynamic/unprovable expressions or sibling-job timeouts. | +| General GitHub Actions + shell control-flow analysis | The detector family deliberately uses bounded regex grammar and adjacency windows. Composite actions, generated workflows, non-shell control flow, cross-file state, noncanonical YAML structures, and materially different loop frameworks are explicit false-negative boundaries. | Introduce a structural Actions/shell analysis layer only with executable regression migration oracles, bounded performance evidence, and no regression in current detector identities. Do not claim universal shell semantics from more regex. | +| Exact-head security execution | PR #1088 has repeatedly produced CodeQL `startup_failure` runs with no materialized source job. Test-contract head `d50f49ccea1cbf2aecc6da268850fddfc80db3b6` reproduced this as CodeQL run `33682495071` with `jobs=[]`. | Canonical `.github` owner restores real job materialization/runner admission and the unchanged candidate obtains terminal source-analysis evidence. Startup failure, zero-job runs, predecessor success, or leaf no-op retriggers are not GREEN. | +| Protected integration | Production/test changes invalidate predecessor checks. AppGuardrail review threads remain evidence obligations until current-head tests and required controls are terminal GREEN. | Ordinary protected merge only after all live findings are reconciled, exact-head tests/security/SAST/code-scanning requirements pass, qualifying independent review is satisfied, and base ancestry remains valid. | +| Immutable release evidence | An active PR is not a released detector contract. | After protected integration, publish version/CHANGELOG/tag/package plus SBOM, provenance, reproducibility and rollback evidence through the repository's canonical release path before consumers treat this detector family as immutable release evidence. | + +## Architecture decision + +For #1087, retain the existing lightweight detector family as a bounded product capability and use its production `_scan_file` regressions as migration oracles. Do not expand regex until it can prove the next concrete buyer/security case without blocker-class false positives. If shell ownership, nested conditional pairing, composite-action flow, or cross-file state becomes material, move that responsibility into a structural Actions/shell analyzer with explicit AST/control-flow/data-flow semantics rather than accumulating regex that implies unsupported completeness. + +The choice preserves the current low-cost scanner, keeps `.github` prevention authority separate from AppGuardrail detection authority, and makes the remaining coverage boundary visible to buyers and maintainers instead of hiding it behind broad security claims. + +## Release gate + +This baseline does not mark PR #1088 merge-ready or release-ready. Every source or documentation commit requires fresh exact-head verification. A current PR head, required checks, reviews, branch protection state, release state, and canonical-owner runner evidence must be read live before promotion. diff --git a/scanner/rules/github_actions.yml b/scanner/rules/github_actions.yml index 643f8f23..9194d7a2 100644 --- a/scanner/rules/github_actions.yml +++ b/scanner/rules/github_actions.yml @@ -31,3 +31,42 @@ rules: include: - ".github/workflows/*.yml" - ".github/workflows/*.yaml" + + # A transport-failure counter is not a total polling bound. The verified + # ContextualWisdomLab/.github incident kept every API request successful while + # no review verdict arrived, so the loop never incremented its transport + # failure counter and retained a shared runner for hours. Keep the lightweight + # detector intentionally bounded to conventional two-space Actions job syntax: + # all positive evidence must execute inside one literal-shell run block and + # gh/sleep must occur before that same unbounded loop's `done`. The historical + # rule also requires the configured transport budget to be wired to a zeroed + # failure counter, failure-path increment, threshold comparison, and nonzero + # exit; a merely declared retry setting is not executable safety evidence. + # Same-job timeout and causally initialized fail-closed deadline/attempt guards + # are safe boundaries regardless of safety-variable spelling. Uninitialized + # or post-guard state is not safety evidence, and a healthy path that + # unconditionally breaks/exits before the back edge is finite. + - id: github-actions-transport-only-poll-bound + patterns: + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ timeout-minutes[ \t]*:|^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?=(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ max_poll_transport_failures)^ {10,}[^\n]*\n)){0,40}^ max_poll_transport_failures[ \t]*=[ \t]*[1-9][0-9]*[^\n]*\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )if[ \t]+![^\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12}(?! )if[ \t]+![^\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b[^\n]*\n(?>(?:(?!^ {14}(?! )(?P=hist_transport_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_transport_counter)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {14,}[^\n]*\n)){0,20}^ {14}(?! )(?P=hist_transport_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_transport_counter)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {14}(?! )if[^\n]*\$(?P=hist_transport_counter)[^\n]*-ge[^\n]*\$max_poll_transport_failures\b[^\n]*;[ \t]*then|^ done\b)^ {14,}[^\n]*\n)){0,12}^ {14}(?! )if[^\n]*\$(?P=hist_transport_counter)[^\n]*-ge[^\n]*\$max_poll_transport_failures\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {16}(?! )exit[ \t]+[1-9][0-9]*\b)^ {16,}[^\n]*\n)){0,8}^ {16}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?>(?:(?!^ done\b)^ {12,}[^\n]*\n)){0,80}^ done\b)(?>(?:(?!^ max_poll_transport_failures)^ {10,}[^\n]*\n)){0,80}^ max_poll_transport_failures[ \t]*=[ \t]*[1-9][0-9]*[^\n]*\n(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\))^ {10,}[^\n]*\n)){0,80}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-[gl]e[^\n]*\$(?P=hist_deadline_any)\b[^\n]*;[ \t]*then)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-[gl]e[^\n]*\$(?P=hist_deadline_any)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=hist_counter_a)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_counter_a)[ \t]*\+[ \t]*1[ \t]*\)\))^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=hist_counter_a)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_counter_a)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=hist_counter_a)[^\n]*-ge[^\n]*\$(?P=hist_limit_a)\b[^\n]*;[ \t]*then)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=hist_counter_a)[^\n]*-ge[^\n]*\$(?P=hist_limit_a)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=hist_counter_b)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_counter_b)[ \t]*\+[ \t]*1[ \t]*\)\))^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=hist_counter_b)[ \t]*=[ \t]*\$\(\([ \t]*(?P=hist_counter_b)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=hist_counter_b)[^\n]*-ge[^\n]*\$(?P=hist_limit_b)\b[^\n]*;[ \t]*then)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=hist_counter_b)[^\n]*-ge[^\n]*\$(?P=hist_limit_b)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,80}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b))^ {12,}[^\n]*\n)){0,80}^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n(?!(?>^ {12}[^\n]*\n){0,80}^ {12}(?:break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+0)?)[ \t]*(?:#[^\n]*)?$\n)(?>(?:(?!^ {12}(?! )sleep(?:[ \t]+|$))^ {12,}[^\n]*\n)){0,80}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?>(?:(?!^ done\b)^ {12,}[^\n]*\n)){0,80}^ done\b' + prefilter: [while, sleep] + message: | + This GitHub Actions job has a remote polling loop whose explicit retry + limit covers transport failures but not the successful-no-result path. + If the API stays reachable while the awaited verdict/state never appears, + the job can retain a runner until an external platform limit, degrading + availability of required review/security controls. Add a total wall-clock + deadline or finite total-attempt guard checked on every polling path and + fail closed. An explicit timeout anywhere on this same job can also bound + the runner. A timeout/counter in a sibling job, command-like text in + another run step, an echoed command string, or a shell comment is not + evidence for this loop. Keep per-request transport timeouts as defense in + depth; they do not replace the total polling bound. + severity: HIGH + languages: [generic] + cwe: [CWE-400] + owasp: [A04:2021] + paths: + include: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" diff --git a/scanner/rules/github_actions_poll_bound_state_reset.yml b/scanner/rules/github_actions_poll_bound_state_reset.yml new file mode 100644 index 00000000..f0d894b4 --- /dev/null +++ b/scanner/rules/github_actions_poll_bound_state_reset.yml @@ -0,0 +1,29 @@ +rules: + # A total poll bound is only safety evidence while its state remains monotonic. + # This detector models three reviewed non-convergent mutations and excludes a + # separately monotonic deadline/attempt bound in the same polling loop. + - id: github-actions-poll-bound-state-reset + patterns: + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ timeout-minutes[ \t]*:|^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[^\n]*\n(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=ind_d)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?=(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n)(?=(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n)(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=ind_c)[ \t]*=[ \t]*\$\(\([ \t]*(?P=ind_c)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=ind_c)\b[^\n]*-ge[^\n]*\$(?P=ind_l)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=dline)[ \t]*=[ \t]*(?:\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)|\$\(\([ \t]*(?P=dline)[ \t]*\+[ \t]*[1-9][0-9]*[ \t]*\)\))[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=dline)\b[^\n]*;[ \t]*then[^\n]*\n(?:^ {14,}(?!continue\b|break\b|return\b|exit\b)[^\n]*\n){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?:(?:if[ \t]+![ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[^#\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)[ \t]*(?:#[^\n]*)?\n)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,180}?^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ timeout-minutes[ \t]*:|^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=ind_d2)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?!(?P=creset)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=sclc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=scll)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=sclc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=sclc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=sclc)\b[^\n]*-ge[^\n]*\$(?P=scll)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?!(?P=creset)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=slcc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=slcl)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=slcc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=slcc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=slcc)\b[^\n]*-ge[^\n]*\$(?P=slcl)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,140}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=creset)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=creset)[ \t]*=[ \t]*\$\(\([ \t]*(?P=creset)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )if[^\n]*\$(?P=creset)\b[^\n]*-ge[^\n]*\$[A-Za-z_][A-Za-z0-9_]*\b[^\n]*;[ \t]*then[^\n]*\n(?:^ {14,}(?!continue\b|break\b|return\b|exit\b)[^\n]*\n){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?:(?:if[ \t]+![ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[^#\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)[ \t]*(?:#[^\n]*)?\n)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,180}?^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ timeout-minutes[ \t]*:|^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=gindd_cl)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?!(?P=gcounter)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gicl_clc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gicl_cll)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=gicl_clc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gicl_clc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=gicl_clc)\b[^\n]*-ge[^\n]*\$(?P=gicl_cll)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?!(?P=gcounter)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gilc_clc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gilc_cll)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=gilc_clc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gilc_clc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=gilc_clc)\b[^\n]*-ge[^\n]*\$(?P=gilc_cll)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=gcounter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gcounter)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=glimit)[ \t]*=[ \t]*\$\(\([ \t]*(?P=glimit)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )if[^\n]*\$(?P=gcounter)\b[^\n]*-ge[^\n]*\$(?P=glimit)\b[^\n]*;[ \t]*then[^\n]*\n(?:^ {14,}(?!continue\b|break\b|return\b|exit\b)[^\n]*\n){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?:(?:if[ \t]+![ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[^#\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)[ \t]*(?:#[^\n]*)?\n)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,180}?^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ timeout-minutes[ \t]*:|^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=gindd_lc)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?!(?P=gcounter)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gicl_lcc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gicl_lcl)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=gicl_lcc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gicl_lcc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=gicl_lcc)\b[^\n]*-ge[^\n]*\$(?P=gicl_lcl)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?!(?P=gcounter)\b)(?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gilc_lcc)[ \t]*=[ \t]*0\b)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?P=gilc_lcl)[ \t]*=)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,80}?^ {12}(?! )(?P=gilc_lcc)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gilc_lcc)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )if[^\n]*\$(?P=gilc_lcc)\b[^\n]*-ge[^\n]*\$(?P=gilc_lcl)\b[^\n]*;[ \t]*then[^\n]*\n^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,120}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=gcounter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=gcounter)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )(?P=glimit)[ \t]*=[ \t]*\$\(\([ \t]*(?P=glimit)[ \t]*\+[ \t]*1[ \t]*\)\)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )if[^\n]*\$(?P=gcounter)\b[^\n]*-ge[^\n]*\$(?P=glimit)\b[^\n]*;[ \t]*then[^\n]*\n(?:^ {14,}(?!continue\b|break\b|return\b|exit\b)[^\n]*\n){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12,}(?:(?:if[ \t]+![ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[^#\n]*\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n)(?=(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n)(?!(?:(?!^ done\b)^ {12,}[^\n]*\n){0,120}?^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?:(?!^ done\b)^ {12,}[^\n]*\n){0,40}?^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)[ \t]*(?:#[^\n]*)?\n)(?:(?!^ done\b)^ {12,}[^\n]*\n){0,180}?^ done\b' + prefilter: [while, sleep] + message: | + This GitHub Actions polling loop appears to have a total deadline or + attempt bound, but the bound's state is mutated inside the same loop in + a way that can prevent termination: a deadline is refreshed or extended, + a total counter is reset, or a fixed attempt limit grows alongside its + counter. Keep the deadline and limit convergent after loop entry and + advance a total counter monotonically toward a fixed limit. A separate + monotonic total deadline/attempt guard or positive owning-job timeout is + independent hard-bound evidence, and a direct post-sleep break/exit 0 + removes the loop back edge. + severity: HIGH + languages: [generic] + cwe: [CWE-400] + owasp: [A04:2021] + paths: + include: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" diff --git a/scanner/rules/github_actions_poll_unreachable_exit.yml b/scanner/rules/github_actions_poll_unreachable_exit.yml new file mode 100644 index 00000000..9418785d --- /dev/null +++ b/scanner/rules/github_actions_poll_unreachable_exit.yml @@ -0,0 +1,25 @@ +rules: + # A nominal total bound is not fail-closed when control jumps to the loop back + # edge before its nonzero exit. Keep this companion narrow: it proves the + # directly reachable unreachable-exit path inside one conventional literal-shell polling loop. + - id: github-actions-poll-bound-unreachable-exit + patterns: + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,100}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=unreachable_deadline)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=unreachable_deadline)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )(?:continue(?:[ \t]+1)?|break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+[0-9]+)?|return(?:[ \t]+[0-9]+)?)\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )continue(?:[ \t]+1)?\b[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}fi\b)^ {14,}[^\n]*\n)){0,20}^ {12}fi[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,100}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=unreachable_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=unreachable_counter)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?P=unreachable_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=unreachable_counter)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=unreachable_counter)\b[^\n]*-ge[^\n]*\$(?P=unreachable_limit)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )if[^\n]*\$(?P=unreachable_counter)\b[^\n]*-ge[^\n]*\$(?P=unreachable_limit)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )(?:continue(?:[ \t]+1)?|break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+[0-9]+)?|return(?:[ \t]+[0-9]+)?)\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )continue(?:[ \t]+1)?\b[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}fi\b)^ {14,}[^\n]*\n)){0,20}^ {12}fi[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,100}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,40}?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?:(?!^ while[ \t])^ {10,}[^\n]*\n){0,80}?^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=unreachable_counter_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=unreachable_counter_second)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?P=unreachable_counter_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=unreachable_counter_second)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=unreachable_counter_second)\b[^\n]*-ge[^\n]*\$(?P=unreachable_limit_first)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )if[^\n]*\$(?P=unreachable_counter_second)\b[^\n]*-ge[^\n]*\$(?P=unreachable_limit_first)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )(?:continue(?:[ \t]+1)?|break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+[0-9]+)?|return(?:[ \t]+[0-9]+)?)\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )continue(?:[ \t]+1)?\b[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b|^ {12}fi\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}fi\b)^ {14,}[^\n]*\n)){0,20}^ {12}fi[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12,}(?:(?:if[ \t]+![ \t]+)?(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)?["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:if[ \t]+![ \t]+)?(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*\n(?>(?:(?!^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit[ \t]+0)\b|^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,80}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n^ done\b' + prefilter: [while, sleep] + message: | + This GitHub Actions polling loop has an apparent total deadline or attempt + bound whose nonzero fail-closed exit is unreachable because a directly + reachable unconditional continue transfers control to the loop back edge first. + The polling command therefore remains repeatable on the reviewed path. Move + the fail-closed exit before any continue, or use an owning-job timeout as an + independent hard bound. + severity: HIGH + languages: [generic] + cwe: [CWE-400] + owasp: [A04:2021] + paths: + include: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" \ No newline at end of file diff --git a/scanner/rules/github_actions_transport_budget.yml b/scanner/rules/github_actions_transport_budget.yml new file mode 100644 index 00000000..32fd82b9 --- /dev/null +++ b/scanner/rules/github_actions_transport_budget.yml @@ -0,0 +1,31 @@ +rules: + # Identifier-agnostic companion for issue #1087. The historical detector in + # github_actions.yml intentionally keeps the exact source-incident spelling; + # this rule proves the retry-budget relationship from shell control flow. + # Safe total-bound state must belong to the same vulnerable while loop. An + # earlier bounded helper loop cannot donate safety to a later polling loop, + # and a direct successful-path break/exit before the back edge is finite. + - id: github-actions-transport-failure-budget-poll-bound + patterns: + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?!(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,80}^ max_poll_transport_failures\b)(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80}(?:^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=helper_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=helper_counter)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=helper_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=helper_counter)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=helper_counter)[^\n]*-ge[^\n]*\$(?P=helper_limit)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=helper_counter)[^\n]*-ge[^\n]*\$(?P=helper_limit)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ done\b)^ {12,}[^\n]*\n)){0,40}^ done\b[^\n]*\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80})?^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\))^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=safe_deadline)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=safe_deadline)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=safe_total_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=safe_total_counter)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=safe_total_counter)[^\n]*-ge[^\n]*\$(?P=safe_total_limit)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=safe_total_counter)[^\n]*-ge[^\n]*\$(?P=safe_total_limit)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=safe_total_counter_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_second)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=safe_total_counter_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_second)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_second)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_first)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_second)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_first)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12,}if[ \t]+![ \t]+(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12,}if[ \t]+![ \t]+(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14,}(?P=retry_counter_alias)[ \t]*=[ \t]*\$\(\([ \t]*(?P=retry_counter_alias)[ \t]*\+[ \t]*1[ \t]*\)\)|^ {12}fi\b)^ {14,}[^\n]*\n)){0,12}^ {14,}(?P=retry_counter_alias)[ \t]*=[ \t]*\$\(\([ \t]*(?P=retry_counter_alias)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {14,}if[^\n]*\$(?P=retry_counter_alias)[^\n]*-ge[^\n]*\$(?P=retry_limit_alias)\b[^\n]*;[ \t]*then|^ {12}fi\b)^ {14,}[^\n]*\n)){0,12}^ {14,}if[^\n]*\$(?P=retry_counter_alias)[^\n]*-ge[^\n]*\$(?P=retry_limit_alias)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {16,}exit[ \t]+[1-9][0-9]*\b)^ {16,}[^\n]*\n)){0,8}^ {16,}exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}fi[ \t]*(?:#[^\n]*)?$)^ {12,}[^\n]*\n)){0,40}^ {12}fi[ \t]*(?:#[^\n]*)?\n(?!(?>(?:(?!^ {12}(?! )(?:if\b|fi\b|break(?:[ \t]+[1-9][0-9]*)?\b|exit(?:[ \t]+0)?\b))^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+0)?)[ \t]*(?:#[^\n]*)?$\n)(?>(?:(?!^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?!(?>(?:(?!^ {12}(?! )(?:if\b|fi\b|break(?:[ \t]+[1-9][0-9]*)?\b|exit(?:[ \t]+0)?\b)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+0)?)[ \t]*(?:#[^\n]*)?$\n)(?>(?:(?!^ done\b)^ {12,}[^\n]*\n)){0,40}^ done\b' + - pattern-regex: '^ [A-Za-z0-9_.-]+[^\n]*\n(?!(?>(?:(?!^(?:[A-Za-z0-9_.-]+| [A-Za-z0-9_.-]+)[ \t]*:)[^\n]*\n))*^ timeout-minutes[ \t]*:[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)(?>(?:(?!^ -[ \t]*run|^ run)^ {4,}[^\n]*\n)){0,80}^(?: -[ \t]*run| run)[ \t]*:[ \t]*\|[+-]?[^\n]*\n(?!(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,80}^ max_poll_transport_failures\b)(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,80}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\))^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*\$\(\([^\n]*\bdate\b[^\n]*\+%s[^\n]*\+[ \t]*[1-9][0-9]*[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=safe_deadline_reverse)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )if[^\n]*\bdate\b[^\n]*\+%s[^\n]*-ge[^\n]*\$(?P=safe_deadline_reverse)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=safe_total_counter_reverse)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_reverse)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=safe_total_counter_reverse)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_reverse)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_reverse)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_reverse)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_reverse)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_reverse)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?!(?>(?:(?!^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?$)^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*[1-9][0-9]*[ \t]*(?:#[^\n]*)?\n(?>(?:(?!(?:^ [A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?$))^ {10,}[^\n]*\n)){0,40}^ (?P[A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*0[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12}(?! )(?P=safe_total_counter_reverse_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_reverse_second)[ \t]*\+[ \t]*1[ \t]*\)\)|^ done\b)^ {12,}[^\n]*\n)){0,20}^ {12}(?! )(?P=safe_total_counter_reverse_second)[ \t]*=[ \t]*\$\(\([ \t]*(?P=safe_total_counter_reverse_second)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_reverse_second)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_reverse_first)\b[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,12}^ {12}(?! )if[^\n]*\$(?P=safe_total_counter_reverse_second)[^\n]*-ge[^\n]*\$(?P=safe_total_limit_reverse_first)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14}(?! )exit[ \t]+[1-9][0-9]*\b)^ {14,}[^\n]*\n)){0,8}^ {14}(?! )exit[ \t]+[1-9][0-9]*\b[^\n]*\n)(?>(?:(?!^ while[ \t])^ {10,}[^\n]*\n)){0,40}^ while[ \t]+(?::|true)[ \t]*;[ \t]*do[^\n]*\n(?>(?:(?!^ {12,}if[ \t]+![ \t]+(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*;[ \t]*then|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12,}if[ \t]+![ \t]+(?:[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*["'']?\$\([ \t]*(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b|(?:timeout[ \t]+\S+[ \t]+)?gh[ \t]+api\b)[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {14,}(?P=retry_counter_reverse)[ \t]*=[ \t]*\$\(\([ \t]*(?P=retry_counter_reverse)[ \t]*\+[ \t]*1[ \t]*\)\)|^ {12}fi\b)^ {14,}[^\n]*\n)){0,12}^ {14,}(?P=retry_counter_reverse)[ \t]*=[ \t]*\$\(\([ \t]*(?P=retry_counter_reverse)[ \t]*\+[ \t]*1[ \t]*\)\)[ \t]*(?:#[^\n]*)?\n(?>(?:(?!^ {14,}if[^\n]*\$(?P=retry_counter_reverse)[^\n]*-ge[^\n]*\$(?P=retry_limit_reverse)\b[^\n]*;[ \t]*then|^ {12}fi\b)^ {14,}[^\n]*\n)){0,12}^ {14,}if[^\n]*\$(?P=retry_counter_reverse)[^\n]*-ge[^\n]*\$(?P=retry_limit_reverse)\b[^\n]*;[ \t]*then[^\n]*\n(?>(?:(?!^ {16,}exit[ \t]+[1-9][0-9]*\b)^ {16,}[^\n]*\n)){0,8}^ {16,}exit[ \t]+[1-9][0-9]*\b[^\n]*\n(?>(?:(?!^ {12}fi[ \t]*(?:#[^\n]*)?$)^ {12,}[^\n]*\n)){0,40}^ {12}fi[ \t]*(?:#[^\n]*)?\n(?!(?>(?:(?!^ {12}(?! )(?:if\b|fi\b|break(?:[ \t]+[1-9][0-9]*)?\b|exit(?:[ \t]+0)?\b))^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+0)?)[ \t]*(?:#[^\n]*)?$\n)(?>(?:(?!^ {12}(?! )sleep(?:[ \t]+|$)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )sleep(?:[ \t]+|$)[^\n]*\n(?!(?>(?:(?!^ {12}(?! )(?:if\b|fi\b|break(?:[ \t]+[1-9][0-9]*)?\b|exit(?:[ \t]+0)?\b)|^ done\b)^ {12,}[^\n]*\n)){0,40}^ {12}(?! )(?:break(?:[ \t]+[1-9][0-9]*)?|exit(?:[ \t]+0)?)[ \t]*(?:#[^\n]*)?$\n)(?>(?:(?!^ done\b)^ {12,}[^\n]*\n)){0,40}^ done\b' + prefilter: [while, sleep] + message: | + This GitHub Actions job bounds repeated transport failures from a remote + API poll but leaves the healthy-transport/no-result path without a total + termination bound. Renaming the retry counter or budget does not make the + loop safe. Add a total wall-clock deadline, finite total-attempt budget, + or explicit timeout on the same job and fail closed when it expires. A + deadline or attempt limit only counts when its state is initialized before + this same polling loop and the same state is consumed by the loop-local + fail-closed guard. A bound consumed by an earlier helper loop does not + sanitize a later poll, and a successful path that unconditionally breaks + or exits zero before the back edge is finite rather than resource-retaining. + severity: HIGH + languages: [generic] + cwe: [CWE-400] + owasp: [A04:2021] + paths: + include: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" \ No newline at end of file diff --git a/tests/fixtures/security_corpus/github_actions_transport_only_poll_fixed.yml b/tests/fixtures/security_corpus/github_actions_transport_only_poll_fixed.yml new file mode 100644 index 00000000..a82a1866 --- /dev/null +++ b/tests/fixtures/security_corpus/github_actions_transport_only_poll_fixed.yml @@ -0,0 +1,40 @@ +# Fixed regression oracle extracted from the protected causal repair +# ContextualWisdomLab/.github@e29302c05eade7da7b0bdbb453e53980bc9d577b. +# The wall-clock deadline is distinct from the transport-failure counter. +name: Required review +on: + pull_request_target: +jobs: + review-verdict: + runs-on: ubuntu-24.04 + steps: + - name: Wait for current-head verdict + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + verdict="" + review_poll_failures=0 + max_poll_transport_failures=3 + poll_interval_seconds=60 + poll_deadline_epoch=$(( $(date -u +%s) + 10800 )) + while :; do + if [ "$(date -u +%s)" -ge "$poll_deadline_epoch" ]; then + echo "::error::No current-head verdict after bounded polling." + exit 1 + fi + if ! reviews="$(timeout 30s gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/1/reviews?per_page=100")"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + sleep "$poll_interval_seconds" + continue + fi + review_poll_failures=0 + verdict="$(printf '%s\n' "$reviews" | jq -r '.[] | select(.state == "APPROVED") | .state' | tail -1)" + if [ -n "$verdict" ]; then + break + fi + sleep "$poll_interval_seconds" + done diff --git a/tests/fixtures/security_corpus/github_actions_transport_only_poll_vulnerable.yml b/tests/fixtures/security_corpus/github_actions_transport_only_poll_vulnerable.yml new file mode 100644 index 00000000..968a1480 --- /dev/null +++ b/tests/fixtures/security_corpus/github_actions_transport_only_poll_vulnerable.yml @@ -0,0 +1,36 @@ +# Historical regression oracle extracted from +# ContextualWisdomLab/.github@5c561a65cca3b925d533e4b40c5c3ac00f16524e +# .github/workflows/opencode-review.yml. The source incident bounded only +# consecutive API transport failures, not the successful-no-verdict path. +name: Required review +on: + pull_request_target: +jobs: + review-verdict: + runs-on: ubuntu-24.04 + steps: + - name: Wait for current-head verdict + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + verdict="" + review_poll_failures=0 + max_poll_transport_failures=3 + poll_interval_seconds=60 + while :; do + if ! reviews="$(timeout 30s gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/1/reviews?per_page=100")"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + sleep "$poll_interval_seconds" + continue + fi + review_poll_failures=0 + verdict="$(printf '%s\n' "$reviews" | jq -r '.[] | select(.state == "APPROVED") | .state' | tail -1)" + if [ -n "$verdict" ]; then + break + fi + sleep "$poll_interval_seconds" + done diff --git a/tests/test_github_actions_poll_bound_aliases.py b/tests/test_github_actions_poll_bound_aliases.py new file mode 100644 index 00000000..b737f0c0 --- /dev/null +++ b/tests/test_github_actions_poll_bound_aliases.py @@ -0,0 +1,242 @@ +"""Regression tests for renamed transport-failure polling budgets. + +The organization incident in issue #1087 is a control-flow defect, not a +requirement to spell its retry variables exactly like the historical .github +workflow. These tests exercise a companion production detector with unrelated +identifier names so isomorphic transport-only bounds remain detectable without +making the historical rule less precise. +""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + + +_RULE_ID = "github-actions-transport-failure-budget-poll-bound" + + +def _scan_workflow(tmp_path: Path, content: str) -> list[dict]: + """Run production scanning against a workflow-scoped temporary file.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text(content, encoding="utf-8") + return _scan_file(workflow, tmp_path) + + +def _rule_ids(findings: list[dict]) -> list[str]: + """Return finding identities in scan order.""" + return [finding["rule_id"] for finding in findings] + + +def _renamed_poll(*, total_deadline: bool = False) -> str: + """Return one causal retry-budget loop using non-historical identifiers.""" + deadline_setup = ( + " overall_stop_epoch=$(( $(date -u +%s) + 600 ))\n" + if total_deadline + else "" + ) + deadline_guard = ( + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + if total_deadline + else "" + ) + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 +{deadline_setup} while :; do +{deadline_guard} if ! response="$(timeout 20s gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + sleep 5 + continue + fi + api_error_streak=0 + if [ -n "$response" ]; then + break + fi + sleep 30 + done +""" + + +def test_generic_transport_budget_rule_is_packaged_once() -> None: + """The alias-safe causal companion must be a single packaged HIGH rule.""" + rules = [rule for rule in SCAN_RULES if rule["id"] == _RULE_ID] + + assert len(rules) == 1 + assert rules[0]["severity"] == "HIGH" + assert rules[0]["include_paths"] == [ + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ] + + +def test_renamed_transport_failure_budget_is_reported(tmp_path: Path) -> None: + """Identifier renaming must not evade an otherwise isomorphic defect.""" + findings = _scan_workflow(tmp_path, _renamed_poll()) + + assert _rule_ids(findings).count(_RULE_ID) == 1 + + +def test_renamed_budget_with_total_deadline_is_not_reported(tmp_path: Path) -> None: + """A fail-closed total deadline terminates the successful-no-result path.""" + findings = _scan_workflow(tmp_path, _renamed_poll(total_deadline=True)) + + assert _RULE_ID not in _rule_ids(findings) + + +def test_renamed_budget_with_total_attempt_limit_is_not_reported( + tmp_path: Path, +) -> None: + """A loop-wide attempt budget bounds both transport and no-result paths.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + all_poll_attempts=0 + overall_attempt_limit=12 + while :; do + all_poll_attempts=$((all_poll_attempts + 1)) + if [ "$all_poll_attempts" -ge "$overall_attempt_limit" ]; then + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + sleep 5 + continue + fi + api_error_streak=0 + sleep 30 + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_non_enforcing_clock_comparison_does_not_hide_renamed_poll( + tmp_path: Path, +) -> None: + """Clock text without fail-closed termination is not a total deadline.""" + workflow = _renamed_poll().replace( + " while :; do\n", + " overall_stop_epoch=$(( $(date -u +%s) + 600 ))\n" + " while :; do\n" + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " echo \"still waiting\"\n" + " fi\n", + ) + + assert _rule_ids(_scan_workflow(tmp_path, workflow)).count(_RULE_ID) == 1 + + +def test_uninitialized_deadline_does_not_hide_renamed_poll(tmp_path: Path) -> None: + """A deadline comparison is not a bound unless its deadline is initialized.""" + workflow = _renamed_poll().replace( + " while :; do\n", + " while :; do\n" + " if [ \"$(date -u +%s)\" -ge \"$missing_deadline\" ]; then\n" + " exit 1\n" + " fi\n", + ) + + assert _rule_ids(_scan_workflow(tmp_path, workflow)).count(_RULE_ID) == 1 + + +def test_uninitialized_attempt_limit_does_not_hide_renamed_poll( + tmp_path: Path, +) -> None: + """A total-attempt comparison needs a finite initialized limit.""" + workflow = _renamed_poll().replace( + " while :; do\n", + " all_poll_attempts=0\n" + " while :; do\n" + " all_poll_attempts=$((all_poll_attempts + 1))\n" + " if [ \"$all_poll_attempts\" -ge \"$missing_limit\" ]; then\n" + " exit 1\n" + " fi\n", + ) + + assert _rule_ids(_scan_workflow(tmp_path, workflow)).count(_RULE_ID) == 1 + + +def test_unused_numeric_variables_do_not_create_transport_budget_evidence( + tmp_path: Path, +) -> None: + """Numbers near a poll are not a causal retry budget unless linked by flow.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + unrelated_zero=0 + unrelated_limit=4 + while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + echo "network failed" + sleep 5 + continue + fi + sleep 30 + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_historical_named_rule_is_not_duplicated_by_generic_companion( + tmp_path: Path, +) -> None: + """The generic companion must yield to the pinned historical rule identity.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 + while :; do + if ! reviews="$(gh api repos/example/repo/pulls/7/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + sleep 5 + continue + fi + review_poll_failures=0 + sleep 30 + done +""" + + findings = _scan_workflow(tmp_path, workflow) + + assert _RULE_ID not in _rule_ids(findings) + assert _rule_ids(findings).count("github-actions-transport-only-poll-bound") == 1 diff --git a/tests/test_github_actions_poll_bound_bare_exit.py b/tests/test_github_actions_poll_bound_bare_exit.py new file mode 100644 index 00000000..1d7aead2 --- /dev/null +++ b/tests/test_github_actions_poll_bound_bare_exit.py @@ -0,0 +1,146 @@ +"""Bare-exit regressions for issue #1087 polling detector control flow.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL = "github-actions-transport-only-poll-bound" +_GENERIC = "github-actions-transport-failure-budget-poll-bound" +_RESET = "github-actions-poll-bound-state-reset" +_UNREACHABLE = "github-actions-poll-bound-unreachable-exit" + + +def _scan(tmp_path: Path, shell: str) -> list[str]: + """Scan one conventional literal-shell GitHub Actions workflow.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _termination(position: str) -> str: + """Place one unconditional bare exit on either side of the polling sleep.""" + if position == "before_sleep": + return " exit\n sleep 30" + if position == "after_sleep": + return " sleep 30\n exit" + raise AssertionError(position) + + +def _historical_poll(position: str) -> str: + """Return the historical transport-budget poll with a finite bare exit.""" + return f""" +review_poll_failures=0 +max_poll_transport_failures=3 +while :; do + if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 +{_termination(position)} +done +""" + + +def _renamed_poll(position: str) -> str: + """Return an identifier-agnostic transport-budget poll with a finite bare exit.""" + return f""" +api_error_streak=0 +transport_error_budget=4 +while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 +{_termination(position)} +done +""" + + +@pytest.mark.parametrize("position", ["before_sleep", "after_sleep"]) +def test_historical_poll_with_unconditional_bare_exit_is_finite( + tmp_path: Path, position: str +) -> None: + """A bare exit terminates the shell, so the historical poll has no back edge.""" + assert _HISTORICAL not in _scan(tmp_path, _historical_poll(position)) + + +@pytest.mark.parametrize("position", ["before_sleep", "after_sleep"]) +def test_renamed_poll_with_unconditional_bare_exit_is_finite( + tmp_path: Path, position: str +) -> None: + """A bare exit terminates the shell, so the renamed poll has no back edge.""" + assert _GENERIC not in _scan(tmp_path, _renamed_poll(position)) + + +@pytest.mark.parametrize("position", ["before_sleep", "after_sleep"]) +def test_state_reset_poll_with_unconditional_bare_exit_is_finite( + tmp_path: Path, position: str +) -> None: + """Mutable bound state is irrelevant when a direct bare exit removes the back edge.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ "$(date +%s)" -ge "$poll_deadline" ]; then + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 +{_termination(position)} +done +""" + assert _RESET not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("position", ["before_sleep", "after_sleep"]) +def test_unreachable_exit_poll_with_unconditional_bare_exit_is_finite( + tmp_path: Path, position: str +) -> None: + """An unreachable fail-closed guard is not a blocker once a direct bare exit ends the poll.""" + shell = f""" +overall_deadline=$(($(date +%s) + 600)) +while :; do + if [ "$(date +%s)" -ge "$overall_deadline" ]; then + continue + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + continue + fi +{_termination(position)} +done +""" + assert _UNREACHABLE not in _scan(tmp_path, shell) diff --git a/tests/test_github_actions_poll_bound_command_whitespace.py b/tests/test_github_actions_poll_bound_command_whitespace.py new file mode 100644 index 00000000..45e576d6 --- /dev/null +++ b/tests/test_github_actions_poll_bound_command_whitespace.py @@ -0,0 +1,91 @@ +"""Regressions for shell whitespace accepted by polling-bound command grammar.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL_RULE = "github-actions-transport-only-poll-bound" +_GENERIC_RULE = "github-actions-transport-failure-budget-poll-bound" + + +def _scan(tmp_path: Path, content: str) -> list[str]: + """Scan one workflow through the production scanner and return rule ids.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text(content, encoding="utf-8") + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _historical(command: str) -> str: + """Build the pinned transport-budget shape with caller-selected gh spacing.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 + while :; do + if ! reviews="$({command} repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + sleep 30 + done +""" + + +def _generic(command: str) -> str: + """Build the renamed transport-budget shape with caller-selected gh spacing.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + while :; do + if ! response="$({command} repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 + done +""" + + +def test_historical_repeated_spaces_remain_detectable(tmp_path: Path) -> None: + """A literal-substring prefilter must not reject valid repeated shell spaces.""" + assert _scan(tmp_path, _historical("gh api")).count(_HISTORICAL_RULE) == 1 + + +def test_historical_tab_remains_detectable(tmp_path: Path) -> None: + """A tab between gh and api is accepted by the detector's command grammar.""" + assert _scan(tmp_path, _historical("gh\tapi")).count(_HISTORICAL_RULE) == 1 + + +def test_generic_repeated_spaces_remain_detectable(tmp_path: Path) -> None: + """The renamed companion must preserve the same shell-whitespace semantics.""" + assert _scan(tmp_path, _generic("gh api")).count(_GENERIC_RULE) == 1 + + +def test_generic_tab_remains_detectable(tmp_path: Path) -> None: + """The generic companion accepts a tab wherever its regex accepts whitespace.""" + assert _scan(tmp_path, _generic("gh\tapi")).count(_GENERIC_RULE) == 1 diff --git a/tests/test_github_actions_poll_bound_control_flow_regression.py b/tests/test_github_actions_poll_bound_control_flow_regression.py new file mode 100644 index 00000000..b90c2897 --- /dev/null +++ b/tests/test_github_actions_poll_bound_control_flow_regression.py @@ -0,0 +1,166 @@ +"""Regression boundaries for issue #1087 polling-loop control flow.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL_RULE = "github-actions-transport-only-poll-bound" +_GENERIC_RULE = "github-actions-transport-failure-budget-poll-bound" + + +def _scan(tmp_path: Path, content: str) -> list[str]: + """Scan one GitHub Actions workflow through the production rule loader.""" + workflow_path = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow_path.parent.mkdir(parents=True, exist_ok=True) + workflow_path.write_text(content, encoding="utf-8") + return [finding["rule_id"] for finding in _scan_file(workflow_path, tmp_path)] + + +def test_historical_helper_deadline_does_not_bound_later_poll(tmp_path: Path) -> None: + """A bounded helper loop cannot donate its deadline to a later poll.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 + helper_deadline=$(( $(date -u +%s) + 30 )) + while :; do + if [ "$(date -u +%s)" -ge "$helper_deadline" ]; then + exit 1 + fi + break + done + while :; do + if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + sleep 30 + done +""" + + assert _scan(tmp_path, workflow).count(_HISTORICAL_RULE) == 1 + + +def test_renamed_helper_deadline_does_not_bound_later_poll(tmp_path: Path) -> None: + """A helper deadline before retry state is not safety for the later poll.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + helper_deadline=$(( $(date -u +%s) + 30 )) + while :; do + if [ "$(date -u +%s)" -ge "$helper_deadline" ]; then + exit 1 + fi + break + done + api_error_streak=0 + transport_error_budget=4 + while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 + done +""" + + assert _scan(tmp_path, workflow).count(_GENERIC_RULE) == 1 + + +def test_renamed_helper_deadline_after_retry_state_does_not_bound_poll( + tmp_path: Path, +) -> None: + """Safety state between retry setup and poll must belong to the poll loop.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + helper_deadline=$(( $(date -u +%s) + 30 )) + while :; do + if [ "$(date -u +%s)" -ge "$helper_deadline" ]; then + exit 1 + fi + break + done + while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 + done +""" + + assert _scan(tmp_path, workflow).count(_GENERIC_RULE) == 1 + + +def _quoted_only_poll(command: str) -> str: + """Build a loop where gh api exists only as inert quoted text.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + while :; do + if ! {command}; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 + done +""" + + +def test_echoed_gh_api_text_is_not_executable_poll_evidence(tmp_path: Path) -> None: + """An echo containing gh api is text, not a remote polling command.""" + rule_ids = _scan(tmp_path, _quoted_only_poll('echo "gh api"')) + + assert _GENERIC_RULE not in rule_ids + + +def test_printf_gh_api_text_is_not_executable_poll_evidence(tmp_path: Path) -> None: + """A printf argument containing gh api is text, not executable evidence.""" + rule_ids = _scan(tmp_path, _quoted_only_poll("printf '%s\\n' 'gh api'")) + + assert _GENERIC_RULE not in rule_ids diff --git a/tests/test_github_actions_poll_bound_current_head_regressions.py b/tests/test_github_actions_poll_bound_current_head_regressions.py new file mode 100644 index 00000000..17be050d --- /dev/null +++ b/tests/test_github_actions_poll_bound_current_head_regressions.py @@ -0,0 +1,437 @@ +"""Current-head regressions for issue #1087 polling detector precision.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL = "github-actions-transport-only-poll-bound" +_GENERIC = "github-actions-transport-failure-budget-poll-bound" +_RESET = "github-actions-poll-bound-state-reset" +_UNREACHABLE = "github-actions-poll-bound-unreachable-exit" +_FAMILY = {_HISTORICAL, _GENERIC, _RESET, _UNREACHABLE} + + +def _scan(tmp_path: Path, shell: str) -> list[str]: + """Scan one conventional literal-shell GitHub Actions workflow.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _scan_with_timeout(tmp_path: Path, shell: str, timeout: str) -> list[str]: + """Scan a workflow whose owning job declares the supplied timeout expression.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + f" timeout-minutes: {timeout}\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _renamed_transport_branch(command: str = "gh api repos/example/repo/pulls/7/reviews") -> str: + """Return the reviewed renamed transport-failure branch.""" + return ''' if ! response="$(%s)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0''' % command + + +def _historical_transport_branch(command: str = "gh api repos/example/repo/pulls/7/reviews") -> str: + """Return the source-incident transport-failure branch.""" + return ''' if ! response="$(%s)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0''' % command + + +def _transport_case(historical: bool) -> tuple[str, str, str]: + """Return setup, transport branch, and detector identity for one primary rule.""" + if historical: + return ( + "review_poll_failures=0\nmax_poll_transport_failures=3", + _historical_transport_branch(), + _HISTORICAL, + ) + return ( + "api_error_streak=0\ntransport_error_budget=4", + _renamed_transport_branch(), + _GENERIC, + ) + + +def test_mutable_retry_deadline_is_safe_with_independent_total_deadline(tmp_path: Path) -> None: + """One mutable candidate cannot invalidate a separate monotonic wall-clock bound.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +retry_deadline=$(($(date +%s) + 30)) +overall_deadline=$(($(date +%s) + 600)) +while :; do + retry_deadline=$(($(date +%s) + 30)) + if [ "$(date +%s)" -ge "$retry_deadline" ]; then + exit 1 + fi + if [ "$(date +%s)" -ge "$overall_deadline" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + assert _RESET not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize( + "declarations", + [ + "overall_attempts=0\noverall_attempt_limit=12", + "overall_attempt_limit=12\noverall_attempts=0", + ], +) +def test_mutable_retry_counter_is_safe_with_independent_total_attempt_bound( + tmp_path: Path, declarations: str +) -> None: + """A separate monotonic total-attempt guard bounds the loop in either declaration order.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +retry_attempts=0 +retry_limit=4 +{declarations} +while :; do + retry_attempts=0 + retry_attempts=$((retry_attempts + 1)) + if [ "$retry_attempts" -ge "$retry_limit" ]; then + exit 1 + fi + overall_attempts=$((overall_attempts + 1)) + if [ "$overall_attempts" -ge "$overall_attempt_limit" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + assert _RESET not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("termination", ["break", "exit 0"]) +def test_state_reset_loop_with_unconditional_post_sleep_termination_is_finite( + tmp_path: Path, termination: str +) -> None: + """A direct successful termination before done removes the polling back edge.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ "$(date +%s)" -ge "$poll_deadline" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 + {termination} +done +""" + assert _RESET not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize("termination", ["break", "exit 0"]) +def test_primary_poll_with_unconditional_post_sleep_termination_is_finite( + tmp_path: Path, historical: bool, termination: str +) -> None: + """A direct termination after sleep still removes the primary polling back edge.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +while :; do +{branch} + sleep 30 + {termination} +done +""" + assert target not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize("termination", ["break", "exit 0"]) +def test_primary_poll_keeps_same_indent_conditional_post_sleep_termination_positive( + tmp_path: Path, historical: bool, termination: str +) -> None: + """A conditional termination after sleep does not remove the repeatable healthy path.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +while :; do +{branch} + sleep 30 + if [ -n "$REVIEW_RESULT" ]; then + {termination} + fi +done +""" + assert target in _scan(tmp_path, shell) + + +@pytest.mark.parametrize( + "fake_command", + [ + "echo 'gh api repos/example/repo/pulls/7/reviews'", + "printf '%s\\n' 'gh api repos/example/repo/pulls/7/reviews'", + "printf '%s\\n' '$(gh api repos/example/repo/pulls/7/reviews)'", + ], +) +def test_quoted_command_substitution_is_not_executable_poll_evidence( + tmp_path: Path, fake_command: str +) -> None: + """Text produced by echo/printf must not impersonate an executable gh command token.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +while :; do +{_renamed_transport_branch(fake_command)} + sleep 30 +done +""" + assert _FAMILY.isdisjoint(_scan(tmp_path, shell)) + + +def test_unreachable_exit_requires_executable_poll_command(tmp_path: Path) -> None: + """Quoted nested command text cannot witness an unreachable-bound polling defect.""" + shell = """ +overall_deadline=$(($(date +%s) + 600)) +while :; do + if [ "$(date +%s)" -ge "$overall_deadline" ]; then + continue + exit 1 + fi + if ! response="$(printf '%s\\n' '$(gh api repos/example/repo/pulls/7/reviews)')"; then + continue + fi + sleep 30 +done +""" + assert _UNREACHABLE not in _scan(tmp_path, shell) + + +def test_unreachable_exit_keeps_direct_poll_command_positive(tmp_path: Path) -> None: + """A directly executed gh api poll keeps the unreachable-exit finding positive.""" + shell = """ +overall_deadline=$(($(date +%s) + 600)) +while :; do + if [ "$(date +%s)" -ge "$overall_deadline" ]; then + continue + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + continue + fi + sleep 30 +done +""" + assert _UNREACHABLE in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +def test_unconditional_break_is_not_made_conditional_by_unrelated_later_fi( + tmp_path: Path, historical: bool +) -> None: + """A later unrelated if/fi block cannot change ownership of an earlier direct break.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +while :; do +{branch} + break + if [ -n "$GITHUB_ACTIONS" ]; then + echo done + fi + sleep 30 +done +""" + assert target not in _scan(tmp_path, shell) + + +def test_unused_historical_transport_budget_is_not_transport_bound_evidence( + tmp_path: Path, +) -> None: + """A named retry limit alone cannot fabricate the historical transport pattern.""" + shell = """ +max_poll_transport_failures=3 +while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + continue + fi + sleep 30 +done +""" + assert _HISTORICAL not in _scan(tmp_path, shell) + + +def test_historical_transport_budget_requires_complete_counter_flow(tmp_path: Path) -> None: + """The source-incident counter/limit flow remains positive after precision repair.""" + shell = f""" +review_poll_failures=0 +max_poll_transport_failures=3 +while :; do +{_historical_transport_branch()} + sleep 30 +done +""" + assert _HISTORICAL in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +def test_primary_poll_accepts_strict_total_deadline_bound( + tmp_path: Path, historical: bool +) -> None: + """A strict clock-greater-than deadline is still a finite total poll bound.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +overall_deadline=$(($(date +%s) + 600)) +while :; do + if [ "$(date +%s)" -gt "$overall_deadline" ]; then + exit 1 + fi +{branch} + sleep 30 +done +""" + assert target not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize( + "declarations", + [ + "overall_attempts=0\noverall_attempt_limit=12", + "overall_attempt_limit=12\noverall_attempts=0", + ], +) +def test_primary_poll_accepts_strict_total_attempt_bound( + tmp_path: Path, historical: bool, declarations: str +) -> None: + """A strict total-attempt comparison bounds either declaration order.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +{declarations} +while :; do + overall_attempts=$((overall_attempts + 1)) + if [ "$overall_attempts" -gt "$overall_attempt_limit" ]; then + exit 1 + fi +{branch} + sleep 30 +done +""" + assert target not in _scan(tmp_path, shell) + + +@pytest.mark.parametrize("historical", [False, True]) +def test_primary_poll_accepts_positive_constant_timeout_expression( + tmp_path: Path, historical: bool +) -> None: + """A statically positive Actions timeout expression bounds the owning job.""" + setup, branch, target = _transport_case(historical) + shell = f""" +{setup} +while :; do +{branch} + sleep 30 +done +""" + assert target not in _scan_with_timeout(tmp_path, shell, "${{ 20 }}") + + +def test_state_reset_accepts_positive_constant_timeout_expression(tmp_path: Path) -> None: + """The mutable-state companion respects a static positive owning-job timeout.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ "$(date +%s)" -ge "$poll_deadline" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + assert _RESET not in _scan_with_timeout(tmp_path, shell, "${{ 20 }}") + + +def test_unreachable_exit_accepts_positive_constant_timeout_expression(tmp_path: Path) -> None: + """The unreachable-exit companion respects a static positive owning-job timeout.""" + shell = """ +overall_deadline=$(($(date +%s) + 600)) +while :; do + if [ "$(date +%s)" -ge "$overall_deadline" ]; then + continue + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + continue + fi + sleep 30 +done +""" + assert _UNREACHABLE not in _scan_with_timeout(tmp_path, shell, "${{ 20 }}") + + +@pytest.mark.parametrize( + "timeout", + ["${{ 0 }}", "${{ -1 }}", "${{ inputs.timeout }}"], +) +def test_unproved_timeout_expression_does_not_suppress_poll_finding( + tmp_path: Path, timeout: str +) -> None: + """Zero, negative, or dynamic timeout expressions are not static safety evidence.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +while :; do +{_renamed_transport_branch()} + sleep 30 +done +""" + assert _GENERIC in _scan_with_timeout(tmp_path, shell, timeout) diff --git a/tests/test_github_actions_poll_bound_late_initialization.py b/tests/test_github_actions_poll_bound_late_initialization.py new file mode 100644 index 00000000..42274ce1 --- /dev/null +++ b/tests/test_github_actions_poll_bound_late_initialization.py @@ -0,0 +1,110 @@ +"""Regression tests for late initialization of transport-poll safety guards. + +A total deadline or attempt limit is only causal safety evidence when its state +exists before the polling loop starts. Assignments after the guard or after the +loop cannot retroactively bound the successful-no-result path. +""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_RULE_ID = "github-actions-transport-failure-budget-poll-bound" + + +def _scan(tmp_path: Path, shell: str) -> list[str]: + """Scan one conventional GitHub Actions workflow through production code.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\non: pull_request_target\njobs:\n" + " review:\n runs-on: ubuntu-24.04\n steps:\n" + " - run: |\n" + shell, + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _body(prefix: str = "", loop_prefix: str = "", suffix: str = "") -> str: + """Build the renamed vulnerable transport-only polling loop.""" + return ( + " api_error_streak=0\n" + " transport_error_budget=4\n" + + prefix + + " while :; do\n" + + loop_prefix + + " if ! response=\"$(gh api repos/example/repo/pulls/7/reviews)\"; then\n" + " api_error_streak=$((api_error_streak + 1))\n" + " if [ \"$api_error_streak\" -ge \"$transport_error_budget\" ]; then\n" + " exit 1\n" + " fi\n" + " sleep 5\n" + " continue\n" + " fi\n" + " sleep 30\n" + " done\n" + + suffix + ) + + +def test_deadline_initialized_after_done_does_not_suppress(tmp_path: Path) -> None: + """A post-loop deadline assignment cannot bound the loop that preceded it.""" + shell = _body( + loop_prefix=( + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + ), + suffix=" overall_stop_epoch=$(( $(date -u +%s) + 600 ))\n", + ) + + assert _scan(tmp_path, shell).count(_RULE_ID) == 1 + + +def test_deadline_initialized_after_guard_does_not_suppress(tmp_path: Path) -> None: + """Initializing a deadline after its guard cannot prove the first iteration safe.""" + shell = _body( + loop_prefix=( + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + " overall_stop_epoch=$(( $(date -u +%s) + 600 ))\n" + ) + ) + + assert _scan(tmp_path, shell).count(_RULE_ID) == 1 + + +def test_attempt_limit_initialized_after_done_does_not_suppress(tmp_path: Path) -> None: + """A post-loop attempt limit cannot bound iterations that already executed.""" + shell = _body( + prefix=" all_poll_attempts=0\n", + loop_prefix=( + " all_poll_attempts=$((all_poll_attempts + 1))\n" + " if [ \"$all_poll_attempts\" -ge \"$overall_attempt_limit\" ]; then\n" + " exit 1\n" + " fi\n" + ), + suffix=" overall_attempt_limit=12\n", + ) + + assert _scan(tmp_path, shell).count(_RULE_ID) == 1 + + +def test_attempt_limit_initialized_after_guard_does_not_suppress(tmp_path: Path) -> None: + """A limit assigned after comparison is not a pre-loop finite bound.""" + shell = _body( + prefix=" all_poll_attempts=0\n", + loop_prefix=( + " all_poll_attempts=$((all_poll_attempts + 1))\n" + " if [ \"$all_poll_attempts\" -ge \"$overall_attempt_limit\" ]; then\n" + " exit 1\n" + " fi\n" + " overall_attempt_limit=12\n" + ), + ) + + assert _scan(tmp_path, shell).count(_RULE_ID) == 1 diff --git a/tests/test_github_actions_poll_bound_mixed_safety.py b/tests/test_github_actions_poll_bound_mixed_safety.py new file mode 100644 index 00000000..166cf8f0 --- /dev/null +++ b/tests/test_github_actions_poll_bound_mixed_safety.py @@ -0,0 +1,113 @@ +"""Regression tests for historical transport names with renamed safety state.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_RULE_ID = "github-actions-transport-only-poll-bound" + + +def _scan(tmp_path: Path, content: str) -> list[str]: + """Scan one workflow through the production scanner and return rule ids.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text(content, encoding="utf-8") + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _historical(*, prefix: str = "", guard: str = "") -> str: + """Build the historical transport budget with caller-selected safety state.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 +{prefix} while :; do +{guard} if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + sleep 30 + done +""" + + +def test_historical_transport_with_renamed_deadline_is_bounded(tmp_path: Path) -> None: + """Safety semantics must not depend on the historical deadline identifier.""" + workflow = _historical( + prefix=" overall_stop_epoch=$(( $(date -u +%s) + 600 ))\n", + guard=( + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + ), + ) + + assert _RULE_ID not in _scan(tmp_path, workflow) + + +def test_historical_transport_with_renamed_total_attempts_is_bounded( + tmp_path: Path, +) -> None: + """Renamed loop-wide attempt state is equivalent fail-closed safety evidence.""" + workflow = _historical( + prefix=( + " all_poll_attempts=0\n" + " overall_attempt_limit=12\n" + ), + guard=( + " all_poll_attempts=$((all_poll_attempts + 1))\n" + " if [ \"$all_poll_attempts\" -ge \"$overall_attempt_limit\" ]; then\n" + " exit 1\n" + " fi\n" + ), + ) + + assert _RULE_ID not in _scan(tmp_path, workflow) + + +def test_historical_transport_with_reversed_renamed_attempt_declarations_is_bounded( + tmp_path: Path, +) -> None: + """Declaration order does not change a valid pre-loop total-attempt bound.""" + workflow = _historical( + prefix=( + " overall_attempt_limit=12\n" + " all_poll_attempts=0\n" + ), + guard=( + " all_poll_attempts=$((all_poll_attempts + 1))\n" + " if [ \"$all_poll_attempts\" -ge \"$overall_attempt_limit\" ]; then\n" + " exit 1\n" + " fi\n" + ), + ) + + assert _RULE_ID not in _scan(tmp_path, workflow) + + +def test_historical_transport_with_uninitialized_renamed_deadline_remains_vulnerable( + tmp_path: Path, +) -> None: + """Identifier-agnostic safety must still require causal initialization.""" + workflow = _historical( + guard=( + " if [ \"$(date -u +%s)\" -ge \"$overall_stop_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + ) + ) + + assert _scan(tmp_path, workflow).count(_RULE_ID) == 1 diff --git a/tests/test_github_actions_poll_bound_review_20260902.py b/tests/test_github_actions_poll_bound_review_20260902.py new file mode 100644 index 00000000..d067fdd4 --- /dev/null +++ b/tests/test_github_actions_poll_bound_review_20260902.py @@ -0,0 +1,182 @@ +"""Current-review regressions for GitHub Actions polling-bound detectors. + +These cases pin causal state locality for issue #1087: safety state must be +initialized before the same vulnerable loop, an earlier helper loop cannot +donate a bound, and a healthy API path that terminates before the back edge is +finite rather than resource-retaining. +""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL_RULE = "github-actions-transport-only-poll-bound" +_GENERIC_RULE = "github-actions-transport-failure-budget-poll-bound" + + +def _scan(tmp_path: Path, content: str) -> list[str]: + """Scan one temporary workflow through the production scanner.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text(content, encoding="utf-8") + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _historical(body_prefix: str = "", body_middle: str = "") -> str: + """Build the historical-name polling shape with caller-controlled guards.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 +{body_prefix} while :; do +{body_middle} if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + sleep 30 + done +""" + + +def _renamed_poll(*, successful_termination: str = "") -> str: + """Build the identifier-agnostic transport-only polling shape.""" + return f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi +{successful_termination} api_error_streak=0 + sleep 30 + done +""" + + +def test_historical_uninitialized_deadline_does_not_suppress(tmp_path: Path) -> None: + """An unset historical deadline variable is not causal safety evidence.""" + workflow = _historical( + body_middle=( + " if [ \"$(date -u +%s)\" -ge \"$poll_deadline_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + ) + ) + + assert _scan(tmp_path, workflow).count(_HISTORICAL_RULE) == 1 + + +def test_historical_uninitialized_attempt_limit_does_not_suppress( + tmp_path: Path, +) -> None: + """An unset historical total-attempt limit cannot bound successful polls.""" + workflow = _historical( + body_prefix=" poll_attempts=0\n", + body_middle=( + " poll_attempts=$((poll_attempts + 1))\n" + " if [ \"$poll_attempts\" -ge \"$max_poll_attempts\" ]; then\n" + " exit 1\n" + " fi\n" + ), + ) + + assert _scan(tmp_path, workflow).count(_HISTORICAL_RULE) == 1 + + +def test_historical_late_deadline_initialization_does_not_suppress( + tmp_path: Path, +) -> None: + """Initializing a deadline after its guard cannot retroactively bound it.""" + workflow = _historical( + body_middle=( + " if [ \"$(date -u +%s)\" -ge \"$poll_deadline_epoch\" ]; then\n" + " exit 1\n" + " fi\n" + " poll_deadline_epoch=$(( $(date -u +%s) + 600 ))\n" + ) + ) + + assert _scan(tmp_path, workflow).count(_HISTORICAL_RULE) == 1 + + +def test_earlier_bounded_deadline_loop_cannot_hide_later_renamed_poll( + tmp_path: Path, +) -> None: + """A helper loop deadline is not a bound for a later vulnerable loop.""" + workflow = _renamed_poll().replace( + " api_error_streak=0\n", + " helper_deadline=$(( $(date -u +%s) + 30 ))\n" + " while :; do\n" + " if [ \"$(date -u +%s)\" -ge \"$helper_deadline\" ]; then\n" + " exit 1\n" + " fi\n" + " break\n" + " done\n" + " api_error_streak=0\n", + 1, + ) + + assert _scan(tmp_path, workflow).count(_GENERIC_RULE) == 1 + + +def test_earlier_bounded_attempt_loop_cannot_hide_later_renamed_poll( + tmp_path: Path, +) -> None: + """A helper-loop attempt budget cannot sanitize a later polling loop.""" + workflow = _renamed_poll().replace( + " api_error_streak=0\n", + " helper_attempts=0\n" + " helper_limit=2\n" + " while :; do\n" + " helper_attempts=$((helper_attempts + 1))\n" + " if [ \"$helper_attempts\" -ge \"$helper_limit\" ]; then\n" + " exit 1\n" + " fi\n" + " break\n" + " done\n" + " api_error_streak=0\n", + 1, + ) + + assert _scan(tmp_path, workflow).count(_GENERIC_RULE) == 1 + + +def test_historical_success_break_before_sleep_is_finite(tmp_path: Path) -> None: + """An unconditional successful-path break prevents the polling back edge.""" + workflow = _historical(body_middle="").replace( + " review_poll_failures=0\n sleep 30\n", + " break\n review_poll_failures=0\n sleep 30\n", + ) + + assert _HISTORICAL_RULE not in _scan(tmp_path, workflow) + + +def test_renamed_success_exit_zero_before_sleep_is_finite(tmp_path: Path) -> None: + """An unconditional successful-path exit 0 makes the remote poll finite.""" + workflow = _renamed_poll(successful_termination=" exit 0\n") + + assert _GENERIC_RULE not in _scan(tmp_path, workflow) diff --git a/tests/test_github_actions_poll_bound_review_precision.py b/tests/test_github_actions_poll_bound_review_precision.py new file mode 100644 index 00000000..80374580 --- /dev/null +++ b/tests/test_github_actions_poll_bound_review_precision.py @@ -0,0 +1,127 @@ +"""Current-review regressions for GitHub Actions polling-bound precision.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_GENERIC_RULE = "github-actions-transport-failure-budget-poll-bound" +_RESET_RULE = "github-actions-poll-bound-state-reset" + + +def _scan_ids(tmp_path: Path, shell: str) -> list[str]: + """Scan one conventional literal-shell GitHub Actions workflow.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _renamed_transport_branch() -> str: + """Return the reviewed transport-failure-only retry branch.""" + return """ if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0""" + + +def test_reverse_total_attempt_safety_declarations_bound_renamed_poll( + tmp_path: Path, +) -> None: + """A fixed total-attempt limit may precede its counter before the same loop.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +overall_attempt_limit=12 +all_poll_attempts=0 +while :; do + all_poll_attempts=$((all_poll_attempts + 1)) + if [ "$all_poll_attempts" -ge "$overall_attempt_limit" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + + assert _GENERIC_RULE not in _scan_ids(tmp_path, shell) + + +def test_deadline_refresh_with_nonempty_guard_is_not_state_reset( + tmp_path: Path, +) -> None: + """A refresh followed by an unconditional nonempty-value exit is finite.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ -n "$poll_deadline" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + + assert _RESET_RULE not in _scan_ids(tmp_path, shell) + + +def test_deadline_refresh_with_reversed_clock_guard_is_not_state_reset( + tmp_path: Path, +) -> None: + """A future deadline on the left of >= terminates immediately after refresh.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ "$poll_deadline" -ge "$(date +%s)" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + + assert _RESET_RULE not in _scan_ids(tmp_path, shell) + + +def test_deadline_refresh_with_expiration_guard_remains_state_reset( + tmp_path: Path, +) -> None: + """Refreshing a future deadline before a clock>=deadline guard can prevent expiry.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ "$(date +%s)" -ge "$poll_deadline" ]; then + exit 1 + fi +{_renamed_transport_branch()} + sleep 30 +done +""" + + assert _scan_ids(tmp_path, shell).count(_RESET_RULE) == 1 diff --git a/tests/test_github_actions_poll_bound_state_reset.py b/tests/test_github_actions_poll_bound_state_reset.py new file mode 100644 index 00000000..90e6cdc7 --- /dev/null +++ b/tests/test_github_actions_poll_bound_state_reset.py @@ -0,0 +1,202 @@ +"""Regressions for mutable total-bound state inside GitHub Actions polling loops.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scanner.cli.appguardrail import _scan_file + + +_RULE = "github-actions-poll-bound-state-reset" + + +def _scan(tmp_path: Path, shell: str, *, timeout_minutes: int | None = None) -> list[str]: + """Scan one conventional workflow through the production scanner.""" + timeout = f" timeout-minutes: {timeout_minutes}\n" if timeout_minutes else "" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + f"{timeout}" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _transport_failure_block(counter: str, limit: str) -> str: + """Return the loop-local transport-error branch used by the reviewed incident family.""" + return f""" if ! response=\"$(gh api repos/example/repo/pulls/7/reviews)\"; then + {counter}=$(({counter} + 1)) + if [ \"${counter}\" -ge \"${limit}\" ]; then + exit 1 + fi + continue + fi + {counter}=0""" + + +@pytest.mark.parametrize( + ("failure_counter", "failure_limit"), + [ + ("review_poll_failures", "max_poll_transport_failures"), + ("api_error_streak", "transport_error_budget"), + ], +) +def test_resetting_deadline_inside_loop_remains_detectable( + tmp_path: Path, failure_counter: str, failure_limit: str +) -> None: + """Recomputing a deadline every iteration must not masquerade as a total bound.""" + shell = f""" +{failure_counter}=0 +{failure_limit}=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + exit 1 + fi +{_transport_failure_block(failure_counter, failure_limit)} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +@pytest.mark.parametrize( + ("failure_counter", "failure_limit"), + [ + ("review_poll_failures", "max_poll_transport_failures"), + ("api_error_streak", "transport_error_budget"), + ], +) +def test_resetting_total_attempt_counter_remains_detectable( + tmp_path: Path, failure_counter: str, failure_limit: str +) -> None: + """A loop-local reset prevents a total-attempt counter from ever reaching its limit.""" + shell = f""" +{failure_counter}=0 +{failure_limit}=4 +total_attempts=0 +max_attempts=12 +while :; do + total_attempts=0 + total_attempts=$((total_attempts + 1)) + if [ \"$total_attempts\" -ge \"$max_attempts\" ]; then + exit 1 + fi +{_transport_failure_block(failure_counter, failure_limit)} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +@pytest.mark.parametrize( + ("failure_counter", "failure_limit"), + [ + ("review_poll_failures", "max_poll_transport_failures"), + ("api_error_streak", "transport_error_budget"), + ], +) +def test_growing_total_attempt_limit_remains_detectable( + tmp_path: Path, failure_counter: str, failure_limit: str +) -> None: + """Growing the limit with the counter keeps an apparent total-attempt guard non-terminating.""" + shell = f""" +{failure_counter}=0 +{failure_limit}=4 +total_attempts=0 +max_attempts=12 +while :; do + total_attempts=$((total_attempts + 1)) + max_attempts=$((max_attempts + 1)) + if [ \"$total_attempts\" -ge \"$max_attempts\" ]; then + exit 1 + fi +{_transport_failure_block(failure_counter, failure_limit)} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +def test_stable_deadline_is_not_reported_as_mutable(tmp_path: Path) -> None: + """A fixed pre-loop wall-clock deadline remains valid safety evidence.""" + shell = f""" +review_poll_failures=0 +max_poll_transport_failures=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + exit 1 + fi +{_transport_failure_block("review_poll_failures", "max_poll_transport_failures")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) + + +def test_stable_total_attempt_bound_is_not_reported_as_mutable(tmp_path: Path) -> None: + """A monotonically increasing counter toward a fixed limit is a valid total bound.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +total_attempts=0 +max_attempts=12 +while :; do + total_attempts=$((total_attempts + 1)) + if [ \"$total_attempts\" -ge \"$max_attempts\" ]; then + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) + + +def test_job_timeout_bounds_mutable_loop(tmp_path: Path) -> None: + """A positive timeout on the owning job remains an independent hard resource bound.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + poll_deadline=$(($(date +%s) + 300)) + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell, timeout_minutes=20) + + +def test_post_loop_assignment_does_not_make_bound_mutable(tmp_path: Path) -> None: + """A reassignment after the back edge cannot invalidate an otherwise fixed in-loop bound.""" + shell = f""" +review_poll_failures=0 +max_poll_transport_failures=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + exit 1 + fi +{_transport_failure_block("review_poll_failures", "max_poll_transport_failures")} + sleep 30 +done +poll_deadline=$(($(date +%s) + 300)) +""" + assert _RULE not in _scan(tmp_path, shell) diff --git a/tests/test_github_actions_poll_bound_unreachable_exit.py b/tests/test_github_actions_poll_bound_unreachable_exit.py new file mode 100644 index 00000000..edda3d6a --- /dev/null +++ b/tests/test_github_actions_poll_bound_unreachable_exit.py @@ -0,0 +1,197 @@ +"""Regressions for total polling bounds whose fail-closed exit is unreachable.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scanner.cli.appguardrail import _scan_file + + +_RULE = "github-actions-poll-bound-unreachable-exit" + + +def _scan(tmp_path: Path, shell: str) -> list[str]: + """Scan one conventional workflow through the production scanner.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow, tmp_path)] + + +def _transport_failure_block(counter: str, limit: str) -> str: + """Return the transport-error budget from the reviewed incident family.""" + return f""" if ! response=\"$(gh api repos/example/repo/pulls/7/reviews)\"; then + {counter}=$(({counter} + 1)) + if [ \"${counter}\" -ge \"${limit}\" ]; then + exit 1 + fi + continue + fi + {counter}=0""" + + +@pytest.mark.parametrize( + ("failure_counter", "failure_limit"), + [ + ("review_poll_failures", "max_poll_transport_failures"), + ("api_error_streak", "transport_error_budget"), + ], +) +def test_continue_before_deadline_exit_remains_detectable( + tmp_path: Path, failure_counter: str, failure_limit: str +) -> None: + """A continue before the nonzero exit makes a deadline guard non-enforcing.""" + shell = f""" +{failure_counter}=0 +{failure_limit}=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + continue + exit 1 + fi +{_transport_failure_block(failure_counter, failure_limit)} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +@pytest.mark.parametrize( + ("failure_counter", "failure_limit"), + [ + ("review_poll_failures", "max_poll_transport_failures"), + ("api_error_streak", "transport_error_budget"), + ], +) +def test_continue_before_total_attempt_exit_remains_detectable( + tmp_path: Path, failure_counter: str, failure_limit: str +) -> None: + """A continue before the nonzero exit leaves a total-attempt guard unbounded.""" + shell = f""" +{failure_counter}=0 +{failure_limit}=4 +total_attempts=0 +max_attempts=12 +while :; do + total_attempts=$((total_attempts + 1)) + if [ \"$total_attempts\" -ge \"$max_attempts\" ]; then + continue + exit 1 + fi +{_transport_failure_block(failure_counter, failure_limit)} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +@pytest.mark.parametrize("terminator", ["exit 0", "break"]) +def test_terminating_command_before_continue_is_not_reported( + tmp_path: Path, terminator: str +) -> None: + """A command that terminates first makes the later continue unreachable.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + {terminator} + continue + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) + + +def test_continue_zero_is_not_reported_as_a_valid_back_edge(tmp_path: Path) -> None: + """Invalid ``continue 0`` is not evidence of a reachable loop back edge.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + continue 0 + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) + + +def test_continue_one_before_exit_remains_detectable(tmp_path: Path) -> None: + """A directly reachable level-one continue is a real loop back edge.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + continue 1 + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _scan(tmp_path, shell).count(_RULE) == 1 + + +def test_reachable_deadline_exit_is_not_reported_by_unreachable_exit_companion( + tmp_path: Path, +) -> None: + """A directly reachable fail-closed deadline exit remains valid safety evidence.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +poll_deadline=$(($(date +%s) + 300)) +while :; do + if [ \"$(date +%s)\" -ge \"$poll_deadline\" ]; then + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) + + +def test_reachable_total_attempt_exit_is_not_reported_by_unreachable_exit_companion( + tmp_path: Path, +) -> None: + """A directly reachable total-attempt exit is a real finite bound.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +total_attempts=0 +max_attempts=12 +while :; do + total_attempts=$((total_attempts + 1)) + if [ \"$total_attempts\" -ge \"$max_attempts\" ]; then + exit 1 + fi +{_transport_failure_block("api_error_streak", "transport_error_budget")} + sleep 30 +done +""" + assert _RULE not in _scan(tmp_path, shell) diff --git a/tests/test_github_actions_poll_bounds.py b/tests/test_github_actions_poll_bounds.py new file mode 100644 index 00000000..ccdde15f --- /dev/null +++ b/tests/test_github_actions_poll_bounds.py @@ -0,0 +1,389 @@ +"""Regression tests for transport-only GitHub Actions polling bounds. + +The historical vulnerable/fixed fixtures are answer-free source-code oracles +pinned to the verified ContextualWisdomLab/.github incident in AppGuardrail +issue #1087. Tests execute the packaged production scanner rather than reading +an expected-result field from the fixtures. +""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + + +_RULE_ID = "github-actions-transport-only-poll-bound" +_FIXTURES = Path(__file__).parent / "fixtures" / "security_corpus" + + +def _rule() -> dict: + """Return the single packaged transport-only polling rule.""" + matches = [rule for rule in SCAN_RULES if rule["id"] == _RULE_ID] + assert len(matches) == 1 + return matches[0] + + +def _scan_workflow(tmp_path: Path, content: str) -> list[dict]: + """Run production scanning against a workflow-scoped temporary file.""" + workflow = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text(content, encoding="utf-8") + return _scan_file(workflow, tmp_path) + + +def _rule_ids(findings: list[dict]) -> list[str]: + """Return finding identities in scan order.""" + return [finding["rule_id"] for finding in findings] + + +def test_transport_only_poll_rule_is_packaged_and_workflow_scoped() -> None: + """The detector must load once and only target GitHub Actions YAML paths.""" + rule = _rule() + + assert rule["severity"] == "HIGH" + assert rule["extensions"] is None + assert rule["include_paths"] == [ + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ] + + +def test_verified_vulnerable_control_plane_poll_is_reported(tmp_path: Path) -> None: + """The protected-predecessor incident must remain a positive regression.""" + content = ( + _FIXTURES / "github_actions_transport_only_poll_vulnerable.yml" + ).read_text(encoding="utf-8") + + findings = _scan_workflow(tmp_path, content) + + assert _rule_ids(findings).count(_RULE_ID) == 1 + + +def test_protected_wall_clock_repair_is_not_reported(tmp_path: Path) -> None: + """The causal .github wall-clock repair must remain a fixed oracle.""" + content = ( + _FIXTURES / "github_actions_transport_only_poll_fixed.yml" + ).read_text(encoding="utf-8") + + findings = _scan_workflow(tmp_path, content) + + assert _RULE_ID not in _rule_ids(findings) + + +def test_finite_total_attempt_bound_is_not_reported(tmp_path: Path) -> None: + """A finite total attempt budget bounds successful-no-verdict polling too.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + max_poll_attempts=120 + poll_attempts=0 + while :; do + poll_attempts=$((poll_attempts + 1)) + if [ "$poll_attempts" -ge "$max_poll_attempts" ]; then + exit 1 + fi + reviews="$(gh api repos/example/repo/pulls/1/reviews)" + [ -n "$reviews" ] && break + sleep 30 + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_explicit_job_timeout_is_a_bounded_negative(tmp_path: Path) -> None: + """An explicit job wall-clock bound prevents an indefinitely held runner.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - run: | + max_poll_transport_failures=3 + while true; do + reviews="$(gh api repos/example/repo/pulls/1/reviews)" + [ -n "$reviews" ] && break + sleep 30 + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_job_timeout_after_steps_is_still_a_bounded_negative(tmp_path: Path) -> None: + """YAML key order must not change the owning job's timeout semantics.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while true; do + reviews="$(gh api repos/example/repo/pulls/1/reviews)" + sleep 30 + done + timeout-minutes: 20 +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_late_job_timeout_after_more_than_300_lines_is_bounded_negative( + tmp_path: Path, +) -> None: + """Owning-job timeout discovery must not depend on an arbitrary line cap.""" + spacer = "\n".join(" # realistic large-job spacer" for _ in range(305)) + workflow = f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while true; do + reviews="$(gh api repos/example/repo/pulls/1/reviews)" + sleep 30 + done +{spacer} + timeout-minutes: 20 +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_sibling_job_bounds_do_not_suppress_vulnerable_poll(tmp_path: Path) -> None: + """A bound in another job cannot terminate the vulnerable polling job.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + bounded-helper: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: | + max_poll_attempts=2 + poll_attempts=0 + poll_deadline_epoch=$(( $(date -u +%s) + 60 )) + while :; do + poll_attempts=$((poll_attempts + 1)) + if [ "$poll_attempts" -ge "$max_poll_attempts" ]; then exit 1; fi + if [ "$(date -u +%s)" -ge "$poll_deadline_epoch" ]; then exit 1; fi + gh api repos/example/repo + sleep 1 + done + required-review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 + while :; do + if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + [ -n "$reviews" ] && break + sleep 30 + done +""" + + assert _rule_ids(_scan_workflow(tmp_path, workflow)).count(_RULE_ID) == 1 + + +def test_split_steps_do_not_donate_poll_evidence(tmp_path: Path) -> None: + """Transport budget and poll evidence must belong to one shell run block.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - name: Configure retry policy + run: | + max_poll_transport_failures=3 + echo "configuration only" + - name: Independent bounded watcher + run: | + while :; do + gh api repos/example/repo + sleep 30 + break + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_remote_poll_and_sleep_must_be_inside_the_unbounded_loop(tmp_path: Path) -> None: + """Commands elsewhere in one run block cannot fabricate a polling loop.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while :; do + echo "local loop" + break + done + gh api repos/example/repo + sleep 30 +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_quoted_poll_commands_are_not_executable_evidence(tmp_path: Path) -> None: + """Echoed or printf-only command text must not create a HIGH finding.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while :; do + echo "gh api repos/example/repo" + printf '%s\\n' 'sleep 30' + break + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_indented_shell_comments_are_not_executable_poll_evidence( + tmp_path: Path, +) -> None: + """Whitespace before a shell comment marker must not bypass comment filtering.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while :; do + # gh api repos/example/repo + # sleep 30 + break + done +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_unrelated_post_loop_deadline_comparison_does_not_suppress( + tmp_path: Path, +) -> None: + """A comparison outside the poll cannot bound the successful-no-result path.""" + workflow = """ +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + review_poll_failures=0 + max_poll_transport_failures=3 + poll_deadline_epoch=$(( $(date -u +%s) + 60 )) + while :; do + if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + [ -n "$reviews" ] && break + sleep 30 + done + if [ "$(date -u +%s)" -ge "$poll_deadline_epoch" ]; then + echo "too late to bound the completed loop" + fi +""" + + assert _rule_ids(_scan_workflow(tmp_path, workflow)).count(_RULE_ID) == 1 + + +def test_transport_counter_without_remote_sleeping_poll_is_not_reported( + tmp_path: Path, +) -> None: + """A transport counter alone is not the causal resource-retention path.""" + workflow = """ +name: One-shot API read +on: workflow_dispatch +jobs: + inspect: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + gh api repos/example/repo +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_comment_only_poll_example_is_not_reported(tmp_path: Path) -> None: + """Documentation comments must not masquerade as executable polling.""" + workflow = """ +name: Docs +on: push +# max_poll_transport_failures=3 +# while :; do +# gh api repos/example/repo +# sleep 60 +# done +jobs: + docs: + runs-on: ubuntu-24.04 + steps: + - run: echo ok +""" + + assert _RULE_ID not in _rule_ids(_scan_workflow(tmp_path, workflow)) + + +def test_rule_respects_workflow_path_scope(tmp_path: Path) -> None: + """The same shell text outside .github/workflows must not be scanned.""" + content = ( + _FIXTURES / "github_actions_transport_only_poll_vulnerable.yml" + ).read_text(encoding="utf-8") + documentation = tmp_path / "docs" / "polling-example.yml" + documentation.parent.mkdir(parents=True) + documentation.write_text(content, encoding="utf-8") + + findings = _scan_file(documentation, tmp_path) + + assert _RULE_ID not in _rule_ids(findings) diff --git a/tests/test_github_actions_poll_bounds_large_job.py b/tests/test_github_actions_poll_bounds_large_job.py new file mode 100644 index 00000000..c8b9f818 --- /dev/null +++ b/tests/test_github_actions_poll_bounds_large_job.py @@ -0,0 +1,40 @@ +"""Large-job regression for the transport-only polling detector.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_RULE_ID = "github-actions-transport-only-poll-bound" + + +def test_late_same_job_timeout_still_bounds_poll(tmp_path: Path) -> None: + """A valid timeout remains job-scoped even after more than 300 YAML lines.""" + filler = " env:\n" + "".join( + f" LARGE_JOB_FILLER_{index}: value_{index}\n" for index in range(305) + ) + workflow = f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + max_poll_transport_failures=3 + while :; do + reviews="$(gh api repos/example/repo/pulls/1/reviews)" + [ -n "$reviews" ] && break + sleep 30 + done +{filler} timeout-minutes: 20 +""" + path = tmp_path / ".github" / "workflows" / "required-review.yml" + path.parent.mkdir(parents=True) + path.write_text(workflow, encoding="utf-8") + + findings = _scan_file(path, tmp_path) + + assert _RULE_ID not in [finding["rule_id"] for finding in findings] diff --git a/tests/test_github_actions_poll_control_flow_review.py b/tests/test_github_actions_poll_control_flow_review.py new file mode 100644 index 00000000..c39d15f1 --- /dev/null +++ b/tests/test_github_actions_poll_control_flow_review.py @@ -0,0 +1,109 @@ +"""Current-review regressions for issue #1087 shell control-flow boundaries.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scanner.cli.appguardrail import _scan_file + + +_HISTORICAL_RULE = "github-actions-transport-only-poll-bound" +_GENERIC_RULE = "github-actions-transport-failure-budget-poll-bound" + + +def _scan(tmp_path: Path, shell: str) -> list[str]: + """Scan a conventional literal-shell Actions workflow through production.""" + workflow_path = tmp_path / ".github" / "workflows" / "required-review.yml" + workflow_path.parent.mkdir(parents=True, exist_ok=True) + workflow_path.write_text( + "name: Required review\n" + "on: pull_request_target\n" + "jobs:\n" + " review:\n" + " runs-on: ubuntu-24.04\n" + " steps:\n" + " - run: |\n" + + "\n".join(f" {line}" for line in shell.strip().splitlines()) + + "\n", + encoding="utf-8", + ) + return [finding["rule_id"] for finding in _scan_file(workflow_path, tmp_path)] + + +@pytest.mark.parametrize("termination", ["break", "exit 0"]) +def test_historical_conditional_success_termination_does_not_hide_poll( + tmp_path: Path, + termination: str, +) -> None: + """A same-indent conditional termination is not an unconditional back-edge cut.""" + shell = f""" +review_poll_failures=0 +max_poll_transport_failures=3 +while :; do + if ! reviews="$(gh api repos/example/repo/pulls/1/reviews)"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + exit 1 + fi + continue + fi + review_poll_failures=0 + if [ -n "$reviews" ]; then + {termination} + fi + sleep 30 +done +""" + + assert _scan(tmp_path, shell).count(_HISTORICAL_RULE) == 1 + + +@pytest.mark.parametrize("termination", ["break", "exit 0"]) +def test_generic_conditional_success_termination_does_not_hide_poll( + tmp_path: Path, + termination: str, +) -> None: + """Conditional success termination must not suppress the renamed detector.""" + shell = f""" +api_error_streak=0 +transport_error_budget=4 +while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + if [ -n "$response" ]; then + {termination} + fi + sleep 30 +done +""" + + assert _scan(tmp_path, shell).count(_GENERIC_RULE) == 1 + + +def test_generic_reversed_retry_declarations_remain_detectable(tmp_path: Path) -> None: + """Counter/limit declaration order before the loop is semantically irrelevant.""" + shell = """ +transport_error_budget=4 +api_error_streak=0 +while :; do + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 +done +""" + + assert _scan(tmp_path, shell).count(_GENERIC_RULE) == 1 diff --git a/tests/test_github_actions_poll_deadline_tightening.py b/tests/test_github_actions_poll_deadline_tightening.py new file mode 100644 index 00000000..7d144151 --- /dev/null +++ b/tests/test_github_actions_poll_deadline_tightening.py @@ -0,0 +1,74 @@ +"""Regression tests for convergent deadline mutation in issue #1087 polling guards.""" + +from __future__ import annotations + +from pathlib import Path + +from scanner.cli.appguardrail import _scan_file + + +_RULE_ID = "github-actions-poll-bound-state-reset" + + +def _scan(tmp_path: Path, deadline_mutation: str) -> list[str]: + """Scan one polling workflow with a caller-selected deadline mutation.""" + workflow = f""" +name: Required review +on: pull_request_target +jobs: + review: + runs-on: ubuntu-24.04 + steps: + - run: | + api_error_streak=0 + transport_error_budget=4 + poll_deadline=$(( $(date +%s) + 300 )) + while :; do + {deadline_mutation} + if [ "$(date +%s)" -ge "$poll_deadline" ]; then + exit 1 + fi + if ! response="$(gh api repos/example/repo/pulls/7/reviews)"; then + api_error_streak=$((api_error_streak + 1)) + if [ "$api_error_streak" -ge "$transport_error_budget" ]; then + exit 1 + fi + continue + fi + api_error_streak=0 + sleep 30 + done +""" + path = tmp_path / ".github" / "workflows" / "required-review.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(workflow, encoding="utf-8") + return [finding["rule_id"] for finding in _scan_file(path, tmp_path)] + + +def test_deadline_assignment_to_zero_is_not_nonconvergent(tmp_path: Path) -> None: + """Setting a deadline to zero tightens the bound and must not be HIGH.""" + assert _RULE_ID not in _scan(tmp_path, "poll_deadline=0") + + +def test_deadline_subtraction_is_not_nonconvergent(tmp_path: Path) -> None: + """Moving a deadline earlier is convergent safety, not a reset finding.""" + assert _RULE_ID not in _scan( + tmp_path, + "poll_deadline=$((poll_deadline - 30))", + ) + + +def test_deadline_refresh_from_current_time_remains_detectable(tmp_path: Path) -> None: + """Refreshing from the current clock can move the deadline forward forever.""" + assert _scan( + tmp_path, + "poll_deadline=$(( $(date +%s) + 300 ))", + ).count(_RULE_ID) == 1 + + +def test_deadline_positive_extension_remains_detectable(tmp_path: Path) -> None: + """Incrementing the deadline each iteration can prevent convergence.""" + assert _scan( + tmp_path, + "poll_deadline=$((poll_deadline + 30))", + ).count(_RULE_ID) == 1