Skip to content

feat(ci): record time-limited security finding dispositions for the release gate #3671

Open

Description

@purp

Sub-issue of #3559. This is a deliberately narrow first version of Section 4 (Security finding dispositions) of the design in #3559 (comment).

User Story

As a maintainer running the release train, I want to record a reviewed, time-limited decision about each High/Critical security finding, so that a known backlog doesn't block releases and every deferred finding still comes back for action on a schedule.

Problem Statement

The Security Scan gate in release-tag.yml has two states. It either fails on every HIGH/CRITICAL finding from CodeQL, Trivy, and Zizmor (fail-on-static-findings: true), or it ignores all of them (false). There's no way to say "this finding has been reviewed, and here's the decision."

For example, v0.1.0-pre.11 (run 35997548356) produced 37 findings: Zizmor 27, CodeQL Rust 8, Trivy 2. Those collapse to 10 distinct issues. After review:

ID Issue Findings Decision
V1 OIDC client skips IdP TLS verification whenever --gateway-insecure is set CodeQL rust/disabled-certificate-check x2 Defer to 0.1.1, top priority
V5 CI container image pinned to floating ci:latest Zizmor unpinned-images x17 Defer to 0.1.1, top priority
V2 certgen --dry-run prints private keys CodeQL rust/cleartext-logging x3 Defer; fix in a 0.1.x release before 0.2.0
V6 ${{ }} expressions expanded inside run: Zizmor template-injection x5 Defer; fix before 0.2.0
V7 Composite actions write to GITHUB_ENV Zizmor github-env x3 Defer; fix before 0.2.0
V8 Write permissions granted at workflow level Zizmor excessive-permissions x2 Defer; fix before 0.2.0
V9 Supervisor image has no USER Trivy DS-0002 Defer; fix before 0.2.0
V10 Gateway container lacks readOnlyRootFilesystem Trivy KSV-0014 Defer; fix before 0.2.0
V3 Provider profile export "logs" the TLS key path CodeQL rust/cleartext-logging x2 Reject: false positive, not exploitable
V4 Login prints the user's own username CodeQL rust/cleartext-logging x1 Reject: not exploitable

The current mechanisms can't record any of these decisions:

Impact / Why This Matters

With strict enforcement on, one unrelated backlog finding holds up stable promotion. The only escape is turning fail-on-static-findings back off, which is the #3552 override that #3559 argues against. That removes the signal for every finding, including new ones, and records nothing about what was reviewed or when it must be fixed.

Proposed Design

Disposition record

A repository file, security-dispositions.yaml, is the single record of security finding decisions. It changes only through PRs, reviewed under CODEOWNERS. Each entry covers one issue and has:

  • Findings matched: one or more scanner findings. One entry must be able to cover a group (V5 covers 17 Zizmor findings) without also matching unrelated new findings. The matching key (rule + path, rule + path + line, or scanner fingerprint) is a design decision for this issue. It must survive routine line drift.
  • Disposition: deferred or rejected. The full feat: gate stable promotion on security qualification instead of pre-release publication #3559 design also has not-affected, which is out of scope unless the design needs it.
  • Severity: the scanner's rating and the assessed rating. The SLA uses the assessed rating.
  • Reason: a short justification. For rejections, why the finding isn't exploitable or relevant.
  • Approver: the maintainer who made the decision.
  • First seen: the date the finding was first reported.
  • Expires (deferrals only): a date. The default is first-seen plus the SLA for the assessed severity (see below). An earlier date may be set.

Gate behavior

  • A HIGH/CRITICAL finding with a matching, current entry doesn't fail the gate.
  • A finding with no entry, or whose deferral has expired, fails the gate as it does today.
  • rejected entries don't expire.
  • The gate output and the step summary list every finding covered by an entry: disposition, approver, and expiry. Deferred findings stay visible; they just stop blocking.
  • Entries that no longer match any finding are reported as stale so they can be removed.
  • Scanner execution errors still fail, unchanged.

Remediation SLAs

Deferral expiry defaults to these fix windows, keyed on assessed severity:

Severity Default SLA
Critical 7 days
High 14 days
Medium 30 days
Low 90 days

The SLAs are published in SECURITY.md or CI.md.

Initial entries

This issue lands with entries for V1–V10, using the decisions in the table above. The expiry dates come from the release milestones, which stand in for the intended release targets:

  • V1, V5 (assessed Medium and High): expire 2026-10-06, the due date of the OpenShell 0.1.1 milestone. That is earlier than the SLA dates: 2026-10-08 for V5 (High, 14 days) and 2026-10-24 for V1 (Medium, 30 days).
  • V2, V6–V10 (assessed Low): expire 2026-11-17, the due date of the OpenShell 0.2.0 milestone. That is earlier than the 90-day Low SLA (2026-12-23).
  • V3, V4: rejected, no expiry.

