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..e0b30fb16 --- /dev/null +++ b/.github/workflows/security-scan-exact-head-quality-ci.yml @@ -0,0 +1,43 @@ +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() + 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..4314ad3f1 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -257,9 +257,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 @@ -311,9 +313,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 +391,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 +409,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 +469,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 e601de81b..9b4e84e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,6 @@ 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. - 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/docs/doctoring/security-scan-exact-head.md b/docs/doctoring/security-scan-exact-head.md new file mode 100644 index 000000000..dedad3a41 --- /dev/null +++ b/docs/doctoring/security-scan-exact-head.md @@ -0,0 +1,49 @@ +# 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. + +## 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 only makes scanner input and result identity consistent. + +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 for all three affected jobs. `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. + +## Rollback + +Rollback requires an independently reviewed revert and fresh exact-head security evidence. Do not restore implicit checkout or automatic SARIF revision detection unless an equally strict mechanism proves that the scanned filesystem, SARIF `ref`, and SARIF `sha` all identify the same current pull-request head. + +## APA 7th references + +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..3b6e34a57 --- /dev/null +++ b/tests/test_security_scan_exact_head.py @@ -0,0 +1,57 @@ +"""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 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