diff --git a/.github/workflows/security-scan-exact-head-quality-ci.yml b/.github/workflows/security-scan-exact-head-quality-ci.yml new file mode 100644 index 000000000..8c84ecceb --- /dev/null +++ b/.github/workflows/security-scan-exact-head-quality-ci.yml @@ -0,0 +1,44 @@ +name: Security Scan Exact-Head Quality CI + +on: + pull_request: + paths: + - ".github/workflows/security-scan.yml" + - ".github/workflows/security-scan-exact-head-quality-ci.yml" + - "tests/test_security_scan_exact_head.py" + - "tests/test_security_scan_sarif_exact_head.py" + +concurrency: + group: security-scan-exact-head-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + exact-head-contract: + runs-on: ubuntu-24.04 + steps: + - name: Checkout literal pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Compile exact-head contracts + run: >- + python3 -m py_compile + tests/test_security_scan_exact_head.py + tests/test_security_scan_sarif_exact_head.py + - name: Execute dependency-free exact-head contracts + run: | + python3 - <<'PY' + from tests import test_security_scan_exact_head as checkout_contract + from tests import test_security_scan_sarif_exact_head as sarif_contract + + checkout_contract.test_repository_scanners_checkout_the_literal_pull_request_head() + checkout_contract.test_dependency_review_checkout_is_bound_to_the_same_exact_head() + checkout_contract.test_dependency_review_support_probe_fails_closed_unless_api_returns_200() + sarif_contract.test_repository_scanner_sarif_is_attributed_to_the_literal_head() + print("security scan exact-head contracts passed") + PY diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c3b8fa5db..97f02f67f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -16,10 +16,11 @@ # pull_request workflows upload to refs/pull/N/merge, so no single ref ever holds # all tools. Bundling at the workflow/check level is ref-independent. # -# NOTE on dependency-review: dependency graph can be unavailable on some repos. -# Treat that as "not enforceable here" instead of making the required workflow -# unsatisfiable; keep medium-or-higher dependency findings hard-failing where the -# API is supported. +# NOTE on dependency-review: for this organization-owned hard gate, unavailable +# dependency-review evidence is not a clean result. Only an exact base/head API +# comparison that returns HTTP 200 may proceed to the pinned dependency-review +# action; every other support-probe outcome fails closed without printing the +# untrusted API response body. # # NOTE on trivy-fs: it scans the whole repo, so a pre-existing FIXABLE # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. @@ -257,9 +258,11 @@ jobs: contents: read pull-requests: read steps: - - name: Checkout + - name: Checkout exact head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Check dependency review support id: dependency_review_support @@ -272,9 +275,10 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" - response_file="$(mktemp)" status="$( - curl -fsS -o "$response_file" -w '%{http_code}' \ + curl -sS --connect-timeout 10 --max-time 30 \ + -o /dev/null \ + -w '%{http_code}' \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ @@ -282,20 +286,12 @@ jobs: || true )" - if [ "$status" = "200" ]; then - echo "supported=true" >>"$GITHUB_OUTPUT" - exit 0 - fi - - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." - echo "supported=false" >>"$GITHUB_OUTPUT" - exit 0 + if [ "$status" != "200" ]; then + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${status:-unavailable}. Failing closed." + exit 1 fi - echo "::error::Dependency review support check failed with HTTP ${status}." - cat "$response_file" - exit 1 + echo "supported=true" >>"$GITHUB_OUTPUT" - name: Dependency review if: steps.dependency_review_support.outputs.supported == 'true' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 @@ -311,9 +307,11 @@ jobs: security-events: write actions: read steps: - - name: Checkout + - name: Checkout exact head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Trivy filesystem scan uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 @@ -387,6 +385,8 @@ jobs: with: sarif_file: trivy-results.sarif category: trivy-fs + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} wait-for-processing: false - name: Report Trivy SARIF upload failure if: steps.upload_trivy_sarif.outcome == 'failure' @@ -403,9 +403,11 @@ jobs: contents: read actions: read steps: - - name: Checkout + - name: Checkout exact head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Run Scorecard uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 @@ -461,6 +463,8 @@ jobs: with: sarif_file: results.sarif category: scorecard + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} wait-for-processing: false - name: Report Scorecard SARIF upload failure if: steps.upload_scorecard_sarif.outcome == 'failure' diff --git a/CHANGELOG.md b/CHANGELOG.md index c993bf7cb..3a364565b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Bound dependency-review support, Trivy, and Scorecard checkouts to the literal pull-request head repository and SHA; bound Trivy and Scorecard SARIF uploads to the matching `refs/pull//head` identity; and added permanent dependency-free exact-head regression evidence. Dependency-review availability now fails closed unless the exact base/head comparison returns HTTP 200, discards the untrusted API response body, and never translates 403, 404, transport failure, or another unavailable probe outcome into a green hard gate. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. - Bound both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. diff --git a/README.md b/README.md index 332324301..7df736c70 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,32 @@ root cause, fix direction, and focused rerun command. Cancelled or superseded checks must be described as queue or evidence blockers rather than invented source-code findings. +## Security Scan exact-head operator contract + +Organization-required repository scanners authorize evidence against the +literal pull-request head, not GitHub's generated merge revision. The central +`Security Scan` therefore binds dependency-review support, Trivy, and Scorecard +to the event's explicit head repository and immutable head SHA, and binds Trivy +and Scorecard SARIF uploads to `refs/pull//head` plus that same SHA. +A queued, cancelled, skipped, failed, missing, predecessor-head, or synthetic +merge run is not current-head security evidence. + +Dependency review is a hard supply-chain gate. Its exact base-to-head capability +probe may proceed only on HTTP `200`; `403`, `404`, `000`, malformed or empty +status, timeout, transport failure, and other unexpected outcomes fail closed. +Operators must repair the repository or organization dependency-graph/security +capability, organization policy, entitlement, or read-token access and then +rerun the exact head. Do not convert an unavailable dependency-review endpoint +into a green skip, and do not treat OSV, Trivy, CodeQL, Semgrep, Secret Scan, +Scorecard, or Dependabot as substitutes for dependency-review evidence. +Private or internal repository exceptions require an explicit organization +policy backed by independently reviewable entitlement evidence rather than an +inference from a failed probe. + +The authoritative rationale, rollback procedure, verification contracts, and +APA 7th primary-source references are maintained in +[`docs/doctoring/security-scan-exact-head.md`](docs/doctoring/security-scan-exact-head.md). + Operational cases folded into the central policy: - `naruon`: approved PRs can become `BEHIND`; the scheduler treats that as an diff --git a/docs/doctoring/security-scan-exact-head.md b/docs/doctoring/security-scan-exact-head.md new file mode 100644 index 000000000..6c3d41d11 --- /dev/null +++ b/docs/doctoring/security-scan-exact-head.md @@ -0,0 +1,71 @@ +# Security scan exact-head evidence + +## Decision + +The central `Security Scan` workflow treats the literal pull-request head as the only valid repository-scanner input. GitHub's `pull_request` event normally exposes a generated merge revision through `GITHUB_SHA`; that revision is useful for integration testing but cannot prove that Trivy or Scorecard scanned the exact current contributor head required by CWL authorization policy. + +The dependency-review support checkout, Trivy filesystem scan, and Scorecard posture scan therefore set both: + +```yaml +repository: ${{ github.event.pull_request.head.repo.full_name }} +ref: ${{ github.event.pull_request.head.sha }} +``` + +Persisted checkout credentials remain disabled. Fork pull requests are read through their explicit head repository and immutable commit SHA; no write credential is added. + +## Dependency-review availability is evidence, not an optimization + +Dependency review is a hard supply-chain gate. The support probe compares the exact pull-request base SHA with the exact pull-request head SHA through GitHub's dependency-review API. Only HTTP `200` is accepted as evidence that the pinned `actions/dependency-review-action` may execute. HTTP `403`, `404`, `000`, an empty or malformed status, a transport failure, timeout, or any other unexpected probe result is **not** a clean dependency review and fails the job closed. + +The failure diagnostic records only the repository identifier, exact base SHA, exact head SHA, and HTTP status. The API response body is discarded rather than printed because it is unnecessary for the authorization decision and can contain operational details that do not belong in a public workflow log. Authentication material is never included in the diagnostic. + +A dependency-neutral path classifier is not a substitute for dependency-review evidence. In particular, the workflow must not translate an unavailable API into `not-applicable` merely because another mechanism believes the current diff contains no dependency change. OSV, Trivy, CodeQL, Semgrep, Secret Scan, Scorecard, and Dependabot remain independent controls; none semantically replaces the dependency-diff gate. + +## Operator remediation for an unavailable gate + +For a public GitHub.com repository, a `403` or `404` from the dependency-review comparison endpoint is treated as a repository or organization configuration problem until evidence proves otherwise. An operator should verify that the dependency graph and the GitHub security features required for dependency review are enabled for the repository and organization, that organization policy permits the endpoint, and that the workflow's read-only token receives the documented access needed by the dependency-review API and action. Rerun only after the capability or policy path is corrected; do not weaken the workflow to manufacture a green check. + +Private or internal repositories can have different product-entitlement and policy requirements. Any exception for those repository classes must be designed as an explicit organization policy with independently reviewable entitlement evidence. It must not be inferred from a failed probe and must not weaken the public-repository canary semantics. + +## Durable SARIF identity + +Scanning the head is insufficient when durable code-scanning evidence is attributed to a different revision. Trivy and Scorecard uploads explicitly bind: + +```yaml +ref: refs/pull/${{ github.event.pull_request.number }}/head +sha: ${{ github.event.pull_request.head.sha }} +``` + +GitHub's code-scanning API requires both a full Git reference and the commit SHA to which an uploaded analysis relates. The pair above states that the SARIF describes the pull-request head, not the generated merge commit. + +## Preserved security behavior + +This change does not alter scanner versions, vulnerability severities, Trivy's fixable Medium-or-higher hard gate, dependency-review thresholds, Scorecard's soft posture role, SARIF sanitation, permissions, or the existing OSV base-versus-head comparison. It makes scanner input and result identity consistent and makes unavailable dependency-review evidence an explicit hard failure instead of a green skip. + +The workflow remains fail closed for absent scanner output and actionable findings. SARIF upload failures remain separately visible without suppressing the repository-local Trivy finding gate. A queued, cancelled, skipped, failed, missing, or predecessor-head run is not current-head evidence. + +## Verification + +`tests/test_security_scan_exact_head.py` verifies literal-head checkout and the rule that only an HTTP `200` support probe may reach dependency review. It also rejects the former `supported=false` / skip path and response-body logging. `tests/test_security_scan_sarif_exact_head.py` verifies durable Trivy and Scorecard SARIF attribution. The dedicated read-only quality workflow checks out the literal PR head, compiles both contracts, and executes them without package installation. + +The initiating DiskSage evidence was Security Scan run `31070907732`, whose Trivy job log checked out `refs/remotes/pull/137/merge` rather than DiskSage PR #137 head `87ac0e08cceed3d1a766da13a8f8123912178192`. That result remains historical merge-tree evidence and is not reclassified as exact-head proof. + +The dependency-review availability regression was reproduced on the public EgressWeave canary: a support probe returned HTTP `403`, the former workflow marked the hard action skipped, and the aggregate Security Scan still concluded success. That historical result is unavailable dependency-review evidence, not proof of a clean dependency diff. + +## Rollback + +Rollback requires an independently reviewed revert and fresh exact-head security evidence. Do not restore implicit checkout, automatic SARIF revision detection, or a fail-open dependency-review support path unless an equally strict mechanism proves the same authorization properties. In particular, never convert `403`, `404`, transport failure, or another unavailable probe outcome into a successful hard gate. + +## APA 7th references + +GitHub. (n.d.). *Dependency review*. GitHub Docs. Retrieved August 7, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-review + +GitHub. (n.d.). *REST API endpoints for dependency review*. GitHub Docs. Retrieved August 7, 2026, from https://docs.github.com/en/enterprise-cloud@latest/rest/dependency-graph/dependency-review + +GitHub. (n.d.). *Customizing your dependency review action configuration*. GitHub Docs. Retrieved August 7, 2026, from https://docs.github.com/en/code-security/tutorials/secure-your-dependencies/customize-dependency-review-action + +GitHub. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub. (n.d.). *REST API endpoints for code scanning*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/code-scanning/code-scanning + +GitHub. (n.d.). *Uploading CodeQL analysis results to GitHub*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/enterprise-cloud@latest/code-security/tutorials/customize-code-scanning/upload-results diff --git a/tests/test_security_scan_exact_head.py b/tests/test_security_scan_exact_head.py new file mode 100644 index 000000000..d544d1e29 --- /dev/null +++ b/tests/test_security_scan_exact_head.py @@ -0,0 +1,76 @@ +"""Exact-head contracts for the organization security scanner workflow.""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "security-scan.yml" + + +def workflow_job(workflow: str, job_name: str) -> str: + """Return one top-level job block from the central security workflow. + + The workflow uses two-space-indented job identifiers. Normalizing line + endings keeps this contract deterministic on Windows and Unix checkouts. + """ + + normalized = workflow.replace("\r\n", "\n").replace("\r", "\n") + marker = f"\n {job_name}:\n" + start = normalized.index(marker) + len(marker) + remaining = normalized[start:] + candidates = [ + offset + for line in remaining.splitlines(keepends=True) + if (offset := remaining.find(line)) >= 0 + and line.startswith(" ") + and not line.startswith(" ") + and line.rstrip().endswith(":") + ] + if not candidates: + return remaining + first = min(offset for offset in candidates if offset > 0) + return remaining[:first] + + +def test_repository_scanners_checkout_the_literal_pull_request_head() -> None: + """Trivy and Scorecard must never scan GitHub's synthetic merge ref.""" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + exact_repository = "repository: ${{ github.event.pull_request.head.repo.full_name }}" + exact_head = "ref: ${{ github.event.pull_request.head.sha }}" + + for job_name in ("trivy-fs", "scorecard"): + job = workflow_job(workflow, job_name) + assert exact_repository in job + assert exact_head in job + assert "persist-credentials: false" in job + + +def test_dependency_review_checkout_is_bound_to_the_same_exact_head() -> None: + """Supporting checkout evidence must match the API comparison head.""" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + job = workflow_job(workflow, "dependency-review") + + assert "repository: ${{ github.event.pull_request.head.repo.full_name }}" in job + assert "ref: ${{ github.event.pull_request.head.sha }}" in job + assert "HEAD_SHA: ${{ github.event.pull_request.head.sha }}" in job + + +def test_dependency_review_support_probe_fails_closed_unless_api_returns_200() -> None: + """Unavailable dependency-review evidence must never become a green gate.""" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + job = workflow_job(workflow, "dependency-review") + + assert 'if [ "$status" != "200" ]; then' in job + assert "supported=false" not in job + assert "skipping dependency-review hard gate" not in job + assert 'cat "$response_file"' not in job + assert "-o /dev/null" in job + assert "--connect-timeout 10" in job + assert "--max-time 30" in job + assert "${REPOSITORY}" in job + assert "${BASE_SHA}" in job + assert "${HEAD_SHA}" in job + assert "HTTP ${status:-unavailable}" in job diff --git a/tests/test_security_scan_sarif_exact_head.py b/tests/test_security_scan_sarif_exact_head.py new file mode 100644 index 000000000..78f3e8bc3 --- /dev/null +++ b/tests/test_security_scan_sarif_exact_head.py @@ -0,0 +1,40 @@ +"""Durable exact-head SARIF contracts for central repository scanners.""" + +from pathlib import Path + + +WORKFLOW_PATH = ( + Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "security-scan.yml" +) + + +def _job_block(workflow: str, job_name: str) -> str: + """Return one two-space-indented GitHub Actions job block.""" + + normalized = workflow.replace("\r\n", "\n").replace("\r", "\n") + marker = f"\n {job_name}:\n" + start = normalized.index(marker) + len(marker) + remaining = normalized[start:] + offset = 0 + for line in remaining.splitlines(keepends=True): + if offset and line.startswith(" ") and not line.startswith(" "): + if line.rstrip().endswith(":"): + return remaining[:offset] + offset += len(line) + return remaining + + +def test_repository_scanner_sarif_is_attributed_to_the_literal_head() -> None: + """Trivy and Scorecard SARIF must identify the exact scanned head SHA.""" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + expected_ref = "ref: refs/pull/${{ github.event.pull_request.number }}/head" + expected_sha = "sha: ${{ github.event.pull_request.head.sha }}" + + for job_name in ("trivy-fs", "scorecard"): + job = _job_block(workflow, job_name) + assert expected_ref in job + assert expected_sha in job