Two things follow from this. When a release target comes before the SLA date, the target wins. And if a milestone date moves, its deferral dates go stale. Both favor the version-based deferral below.

To explore: version-based deferral

Date expiry only approximates the intent behind "fix in 0.1.1" and "fix before 0.2.0." A version-based deferral would name a release instead: the deferral blocks that release until the finding is addressed. Under that model:

  • V1 and V5 would block v0.1.1 from releasing.
  • V2 and V6–V10, deferred to 0.1.x, would block no 0.1.x release, but would block v0.2.0. That's how "addressed in any v0.1.x release" would behave.

The first version implements date-based expiry only. The design should note where version-based deferral would plug in: which version the gate compares against (candidate pre-release base or stable target), and how it interacts with RFC 0014 promotion. It should also say whether version and date deferral could coexist on one entry.

Research

  • Severity mapping: decide how scanner ratings map onto the four SLA tiers. That covers CodeQL security-severity scores and Trivy and Zizmor HIGH/CRITICAL levels.
  • Finding identity: check how stable each scanner's finding identifiers are across runs and code changes. That covers CodeQL partialFingerprints, Zizmor's SARIF output, and Trivy IDs plus target. Use what you find to pick the matching key.

Acceptance Criteria

  • security-dispositions.yaml exists, with a documented schema covering the fields above.
  • The release Security Scan gate honors current entries: covered findings don't fail it; uncovered or expired findings do.
  • Gate output lists every covered finding with its disposition, approver, and expiry, and flags stale entries.
  • The file contains entries for V1–V10 as described above. A re-run of Release Tag at a commit carrying the same findings passes the static-findings gate.
  • An expired deferral is shown to fail the gate (unit test or fixture).
  • The SLAs and the scanner-to-tier severity mapping are documented in SECURITY.md or CI.md.
  • Version-based deferral is written up as a follow-up: either in this issue's design notes or as a linked issue.
  • CI.md and architecture/build.md describe the disposition mechanism.

Alternatives Considered

  • Turn fail-on-static-findings back to false until 0.1.1. Rejected in feat: gate stable promotion on security qualification instead of pre-release publication #3559: it removes the signal for new findings as well as known ones, and records no decisions.
  • Tool-native suppressions only (.trivyignore.yaml, Zizmor inline ignores, CodeQL query filters). They have no approver, no expiry, and no single place to review. .trivyignore.yaml also forbids accepted risks by its own rule.
  • GitHub code-scanning dismissals. Not code-reviewed, and the gate reads local SARIF files, so dismissals don't affect it.
  • The full feat: gate stable promotion on security qualification instead of pre-release publication #3559 Section 4 design. That version also has not-affected, requires every native suppression (such as the deny.toml ignores) to have a matching entry, and plugs into release qualification. Deferred to keep this unblocking change small. This file should be the one the full design extends.

Agent Investigation

  • Findings, per-issue severity, impact, and reachability analysis come from Release Tag run 35997548356 at a8f98ec09.
  • A separate blocker: in that run, Release Qualification succeeded but Release and the other publish jobs were still skipped. GitHub skips a job when any ancestor job failed, and the suite jobs are ancestors through qualification-result. This is the increment 0 follow-up in the feat: gate stable promotion on security qualification instead of pre-release publication #3559 design, and it needs its own fix. Dispositions alone won't prevent the skip when a non-security suite fails.

