diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index b038c5478e..e828943a49 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -1,5 +1,9 @@ name: Exact Artifact SBOM Attestation +# This reusable workflow intentionally supports same-repository callers only. +# The evidence artifact and source commit are authenticated against the current +# run's repository and SHA below; cross-repository callers must add a separately +# reviewed workflow-identity exchange before this boundary can be widened. on: workflow_call: inputs: @@ -395,9 +399,9 @@ jobs: offline-attestation-evidence/verified-handoff.json - name: Export beginner-readable offline verification evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: exact-artifact-sbom-offline-verification path: offline-attestation-evidence if-no-files-found: error - retention-days: 90 \ No newline at end of file + retention-days: 90 diff --git a/.github/workflows/organization-commercial-readiness-loop.yml b/.github/workflows/organization-commercial-readiness-loop.yml index fbe7efa434..23697989bb 100644 --- a/.github/workflows/organization-commercial-readiness-loop.yml +++ b/.github/workflows/organization-commercial-readiness-loop.yml @@ -30,7 +30,7 @@ jobs: MAX_DEVELOPMENT_DISPATCHES: "1" steps: - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.13.2 + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: block allowed-endpoints: >- diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 500e22b4ab..264913bb21 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -283,7 +283,7 @@ jobs: if len(findings) > 50: print(f"... {len(findings) - 50} additional {label} OSV finding(s) omitted from the log summary.") - name: Report PR-introduced OSV findings - uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.3.8 + uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --output=results.sarif @@ -335,7 +335,7 @@ jobs: echo "::warning::OSV SARIF upload to code scanning failed after the base/head comparison. The PR-introduced vulnerability reporter above remains the hard gate, so upload rate limits cannot hide OSV findings." - name: Upload OSV debug artifacts if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: osv-scan-debug path: | diff --git a/CHANGELOG.d/20260910-action-pin-annotation-integrity.md b/CHANGELOG.d/20260910-action-pin-annotation-integrity.md new file mode 100644 index 0000000000..2ad2caed9f --- /dev/null +++ b/CHANGELOG.d/20260910-action-pin-annotation-integrity.md @@ -0,0 +1 @@ +Validate that immutable GitHub Actions pins use one consistent release annotation across the repository, and correct two stale annotations. diff --git a/tests/test_action_pin_annotation_integrity.py b/tests/test_action_pin_annotation_integrity.py new file mode 100644 index 0000000000..df3bca6d92 --- /dev/null +++ b/tests/test_action_pin_annotation_integrity.py @@ -0,0 +1,159 @@ +"""Keep release annotations consistent for immutable GitHub Action pins.""" + +from collections import defaultdict +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +OSV_SCANNER_SHA = "8e5cf47b818121e8b405931c82126c2630b0b20d" +ACTION_PIN = re.compile( + r"^\s*uses:\s+(?P[^\s@]+)@(?P[0-9a-f]{40})\s+#\s*(?P\S.*?)\s*$" +) +IMMUTABLE_PIN = re.compile(r"^\s*uses:\s+[^\s@]+@[0-9a-f]{40}(?:\s|$)") +ACTION_REFERENCE = re.compile(r"^\s*uses:\s+(?P[^\s@]+)@(?P[^\s#]+)") +RELEASE_ANNOTATION = re.compile(r"^v[0-9]+(?:\.[0-9]+)*(?:[-+][0-9A-Za-z.-]+)?$") + + +def _action_files() -> list[Path]: + """Return workflow and composite-action YAML files.""" + + return sorted( + path + for directory in (ROOT / ".github" / "workflows", ROOT / ".github" / "actions") + for path in (*directory.rglob("*.yml"), *directory.rglob("*.yaml")) + ) + + +def test_each_action_pin_sha_has_one_release_annotation() -> None: + """The same immutable action identity must not advertise different releases.""" + + annotations: defaultdict[tuple[str, str], list[tuple[str, int, str]]] = defaultdict( + list + ) + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = ACTION_PIN.match(line) + if match: + identity = (match["action"], match["sha"]) + annotations[identity].append( + (str(path.relative_to(ROOT)), line_number, match["annotation"]) + ) + + contradictions = { + identity: locations + for identity, locations in annotations.items() + if len({annotation for _, _, annotation in locations}) > 1 + } + assert not contradictions, "contradictory immutable action annotations: " + repr( + contradictions + ) + + +def test_each_immutable_action_pin_has_a_release_annotation() -> None: + """Do not let an unlabelled immutable pin evade the integrity checks.""" + + missing: list[tuple[str, int, str]] = [] + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + if IMMUTABLE_PIN.match(line) and not ACTION_PIN.match(line): + missing.append((str(path.relative_to(ROOT)), line_number, line.strip())) + + assert not missing, "immutable action pins need release annotations: " + repr(missing) + + +def test_each_external_action_reference_is_immutable() -> None: + """Do not permit mutable tags or branches for third-party actions.""" + + mutable: list[tuple[str, int, str]] = [] + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = ACTION_REFERENCE.match(line) + if ( + match + and not match["action"].startswith("./") + and not re.fullmatch(r"[0-9a-f]{40}", match["ref"]) + ): + mutable.append((str(path.relative_to(ROOT)), line_number, line.strip())) + + assert not mutable, "external actions must use immutable commit SHAs: " + repr(mutable) + + +def test_action_pin_annotations_are_release_shaped() -> None: + """Reject labels that cannot identify a concrete released revision.""" + + malformed: list[tuple[str, int, str]] = [] + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = ACTION_PIN.match(line) + if match and not RELEASE_ANNOTATION.fullmatch(match["annotation"]): + malformed.append( + (str(path.relative_to(ROOT)), line_number, match["annotation"]) + ) + + assert not malformed, "action pin annotations must identify a release: " + repr( + malformed + ) + + +def test_action_subpaths_share_the_repository_sha_annotation() -> None: + """Sub-actions from one immutable repository revision share one label.""" + + annotations: defaultdict[tuple[str, str], set[str]] = defaultdict(set) + for directory in (ROOT / ".github" / "workflows", ROOT / ".github" / "actions"): + for path in sorted((*directory.rglob("*.yml"), *directory.rglob("*.yaml"))): + for line in path.read_text(encoding="utf-8").splitlines(): + match = ACTION_PIN.match(line) + if match: + repository = "/".join(match["action"].split("/")[:2]) + annotations[(repository, match["sha"])].add(match["annotation"]) + + contradictions = { + identity: sorted(values) + for identity, values in annotations.items() + if len(values) > 1 + } + assert not contradictions, "contradictory repository pin annotations: " + repr( + contradictions + ) + + +def test_upload_artifact_uses_the_reviewed_node24_pin() -> None: + """Keep artifact uploads off the deprecated Node 20 action runtime.""" + + upload_pins = [] + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = ACTION_PIN.match(line) + if match and match["action"] == "actions/upload-artifact": + upload_pins.append((path, line_number, match["sha"])) + + assert upload_pins + assert all(sha == UPLOAD_ARTIFACT_SHA for _, _, sha in upload_pins), upload_pins + + +def test_osv_action_subpaths_use_one_current_upstream_pin() -> None: + """Keep scanner and reporter sub-actions on one fail-closed revision.""" + + osv_pins = [] + for path in _action_files(): + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + match = ACTION_PIN.match(line) + if match and match["action"].startswith("google/osv-scanner-action/"): + osv_pins.append((path, line_number, match["action"], match["sha"])) + + assert osv_pins + assert all(sha == OSV_SCANNER_SHA for _, _, _, sha in osv_pins), osv_pins