From cb07b8bb28ef9d3a147cc966a0c70654d132da1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:02:47 +0900 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 From fe9d193343d12dbd229815a18ce85b494b3acff9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 3 Oct 2026 16:00:58 +0900 Subject: [PATCH 14/15] test(security): reproduce incomplete 200 compare --- ...dency_review_reusable_workflow_contract.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index ea55f91cfd..f61d7ce6da 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -47,6 +47,7 @@ def _run_availability_probe( base_sha: str = "a" * 40, head_sha: str = "b" * 40, http_status: str = "200", + curl_exit: int = 0, ) -> tuple[subprocess.CompletedProcess[str], Path, Path]: """Execute the real preflight shell against a marker-only fake curl.""" fake_bin = tmp_path / "bin" @@ -61,7 +62,8 @@ def _run_availability_probe( " if [[ \"$arg\" == Authorization:* ]]; then authorized=true; fi\n" "done\n" "printf '%s\\n' \"$authorized\" >>\"${CURL_MARKER}\"\n" - "printf '%s' \"${HTTP_STATUS:-200}\"\n", + "printf '%s' \"${HTTP_STATUS:-200}\"\n" + "exit \"${CURL_EXIT:-0}\"\n", encoding="utf-8", ) fake_curl.chmod(0o755) @@ -78,6 +80,7 @@ def _run_availability_probe( "GITHUB_OUTPUT": str(output), "CURL_MARKER": str(curl_marker), "HTTP_STATUS": http_status, + "CURL_EXIT": str(curl_exit), } ) result = subprocess.run( @@ -260,6 +263,22 @@ def test_preflight_accepts_dotgithub_and_uses_job_token(tmp_path: Path) -> None: assert output.read_text(encoding="utf-8") == "available=true\n" +def test_preflight_rejects_http_200_when_curl_transport_fails(tmp_path: Path) -> None: + """Partial transport failure cannot authorize an incomplete HTTP 200 compare.""" + result, curl_marker, output = _run_availability_probe( + tmp_path, + "ContextualWisdomLab/.github", + http_status="200", + curl_exit=18, + ) + + assert result.returncode != 0, result.stdout + result.stderr + assert curl_marker.read_text(encoding="utf-8") == "true\n" + assert not output.exists() or "available=true" not in output.read_text( + encoding="utf-8" + ) + + 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 229027280e8bce6cc4f13e722b2b0c280a1ac17d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 3 Oct 2026 16:04:49 +0900 Subject: [PATCH 15/15] fix(security): require completed dependency compare --- .github/workflows/dependency-review.yml | 8 ++++---- CHANGELOG.md | 9 +++++++++ ...cy-review-fail-closed-permission-envelope.md | 4 ++-- ...cy-review-fail-closed-permission-envelope.md | 17 ++++++++++++++++- docs/product-technical-gap-baseline.md | 6 ++++++ ...endency_review_reusable_workflow_contract.py | 6 ++++-- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 9062f15b32..6a5e95f4a3 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -147,21 +147,21 @@ jobs: api_url="${GITHUB_API_URL:-https://api.github.com}" response_file="$(mktemp)" + curl_exit=0 status="$( curl -fsS -o "$response_file" -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}" \ - || true - )" + )" || curl_exit=$? - if [ "$status" = "200" ]; then + if [ "$curl_exit" -eq 0 ] && [ "$status" = "200" ]; then echo "available=true" >>"$GITHUB_OUTPUT" exit 0 fi - 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." + echo "::error::Dependency graph comparison failed with curl exit ${curl_exit} and HTTP ${status:-unavailable}. For a pull_request, transport failures and 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d90fa0c899..dbbc88070c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### Dependency Review requires a completed authenticated comparison + +- The reusable Dependency Review preflight now requires both curl exit zero + and HTTP 200 before publishing `available=true`. A regression reproduces a + partial transfer that prints `200` but exits 18, preserving the fail-closed + gate. The bundled Security Scan already enforced both signals and remains + unchanged. PR #1725 stays Proposed until exact-head hosted Checks and an + independent approval are complete. + ### Intel macOS native archives are bound to x86_64 bytes - The release prescreener now requires every native member in an Intel macOS diff --git a/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md b/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md index e7eb7528b5..6588ca6d50 100644 --- a/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md +++ b/docs/adr/0025-dependency-review-fail-closed-permission-envelope.md @@ -41,7 +41,7 @@ Rejected. It increases blast radius and hides a caller-contract defect instead o ## 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. +1. For `pull_request`, the Dependency Graph compare preflight sets `available=true` only when curl exits zero and the endpoint returns HTTP 200. Every transport failure or 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. @@ -63,7 +63,7 @@ Hosted exact-current-head Checks and independent review remain required before t - 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. +- #1643's canary run remains independent diagnostic evidence for the required Security Scan path; its run/check result is not transferred to this gate. #1725 nevertheless integrates the durable immutable-identity validation source delta into the canonical reusable Dependency Review owner and verifies that integrated behavior with its own tests and exact-head Checks. ## References diff --git a/docs/doctoring/dependency-review-fail-closed-permission-envelope.md b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md index ef010dc741..2f4dca0cc2 100644 --- a/docs/doctoring/dependency-review-fail-closed-permission-envelope.md +++ b/docs/doctoring/dependency-review-fail-closed-permission-envelope.md @@ -65,9 +65,24 @@ This proves that anonymous status is not an availability authority and that the - 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. +### Defect D — HTTP text could hide a failed curl transfer + +The reusable preflight appended `|| true` to curl and then authorized solely on +the printed HTTP status. A partial body transfer can print `200` and still exit +nonzero, so the old branch could publish `available=true` for incomplete +evidence. The executable regression makes the fake transport print `200` and +exit `18`; the preflight must remain nonzero and must not publish availability. +The minimal repair captures curl's exit status and requires both exit zero and +HTTP 200. The bundled Security Scan already enforced both conditions and was +left unchanged. + +Test-first evidence: RED `fe9d193343d12dbd229815a18ce85b494b3acff9` adds only the executable transport +contract and fails on the inherited preflight; its ordinary child carries the +minimal production and documentation repair. + ## Security invariants -1. Pull-request Dependency Review executes only after an exact base/head compare returns HTTP 200. +1. Pull-request Dependency Review executes only after curl completes successfully and an exact base/head compare returns HTTP 200. 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6e5f1c549a..9f19265731 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,12 @@ 이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다. +### 2026-10-03 Dependency Review transport-completion delta + +| Gap ID / 상태 | PRD / TRD / Context Map / evidence / next gate | +|---|---| +| `CONTROL-DEPENDENCY-REVIEW-TRANSPORT-EXIT-01` — **Source repair on `.github#1725`; Proposed/HOLD pending exact-head hosted evidence and independent approval** | **PRD:** 구매자는 불완전한 dependency comparison이 보안 gate를 통과하지 않음을 보장받아야 한다. **TRD/RCA:** reusable preflight가 curl의 nonzero 종료를 `|| true`로 숨기고 출력된 HTTP `200`만 검사해 partial transfer에도 `available=true`를 발행할 수 있었다. **Context Map:** 중앙 `.github` Dependency Review admission owner가 thin callers 앞에서 exact base/head와 authenticated transport completion을 소유하며, bundled Security Scan은 이미 curl exit와 HTTP status를 함께 검사한다. **실행 흐름:** `exact identity validation → authenticated curl → curl exit == 0 AND HTTP 200 → available=true → pinned Dependency Review`; DB/UI/ERD 변화는 N/A다. **RED→GREEN:** fake curl이 `200`을 출력한 뒤 exit `18`인 executable regression은 defect head에서 실패하고, 최소 수리 뒤 availability를 발행하지 않는다. **다음 gate:** current-head focused/full suite, hosted security/review Checks, unresolved thread 0, qualifying independent approval, ordinary protected merge가 필요하다. | + ### 2026-09-19 exact-head incident delta | Gap ID | 상태 | exact-head evidence | causal owner / next gate | diff --git a/tests/test_dependency_review_reusable_workflow_contract.py b/tests/test_dependency_review_reusable_workflow_contract.py index f61d7ce6da..93ac57f200 100644 --- a/tests/test_dependency_review_reusable_workflow_contract.py +++ b/tests/test_dependency_review_reusable_workflow_contract.py @@ -133,7 +133,9 @@ def test_dependency_review_runs_only_after_a_confirmed_successful_comparison() - " continue-on-error: ${{ inputs.continue_on_error }}" in workflow ) - assert 'if [ "$status" = "200" ]; then' in workflow + assert ( + 'if [ "$curl_exit" -eq 0 ] && [ "$status" = "200" ]; then' in workflow + ) assert 'echo "available=true" >>"$GITHUB_OUTPUT"' in workflow @@ -205,7 +207,7 @@ def test_pull_request_http_403_and_404_are_not_normalized_to_unavailable() -> No 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 "::error::Dependency graph comparison failed with curl exit" in workflow assert "exit 1" in workflow