From cb07b8bb28ef9d3a147cc966a0c70654d132da1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:02:47 +0900 Subject: [PATCH 01/13] test(security): require non-200 dependency review fail-closed --- ...dency_review_reusable_workflow_contract.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index 3a856b4e2b..ab627a766f 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -36,28 +36,28 @@ def test_declares_workflow_call_with_three_inputs_and_recorded_defaults() -> Non assert "default: false" in workflow -def test_step_order_is_checkout_then_preflight_then_gated_steps() -> None: - """checkout -> dependency-graph preflight -> conditional gate/note, in that order.""" +def test_step_order_is_checkout_then_preflight_then_dependency_review() -> None: + """Checkout, capability preflight, then the gated action must remain ordered.""" workflow = _workflow_text() order = [ "actions/checkout@", "Check dependency graph availability", "Dependency review", - "Dependency graph unavailable note", ] positions = [workflow.index(marker) for marker in order] assert positions == sorted(positions), "steps are out of order" -def test_dependency_review_and_note_steps_are_mutually_exclusive_on_availability() -> None: - """The gate and the fallback note must never both run.""" +def test_dependency_review_runs_only_after_a_confirmed_successful_comparison() -> None: + """The action must execute only after the compare endpoint returned HTTP 200.""" workflow = _workflow_text() assert ( "if: steps.dependency_graph.outputs.available == 'true'\n" " continue-on-error: ${{ inputs.continue_on_error }}" in workflow ) - assert "if: steps.dependency_graph.outputs.available != 'true'" in workflow + assert 'if [ "$status" = "200" ]; then' in workflow + assert 'echo "available=true" >>"$GITHUB_OUTPUT"' in workflow def test_inputs_are_forwarded_to_the_dependency_review_action() -> None: @@ -96,19 +96,23 @@ def test_availability_check_uses_the_dependency_graph_compare_api() -> None: assert "github.event.repository.private" not in workflow -def test_availability_check_distinguishes_unavailable_from_genuine_failure() -> None: - """403/404 means 'unavailable, skip gracefully'; any other status must hard-fail - the job instead of silently treating a real error the same as unavailability.""" +def test_pull_request_http_403_and_404_are_not_normalized_to_unavailable() -> None: + """Authorization-shaped HTTP responses are ambiguous and must remain blocking. + + GitHub may use 403 or 404 for permission/policy failures as well as feature + availability boundaries. A reusable security gate therefore cannot turn + either response into success or delegate coverage to another scanner. + """ workflow = _workflow_text() - assert 'if [ "$status" = "403" ] || [ "$status" = "404" ]' in workflow - assert "available=false" in workflow - assert "::error::Dependency graph availability check failed with HTTP" in workflow + assert 'if [ "$status" = "403" ] || [ "$status" = "404" ]' not in workflow + assert "skipping the dependency-review hard gate" not in workflow + assert "Dependency graph unavailable note" not in workflow + assert "::error::Dependency graph comparison failed with HTTP" in workflow assert "exit 1" in workflow def test_availability_check_only_runs_the_gate_for_pull_request_events() -> None: - """A non-pull_request trigger (e.g. workflow_dispatch) must skip the gate, not error, - since base/head SHAs only exist on a pull_request event.""" + """A non-pull_request trigger may skip because it has no PR base/head identity.""" workflow = _workflow_text() assert '"${{ github.event_name }}" != "pull_request"' in workflow From 31f60e532e135008cabd09fcddd46a53062b0ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:03:36 +0900 Subject: [PATCH 02/13] fix(security): fail closed on dependency graph non-200 --- .github/workflows/dependency-review.yml | 31 ++++++++----------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d199dd36a0..5a41da2dce 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -9,14 +9,13 @@ # calling repo's own thin workflow file -- a workflow_call target cannot also # be the thing GitHub triggers directly on pull_request. # -# Dependency Review requires GitHub Dependency Graph (and, on private repos -# without GitHub Advanced Security, it is unavailable regardless of a repo's -# own settings). scopeweave's original workflow already detected this -# dynamically via the dependency-graph compare API instead of assuming from -# public/private repository status (mightyETL's original approach, which is -# wrong for a private repo that does have GHAS). This reusable workflow -# adopts the dynamic detection as the common, more-correct behavior for -# every caller, so no per-repo public/private input is needed. +# Dependency Review requires a successful Dependency Graph comparison for the +# exact pull-request base/head. Repository visibility is not a sufficient +# capability signal, and HTTP 403/404 are not safe availability signals: +# GitHub can use those statuses for authorization/policy denials as well as +# unavailable resources. For a pull_request, only HTTP 200 authorizes running +# the pinned action; every non-200 comparison fails closed. Non-pull_request +# triggers may skip because they do not carry the exact PR base/head identity. # # Example caller (.github/workflows/dependency-review.yml in a product repo): # @@ -113,14 +112,10 @@ jobs: exit 0 fi - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency graph compare returned HTTP ${status} for ${REPOSITORY}; skipping the dependency-review hard gate (GitHub Dependency Graph, or GitHub Advanced Security on a private repository, is unavailable)." - echo "available=false" >>"$GITHUB_OUTPUT" - exit 0 + echo "::error::Dependency graph comparison failed with HTTP ${status}. For a pull_request, non-200 responses are ambiguous between feature availability and authorization/policy/transport failure, so Dependency Review fails closed instead of being skipped." + if [ -s "$response_file" ]; then + cat "$response_file" fi - - echo "::error::Dependency graph availability check failed with HTTP ${status}. This is not a 'graph unavailable' response (403/404) -- treating it as a genuine failure instead of silently skipping the security gate." - cat "$response_file" exit 1 - name: Dependency review @@ -131,9 +126,3 @@ jobs: fail-on-severity: ${{ inputs.fail_on_severity }} allow-ghsas: ${{ inputs.allow_ghsas }} comment-summary-in-pr: on-failure - - - name: Dependency graph unavailable note - if: steps.dependency_graph.outputs.available != 'true' && github.event_name == 'pull_request' - run: | - echo "Dependency Review requires GitHub Dependency Graph to be enabled for this repository (and, on private repositories, GitHub Advanced Security)." - echo "Other required dependency-vulnerability gates (OSV-Scanner, Scorecard) remain the blocking coverage until Dependency Graph is available here." From ee0f1ce544965772775b590050e40476df4ea8f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:25:41 +0900 Subject: [PATCH 03/13] test(security): require caller permission envelope --- ...t_dependency_review_reusable_workflow_contract.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index ab627a766f..1efca22842 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -83,6 +83,18 @@ def test_uniform_fields_are_hardcoded_not_parameterized() -> None: assert "persist-credentials: false" in workflow +def test_example_caller_preserves_required_permission_envelope() -> None: + """Thin callers must explicitly pass the reusable job's read permission ceiling.""" + workflow = _workflow_text() + assert ( + "# permissions:\n" + "# contents: read\n" + "# pull-requests: read\n" + "# concurrency:" + in workflow + ) + + def test_forces_node24_runtime_for_js_actions() -> None: """newsdom-api's Node24 opt-in applies uniformly, not only to that one caller.""" workflow = _workflow_text() From ca3bdbd210de988ccd31f7fb96d3a97adfdb9bff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:26:30 +0900 Subject: [PATCH 04/13] fix(security): preserve dependency-review caller permissions --- .github/workflows/dependency-review.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5a41da2dce..eac79637ee 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,17 +17,25 @@ # the pinned action; every non-200 comparison fails closed. Non-pull_request # triggers may skip because they do not carry the exact PR base/head identity. # +# Reusable-workflow permissions can only be maintained or reduced through the +# call chain. Every thin caller therefore must declare at least `contents: +# read` and `pull-requests: read`; this workflow cannot elevate a caller token +# that omitted those scopes. +# # Example caller (.github/workflows/dependency-review.yml in a product repo): # # name: Dependency Review # on: # pull_request: +# permissions: +# contents: read +# pull-requests: read # concurrency: # group: dependency-review-${{ github.event.pull_request.number || github.ref }} # cancel-in-progress: true # jobs: # dependency-review: -# uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@main +# uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@ # with: # fail_on_severity: high # allow_ghsas: "GHSA-69w3-r845-3855" From 6d288fff61e02b301da2ec645c824b4e5dfaba53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:29:13 +0900 Subject: [PATCH 05/13] docs(security): record dependency-review authority boundary --- ...-review-fail-closed-permission-envelope.md | 72 ++++++++++++++++++ ...-review-fail-closed-permission-envelope.md | 74 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 docs/adr/0025-dependency-review-fail-closed-permission-envelope.md create mode 100644 docs/doctoring/dependency-review-fail-closed-permission-envelope.md diff --git a/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md b/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md new file mode 100644 index 0000000000..e7eb7528b5 --- /dev/null +++ b/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md @@ -0,0 +1,72 @@ +# ADR-0025: Fail closed on ambiguous Dependency Review authority and preserve caller permissions + +- **Status:** Proposed +- **Date:** 2026-09-02 +- **Scope:** `.github/workflows/dependency-review.yml`, its thin product callers, and the reusable-workflow security contract +- **Supersedes:** ADR-0024 only where ADR-0024 treated HTTP 403/404 as confirmed Dependency Graph unavailability or showed callers without an explicit permission envelope + +## Problem + +The protected-main consolidation from #1724 exposed two independent security-contract defects. + +First, the reusable workflow classified Dependency Graph compare HTTP 403/404 as `available=false` and therefore skipped the blocking Dependency Review action. Those responses are not authoritative proof that the feature is unavailable: they can also be authorization or policy failures. Turning an ambiguous authorization-shaped response into success silently removes a security gate. + +Second, the migration examples and thin callers omitted the permission envelope that the original repository-local workflows carried. GitHub reusable workflows cannot elevate `GITHUB_TOKEN` permissions through the call chain. A called workflow may maintain or reduce permissions granted by the caller, but it cannot manufacture `pull-requests: read` when the caller did not grant it. The result is a workflow-level `startup_failure` before any job is created. + +## Constraints + +1. Dependency Review remains a distinct hard gate; OSV-Scanner, Scorecard, or another scanner cannot substitute for an ambiguous Dependency Review failure. +2. The called workflow needs only `contents: read` and `pull-requests: read`; no write permission is introduced. +3. Product callers remain thin and repository-owned. They keep repository-specific trigger, severity, allowlist, and `continue_on_error` policy. +4. Consumers pin the reusable workflow to an immutable protected-main commit after this proposal is merged. `@main`, branch URLs, and unmerged PR heads are not production authority. +5. Non-`pull_request` invocations may skip because they lack an exact PR base/head pair; pull requests fail closed unless the comparison endpoint returns HTTP 200. + +## Considered alternatives + +### Treat 403/404 as feature unavailable + +Rejected. The status code alone cannot distinguish a genuinely unavailable Dependency Graph from denied authorization/policy. A false negative here converts a required security control into a warning. + +### Infer support from repository visibility or GHAS assumptions + +Rejected. Visibility is not a capability proof and was already the weaker design ADR-0024 replaced. + +### Rely on the called workflow's `permissions:` block + +Rejected as insufficient. GitHub does not let a reusable workflow elevate permissions beyond the caller's grant. The called workflow still declares its least-privilege ceiling, but each thin caller must explicitly grant the same read scopes. + +### Grant broader token permissions globally + +Rejected. It increases blast radius and hides a caller-contract defect instead of repairing it. + +## Decision + +1. For `pull_request`, the Dependency Graph compare preflight sets `available=true` only on HTTP 200. Every other HTTP status is emitted with an error and terminates the job nonzero. +2. Remove the pull-request "Dependency graph unavailable" success path. No alternate scanner is described as replacement authority. +3. Keep the called workflow at `contents: read` + `pull-requests: read` and require every thin caller to declare at least those same scopes explicitly. +4. Make the executable central contract fail when the canonical caller example omits either required scope. +5. Replace the mutable `@main` caller example with an immutable `` placeholder. After merge, consumers pin the resulting protected-main SHA. + +## Exact evidence + +- #1725 first RED: `cb07b8bb28ef9d3a147cc966a0c70654d132da1d`; first production repair: `31f60e532e135008cabd09fcddd46a53062b0ea0`. +- Permission-envelope RED: `ee0f1ce544965772775b590050e40476df4ea8f6`; it changes only the contract and requires the missing caller permission example. +- Permission-envelope production repair: `ca3bdbd210de988ccd31f7fb96d3a97adfdb9bff`. +- `newsdom-api#784@1623977e6c37c78cb1a94a7a48c48f6d02cac86c`: run `33622976911`, `startup_failure`, zero jobs, reusable workflow immutably resolved to `.github@0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03`. +- `mightyETL#330@65efdf7b4064df5b9811c0403defb707e6efbc02`: run `33623035969`, `startup_failure`, zero jobs. +- Consumer permission repairs then produced materialized current-head runs: newsdom-api `9a798d5ac7b9b295a1accb2327fc76611352290f` run `33623818000`; mightyETL `4576f863ede9fca0673d6cce5ae8a4093246f5ab` run `33623854807`; scopeweave `db8b8ed6d36a6dc6cc1d07255a7a9a86bc88bf4f` run `33623761776`; Argos #557 `ee4c5dd326977407435b0f2425fdecebc34a810f` run `33623867278`. + +Hosted exact-current-head Checks and independent review remain required before this ADR may become Accepted. + +## Consequences and follow-up + +- A missing or denied Dependency Graph comparison is visible as a blocking failure instead of silent coverage loss. +- Caller permission omissions become an executable contract defect rather than an undocumented deployment prerequisite. +- The central workflow still cannot repair a consumer's omitted permissions by itself; each consumer must carry the explicit read-only envelope and later bump its immutable reusable-workflow pin to the protected-main SHA that contains this decision. +- #1643 remains a separate diagnostic lane for the required Security Scan path and is not evidence transfer for this reusable Dependency Review gate. + +## References + +GitHub. (n.d.). *Reusing workflow configurations*. GitHub Docs. https://docs.github.com/actions/using-workflows/reusing-workflows + +GitHub. (n.d.). *Use GITHUB_TOKEN for authentication in workflows*. GitHub Docs. https://docs.github.com/actions/security-guides/automatic-token-authentication diff --git a/docs/doctoring/dependency-review-fail-closed-permission-envelope.md b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md new file mode 100644 index 0000000000..8885cd477b --- /dev/null +++ b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md @@ -0,0 +1,74 @@ +# Dependency Review fail-closed authority and caller permission envelope + +## Incident boundary + +Protected `ContextualWisdomLab/.github` main introduced the central reusable Dependency Review workflow through #1724. Two migration defects were then observed independently and are repaired together in #1725 because both belong to the canonical reusable-workflow contract. + +### Defect A — ambiguous HTTP status normalized to success + +The initial reusable preflight treated Dependency Graph compare HTTP 403/404 as proof that the feature was unavailable, wrote `available=false`, and let the pull-request gate finish successfully. That inference is unsafe: an authorization/policy denial can present the same status shape. The repair keeps the exact base/head compare request but authorizes the action only on HTTP 200; every other pull-request response is blocking and reports its status. + +Test-first evidence: + +- RED `cb07b8bb28ef9d3a147cc966a0c70654d132da1d` makes 403/404-as-unavailable, the fallback note, and any non-explicit fail-closed response illegal. +- Production `31f60e532e135008cabd09fcddd46a53062b0ea0` removes the 403/404 success branch and fallback note, preserving the pinned action, inputs, and non-`pull_request` skip boundary. + +### Defect B — thin callers lost required `GITHUB_TOKEN` permissions + +The original repository-local workflows carried read permission envelopes, but the migration examples/thin callers did not preserve them uniformly. GitHub reusable workflows cannot elevate the caller token. Consequently the called workflow's own `permissions: contents: read, pull-requests: read` declaration is only a ceiling; it cannot grant `pull-requests: read` when the caller omitted it. + +Live immutable-pin evidence isolates this from mutable-ref resolution: + +| Consumer | Exact head | Run | Result before caller repair | +| --- | --- | --- | --- | +| `ContextualWisdomLab/newsdom-api#784` | `1623977e6c37c78cb1a94a7a48c48f6d02cac86c` | `33622976911` | `startup_failure`, zero jobs; referenced workflow resolved to `.github@0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03` | +| `ContextualWisdomLab/mightyETL#330` | `65efdf7b4064df5b9811c0403defb707e6efbc02` | `33623035969` | `startup_failure`, zero jobs | + +The consumer-side repair explicitly restores: + +```yaml +permissions: + contents: read + pull-requests: read +``` + +Fresh heads then materialized Dependency Review runs instead of failing before job creation: + +- newsdom-api `9a798d5ac7b9b295a1accb2327fc76611352290f`, run `33623818000`; +- mightyETL `4576f863ede9fca0673d6cce5ae8a4093246f5ab`, run `33623854807`; +- scopeweave `db8b8ed6d36a6dc6cc1d07255a7a9a86bc88bf4f`, run `33623761776`; +- Argos #557 `ee4c5dd326977407435b0f2425fdecebc34a810f`, run `33623867278`. + +Central test-first repair for this second defect: + +- RED `ee0f1ce544965772775b590050e40476df4ea8f6` adds `test_example_caller_preserves_required_permission_envelope()` without changing production/example workflow text. Against its parent it fails because the canonical caller example has no `permissions:` block. +- GREEN production `ca3bdbd210de988ccd31f7fb96d3a97adfdb9bff` adds the least-privilege caller envelope, explains the reusable-workflow permission ceiling, and replaces the mutable `@main` example with ``. + +## Security invariants + +1. Pull-request Dependency Review executes only after an exact base/head compare returns HTTP 200. +2. No HTTP 403/404 or other non-200 response is translated into a successful "unavailable" state. +3. OSV-Scanner, Scorecard, and the separate Security Scan path remain independent controls; they do not satisfy a failed Dependency Review gate. +4. The called workflow and each caller use only `contents: read` and `pull-requests: read` for this path. No write permission is introduced. +5. Product callers pin the central workflow to an immutable protected-main commit after merge. `@main`, PR heads, and branch URLs are not production authority. +6. A non-`pull_request` event may skip because it lacks the PR base/head identity required for the comparison. + +## Verification and merge boundary + +The focused contract is: + +```bash +PYTHONPATH=. pytest -q tests/test_dependency_review_reusable_workflow_contract.py +``` + +The repository's normal exact-current-head required Checks, full coverage evidence, security scans, and independent reviews remain authoritative. #1725 stays Proposed/Draft while those gates are non-terminal or any substantive finding is unresolved. Queue saturation does not authorize bypass of a startup, permission, provenance, review, or security defect. + +After #1725 reaches protected main through ordinary protection, each consumer must bump its immutable reusable-workflow pin to that protected-main SHA and regenerate exact-head Dependency Review evidence. No consumer should return to `@main`. + +## References + +GitHub. (n.d.-a). *Reusing workflow configurations*. GitHub Docs. https://docs.github.com/actions/using-workflows/reusing-workflows + +GitHub. (n.d.-b). *Use GITHUB_TOKEN for authentication in workflows*. GitHub Docs. https://docs.github.com/actions/security-guides/automatic-token-authentication + +GitHub. (n.d.-c). *REST API endpoints for the dependency graph*. GitHub Docs. https://docs.github.com/rest/dependency-graph From 2595e246e8f4aba89fd1bbf0fe4c6980d0ee026c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:26:21 +0900 Subject: [PATCH 06/13] test(security): preserve latest reusable dependency-review contract --- ...dency_review_reusable_workflow_contract.py | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index 1efca22842..cf4021f2cb 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -1,6 +1,6 @@ """Contract for the reusable Dependency Review workflow. -Replaces argos's, mightyETL's, newsdom-api's, and scopeweave's +Replaces Argos's, mightyETL's, naruon's, newsdom-api's, and scopeweave's independently hand-written ``dependency-review.yml`` files with one reusable ``workflow_call`` workflow, ``.github/workflows/dependency-review.yml``, plus a thin caller left in each product repository. See @@ -24,22 +24,29 @@ def _workflow_text() -> str: return _WORKFLOW.read_text(encoding="utf-8") -def test_declares_workflow_call_with_three_inputs_and_recorded_defaults() -> None: - """Every genuinely-varying field found while auditing the four originals is an input.""" +def test_declares_workflow_call_with_four_inputs_and_recorded_defaults() -> None: + """Every genuinely varying field found while auditing the five originals is an input.""" workflow = _workflow_text() assert "on:\n workflow_call:\n inputs:" in workflow - for name in ("fail_on_severity:", "allow_ghsas:", "continue_on_error:"): + for name in ( + "fail_on_severity:", + "allow_ghsas:", + "continue_on_error:", + "comment_summary_in_pr:", + ): assert name in workflow assert 'default: "moderate"' in workflow assert 'default: ""' in workflow assert "default: false" in workflow + assert 'default: "on-failure"' in workflow -def test_step_order_is_checkout_then_preflight_then_dependency_review() -> None: - """Checkout, capability preflight, then the gated action must remain ordered.""" +def test_step_order_is_harden_then_checkout_then_preflight_then_dependency_review() -> None: + """Runner hardening, checkout, capability proof, then the gated action stay ordered.""" workflow = _workflow_text() order = [ + "Harden the runner", "actions/checkout@", "Check dependency graph availability", "Dependency review", @@ -61,23 +68,29 @@ def test_dependency_review_runs_only_after_a_confirmed_successful_comparison() - def test_inputs_are_forwarded_to_the_dependency_review_action() -> None: - """fail_on_severity and allow_ghsas must reach the underlying action untouched.""" + """Every caller-varying action input must reach the pinned action untouched.""" workflow = _workflow_text() assert "fail-on-severity: ${{ inputs.fail_on_severity }}" in workflow assert "allow-ghsas: ${{ inputs.allow_ghsas }}" in workflow + assert "comment-summary-in-pr: ${{ inputs.comment_summary_in_pr }}" in workflow + + +def test_harden_runner_audits_egress() -> None: + """naruon's harden-runner control applies uniformly in the reusable owner.""" + workflow = _workflow_text() + assert "step-security/harden-runner@" in workflow + assert "egress-policy: audit" in workflow def test_action_pins_are_current_and_uniform() -> None: - """checkout and dependency-review-action share one current pin, not per-caller drift.""" + """Checkout and Dependency Review use one immutable current pin.""" workflow = _workflow_text() assert f"actions/checkout@{_CHECKOUT_PIN}" in workflow - assert ( - f"actions/dependency-review-action@{_DEPENDENCY_REVIEW_PIN}" in workflow - ) + assert f"actions/dependency-review-action@{_DEPENDENCY_REVIEW_PIN}" in workflow def test_uniform_fields_are_hardcoded_not_parameterized() -> None: - """Fields byte-identical across all four originals stay static, not inputs.""" + """Uniform least-privilege and checkout controls stay static.""" workflow = _workflow_text() assert "permissions:\n contents: read\n pull-requests: read" in workflow assert "persist-credentials: false" in workflow @@ -95,26 +108,28 @@ def test_example_caller_preserves_required_permission_envelope() -> None: ) +def test_example_caller_requires_immutable_protected_main_pin() -> None: + """The canonical example must never teach consumers to execute a mutable owner ref.""" + workflow = _workflow_text() + assert "@" in workflow + assert "uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@main" not in workflow + + def test_forces_node24_runtime_for_js_actions() -> None: - """newsdom-api's Node24 opt-in applies uniformly, not only to that one caller.""" + """newsdom-api's Node24 opt-in applies uniformly, not only to one caller.""" workflow = _workflow_text() assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in workflow def test_availability_check_uses_the_dependency_graph_compare_api() -> None: - """The preflight must query the real capability, not infer from repository visibility.""" + """The preflight must query the real capability, not infer from visibility.""" workflow = _workflow_text() assert "dependency-graph/compare" in workflow assert "github.event.repository.private" not in workflow def test_pull_request_http_403_and_404_are_not_normalized_to_unavailable() -> None: - """Authorization-shaped HTTP responses are ambiguous and must remain blocking. - - GitHub may use 403 or 404 for permission/policy failures as well as feature - availability boundaries. A reusable security gate therefore cannot turn - either response into success or delegate coverage to another scanner. - """ + """Authorization-shaped HTTP responses are ambiguous and must remain blocking.""" workflow = _workflow_text() assert 'if [ "$status" = "403" ] || [ "$status" = "404" ]' not in workflow assert "skipping the dependency-review hard gate" not in workflow @@ -129,7 +144,9 @@ def test_availability_check_only_runs_the_gate_for_pull_request_events() -> None assert '"${{ github.event_name }}" != "pull_request"' in workflow -def test_dependency_review_posts_a_pr_comment_on_failure() -> None: - """scopeweave's PR-comment-on-failure UX applies uniformly, not only to that one caller.""" +def test_dependency_review_comment_summary_defaults_to_on_failure() -> None: + """The shared UX defaults to on-failure while remaining caller-overridable.""" workflow = _workflow_text() - assert "comment-summary-in-pr: on-failure" in workflow + assert "comment_summary_in_pr:" in workflow + assert 'default: "on-failure"' in workflow + assert "comment-summary-in-pr: ${{ inputs.comment_summary_in_pr }}" in workflow From 3736634f95bf132bbbe208ffc80103863fe3a7c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:40:42 +0900 Subject: [PATCH 07/13] test(security): require immutable dependency-review identities --- ...dency_review_reusable_workflow_contract.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index cf4021f2cb..ea55f91cfd 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -11,6 +11,9 @@ from __future__ import annotations +import os +import subprocess +import textwrap from pathlib import Path _WORKFLOW = Path(".github/workflows/dependency-review.yml") @@ -24,6 +27,70 @@ def _workflow_text() -> str: return _WORKFLOW.read_text(encoding="utf-8") +def _availability_probe_script() -> str: + """Extract the dependency-graph preflight shell body for executable tests.""" + workflow = _workflow_text() + step = " - name: Check dependency graph availability\n" + start = workflow.index(step) + end = workflow.index("\n - name:", start + len(step)) + block = workflow[start:end] + run_marker = " run: |\n" + run_start = block.index(run_marker) + len(run_marker) + script = textwrap.dedent(block[run_start:]) + return script.replace('${{ github.event_name }}', "pull_request") + + +def _run_availability_probe( + tmp_path: Path, + repository: str, + *, + base_sha: str = "a" * 40, + head_sha: str = "b" * 40, + http_status: str = "200", +) -> tuple[subprocess.CompletedProcess[str], Path, Path]: + """Execute the real preflight shell against a marker-only fake curl.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + curl_marker = tmp_path / "curl-called" + fake_curl = fake_bin / "curl" + fake_curl.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "authorized=false\n" + "for arg in \"$@\"; do\n" + " if [[ \"$arg\" == Authorization:* ]]; then authorized=true; fi\n" + "done\n" + "printf '%s\\n' \"$authorized\" >>\"${CURL_MARKER}\"\n" + "printf '%s' \"${HTTP_STATUS:-200}\"\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + output = tmp_path / "github-output" + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env.get('PATH', '')}", + "GH_TOKEN": "test-token", + "BASE_SHA": base_sha, + "HEAD_SHA": head_sha, + "REPOSITORY": repository, + "GITHUB_API_URL": "https://api.github.invalid", + "GITHUB_OUTPUT": str(output), + "CURL_MARKER": str(curl_marker), + "HTTP_STATUS": http_status, + } + ) + result = subprocess.run( + ["bash", "-c", _availability_probe_script()], + cwd=Path.cwd(), + env=env, + text=True, + capture_output=True, + check=False, + ) + return result, curl_marker, output + + def test_declares_workflow_call_with_four_inputs_and_recorded_defaults() -> None: """Every genuinely varying field found while auditing the five originals is an input.""" workflow = _workflow_text() @@ -126,6 +193,7 @@ def test_availability_check_uses_the_dependency_graph_compare_api() -> None: workflow = _workflow_text() assert "dependency-graph/compare" in workflow assert "github.event.repository.private" not in workflow + assert '-H "Authorization: Bearer ${GH_TOKEN}"' in workflow def test_pull_request_http_403_and_404_are_not_normalized_to_unavailable() -> None: @@ -144,6 +212,54 @@ def test_availability_check_only_runs_the_gate_for_pull_request_events() -> None assert '"${{ github.event_name }}" != "pull_request"' in workflow +def test_preflight_rejects_named_revisions_before_transport(tmp_path: Path) -> None: + """Named or malformed revisions never reach the dependency-graph endpoint.""" + for index, (base_sha, head_sha) in enumerate( + (("main", "b" * 40), ("a" * 40, "develop"), ("a" * 39, "b" * 40)) + ): + case_dir = tmp_path / f"revision-{index}" + case_dir.mkdir() + result, curl_marker, _output = _run_availability_probe( + case_dir, + "ContextualWisdomLab/Orgmetra", + base_sha=base_sha, + head_sha=head_sha, + ) + assert result.returncode != 0, (base_sha, head_sha) + assert not curl_marker.exists(), (base_sha, head_sha) + assert "exact 40- or 64-character hexadecimal" in result.stdout.lower() + + +def test_preflight_rejects_malformed_repository_before_transport(tmp_path: Path) -> None: + """Only one non-dot owner/name repository identity may reach transport.""" + repositories = ( + "ContextualWisdomLab", + "ContextualWisdomLab/Orgmetra/extra", + "/Orgmetra", + "../.github", + "ContextualWisdomLab/..", + "ContextualWisdomLab/.", + "./.github", + ) + for index, repository in enumerate(repositories): + case_dir = tmp_path / f"repository-{index}" + case_dir.mkdir() + result, curl_marker, _output = _run_availability_probe(case_dir, repository) + assert result.returncode != 0, repository + assert not curl_marker.exists(), repository + assert "repository identity" in result.stdout.lower(), repository + + +def test_preflight_accepts_dotgithub_and_uses_job_token(tmp_path: Path) -> None: + """The legitimate .github repository reaches exactly one authenticated compare.""" + result, curl_marker, output = _run_availability_probe( + tmp_path, "ContextualWisdomLab/.github" + ) + assert result.returncode == 0, result.stdout + result.stderr + assert curl_marker.read_text(encoding="utf-8") == "true\n" + assert output.read_text(encoding="utf-8") == "available=true\n" + + def test_dependency_review_comment_summary_defaults_to_on_failure() -> None: """The shared UX defaults to on-failure while remaining caller-overridable.""" workflow = _workflow_text() From b1e6263d9d9626b6cfd2046ce9147ab67867beec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:42:21 +0900 Subject: [PATCH 08/13] fix(security): validate immutable dependency-review identity --- .github/workflows/dependency-review.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 35c0a4452a..9062f15b32 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,9 +13,11 @@ # exact pull-request base/head. Repository visibility is not a sufficient # capability signal, and HTTP 403/404 are not safe availability signals: # GitHub can use those statuses for authorization/policy denials as well as -# unavailable resources. For a pull_request, only HTTP 200 authorizes running -# the pinned action; every non-200 comparison fails closed. Non-pull_request -# triggers may skip because they do not carry the exact PR base/head identity. +# unavailable resources. For a pull_request, only exact immutable base/head +# object IDs plus a valid owner/name repository identity may reach transport, +# and only HTTP 200 authorizes running the pinned action. Every non-200 +# comparison fails closed. Non-pull_request triggers may skip because they do +# not carry the exact PR base/head identity. # # Reusable-workflow permissions can only be maintained or reduced through the # call chain. Every thin caller therefore must declare at least `contents: @@ -126,6 +128,23 @@ jobs: exit 0 fi + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then + echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + api_url="${GITHUB_API_URL:-https://api.github.com}" response_file="$(mktemp)" status="$( From 8b86c0d2c6b0186538db1ed263f7cb9d222f3ca1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:44:06 +0900 Subject: [PATCH 09/13] fix(security): carry exact identity validation into bundled scan --- .github/workflows/security-scan.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 860d861544..40d9776d12 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -307,6 +307,23 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then + echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + set +e status="$( curl -sS --connect-timeout 10 --max-time 30 \ From ae128374a2e38e60ada8bf5e89a9c7a4137f864f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:46:10 +0900 Subject: [PATCH 10/13] docs(security): record authenticated dependency-review evidence --- ...-review-fail-closed-permission-envelope.md | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/dependency-review-fail-closed-permission-envelope.md b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md index 8885cd477b..ef010dc741 100644 --- a/docs/doctoring/dependency-review-fail-closed-permission-envelope.md +++ b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md @@ -2,7 +2,7 @@ ## Incident boundary -Protected `ContextualWisdomLab/.github` main introduced the central reusable Dependency Review workflow through #1724. Two migration defects were then observed independently and are repaired together in #1725 because both belong to the canonical reusable-workflow contract. +Protected `ContextualWisdomLab/.github` main introduced the central reusable Dependency Review workflow through #1724. Subsequent exact-head evidence exposed three owner-contract defects that are repaired together in #1725 because all belong to the canonical dependency-review admission boundary. ### Defect A — ambiguous HTTP status normalized to success @@ -44,21 +44,46 @@ Central test-first repair for this second defect: - RED `ee0f1ce544965772775b590050e40476df4ea8f6` adds `test_example_caller_preserves_required_permission_envelope()` without changing production/example workflow text. Against its parent it fails because the canonical caller example has no `permissions:` block. - GREEN production `ca3bdbd210de988ccd31f7fb96d3a97adfdb9bff` adds the least-privilege caller envelope, explains the reusable-workflow permission ceiling, and replaces the mutable `@main` example with ``. +### Defect C — compare identity was trusted before transport + +The earlier bundled Security Scan accepted the event-provided repository/base/head strings without first proving they were exact immutable Git object identities and one legal `owner/name` repository identity. That left the preflight contract weaker than the evidence it claimed to authorize. + +The predecessor owner lane #1643 added fail-before-transport validation and a temporary A/B canary. Its exact-head canary run `33589436750`, job `100120235906`, executed on `2026-09-02` and produced decisive evidence for the same immutable pair: + +- repository: `ContextualWisdomLab/.github`; +- base: `bb14b014eee31e6abdb5d2fffbb805aa29420eac`; +- head: `a6a2759640e6aa1d1e1219e1cd7aacdeffef32c0`; +- anonymous compare: HTTP `404`, curl exit `0`; +- job-token compare with `contents: read` and `pull-requests: read`: HTTP `200`, curl exit `0`. + +This proves that anonymous status is not an availability authority and that the least-privilege job token can establish the exact comparison. The temporary canary itself is diagnostic-only and is not part of the publishable successor contract. + +#1725 carries the durable part of that predecessor delta into the canonical writer: + +- both the reusable workflow and bundled Security Scan reject non-exact base/head revisions before curl; +- repository identity must be exactly one non-dot `owner/name` pair; `ContextualWisdomLab/.github` remains legal; +- the compare request uses the job token and only HTTP 200 authorizes the pinned Dependency Review action; +- named refs, malformed identities, 403/404 and all other non-200 outcomes fail closed. + ## Security invariants 1. Pull-request Dependency Review executes only after an exact base/head compare returns HTTP 200. -2. No HTTP 403/404 or other non-200 response is translated into a successful "unavailable" state. -3. OSV-Scanner, Scorecard, and the separate Security Scan path remain independent controls; they do not satisfy a failed Dependency Review gate. -4. The called workflow and each caller use only `contents: read` and `pull-requests: read` for this path. No write permission is introduced. -5. Product callers pin the central workflow to an immutable protected-main commit after merge. `@main`, PR heads, and branch URLs are not production authority. -6. A non-`pull_request` event may skip because it lacks the PR base/head identity required for the comparison. +2. Base/head revisions must be exact 40- or 64-character lowercase hexadecimal Git object IDs before transport. +3. Repository identity must be exactly one non-dot `owner/name` pair before transport. +4. No anonymous response, HTTP 403/404, or other non-200 response is translated into a successful "unavailable" state. +5. OSV-Scanner, Scorecard, and the separate Security Scan path remain independent controls; they do not satisfy a failed Dependency Review gate. +6. The called workflow and each caller use only `contents: read` and `pull-requests: read` for this path. No write permission is introduced. +7. Product callers pin the central workflow to an immutable protected-main commit after merge. `@main`, PR heads, and branch URLs are not production authority. +8. A non-`pull_request` event may skip because it lacks the PR base/head identity required for the comparison. ## Verification and merge boundary -The focused contract is: +The focused owner contracts are: ```bash -PYTHONPATH=. pytest -q tests/test_dependency_review_reusable_workflow_contract.py +PYTHONPATH=. pytest -q \ + tests/test_dependency_review_reusable_workflow_contract.py \ + tests/test_dependency_review_bundled_scan_identity_contract.py ``` The repository's normal exact-current-head required Checks, full coverage evidence, security scans, and independent reviews remain authoritative. #1725 stays Proposed/Draft while those gates are non-terminal or any substantive finding is unresolved. Queue saturation does not authorize bypass of a startup, permission, provenance, review, or security defect. From 58a0b4c8ecc3073a64bd91457101229a21f020d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:46:54 +0900 Subject: [PATCH 11/13] test(security): preserve bundled dependency-review identity gate --- ...y_review_bundled_scan_identity_contract.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_dependency_review_bundled_scan_identity_contract.py diff --git a/tests/test_dependency_review_bundled_scan_identity_contract.py b/tests/test_dependency_review_bundled_scan_identity_contract.py new file mode 100644 index 0000000000..cee8d44374 --- /dev/null +++ b/tests/test_dependency_review_bundled_scan_identity_contract.py @@ -0,0 +1,46 @@ +"""Regression contract for the bundled Security Scan Dependency Review preflight.""" + +from __future__ import annotations + +from pathlib import Path + + +_WORKFLOW = Path(".github/workflows/security-scan.yml") + + +def _workflow_text() -> str: + """Return the bundled Security Scan workflow as UTF-8 text.""" + return _WORKFLOW.read_text(encoding="utf-8") + + +def test_bundled_scan_requires_exact_git_object_ids_before_transport() -> None: + """Named or malformed base/head revisions must fail before compare transport.""" + workflow = _workflow_text() + assert "git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$'" in workflow + assert 'if ! [[ "${BASE_SHA}" =~ $git_object_id ]]' in workflow + assert '! [[ "${HEAD_SHA}" =~ $git_object_id ]]' in workflow + assert "exact 40- or 64-character hexadecimal base and head revisions" in workflow + assert "Named refs are not evidence" in workflow + + +def test_bundled_scan_requires_one_non_dot_owner_name_identity() -> None: + """Repository identity validation keeps .github legal but rejects path sentinels.""" + workflow = _workflow_text() + assert "repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'" in workflow + assert 'if ! [[ "${REPOSITORY}" =~ $repository_identity ]]' in workflow + assert 'repository_owner="${REPOSITORY%%/*}"' in workflow + assert 'repository_name="${REPOSITORY#*/}"' in workflow + assert '[ "${repository_owner}" = "." ]' in workflow + assert '[ "${repository_owner}" = ".." ]' in workflow + assert '[ "${repository_name}" = "." ]' in workflow + assert '[ "${repository_name}" = ".." ]' in workflow + + +def test_bundled_scan_uses_job_token_and_fails_closed_on_non_200() -> None: + """Only an authenticated successful exact comparison may admit Dependency Review.""" + workflow = _workflow_text() + assert "GH_TOKEN: ${{ github.token }}" in workflow + assert '-H "Authorization: Bearer ${GH_TOKEN}"' in workflow + assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow + assert 'echo "supported=true" >>"$GITHUB_OUTPUT"' in workflow + assert "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294" in workflow From 04aad03341c665dba8b3e76aa5f4e99dc59eaf7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:19:11 +0900 Subject: [PATCH 12/13] fix(security): reconcile immutable compare preflight with protected main --- .github/workflows/security-scan.yml | 899 +++++++++++++++++++++++++++- 1 file changed, 869 insertions(+), 30 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 40d9776d12..8efb982b02 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -7,8 +7,13 @@ # osv-scan HARD diff-scoped — fails on NEW vulns the PR introduces # dependency-review HARD diff-scoped — fails on vulnerable/denied deps the PR adds # trivy-fs HARD repo-wide — fails on FIXABLE MEDIUM/HIGH/CRITICAL findings +# gitleaks HARD commit-range — blocks secrets in ContextualWisdomLab/.github PRs # 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 @@ -25,6 +30,13 @@ # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. # Trivy itself exits 0 so SARIF is always available; the following parser prints # exact findings and then fails the job. +# +# NOTE on the changed-scope gate: each job below now runs only when the +# `changed-scope` job's diff-scoped output says it is in scope (`code` for +# trivy-fs/scorecard, `deps` for osv-scan/dependency-review). A doc/image-only +# PR skips every one of these jobs, and `scheduled-security-scan.yml` (push + +# default-branch schedule) and `scorecard-analysis.yml` (push + weekly cron) +# remain the full repo-wide backstops that make those skips safe. name: Security Scan on: @@ -49,9 +61,72 @@ permissions: contents: read jobs: - osv-scan: + changed-scope: + name: Detect changed scope + # The org ruleset IGNORES every `on:` filter (paths, branches, types) when it + # runs this workflow in another repository, and a trigger-level skip would + # leave `.github`'s classic required contexts Pending forever. Both + # mechanisms honour a JOB-level skip, so the doc/image-only decision is made + # here and consumed through `needs`. See + # docs/doctoring/required-workflow-path-filter-boundary.md. + # Fails OPEN: an unreadable, empty, or truncated file list scans everything. if: github.event.action != 'closed' runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.scope.outputs.code }} + deps: ${{ steps.scope.outputs.deps }} + steps: + - name: Classify changed paths + id: scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED_FILES: ${{ github.event.pull_request.changed_files }} + shell: bash + run: | + set -uo pipefail + code=true + deps=true + if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then + changed="" + for attempt in 1 2 3; do + if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then + break + fi + changed="" + sleep $((attempt * 3)) + done + # GitHub caps /pulls/N/files at 3000 entries; a short list would hide + # source files behind a doc-only verdict, so require an exact count. + if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then + code=false + deps=false + while IFS= read -r changed_path; do + case "$changed_path" in + *.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;; + *) code=true ;; + esac + case "$changed_path" in + requirements*.txt|*/requirements*.txt|pyproject.toml|*/pyproject.toml|uv.lock|*/uv.lock|pylock.*.toml|*/pylock.*.toml|package.json|*/package.json|package-lock.json|*/package-lock.json|pnpm-lock.yaml|*/pnpm-lock.yaml|yarn.lock|*/yarn.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|go.mod|*/go.mod|go.sum|*/go.sum|pom.xml|*/pom.xml|build.gradle|*/build.gradle|build.gradle.kts|*/build.gradle.kts|DESCRIPTION|*/DESCRIPTION) deps=true ;; + esac + done <<<"$changed" + else + echo "::notice::changed-scope could not read a complete PR file list; scanning everything." + fi + fi + echo "code=${code}" >> "$GITHUB_OUTPUT" + echo "deps=${deps}" >> "$GITHUB_OUTPUT" + echo "changed-scope code=${code} deps=${deps}" + + osv-scan: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true' + runs-on: ubuntu-24.04 timeout-minutes: 25 permissions: actions: read @@ -85,7 +160,7 @@ jobs: id: osv_base continue-on-error: true timeout-minutes: 8 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -103,7 +178,7 @@ jobs: if: steps.osv_base.outcome == 'failure' continue-on-error: true timeout-minutes: 4 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -136,7 +211,7 @@ jobs: id: osv_head continue-on-error: true timeout-minutes: 8 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -154,7 +229,7 @@ jobs: if: steps.osv_head.outcome == 'failure' continue-on-error: true timeout-minutes: 4 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -208,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@8dc09193bb540e09b23da07ad7e30bd33bf87018 # v2.3.8 + uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.3.8 with: scan-args: | --output=results.sarif @@ -245,7 +320,7 @@ jobs: # The reporter above is the vulnerability gate. Preserve an upload # quota failure in this step's log without reclassifying it as a CVE. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif # results.sarif is produced after checkout of the pull request head. @@ -271,7 +346,8 @@ jobs: retention-days: 5 dependency-review: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -307,24 +383,7 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" - git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' - repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' - if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then - echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." - exit 1 - fi - if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then - echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." - exit 1 - fi - repository_owner="${REPOSITORY%%/*}" - repository_name="${REPOSITORY#*/}" - if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then - echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." - exit 1 - fi - - set +e + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})? set +e status="$( curl -sS --connect-timeout 10 --max-time 30 \ -o /dev/null \ @@ -365,8 +424,104 @@ jobs: fail-on-severity: moderate comment-summary-in-pr: never + # Keep the existing central-repository Gitleaks PR gate inside the required + # security bundle. It deliberately does not depend on changed-scope: secrets + # in Markdown or other document-only changes must still fail the PR. The + # repository condition preserves the standalone workflow's previous scope; + # push, schedule, and manual backstops remain in secret-scan.yml. + gitleaks: + name: gitleaks (secret scan) + if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + - name: Checkout PR commit range + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install gitleaks (pinned, checksum-verified) + run: | + set -euo pipefail + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL "$url" -o gitleaks.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + - name: Run gitleaks on PR commit range + id: gitleaks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set +e + config_args=() + if [ -f .gitleaks.toml ]; then + config_args=(--config .gitleaks.toml) + fi + log_opts="${BASE_SHA}..${HEAD_SHA}" + echo "::notice::gitleaks scanning pull request commit range ${log_opts}." + ./gitleaks git . \ + "${config_args[@]}" \ + --log-opts="${log_opts}" \ + --redact \ + --report-format sarif \ + --report-path gitleaks-results.sarif \ + --exit-code 2 + echo "rc=$?" >> "$GITHUB_OUTPUT" + set -e + - name: Summarize redacted gitleaks findings + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + set -euo pipefail + count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" + if [ "$count" = "0" ]; then + echo "::notice::gitleaks completed with no findings." + exit 0 + fi + echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." + jq -r ' + .runs[].results[]? + | "- rule: `" + (.ruleId // "unknown") + "`" + + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" + + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" + ' gitleaks-results.sarif | sort | uniq -c + - name: Filter test-classified Gitleaks SARIF results + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + python3 scripts/ci/filter_gitleaks_sarif.py \ + gitleaks-results.sarif \ + gitleaks-results.upload.sarif + - name: Upload gitleaks SARIF to code scanning + if: always() && hashFiles('gitleaks-results.upload.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: gitleaks-results.upload.sarif + category: gitleaks + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} + wait-for-processing: false + - name: Enforce secret-scan gate + if: steps.gitleaks.outputs.rc != '0' + run: | + echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." + exit 1 + trivy-fs: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -459,7 +614,7 @@ jobs: if: always() && hashFiles('trivy-results.sarif') != '' # The parser above fails on every fixable Medium+ finding independently. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: trivy-results.sarif category: trivy-fs @@ -472,7 +627,691 @@ jobs: echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." scorecard: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' + runs-on: ubuntu-24.04 + # SOFT: posture findings are unrelated to the PR diff, so never block merge. + continue-on-error: true + permissions: + security-events: write + contents: read + actions: read + steps: + - name: Checkout exact Scorecard 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: Verify Scorecard head checkout + env: + EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Scorecard checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." + exit 1 + fi + echo "SECURITY_CHECKOUT scanner=scorecard revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + - name: Filter delegated PR-only Scorecard SARIF findings + run: | + python3 <<'PY' + import json + import pathlib + + PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} + PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} + PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS + + sarif_path = pathlib.Path("results.sarif") + sarif = json.loads(sarif_path.read_text(encoding="utf-8")) + hard_gate_delegated = 0 + governance_delegated = 0 + for run in sarif.get("runs", []): + kept = [] + for result in run.get("results", []): + rule_id = result.get("ruleId") + if rule_id in PR_DELEGATED_RULE_IDS: + if rule_id in PR_HARD_GATE_RULE_IDS: + hard_gate_delegated += 1 + if rule_id in PR_GOVERNANCE_RULE_IDS: + governance_delegated += 1 + continue + kept.append(result) + run["results"] = kept + filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") + filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") + filtered_path.replace(sarif_path) + print( + "Delegated " + f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " + "CodeQL, OSV, Trivy, and dependency-review hard gates." + ) + print( + "Delegated " + f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " + "to default-branch governance tracking." + ) + PY + - name: Upload Scorecard SARIF to code scanning + id: upload_scorecard_sarif + # Scorecard is soft repository-posture evidence; upload quota is external. + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + 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' + run: | + echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates." + + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+ set +e + status="$( + 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" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" + )" + curl_status=$? + set -e + + case "$status" in + [0-9][0-9][0-9]) http_status="$status" ;; + "") http_status="unavailable" ;; + *) http_status="malformed" ;; + esac + + case "${REPOSITORY_VISIBILITY:-}" in + public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;; + *) repository_visibility="unknown" ;; + esac + + echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}" + + if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." + exit 1 + fi + + 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 + with: + base-ref: ${{ github.event.pull_request.base.sha }} + head-ref: ${{ github.event.pull_request.head.sha }} + fail-on-severity: moderate + comment-summary-in-pr: never + + # Keep the existing central-repository Gitleaks PR gate inside the required + # security bundle. It deliberately does not depend on changed-scope: secrets + # in Markdown or other document-only changes must still fail the PR. The + # repository condition preserves the standalone workflow's previous scope; + # push, schedule, and manual backstops remain in secret-scan.yml. + gitleaks: + name: gitleaks (secret scan) + if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + - name: Checkout PR commit range + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install gitleaks (pinned, checksum-verified) + run: | + set -euo pipefail + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL "$url" -o gitleaks.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + - name: Run gitleaks on PR commit range + id: gitleaks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set +e + config_args=() + if [ -f .gitleaks.toml ]; then + config_args=(--config .gitleaks.toml) + fi + log_opts="${BASE_SHA}..${HEAD_SHA}" + echo "::notice::gitleaks scanning pull request commit range ${log_opts}." + ./gitleaks git . \ + "${config_args[@]}" \ + --log-opts="${log_opts}" \ + --redact \ + --report-format sarif \ + --report-path gitleaks-results.sarif \ + --exit-code 2 + echo "rc=$?" >> "$GITHUB_OUTPUT" + set -e + - name: Summarize redacted gitleaks findings + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + set -euo pipefail + count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" + if [ "$count" = "0" ]; then + echo "::notice::gitleaks completed with no findings." + exit 0 + fi + echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." + jq -r ' + .runs[].results[]? + | "- rule: `" + (.ruleId // "unknown") + "`" + + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" + + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" + ' gitleaks-results.sarif | sort | uniq -c + - name: Filter test-classified Gitleaks SARIF results + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + python3 scripts/ci/filter_gitleaks_sarif.py \ + gitleaks-results.sarif \ + gitleaks-results.upload.sarif + - name: Upload gitleaks SARIF to code scanning + if: always() && hashFiles('gitleaks-results.upload.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: gitleaks-results.upload.sarif + category: gitleaks + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} + wait-for-processing: false + - name: Enforce secret-scan gate + if: steps.gitleaks.outputs.rc != '0' + run: | + echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." + exit 1 + + trivy-fs: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout exact Trivy 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: Verify Trivy head checkout + env: + EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Trivy checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." + exit 1 + fi + echo "SECURITY_CHECKOUT scanner=trivy-fs revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: CRITICAL,HIGH,MEDIUM + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + exit-code: "0" + # Without this, trivy-action rebuilds the SARIF scan with ALL + # severities and the parser below would gate LOW findings too, + # contradicting the documented MEDIUM-or-higher gate above. + limit-severities-for-sarif: true + - name: Require Trivy SARIF output + run: | + set -euo pipefail + if [ ! -s trivy-results.sarif ]; then + echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above." + exit 1 + fi + - name: Print Trivy findings that failed the gate + # SARIF-only output otherwise leaves failures as just "exit code 1". + shell: python3 {0} + run: | + import json, pathlib + + sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8")) + findings = [] + for run in sarif.get("runs", []): + rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])} + for result in run.get("results", []): + rule = rules.get(result.get("ruleId", ""), {}) + severity = rule.get("properties", {}).get("security-severity", "?") + lines = (result.get("message", {}).get("text") or "").strip().splitlines() + fields = {} + for entry in lines: + key, sep, value = entry.partition(":") + if sep: + fields[key.strip().lower()] = value.strip() + if fields.get("severity"): + severity = f"{fields['severity']} (security-severity={severity})" + message = fields.get("message") or (lines[0] if lines else result.get("ruleId", "")) + locations = result.get("locations", []) + if locations: + phys = locations[0].get("physicalLocation", {}) + uri = phys.get("artifactLocation", {}).get("uri", "?") + line = phys.get("region", {}).get("startLine", "?") + where = f"{uri}:{line}" + else: + where = "-" + findings.append((severity, result.get("ruleId", "?"), where, message)) + + if not findings: + print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.") + else: + print(f"Trivy filesystem scan reported {len(findings)} finding(s):") + for severity, rule_id, where, message in findings: + print(f" [{severity}] {rule_id} {where} - {message}") + print("") + print("Remediate each finding at the shared base branch so open PRs inherit the fix.") + raise SystemExit(1) + - name: Upload Trivy SARIF to code scanning + id: upload_trivy_sarif + if: always() && hashFiles('trivy-results.sarif') != '' + # The parser above fails on every fixable Medium+ finding independently. + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + 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' + run: | + echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." + + scorecard: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' + runs-on: ubuntu-24.04 + # SOFT: posture findings are unrelated to the PR diff, so never block merge. + continue-on-error: true + permissions: + security-events: write + contents: read + actions: read + steps: + - name: Checkout exact Scorecard 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: Verify Scorecard head checkout + env: + EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Scorecard checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." + exit 1 + fi + echo "SECURITY_CHECKOUT scanner=scorecard revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + - name: Filter delegated PR-only Scorecard SARIF findings + run: | + python3 <<'PY' + import json + import pathlib + + PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} + PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} + PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS + + sarif_path = pathlib.Path("results.sarif") + sarif = json.loads(sarif_path.read_text(encoding="utf-8")) + hard_gate_delegated = 0 + governance_delegated = 0 + for run in sarif.get("runs", []): + kept = [] + for result in run.get("results", []): + rule_id = result.get("ruleId") + if rule_id in PR_DELEGATED_RULE_IDS: + if rule_id in PR_HARD_GATE_RULE_IDS: + hard_gate_delegated += 1 + if rule_id in PR_GOVERNANCE_RULE_IDS: + governance_delegated += 1 + continue + kept.append(result) + run["results"] = kept + filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") + filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") + filtered_path.replace(sarif_path) + print( + "Delegated " + f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " + "CodeQL, OSV, Trivy, and dependency-review hard gates." + ) + print( + "Delegated " + f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " + "to default-branch governance tracking." + ) + PY + - name: Upload Scorecard SARIF to code scanning + id: upload_scorecard_sarif + # Scorecard is soft repository-posture evidence; upload quota is external. + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + 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' + run: | + echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates." + + if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then + echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + + set +e + status="$( + 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" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" + )" + curl_status=$? + set -e + + case "$status" in + [0-9][0-9][0-9]) http_status="$status" ;; + "") http_status="unavailable" ;; + *) http_status="malformed" ;; + esac + + case "${REPOSITORY_VISIBILITY:-}" in + public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;; + *) repository_visibility="unknown" ;; + esac + + echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}" + + if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." + exit 1 + fi + + 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 + with: + base-ref: ${{ github.event.pull_request.base.sha }} + head-ref: ${{ github.event.pull_request.head.sha }} + fail-on-severity: moderate + comment-summary-in-pr: never + + # Keep the existing central-repository Gitleaks PR gate inside the required + # security bundle. It deliberately does not depend on changed-scope: secrets + # in Markdown or other document-only changes must still fail the PR. The + # repository condition preserves the standalone workflow's previous scope; + # push, schedule, and manual backstops remain in secret-scan.yml. + gitleaks: + name: gitleaks (secret scan) + if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + - name: Checkout PR commit range + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install gitleaks (pinned, checksum-verified) + run: | + set -euo pipefail + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL "$url" -o gitleaks.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + - name: Run gitleaks on PR commit range + id: gitleaks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set +e + config_args=() + if [ -f .gitleaks.toml ]; then + config_args=(--config .gitleaks.toml) + fi + log_opts="${BASE_SHA}..${HEAD_SHA}" + echo "::notice::gitleaks scanning pull request commit range ${log_opts}." + ./gitleaks git . \ + "${config_args[@]}" \ + --log-opts="${log_opts}" \ + --redact \ + --report-format sarif \ + --report-path gitleaks-results.sarif \ + --exit-code 2 + echo "rc=$?" >> "$GITHUB_OUTPUT" + set -e + - name: Summarize redacted gitleaks findings + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + set -euo pipefail + count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" + if [ "$count" = "0" ]; then + echo "::notice::gitleaks completed with no findings." + exit 0 + fi + echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." + jq -r ' + .runs[].results[]? + | "- rule: `" + (.ruleId // "unknown") + "`" + + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" + + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" + ' gitleaks-results.sarif | sort | uniq -c + - name: Filter test-classified Gitleaks SARIF results + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + python3 scripts/ci/filter_gitleaks_sarif.py \ + gitleaks-results.sarif \ + gitleaks-results.upload.sarif + - name: Upload gitleaks SARIF to code scanning + if: always() && hashFiles('gitleaks-results.upload.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: gitleaks-results.upload.sarif + category: gitleaks + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} + wait-for-processing: false + - name: Enforce secret-scan gate + if: steps.gitleaks.outputs.rc != '0' + run: | + echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." + exit 1 + + trivy-fs: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout exact Trivy 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: Verify Trivy head checkout + env: + EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Trivy checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." + exit 1 + fi + echo "SECURITY_CHECKOUT scanner=trivy-fs revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: CRITICAL,HIGH,MEDIUM + ignore-unfixed: true + format: sarif + output: trivy-results.sarif + exit-code: "0" + # Without this, trivy-action rebuilds the SARIF scan with ALL + # severities and the parser below would gate LOW findings too, + # contradicting the documented MEDIUM-or-higher gate above. + limit-severities-for-sarif: true + - name: Require Trivy SARIF output + run: | + set -euo pipefail + if [ ! -s trivy-results.sarif ]; then + echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above." + exit 1 + fi + - name: Print Trivy findings that failed the gate + # SARIF-only output otherwise leaves failures as just "exit code 1". + shell: python3 {0} + run: | + import json, pathlib + + sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8")) + findings = [] + for run in sarif.get("runs", []): + rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])} + for result in run.get("results", []): + rule = rules.get(result.get("ruleId", ""), {}) + severity = rule.get("properties", {}).get("security-severity", "?") + lines = (result.get("message", {}).get("text") or "").strip().splitlines() + fields = {} + for entry in lines: + key, sep, value = entry.partition(":") + if sep: + fields[key.strip().lower()] = value.strip() + if fields.get("severity"): + severity = f"{fields['severity']} (security-severity={severity})" + message = fields.get("message") or (lines[0] if lines else result.get("ruleId", "")) + locations = result.get("locations", []) + if locations: + phys = locations[0].get("physicalLocation", {}) + uri = phys.get("artifactLocation", {}).get("uri", "?") + line = phys.get("region", {}).get("startLine", "?") + where = f"{uri}:{line}" + else: + where = "-" + findings.append((severity, result.get("ruleId", "?"), where, message)) + + if not findings: + print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.") + else: + print(f"Trivy filesystem scan reported {len(findings)} finding(s):") + for severity, rule_id, where, message in findings: + print(f" [{severity}] {rule_id} {where} - {message}") + print("") + print("Remediate each finding at the shared base branch so open PRs inherit the fix.") + raise SystemExit(1) + - name: Upload Trivy SARIF to code scanning + id: upload_trivy_sarif + if: always() && hashFiles('trivy-results.sarif') != '' + # The parser above fails on every fixable Medium+ finding independently. + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + 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' + run: | + echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." + + scorecard: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' runs-on: ubuntu-24.04 # SOFT: posture findings are unrelated to the PR diff, so never block merge. continue-on-error: true @@ -549,7 +1388,7 @@ jobs: id: upload_scorecard_sarif # Scorecard is soft repository-posture evidence; upload quota is external. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif category: scorecard From c2e8ab0e535245f8f53801ad6a11e107fe492341 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:20:19 +0900 Subject: [PATCH 13/13] fix(security): repair restack workflow composition --- .github/workflows/security-scan.yml | 670 +--------------------------- 1 file changed, 2 insertions(+), 668 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 8efb982b02..3c41dbf819 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -383,674 +383,8 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" - git_object_id='^[0-9a-f]{40}([0-9a-f]{24})? set +e - status="$( - 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" \ - "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" - )" - curl_status=$? - set -e - - case "$status" in - [0-9][0-9][0-9]) http_status="$status" ;; - "") http_status="unavailable" ;; - *) http_status="malformed" ;; - esac - - case "${REPOSITORY_VISIBILITY:-}" in - public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;; - *) repository_visibility="unknown" ;; - esac - - echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}" - - if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then - echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." - exit 1 - fi - - 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 - with: - base-ref: ${{ github.event.pull_request.base.sha }} - head-ref: ${{ github.event.pull_request.head.sha }} - fail-on-severity: moderate - comment-summary-in-pr: never - - # Keep the existing central-repository Gitleaks PR gate inside the required - # security bundle. It deliberately does not depend on changed-scope: secrets - # in Markdown or other document-only changes must still fail the PR. The - # repository condition preserves the standalone workflow's previous scope; - # push, schedule, and manual backstops remain in secret-scan.yml. - gitleaks: - name: gitleaks (secret scan) - if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' - runs-on: ubuntu-24.04 - permissions: - contents: read - security-events: write - actions: read - env: - GITLEAKS_VERSION: "8.30.1" - GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - name: Checkout PR commit range - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - fetch-depth: 0 - - name: Install gitleaks (pinned, checksum-verified) - run: | - set -euo pipefail - url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - curl -fsSL "$url" -o gitleaks.tar.gz - echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - - tar -xzf gitleaks.tar.gz gitleaks - chmod +x gitleaks - ./gitleaks version - - name: Run gitleaks on PR commit range - id: gitleaks - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set +e - config_args=() - if [ -f .gitleaks.toml ]; then - config_args=(--config .gitleaks.toml) - fi - log_opts="${BASE_SHA}..${HEAD_SHA}" - echo "::notice::gitleaks scanning pull request commit range ${log_opts}." - ./gitleaks git . \ - "${config_args[@]}" \ - --log-opts="${log_opts}" \ - --redact \ - --report-format sarif \ - --report-path gitleaks-results.sarif \ - --exit-code 2 - echo "rc=$?" >> "$GITHUB_OUTPUT" - set -e - - name: Summarize redacted gitleaks findings - if: always() && hashFiles('gitleaks-results.sarif') != '' - run: | - set -euo pipefail - count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" - if [ "$count" = "0" ]; then - echo "::notice::gitleaks completed with no findings." - exit 0 - fi - echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." - jq -r ' - .runs[].results[]? - | "- rule: `" + (.ruleId // "unknown") + "`" - + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" - + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" - ' gitleaks-results.sarif | sort | uniq -c - - name: Filter test-classified Gitleaks SARIF results - if: always() && hashFiles('gitleaks-results.sarif') != '' - run: | - python3 scripts/ci/filter_gitleaks_sarif.py \ - gitleaks-results.sarif \ - gitleaks-results.upload.sarif - - name: Upload gitleaks SARIF to code scanning - if: always() && hashFiles('gitleaks-results.upload.sarif') != '' - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - sarif_file: gitleaks-results.upload.sarif - category: gitleaks - ref: refs/pull/${{ github.event.pull_request.number }}/head - sha: ${{ github.event.pull_request.head.sha }} - wait-for-processing: false - - name: Enforce secret-scan gate - if: steps.gitleaks.outputs.rc != '0' - run: | - echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." - exit 1 - - trivy-fs: - needs: changed-scope - if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' - runs-on: ubuntu-24.04 - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout exact Trivy 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: Verify Trivy head checkout - env: - EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then - echo "::error::Trivy checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." - exit 1 - fi - echo "SECURITY_CHECKOUT scanner=trivy-fs revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" - - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 - with: - scan-type: fs - scan-ref: . - scanners: vuln,secret,misconfig - severity: CRITICAL,HIGH,MEDIUM - ignore-unfixed: true - format: sarif - output: trivy-results.sarif - exit-code: "0" - # Without this, trivy-action rebuilds the SARIF scan with ALL - # severities and the parser below would gate LOW findings too, - # contradicting the documented MEDIUM-or-higher gate above. - limit-severities-for-sarif: true - - name: Require Trivy SARIF output - run: | - set -euo pipefail - if [ ! -s trivy-results.sarif ]; then - echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above." - exit 1 - fi - - name: Print Trivy findings that failed the gate - # SARIF-only output otherwise leaves failures as just "exit code 1". - shell: python3 {0} - run: | - import json, pathlib - - sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8")) - findings = [] - for run in sarif.get("runs", []): - rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])} - for result in run.get("results", []): - rule = rules.get(result.get("ruleId", ""), {}) - severity = rule.get("properties", {}).get("security-severity", "?") - lines = (result.get("message", {}).get("text") or "").strip().splitlines() - fields = {} - for entry in lines: - key, sep, value = entry.partition(":") - if sep: - fields[key.strip().lower()] = value.strip() - if fields.get("severity"): - severity = f"{fields['severity']} (security-severity={severity})" - message = fields.get("message") or (lines[0] if lines else result.get("ruleId", "")) - locations = result.get("locations", []) - if locations: - phys = locations[0].get("physicalLocation", {}) - uri = phys.get("artifactLocation", {}).get("uri", "?") - line = phys.get("region", {}).get("startLine", "?") - where = f"{uri}:{line}" - else: - where = "-" - findings.append((severity, result.get("ruleId", "?"), where, message)) - - if not findings: - print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.") - else: - print(f"Trivy filesystem scan reported {len(findings)} finding(s):") - for severity, rule_id, where, message in findings: - print(f" [{severity}] {rule_id} {where} - {message}") - print("") - print("Remediate each finding at the shared base branch so open PRs inherit the fix.") - raise SystemExit(1) - - name: Upload Trivy SARIF to code scanning - id: upload_trivy_sarif - if: always() && hashFiles('trivy-results.sarif') != '' - # The parser above fails on every fixable Medium+ finding independently. - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - 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' - run: | - echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." - - scorecard: - needs: changed-scope - if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' - runs-on: ubuntu-24.04 - # SOFT: posture findings are unrelated to the PR diff, so never block merge. - continue-on-error: true - permissions: - security-events: write - contents: read - actions: read - steps: - - name: Checkout exact Scorecard 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: Verify Scorecard head checkout - env: - EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then - echo "::error::Scorecard checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." - exit 1 - fi - echo "SECURITY_CHECKOUT scanner=scorecard revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" - - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: false - - name: Filter delegated PR-only Scorecard SARIF findings - run: | - python3 <<'PY' - import json - import pathlib - - PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} - PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} - PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS - - sarif_path = pathlib.Path("results.sarif") - sarif = json.loads(sarif_path.read_text(encoding="utf-8")) - hard_gate_delegated = 0 - governance_delegated = 0 - for run in sarif.get("runs", []): - kept = [] - for result in run.get("results", []): - rule_id = result.get("ruleId") - if rule_id in PR_DELEGATED_RULE_IDS: - if rule_id in PR_HARD_GATE_RULE_IDS: - hard_gate_delegated += 1 - if rule_id in PR_GOVERNANCE_RULE_IDS: - governance_delegated += 1 - continue - kept.append(result) - run["results"] = kept - filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") - filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") - filtered_path.replace(sarif_path) - print( - "Delegated " - f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " - "CodeQL, OSV, Trivy, and dependency-review hard gates." - ) - print( - "Delegated " - f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " - "to default-branch governance tracking." - ) - PY - - name: Upload Scorecard SARIF to code scanning - id: upload_scorecard_sarif - # Scorecard is soft repository-posture evidence; upload quota is external. - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - 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' - run: | - echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates." - - repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+ set +e - status="$( - 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" \ - "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" - )" - curl_status=$? - set -e - - case "$status" in - [0-9][0-9][0-9]) http_status="$status" ;; - "") http_status="unavailable" ;; - *) http_status="malformed" ;; - esac - - case "${REPOSITORY_VISIBILITY:-}" in - public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;; - *) repository_visibility="unknown" ;; - esac - - echo "DEPENDENCY_REVIEW_SUPPORT repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} http_status=${http_status} curl_exit=${curl_status}" - - if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then - echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: HTTP ${http_status}; curl exit ${curl_status}. Verify dependency-graph/security configuration and GitHub service behavior, then rerun. Failing closed." - exit 1 - fi - - 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 - with: - base-ref: ${{ github.event.pull_request.base.sha }} - head-ref: ${{ github.event.pull_request.head.sha }} - fail-on-severity: moderate - comment-summary-in-pr: never - - # Keep the existing central-repository Gitleaks PR gate inside the required - # security bundle. It deliberately does not depend on changed-scope: secrets - # in Markdown or other document-only changes must still fail the PR. The - # repository condition preserves the standalone workflow's previous scope; - # push, schedule, and manual backstops remain in secret-scan.yml. - gitleaks: - name: gitleaks (secret scan) - if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' - runs-on: ubuntu-24.04 - permissions: - contents: read - security-events: write - actions: read - env: - GITLEAKS_VERSION: "8.30.1" - GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - name: Checkout PR commit range - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - fetch-depth: 0 - - name: Install gitleaks (pinned, checksum-verified) - run: | - set -euo pipefail - url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - curl -fsSL "$url" -o gitleaks.tar.gz - echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - - tar -xzf gitleaks.tar.gz gitleaks - chmod +x gitleaks - ./gitleaks version - - name: Run gitleaks on PR commit range - id: gitleaks - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set +e - config_args=() - if [ -f .gitleaks.toml ]; then - config_args=(--config .gitleaks.toml) - fi - log_opts="${BASE_SHA}..${HEAD_SHA}" - echo "::notice::gitleaks scanning pull request commit range ${log_opts}." - ./gitleaks git . \ - "${config_args[@]}" \ - --log-opts="${log_opts}" \ - --redact \ - --report-format sarif \ - --report-path gitleaks-results.sarif \ - --exit-code 2 - echo "rc=$?" >> "$GITHUB_OUTPUT" - set -e - - name: Summarize redacted gitleaks findings - if: always() && hashFiles('gitleaks-results.sarif') != '' - run: | - set -euo pipefail - count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" - if [ "$count" = "0" ]; then - echo "::notice::gitleaks completed with no findings." - exit 0 - fi - echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." - jq -r ' - .runs[].results[]? - | "- rule: `" + (.ruleId // "unknown") + "`" - + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" - + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" - ' gitleaks-results.sarif | sort | uniq -c - - name: Filter test-classified Gitleaks SARIF results - if: always() && hashFiles('gitleaks-results.sarif') != '' - run: | - python3 scripts/ci/filter_gitleaks_sarif.py \ - gitleaks-results.sarif \ - gitleaks-results.upload.sarif - - name: Upload gitleaks SARIF to code scanning - if: always() && hashFiles('gitleaks-results.upload.sarif') != '' - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - sarif_file: gitleaks-results.upload.sarif - category: gitleaks - ref: refs/pull/${{ github.event.pull_request.number }}/head - sha: ${{ github.event.pull_request.head.sha }} - wait-for-processing: false - - name: Enforce secret-scan gate - if: steps.gitleaks.outputs.rc != '0' - run: | - echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." - exit 1 - - trivy-fs: - needs: changed-scope - if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' - runs-on: ubuntu-24.04 - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout exact Trivy 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: Verify Trivy head checkout - env: - EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then - echo "::error::Trivy checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." - exit 1 - fi - echo "SECURITY_CHECKOUT scanner=trivy-fs revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" - - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 - with: - scan-type: fs - scan-ref: . - scanners: vuln,secret,misconfig - severity: CRITICAL,HIGH,MEDIUM - ignore-unfixed: true - format: sarif - output: trivy-results.sarif - exit-code: "0" - # Without this, trivy-action rebuilds the SARIF scan with ALL - # severities and the parser below would gate LOW findings too, - # contradicting the documented MEDIUM-or-higher gate above. - limit-severities-for-sarif: true - - name: Require Trivy SARIF output - run: | - set -euo pipefail - if [ ! -s trivy-results.sarif ]; then - echo "::error::Trivy did not produce trivy-results.sarif; inspect the Trivy filesystem scan logs above." - exit 1 - fi - - name: Print Trivy findings that failed the gate - # SARIF-only output otherwise leaves failures as just "exit code 1". - shell: python3 {0} - run: | - import json, pathlib - - sarif = json.loads(pathlib.Path("trivy-results.sarif").read_text(encoding="utf-8")) - findings = [] - for run in sarif.get("runs", []): - rules = {r["id"]: r for r in run.get("tool", {}).get("driver", {}).get("rules", [])} - for result in run.get("results", []): - rule = rules.get(result.get("ruleId", ""), {}) - severity = rule.get("properties", {}).get("security-severity", "?") - lines = (result.get("message", {}).get("text") or "").strip().splitlines() - fields = {} - for entry in lines: - key, sep, value = entry.partition(":") - if sep: - fields[key.strip().lower()] = value.strip() - if fields.get("severity"): - severity = f"{fields['severity']} (security-severity={severity})" - message = fields.get("message") or (lines[0] if lines else result.get("ruleId", "")) - locations = result.get("locations", []) - if locations: - phys = locations[0].get("physicalLocation", {}) - uri = phys.get("artifactLocation", {}).get("uri", "?") - line = phys.get("region", {}).get("startLine", "?") - where = f"{uri}:{line}" - else: - where = "-" - findings.append((severity, result.get("ruleId", "?"), where, message)) - - if not findings: - print("Trivy filesystem scan completed with 0 CRITICAL/HIGH/MEDIUM findings in trivy-results.sarif.") - else: - print(f"Trivy filesystem scan reported {len(findings)} finding(s):") - for severity, rule_id, where, message in findings: - print(f" [{severity}] {rule_id} {where} - {message}") - print("") - print("Remediate each finding at the shared base branch so open PRs inherit the fix.") - raise SystemExit(1) - - name: Upload Trivy SARIF to code scanning - id: upload_trivy_sarif - if: always() && hashFiles('trivy-results.sarif') != '' - # The parser above fails on every fixable Medium+ finding independently. - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - 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' - run: | - echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." - - scorecard: - needs: changed-scope - if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' - runs-on: ubuntu-24.04 - # SOFT: posture findings are unrelated to the PR diff, so never block merge. - continue-on-error: true - permissions: - security-events: write - contents: read - actions: read - steps: - - name: Checkout exact Scorecard 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: Verify Scorecard head checkout - env: - EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then - echo "::error::Scorecard checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." - exit 1 - fi - echo "SECURITY_CHECKOUT scanner=scorecard revision=head repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" - - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: false - - name: Filter delegated PR-only Scorecard SARIF findings - run: | - python3 <<'PY' - import json - import pathlib - - PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} - PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} - PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS - - sarif_path = pathlib.Path("results.sarif") - sarif = json.loads(sarif_path.read_text(encoding="utf-8")) - hard_gate_delegated = 0 - governance_delegated = 0 - for run in sarif.get("runs", []): - kept = [] - for result in run.get("results", []): - rule_id = result.get("ruleId") - if rule_id in PR_DELEGATED_RULE_IDS: - if rule_id in PR_HARD_GATE_RULE_IDS: - hard_gate_delegated += 1 - if rule_id in PR_GOVERNANCE_RULE_IDS: - governance_delegated += 1 - continue - kept.append(result) - run["results"] = kept - filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") - filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") - filtered_path.replace(sarif_path) - print( - "Delegated " - f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " - "CodeQL, OSV, Trivy, and dependency-review hard gates." - ) - print( - "Delegated " - f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " - "to default-branch governance tracking." - ) - PY - - name: Upload Scorecard SARIF to code scanning - id: upload_scorecard_sarif - # Scorecard is soft repository-posture evidence; upload quota is external. - continue-on-error: true - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - 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' - run: | - echo "::warning::Scorecard SARIF upload to code scanning failed after delegated PR-only findings were filtered. Scorecard is PR posture evidence only; CodeQL, OSV, Trivy, and dependency-review remain the hard gates." - + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." exit 1