Activity

  1. added this to the OpenShell 0.1.0 milestone on Sep 24, 2026
  2. purp commented on Sep 24, 2026

    @purp
    CollaboratorAuthor

    Security finding dispositions for the release gate (#3671)

    Design spec. Issue: #3671, sub-issue of #3559.

    Goal

    Let maintainers record a reviewed, time-limited decision about a HIGH/CRITICAL static finding (CodeQL, Trivy, Zizmor), so a known backlog stops blocking releases while every deferral still expires and comes back. Anything not covered by a current decision fails the gate exactly as today.

    Out of scope: Codex Security findings, cargo-deny advisories, the full #3559 Section 4 design (not-affected, native-suppression reconciliation, qualification integration), and version-based deferral (written up as a follow-up).

    Decisions

    Topic Decision
    Format and location security-dispositions.toml at the repo root. TOML because Python's stdlib parses it (tomllib), and the CodeQL runner has no guaranteed YAML parser. deny.toml is precedent.
    Which revision's file The workflow revision, the same checkout that supplies the evaluator (the codex-security.yml pattern). Release Auto-Tag dispatches with --ref main, so pre-releases read main's file. A pushed stable tag reads its own. This lets a re-run of an existing tag pick up newly merged dispositions and lets the draft branch be tested by dispatch.
    Finding identity GitHub code-scanning alert numbers. They are repo-wide, durable across refs, and link to the original finding's history. All three scanners already upload SARIF to code scanning.
    Matching Offline, with no API calls at gate time. A finding matches an entry on scanner + rule + path. Each path may have at most as many matched findings as the entry lists alerts for that path.
    Gate placement A shared evaluator called from each scanner's existing enforce step (approach A). Check names and qualification wiring are unchanged.
    SLA clock Starts at first detection (the alert's creation). Timelines: Critical 7, High 14, Medium 30, Low 90 days. An entry may exceed the SLA only with an explicit sla_exception reason.
    Missing file No entries: every finding is uncovered, which is today's behavior.

    The count cap is accepted for now and flagged for reconsideration in the draft PR. Per-path caps derived from real alert numbers replace the hand-set max from the earlier draft.

    File schema

    [[disposition]]
    id = "zizmor/unpinned-images/4"
    scanner = "zizmor"
    rule = "zizmor/unpinned-images"
    disposition = "deferred"
    scanner_severity = "high"
    assessed_severity = "high"
    reason = "CI jobs use floating ghcr.io/nvidia/openshell/ci:latest; pin by digest."
    approver = "purp"
    first_seen = 2026-08-26
    first_seen_at = "https://github.com/NVIDIA/OpenShell/actions/runs/<run-id>"
    expires = 2026-10-06
    sla_exception = "Pre-GA backlog detected during alpha while static findings were not enforced (#3552). Timeline granted in #3671."
    
    [disposition.alerts]
    4 = ".github/workflows/branch-checks.yml"
    5 = ".github/workflows/branch-checks.yml"
    Field Required Meaning
    id yes <rule>/<lowest alert number>, prefixed with codeql/ or trivy/ for those scanners (Zizmor rule IDs already carry zizmor/). Must equal the value derived from rule and alerts, so it's unique by construction.
    scanner yes codeql, trivy, or zizmor.
    rule yes The code-scanning rule ID: CodeQL rust/cleartext-logging, Trivy KSV-0014 or a CVE ID, Zizmor zizmor/template-injection.
    disposition yes deferred or rejected.
    scanner_severity yes critical, high.
    assessed_severity yes critical, high, medium, low, none. none only with rejected.
    reason yes Short justification. For rejections, why the finding isn't exploitable or relevant.
    approver yes GitHub login of the deciding maintainer.
    first_seen yes Date of first detection (the earliest alert's created_at, UTC date).
    first_seen_at yes URL of the Actions run that first reported the finding.
    expires deferred only Date. Must be at or before first_seen + SLA(assessed_severity), unless sla_exception is set. Forbidden on rejected.
    sla_exception no Reason the entry exceeds the SLA. Deferred only. Shown in the gate summary.
    alerts yes Table of alert number to path. For Trivy vulnerabilities the value is the package name, not the image target, because targets change with every digest.

    Validation errors (exit 2): TOML syntax errors, unknown or missing fields, wrong types, expires rules above, an id that doesn't match its derived value, an alert number that appears twice across entries, or two entries covering the same scanner + rule + path.

    Evaluator

    tasks/scripts/security_dispositions.py, stdlib only, with tasks/scripts/security_dispositions_test.py alongside it. It follows the codex_security_range.py pattern.

    security_dispositions.py gate --scanner {codeql,trivy,zizmor} \
        --dispositions <file> [--language <codeql-language>] [--today YYYY-MM-DD] \
        <report>...
    
    1. Load and validate the dispositions file. A missing file means no entries.
    2. Extract HIGH/CRITICAL findings from the native reports, using today's per-scanner rules:
      • CodeQL SARIF: rule security-severity of at least 7.0, not suppressed as accepted, rule looked up in tool.driver and tool.extensions.
      • Zizmor JSON: determinations.severity == "High" and not ignored. The rule is zizmor/<ident> and the path is the location's verbatim_path without a leading ./.
      • Trivy JSON (the same report set trivy-scan.sh gate uses): misconfigurations and vulnerabilities at HIGH/CRITICAL. The rule is the check or CVE ID. The path is Target for misconfigurations and PkgName for vulnerabilities.
    3. Dedupe identical findings (same rule, path, and location or package) that repeat across reports, such as the Trivy arm64/amd64 images and fixture reports.
    4. Match each finding against entries for this scanner. With --language, only CodeQL rules prefixed <language>/ are considered.
    5. Classify:
      • covered: a matching entry that is current (rejected, or deferred with expires on or after today), and the path's count is within its alert count.
      • expired: a matching deferred entry past expires.
      • over cap: matched findings at a path exceed the alerts listed for that path. The excess findings are reported.
      • uncovered: no entry matches.
    6. Warn, without failing, on stale entries (an entry for this scanner and language that matched nothing) and on deferrals that expire within 7 days.
    7. Write a Markdown step summary: a table of covered findings (id, disposition, approver, expires, sla_exception, matched/allowed), then the failures and warnings. Emit ::error/::warning annotations.
    8. Exit 0 if every finding is covered, 10 if any is expired, over cap, or uncovered, and 2 on a validation or report error.

    Workflow wiring

    In codeql.yml, workflow-security.yml, and trivy-scan.yml:

    • Stage security_dispositions.py and security-dispositions.toml from the workflow revision into $RUNNER_TEMP, with a sparse checkout, before the scanned revision is checked out or used.
    • Replace each enforce step's count check with an evaluator call. Map exit 10 to failure when fail-on-findings is true, or to a warning when it's false. Exit 2 always fails.
    • CodeQL: pass --language ${{ matrix.language }}. Keep the existing summary's total counts.
    • Trivy: trivy-scan.sh gate keeps producing its table for the summary, but pass/fail comes from the evaluator. trivy-scan-test.sh is updated to match.
    • Zizmor: the evaluator reads reports/zizmor-high.json.

    .github/CODEOWNERS gets security-dispositions.toml @NVIDIA/openshell-codeowners. Whether branch protection enforces code-owner review is not verified here.

    Initial entries

    From Release Tag run 35997548356 (v0.1.0-pre.11). Every deferral carries the sla_exception above. The approver is purp unless changed in review.

    Entry Rule Alerts Disposition Assessed Expires
    V1 rust/disabled-certificate-check 170, 171 deferred medium 2026-10-06
    V5 zizmor/unpinned-images 4–9, 12, 15, 39, 45, 55, 56, 73–75, 84, 85 (17) deferred high 2026-10-06
    V2 rust/cleartext-logging 163, 164, 165 deferred low 2026-11-17
    V6 zizmor/template-injection 38, 46, 47, 48, 49 deferred low 2026-11-17
    V7 zizmor/github-env 125, 134, 180 deferred low 2026-11-17
    V8 zizmor/excessive-permissions 13, 83 deferred low 2026-11-17
    V9 DS-0002 219 deferred low 2026-11-17
    V10 KSV-0014 210 deferred low 2026-11-17
    V3 rust/cleartext-logging 198, 199 rejected none —
    V4 rust/cleartext-logging 166 rejected none —

    The "V" labels are this doc's shorthand only. The file uses the derived ids. first_seen and first_seen_at come from each entry's earliest alert, resolved during implementation.

    Notes:

    Testing

    • Unit tests with trimmed fixtures from run 35997548356's artifacts, covering:
      • all covered;
      • uncovered;
      • expired;
      • over cap at one path while another path is under;
      • stale warning;
      • expiring-soon warning;
      • --language filtering;
      • Trivy cross-report dedupe;
      • missing file;
      • each validation error, including expires past the SLA with and without sla_exception.
    • Dispatch security-scan.yml from the draft branch with candidate_ref=v0.1.0-pre.11 and confirm the CodeQL, Zizmor, and Trivy static gates pass with the entries in place.
    • mise run pre-commit.

    Documentation

    • SECURITY.md: remediation SLAs, and the rule that the clock starts at first detection.
    • CI.md: how to add, extend, or remove a disposition, and how to find alert numbers.
    • architecture/build.md: a short description of the disposition gate.

    Follow-ups (not in this change)

    • Version-based deferral: a deferral names the release it blocks, instead of a date.
    • A helper that generates [disposition.alerts] blocks from the code-scanning API.
    • Reconsider the per-path count cap.
  3. purp commented on Sep 24, 2026

    @purp
    CollaboratorAuthor

    I'm going to put up a draft PR as a way to look at the approach. Not excited about this big of a late-breaking change, and probably won't push to land it before 0.1.0 (or maybe at all).

  4. purp commented on Sep 24, 2026

    @purp
    CollaboratorAuthor

    Had a skim through it and not comfortable trying to land this before 0.1.0, so we'll manage things manually for the 0.1.0 release then revisit this.

  5. github-actions commented on Oct 12, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:buildRelated to CI/CD and buildsstate:staleInactive item at risk of automatic closure.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions