diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml index a8cb49f756..b04ec6dcea 100644 --- a/.github/workflows/osv-scanner-pr.yml +++ b/.github/workflows/osv-scanner-pr.yml @@ -1,7 +1,7 @@ -# Keeps the upstream OSV base/head diff check available on every PR. The -# central Security Scan workflow owns the blocking OSV result, finding logs, -# and SARIF upload so this supplemental check does not duplicate installation -# API calls or fail an otherwise clean PR when GitHub's upload quota is spent. +# Keeps this repository's historical OSV check context available for classic +# branch protection. Organization PRs receive OSV from security-scan.yml only; +# this supplemental workflow is intentionally absent from the organization +# required-workflow ruleset. name: OSV-Scanner PR on: @@ -121,4 +121,4 @@ jobs: upload-sarif: false # Merge gating is done by central security-scan.yml with # --fail-on-vuln=true after printing package, version, OSV ID and aliases. - fail-on-vuln: false \ No newline at end of file + fail-on-vuln: false diff --git a/.github/workflows/scorecard-pr.yml b/.github/workflows/scorecard-pr.yml index 9051c1b851..f0c5071bb8 100644 --- a/.github/workflows/scorecard-pr.yml +++ b/.github/workflows/scorecard-pr.yml @@ -1,7 +1,7 @@ -# Runs a supplemental OpenSSF Scorecard analysis on every PR and preserves its -# filtered SARIF as an artifact. The central Security Scan workflow owns the -# PR code-scanning upload so this workflow does not duplicate installation API -# calls or fail a clean PR when GitHub's upload quota is spent. +# Keeps this repository's historical Scorecard context available for classic +# branch protection. Organization PRs receive Scorecard from security-scan.yml +# only; this supplemental workflow is intentionally absent from the +# organization required-workflow ruleset. # # NOTE: Scorecard reports repository-posture findings (branch protection, token # permissions, dependency pinning, ...) that are unrelated to the PR diff. The @@ -158,4 +158,4 @@ jobs: with: name: scorecard-pr-sarif-${{ github.run_id }}-${{ github.run_attempt }} path: results.sarif - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 9fa685838e..45f10d5458 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -9,6 +9,10 @@ # trivy-fs HARD repo-wide — fails on FIXABLE MEDIUM/HIGH/CRITICAL findings # scorecard SOFT repo posture — uploaded for visibility, never blocks # +# This is the sole organization-required owner for OSV and Scorecard PR work. +# The standalone workflows remain local to this repository because its classic +# branch protection still requires their historical check contexts. +# # Gating is by the JOB result (a failed job fails this required workflow -> # merge blocked), NOT by the code_scanning ruleset rule. The code_scanning rule # stays CodeQL-only on purpose: requiring multiple code-scanning TOOLS there is diff --git a/scripts/ci/audit_central_required_workflows.py b/scripts/ci/audit_central_required_workflows.py index 8b3d07b406..4820c93b9c 100755 --- a/scripts/ci/audit_central_required_workflows.py +++ b/scripts/ci/audit_central_required_workflows.py @@ -31,8 +31,6 @@ ".github/workflows/security-scan.yml", ".github/workflows/strix.yml", ".github/workflows/sast-semgrep.yml", - ".github/workflows/osv-scanner-pr.yml", - ".github/workflows/scorecard-pr.yml", ) STACKED_WORKFLOW_PATH = ".github/workflows/opencode-review.yml" diff --git a/tests/test_central_required_workflow_ruleset_audit.py b/tests/test_central_required_workflow_ruleset_audit.py index 7f3cc01397..a7320e47ff 100644 --- a/tests/test_central_required_workflow_ruleset_audit.py +++ b/tests/test_central_required_workflow_ruleset_audit.py @@ -17,8 +17,6 @@ def ruleset_payload() -> dict: "security-scan.yml", "strix.yml", "sast-semgrep.yml", - "osv-scanner-pr.yml", - "scorecard-pr.yml", ) return { "id": 18156473, @@ -115,7 +113,7 @@ def test_expected_central_ruleset_passes(monkeypatch, capsys) -> None: assert audit.main([]) == 0 assert ( - "PASS: ruleset 18156473 enforces 9 central required workflows" + "PASS: ruleset 18156473 enforces 7 central required workflows" in capsys.readouterr().out ) @@ -254,32 +252,42 @@ def test_missing_noema_workflow_reports_exact_drift() -> None: assert "missing central required workflow .github/workflows/noema-review.yml" in errors -def test_missing_osv_scanner_workflow_reports_exact_drift() -> None: +def test_readded_osv_scanner_workflow_reports_duplicate_scan() -> None: payload = ruleset_payload() workflow_rule = next(rule for rule in payload["rules"] if rule["type"] == "workflows") - workflow_rule["parameters"]["workflows"] = [ - workflow - for workflow in workflow_rule["parameters"]["workflows"] - if workflow["path"] != ".github/workflows/osv-scanner-pr.yml" - ] + workflow_rule["parameters"]["workflows"].append( + { + "repository_id": 1274066402, + "path": ".github/workflows/osv-scanner-pr.yml", + "ref": "refs/heads/main", + } + ) errors = audit.audit_ruleset(payload) - assert "missing central required workflow .github/workflows/osv-scanner-pr.yml" in errors + assert ( + "unexpected workflow present in required set: .github/workflows/osv-scanner-pr.yml" + in errors + ) -def test_missing_scorecard_workflow_reports_exact_drift() -> None: +def test_readded_scorecard_workflow_reports_duplicate_scan() -> None: payload = ruleset_payload() workflow_rule = next(rule for rule in payload["rules"] if rule["type"] == "workflows") - workflow_rule["parameters"]["workflows"] = [ - workflow - for workflow in workflow_rule["parameters"]["workflows"] - if workflow["path"] != ".github/workflows/scorecard-pr.yml" - ] + workflow_rule["parameters"]["workflows"].append( + { + "repository_id": 1274066402, + "path": ".github/workflows/scorecard-pr.yml", + "ref": "refs/heads/main", + } + ) errors = audit.audit_ruleset(payload) - assert "missing central required workflow .github/workflows/scorecard-pr.yml" in errors + assert ( + "unexpected workflow present in required set: .github/workflows/scorecard-pr.yml" + in errors + ) def test_readded_codeql_workflow_alongside_full_set_reports_unexpected_entry() -> None: @@ -383,8 +391,6 @@ def test_audit_reports_all_structural_and_protection_drift() -> None: "missing central required workflow .github/workflows/security-scan.yml", "missing central required workflow .github/workflows/strix.yml", "missing central required workflow .github/workflows/sast-semgrep.yml", - "missing central required workflow .github/workflows/osv-scanner-pr.yml", - "missing central required workflow .github/workflows/scorecard-pr.yml", "expected one pull_request rule, found 0", "default-branch deletion protection is missing", "default-branch non-fast-forward protection is missing", @@ -397,7 +403,13 @@ def test_audit_reports_malformed_duplicate_workflows_and_weak_review_parameters( workflows = workflow_rule["parameters"]["workflows"] workflows.insert(0, "malformed") workflows.insert(1, {"path": 42}) - workflows.append(deepcopy(workflows[-1])) + security_scan = next( + workflow + for workflow in workflows + if isinstance(workflow, dict) + and workflow.get("path") == ".github/workflows/security-scan.yml" + ) + workflows.append(deepcopy(security_scan)) review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") review_rule["parameters"] = { "required_approving_review_count": 0, @@ -411,7 +423,7 @@ def test_audit_reports_malformed_duplicate_workflows_and_weak_review_parameters( assert "central required workflow entry 0 is malformed" in errors assert "central required workflow entry 1 is malformed" in errors - assert "central required workflow .github/workflows/scorecard-pr.yml is configured 2 times" in errors + assert "central required workflow .github/workflows/security-scan.yml is configured 2 times" in errors assert "exactly two approving reviews are not required" in errors assert "stale-review dismissal on push is disabled" in errors assert "last-push approval protection is disabled" in errors diff --git a/tests/test_code_scanning_required_workflow_contract.py b/tests/test_code_scanning_required_workflow_contract.py index 435e13fa0c..a7a571a79a 100644 --- a/tests/test_code_scanning_required_workflow_contract.py +++ b/tests/test_code_scanning_required_workflow_contract.py @@ -1,17 +1,35 @@ -"""Regression contract for organization-required code-scanning workflows.""" +"""Regression contract for the consolidated organization security scan.""" + +from pathlib import Path from scripts.ci import audit_central_required_workflows as audit -_REQUIRED_CODE_SCANNING_WORKFLOW_PATHS = { +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_SUPPLEMENTAL_CODE_SCANNING_WORKFLOW_PATHS = { ".github/workflows/scorecard-pr.yml", ".github/workflows/osv-scanner-pr.yml", } -def test_ruleset_audit_requires_every_code_scanning_workflow() -> None: - """The central audit must fail if either live code-scanning requirement disappears.""" - assert _REQUIRED_CODE_SCANNING_WORKFLOW_PATHS <= set(audit.REQUIRED_WORKFLOW_PATHS) +def test_ruleset_requires_only_the_consolidated_security_scan() -> None: + """Do not inject duplicate OSV and Scorecard runs into every repository PR.""" + required_paths = set(audit.REQUIRED_WORKFLOW_PATHS) + + assert ".github/workflows/security-scan.yml" in required_paths + assert _SUPPLEMENTAL_CODE_SCANNING_WORKFLOW_PATHS.isdisjoint(required_paths) + + +def test_consolidated_security_scan_preserves_osv_and_scorecard_evidence() -> None: + """The sole required owner must retain both scanners and their SARIF uploads.""" + workflow = ( + REPOSITORY_ROOT / ".github/workflows/security-scan.yml" + ).read_text(encoding="utf-8") + + assert " osv-scan:" in workflow + assert " scorecard:" in workflow + assert "Upload OSV SARIF to code scanning" in workflow + assert "Upload Scorecard SARIF to code scanning" in workflow def test_ruleset_audit_deliberately_excludes_codeql_pr() -> None: