From 640b37d811a39587d49ef76eb2e1635fb7658f13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:19:01 +0900 Subject: [PATCH 1/7] fix(security): fail closed on unavailable dependency review Replay unique #897 source onto current origin/main. Skip shared ARCHITECTURE/CLAUDE trees. Treat non-200 or failed transport as unavailable evidence rather than a clean skip. --- .github/workflows/security-scan.yml | 43 ++++++------ CHANGELOG.md | 2 + .../dependency-review-fail-closed.md | 50 ++++++++++++++ .../test_required_workflow_queue_contract.py | 67 +++++++++++++++++-- 4 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 docs/doctoring/dependency-review-fail-closed.md diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c3b8fa5db..1511c4e06 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -16,10 +16,10 @@ # pull_request workflows upload to refs/pull/N/merge, so no single ref ever holds # all tools. Bundling at the workflow/check level is ref-independent. # -# NOTE on dependency-review: dependency graph can be unavailable on some repos. -# Treat that as "not enforceable here" instead of making the required workflow -# unsatisfiable; keep medium-or-higher dependency findings hard-failing where the -# API is supported. +# NOTE on dependency-review: unavailable evidence is not a clean result. Only +# an exact base/head comparison returning HTTP 200 may reach the pinned hard +# gate. Every other probe outcome fails closed without printing the response +# body. See docs/doctoring/dependency-review-fail-closed.md. # # NOTE on trivy-fs: it scans the whole repo, so a pre-existing FIXABLE # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. @@ -257,9 +257,11 @@ jobs: contents: read pull-requests: read steps: - - name: Checkout + - name: Checkout exact head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Check dependency review support id: dependency_review_support @@ -272,30 +274,31 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" - response_file="$(mktemp)" + set +e status="$( - curl -fsS -o "$response_file" -w '%{http_code}' \ + 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}" \ - || true + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" )" + curl_status=$? + set -e - if [ "$status" = "200" ]; then - echo "supported=true" >>"$GITHUB_OUTPUT" - exit 0 - fi + case "$status" in + [0-9][0-9][0-9]) http_status="$status" ;; + "") http_status="unavailable" ;; + *) http_status="malformed" ;; + esac - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." - echo "supported=false" >>"$GITHUB_OUTPUT" - exit 0 + 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 "::error::Dependency review support check failed with HTTP ${status}." - cat "$response_file" - exit 1 + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1aebf43..2ea797f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ Semantic Versioning where the repository publishes a release. ### Security - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. +- Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, or truncated compare) instead of treating HTTP 403/404 as a clean skip; the probe checks out the exact head SHA and never prints the API body. + - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md new file mode 100644 index 000000000..81681d3f0 --- /dev/null +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -0,0 +1,50 @@ +# Dependency review fail-closed operations + +Status: `active_pr` until the matching workflow and regression contract are present on protected `main`; thereafter `implemented_on_protected_main`. + +## Decision + +Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, empty or malformed status, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. + +The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials and response bodies are never diagnostic output. + +RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. + +## Identity and authority + +The dependency-review job checks out the pull request's explicit head repository and immutable head SHA with persisted credentials disabled. The API comparison independently binds the event's exact base and head revisions. The job retains `contents: read` and `pull-requests: read`; it receives no write, OIDC, model, release, package, or deployment authority. + +Checks, status contexts, review submissions, and merge authorization remain separate evidence classes. OSV, Trivy, CodeQL, Semgrep, Secret Scan, Scorecard, and Dependabot are complementary controls and are not semantic substitutes for dependency review. + +## Failure classification and remediation + +- Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. +- Any other result: fail the job and retain exact repository/base/head/status and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. +- Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. +- Private or internal exception: require a separately reviewed organization policy with explicit entitlement evidence and compensating controls. Never infer `not-applicable` from an unavailable response. + +Retries are operator-initiated only after the capability or service condition changes. Do not rerun unchanged evidence repeatedly and do not convert an unavailable endpoint into a green skip. + +## Acceptance and rollback + +Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. + +Rollback requires an independently reviewed revert and fresh exact-head evidence. A rollback must not restore the `403`/`404` success path or print an API response body. + +## References + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +GitHub. (n.d.). *Dependency review*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-review + +GitHub. (n.d.). *REST API endpoints for dependency review*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/rest/dependency-graph/dependency-review + +GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-graph + +National Institute of Standards and Technology. (2020). *Security and +privacy controls for information systems and organizations* (NIST SP +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +SLSA. (2023). *SLSA v1.0: Supply-chain Levels for Software Artifacts*. +Open Source Security Foundation. https://slsa.dev/spec/v1.0/ diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 535fd513a..70fd270e5 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -839,16 +839,71 @@ def test_fix_scheduler_cancels_superseded_cron_runs() -> None: assert "cancel-in-progress: true" in workflow -def test_security_scan_skips_dependency_review_when_dependency_graph_is_unavailable() -> ( - None -): +def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> None: workflow = workflow_text("security-scan.yml") + support_probe = workflow_step(workflow, "Check dependency review support") assert "id: dependency_review_support" in workflow assert "/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" in workflow - assert '"$status" = "403"' in workflow - assert '"$status" = "404"' in workflow - assert "steps.dependency_review_support.outputs.supported == 'true'" in workflow + assert "repository: ${{ github.event.pull_request.head.repo.full_name }}" in workflow + assert "ref: ${{ github.event.pull_request.head.sha }}" in workflow + assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow + assert "--connect-timeout 10" in workflow + assert "--max-time 30" in workflow + assert "-o /dev/null" in workflow + assert "curl_status=$?" in support_probe + assert "set +e" in support_probe + assert "set -e" in support_probe + assert "|| true" not in support_probe + assert "HTTP ${http_status}; curl exit ${curl_status}" in workflow + assert "supported=false" not in workflow + assert "skipping dependency-review hard gate" not in workflow + assert ( + "steps.dependency_review_support.outputs.supported == 'true'" in workflow + ) + + +def test_dependency_review_transport_failure_cannot_hide_behind_http_200( + tmp_path: Path, +) -> None: + """A failed curl transport must not make HTTP 200 acceptable evidence.""" + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text( + "#!/usr/bin/env bash\nprintf '200'\nexit 18\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + github_output = tmp_path / "github-output" + script = textwrap.dedent( + workflow_step( + workflow_text("security-scan.yml"), + "Check dependency review support", + ).split(" run: |\n", 1)[1] + ) + + result = subprocess.run( + ["bash", "-c", script], + env={ + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "GITHUB_API_URL": "https://api.example.invalid", + "GITHUB_OUTPUT": str(github_output), + "GH_TOKEN": "synthetic-read-token", + "BASE_SHA": "a" * 40, + "HEAD_SHA": "b" * 40, + "REPOSITORY": "ContextualWisdomLab/.github", + }, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert "HTTP 200; curl exit 18" in result.stdout + assert not github_output.exists() def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: From e4f1a2de880d5de53b2281e1e64b2bd9a625e148 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:35:33 +0000 Subject: [PATCH 2/7] fix(security): record visibility on dependency-review fail-closed Close the remaining ContextualWisdomLab/.github#810 diagnostic gap: allowlist public/private/internal/unknown visibility in probe diagnostics, execute 403/404/empty/malformed regressions, and stop making the pinned action independently skippable after a successful probe. Co-authored-by: Seongho Bae --- .github/workflows/security-scan.yml | 11 +- AGENTS.md | 1 + ARCHITECTURE.md | 23 +++ CHANGELOG.md | 2 +- .../dependency-review-fail-closed.md | 6 +- .../test_required_workflow_queue_contract.py | 147 ++++++++++++++---- 6 files changed, 152 insertions(+), 38 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 1511c4e06..5199bd52f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -19,7 +19,8 @@ # NOTE on dependency-review: unavailable evidence is not a clean result. Only # an exact base/head comparison returning HTTP 200 may reach the pinned hard # gate. Every other probe outcome fails closed without printing the response -# body. See docs/doctoring/dependency-review-fail-closed.md. +# body. Diagnostics include allowlisted repository visibility. See +# docs/doctoring/dependency-review-fail-closed.md. # # NOTE on trivy-fs: it scans the whole repo, so a pre-existing FIXABLE # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. @@ -270,10 +271,15 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPOSITORY: ${{ github.repository }} + REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }} run: | set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" + case "${REPOSITORY_VISIBILITY}" in + public|private|internal) visibility="${REPOSITORY_VISIBILITY}" ;; + *) visibility="unknown" ;; + esac set +e status="$( curl -sS --connect-timeout 10 --max-time 30 \ @@ -294,13 +300,12 @@ jobs: esac 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." + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} (visibility ${visibility}) 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: fail-on-severity: moderate diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11..6aab567ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +Dependency-review unavailability fails closed; diagnostics include allowlisted repository visibility. See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e2e70b58..eb9954f84 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,6 +104,29 @@ sequenceDiagram - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +## Dependency-review evidence + +The central `Security Scan` job treats GitHub's exact `BASE_SHA...HEAD_SHA` +comparison as a hard supply-chain evidence boundary. Only transport exit `0` +plus HTTP `200` may reach the immutably pinned dependency-review action. A +`403`, `404`, timeout, truncated transfer, or malformed status fails closed +and records allowlisted repository visibility with the exact revisions. Other +scanners are complementary; they are not substitutes. + +```mermaid +flowchart TD + Probe["Exact base/head compare probe"] + Transport{"curl exit 0 and HTTP 200?"} + Action["Pinned dependency-review action"] + Fail["Fail closed with repo, visibility, SHAs, status"] + + Probe --> Transport + Transport -->|"yes"| Action + Transport -->|"no"| Fail +``` + +See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). + ## Quality gates `scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea797f4f..f5b0944ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ Semantic Versioning where the repository publishes a release. ### Security - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. -- Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, or truncated compare) instead of treating HTTP 403/404 as a clean skip; the probe checks out the exact head SHA and never prints the API body. +- Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, or truncated compare) instead of treating HTTP 403/404 as a clean skip; the probe records allowlisted repository visibility with the exact head SHA and never prints the API body. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 81681d3f0..dc3a4c9e1 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -6,7 +6,7 @@ Status: `active_pr` until the matching workflow and regression contract are pres Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, empty or malformed status, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. -The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials and response bodies are never diagnostic output. +The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, allowlisted visibility (`public`, `private`, `internal`, or `unknown`), exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials, response bodies, and raw untrusted visibility strings are never diagnostic output. After a successful probe the pinned action is not independently skippable. RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. @@ -19,7 +19,7 @@ Checks, status contexts, review submissions, and merge authorization remain sepa ## Failure classification and remediation - Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. -- Any other result: fail the job and retain exact repository/base/head/status and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. +- Any other result: fail the job and retain exact repository, allowlisted visibility, base/head, status, and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. Do not infer a root cause from HTTP `403` or `404`. - Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. - Private or internal exception: require a separately reviewed organization policy with explicit entitlement evidence and compensating controls. Never infer `not-applicable` from an unavailable response. @@ -42,6 +42,8 @@ GitHub. (n.d.). *REST API endpoints for dependency review*. GitHub Docs. Retriev GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-graph +GitHub. (n.d.). *Webhook events and payloads*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/webhooks/webhook-events-and-payloads#repository + National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST SP 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 70fd270e5..5d48e532a 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -27,6 +27,49 @@ def workflow_step(workflow: str, name: str) -> str: return workflow[start:end] +def run_dependency_review_support_probe( + tmp_path: Path, + *, + curl_script: str, + repository_visibility: str = "public", +) -> subprocess.CompletedProcess: + """Run the workflow support probe against a controlled curl binary. + + The helper places a fake ``curl`` first on ``PATH`` so the extracted + workflow script cannot call the real binary, then supplies only the + synthetic environment variables the support step reads. + """ + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text(curl_script, encoding="utf-8") + fake_curl.chmod(0o755) + script = textwrap.dedent( + workflow_step( + workflow_text("security-scan.yml"), + "Check dependency review support", + ).split(" run: |\n", 1)[1] + ) + return subprocess.run( + ["bash", "-c", script], + env={ + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "GITHUB_API_URL": "https://api.example.invalid", + "GITHUB_OUTPUT": str(tmp_path / "github-output"), + "GH_TOKEN": "synthetic-read-token", + "BASE_SHA": "a" * 40, + "HEAD_SHA": "b" * 40, + "REPOSITORY": "ContextualWisdomLab/.github", + "REPOSITORY_VISIBILITY": repository_visibility, + }, + capture_output=True, + text=True, + check=False, + ) + + def test_merge_scheduler_dispatches_one_review_by_default() -> None: workflow = workflow_text("pr-review-merge-scheduler.yml") @@ -842,11 +885,21 @@ def test_fix_scheduler_cancels_superseded_cron_runs() -> None: def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> None: workflow = workflow_text("security-scan.yml") support_probe = workflow_step(workflow, "Check dependency review support") + action_first_line = workflow.split( + " - name: Dependency review\n", + 1, + )[1].splitlines()[0] assert "id: dependency_review_support" in workflow assert "/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" in workflow assert "repository: ${{ github.event.pull_request.head.repo.full_name }}" in workflow assert "ref: ${{ github.event.pull_request.head.sha }}" in workflow + assert ( + "REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}" + in support_probe + ) + assert "public|private|internal)" in support_probe + assert "visibility ${visibility}" in support_probe assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow assert "--connect-timeout 10" in workflow assert "--max-time 30" in workflow @@ -858,8 +911,8 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N assert "HTTP ${http_status}; curl exit ${curl_status}" in workflow assert "supported=false" not in workflow assert "skipping dependency-review hard gate" not in workflow - assert ( - "steps.dependency_review_support.outputs.supported == 'true'" in workflow + assert action_first_line.startswith( + " uses: actions/dependency-review-action@" ) @@ -868,42 +921,72 @@ def test_dependency_review_transport_failure_cannot_hide_behind_http_200( ) -> None: """A failed curl transport must not make HTTP 200 acceptable evidence.""" - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - fake_curl = fake_bin / "curl" - fake_curl.write_text( - "#!/usr/bin/env bash\nprintf '200'\nexit 18\n", - encoding="utf-8", + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 18\n", ) - fake_curl.chmod(0o755) - github_output = tmp_path / "github-output" - script = textwrap.dedent( - workflow_step( - workflow_text("security-scan.yml"), - "Check dependency review support", - ).split(" run: |\n", 1)[1] + + assert result.returncode == 1 + assert "HTTP 200; curl exit 18" in result.stdout + assert "visibility public" in result.stdout + assert not (tmp_path / "github-output").exists() + + +@pytest.mark.parametrize( + ("curl_script", "expected_http"), + [ + ("#!/usr/bin/env bash\nprintf '403'\nexit 0\n", "403"), + ("#!/usr/bin/env bash\nprintf '404'\nexit 0\n", "404"), + ("#!/usr/bin/env bash\nprintf ''\nexit 0\n", "unavailable"), + ("#!/usr/bin/env bash\nprintf 'OK'\nexit 0\n", "malformed"), + ], +) +def test_dependency_review_non_200_status_fails_closed( + tmp_path: Path, + curl_script: str, + expected_http: str, +) -> None: + """HTTP 403/404, empty, and malformed statuses must fail closed.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=curl_script, ) - result = subprocess.run( - ["bash", "-c", script], - env={ - **os.environ, - "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", - "GITHUB_API_URL": "https://api.example.invalid", - "GITHUB_OUTPUT": str(github_output), - "GH_TOKEN": "synthetic-read-token", - "BASE_SHA": "a" * 40, - "HEAD_SHA": "b" * 40, - "REPOSITORY": "ContextualWisdomLab/.github", - }, - capture_output=True, - text=True, - check=False, + assert result.returncode == 1 + assert f"HTTP {expected_http}; curl exit 0" in result.stdout + assert "visibility public" in result.stdout + assert not (tmp_path / "github-output").exists() + + +def test_dependency_review_success_writes_supported_true(tmp_path: Path) -> None: + """Only a complete HTTP 200 with transport exit 0 may emit supported=true.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + ) + + assert result.returncode == 0 + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "supported=true\n" + ) + + +def test_dependency_review_records_unknown_visibility_when_unset( + tmp_path: Path, +) -> None: + """Missing or unrecognized visibility must be recorded as unknown.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '403'\nexit 0\n", + repository_visibility="", ) assert result.returncode == 1 - assert "HTTP 200; curl exit 18" in result.stdout - assert not github_output.exists() + assert "visibility unknown" in result.stdout + assert not (tmp_path / "github-output").exists() def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: From a70e5dc4a7323cbd09d73e30392a4cd5124afc21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:54:55 +0000 Subject: [PATCH 3/7] test(security): prove 403 skip and transport failure cannot go green Add executable regressions for the EgressWeave #66 canary (HTTP 403 skip-was-success), a bare transport failure, and curl exit 18 with a printed 200. Record exact SHAs and allowlisted visibility without leaking the probe token. Co-authored-by: Seongho Bae --- .../dependency-review-fail-closed.md | 4 + .../test_required_workflow_queue_contract.py | 122 +++++++++++++++--- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index dc3a4c9e1..521ab01c8 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -25,6 +25,10 @@ Checks, status contexts, review submissions, and merge authorization remain sepa Retries are operator-initiated only after the capability or service condition changes. Do not rerun unchanged evidence repeatedly and do not convert an unavailable endpoint into a green skip. +## Known canary + +ContextualWisdomLab/EgressWeave#66, Security Scan run `31108241013`, job `92638903658`, compared `10d0c51daf2ad278d66f43be479df8cf6b08ba6d...c038a9509d1a8eae8561cc9081e67e12bd373d42` and received HTTP `403`. The required workflow printed the skip warning, omitted `actions/dependency-review-action`, and still concluded success. Downstream tracking: ContextualWisdomLab/EgressWeave#76. Keep ContextualWisdomLab/.github#810 open until a protected-main public consumer run proves a non-200 or failed-transfer comparison cannot green this job. + ## Acceptance and rollback Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 5d48e532a..1ce445c9f 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -27,6 +27,11 @@ def workflow_step(workflow: str, name: str) -> str: return workflow[start:end] +PROBE_TOKEN = "synthetic-read-token" +PROBE_BASE_SHA = "a" * 40 +PROBE_HEAD_SHA = "b" * 40 + + def run_dependency_review_support_probe( tmp_path: Path, *, @@ -35,7 +40,7 @@ def run_dependency_review_support_probe( ) -> subprocess.CompletedProcess: """Run the workflow support probe against a controlled curl binary. - The helper places a fake ``curl`` first on ``PATH`` so the extracted + The helper places a fake ``curl`` alone on ``PATH`` so the extracted workflow script cannot call the real binary, then supplies only the synthetic environment variables the support step reads. """ @@ -54,13 +59,13 @@ def run_dependency_review_support_probe( return subprocess.run( ["bash", "-c", script], env={ - **os.environ, - "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "PATH": str(fake_bin), + "HOME": str(tmp_path), "GITHUB_API_URL": "https://api.example.invalid", "GITHUB_OUTPUT": str(tmp_path / "github-output"), - "GH_TOKEN": "synthetic-read-token", - "BASE_SHA": "a" * 40, - "HEAD_SHA": "b" * 40, + "GH_TOKEN": PROBE_TOKEN, + "BASE_SHA": PROBE_BASE_SHA, + "HEAD_SHA": PROBE_HEAD_SHA, "REPOSITORY": "ContextualWisdomLab/.github", "REPOSITORY_VISIBILITY": repository_visibility, }, @@ -70,6 +75,26 @@ def run_dependency_review_support_probe( ) +def assert_dependency_review_probe_failed( + result: subprocess.CompletedProcess, + tmp_path: Path, + *, + expected_http: str, + expected_curl_exit: str, + expected_visibility: str = "public", +) -> None: + """Require a failed probe that records identity without leaking secrets.""" + + combined = f"{result.stdout}{result.stderr}" + assert result.returncode == 1 + assert f"HTTP {expected_http}; curl exit {expected_curl_exit}" in result.stdout + assert f"visibility {expected_visibility}" in result.stdout + assert f"exact base {PROBE_BASE_SHA} and head {PROBE_HEAD_SHA}" in result.stdout + assert PROBE_TOKEN not in combined + assert "Authorization:" not in combined + assert not (tmp_path / "github-output").exists() + + def test_merge_scheduler_dispatches_one_review_by_default() -> None: workflow = workflow_text("pr-review-merge-scheduler.yml") @@ -911,9 +936,43 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N assert "HTTP ${http_status}; curl exit ${curl_status}" in workflow assert "supported=false" not in workflow assert "skipping dependency-review hard gate" not in workflow + assert 'cat "$response_file"' not in support_probe + assert "fail-on-severity: moderate" in workflow assert action_first_line.startswith( " uses: actions/dependency-review-action@" ) + osv_job_header = workflow.split(" osv-scan:\n", 1)[1].split(" steps:", 1)[0] + trivy_job_header = workflow.split(" trivy-fs:\n", 1)[1].split(" steps:", 1)[0] + dependency_job_header = workflow.split(" dependency-review:\n", 1)[1].split( + " steps:", + 1, + )[0] + assert "continue-on-error: true" not in osv_job_header + assert "continue-on-error: true" not in trivy_job_header + assert "continue-on-error: true" not in dependency_job_header + + +def test_dependency_review_http_403_skip_cannot_go_green(tmp_path: Path) -> None: + """The EgressWeave #66 canary must not be a green skip. + + ContextualWisdomLab/EgressWeave#66 Security Scan run ``31108241013``, + job ``92638903658``, compared ``10d0c51d…c038a950`` and received HTTP + 403. Current ``main`` printed the skip warning, omitted the pinned + action, and still exited 0. That path is forbidden. + """ + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '403'\nexit 0\n", + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="403", + expected_curl_exit="0", + ) + assert "skipping dependency-review hard gate" not in result.stdout def test_dependency_review_transport_failure_cannot_hide_behind_http_200( @@ -926,16 +985,33 @@ def test_dependency_review_transport_failure_cannot_hide_behind_http_200( curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 18\n", ) - assert result.returncode == 1 - assert "HTTP 200; curl exit 18" in result.stdout - assert "visibility public" in result.stdout - assert not (tmp_path / "github-output").exists() + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="200", + expected_curl_exit="18", + ) + + +def test_dependency_review_transport_failure_fails_closed(tmp_path: Path) -> None: + """A transport failure with no HTTP status must fail the hard gate.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf ''\nexit 28\n", + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="unavailable", + expected_curl_exit="28", + ) @pytest.mark.parametrize( ("curl_script", "expected_http"), [ - ("#!/usr/bin/env bash\nprintf '403'\nexit 0\n", "403"), ("#!/usr/bin/env bash\nprintf '404'\nexit 0\n", "404"), ("#!/usr/bin/env bash\nprintf ''\nexit 0\n", "unavailable"), ("#!/usr/bin/env bash\nprintf 'OK'\nexit 0\n", "malformed"), @@ -946,17 +1022,19 @@ def test_dependency_review_non_200_status_fails_closed( curl_script: str, expected_http: str, ) -> None: - """HTTP 403/404, empty, and malformed statuses must fail closed.""" + """HTTP 404, empty, and malformed statuses must fail closed.""" result = run_dependency_review_support_probe( tmp_path, curl_script=curl_script, ) - assert result.returncode == 1 - assert f"HTTP {expected_http}; curl exit 0" in result.stdout - assert "visibility public" in result.stdout - assert not (tmp_path / "github-output").exists() + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http=expected_http, + expected_curl_exit="0", + ) def test_dependency_review_success_writes_supported_true(tmp_path: Path) -> None: @@ -967,10 +1045,12 @@ def test_dependency_review_success_writes_supported_true(tmp_path: Path) -> None curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", ) + combined = f"{result.stdout}{result.stderr}" assert result.returncode == 0 assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( "supported=true\n" ) + assert PROBE_TOKEN not in combined def test_dependency_review_records_unknown_visibility_when_unset( @@ -984,9 +1064,13 @@ def test_dependency_review_records_unknown_visibility_when_unset( repository_visibility="", ) - assert result.returncode == 1 - assert "visibility unknown" in result.stdout - assert not (tmp_path / "github-output").exists() + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="403", + expected_curl_exit="0", + expected_visibility="unknown", + ) def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: From fa63c43b914d6c20befcdbaa715e02bf87713f10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:56:21 +0000 Subject: [PATCH 4/7] test(security): keep bash resolvable in the dependency-review probe harness Invoke the extracted support probe with an absolute bash path and keep the fake curl first on PATH so isolated executable regressions can run without calling the real binary. Co-authored-by: Seongho Bae --- tests/test_required_workflow_queue_contract.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 1ce445c9f..6d75dbdea 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -40,7 +40,7 @@ def run_dependency_review_support_probe( ) -> subprocess.CompletedProcess: """Run the workflow support probe against a controlled curl binary. - The helper places a fake ``curl`` alone on ``PATH`` so the extracted + The helper places a fake ``curl`` first on ``PATH`` so the extracted workflow script cannot call the real binary, then supplies only the synthetic environment variables the support step reads. """ @@ -56,10 +56,12 @@ def run_dependency_review_support_probe( "Check dependency review support", ).split(" run: |\n", 1)[1] ) + bash = shutil.which("bash") + assert bash is not None return subprocess.run( - ["bash", "-c", script], + [bash, "-c", script], env={ - "PATH": str(fake_bin), + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '/usr/bin')}", "HOME": str(tmp_path), "GITHUB_API_URL": "https://api.example.invalid", "GITHUB_OUTPUT": str(tmp_path / "github-output"), From b8276c6548cbdf085accc1f01aa1618a1bdc74bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:00:51 +0000 Subject: [PATCH 5/7] fix(security): treat curl 000 as unavailable dependency-review evidence Classify curl's no-status 000 write-out as unavailable instead of a three-digit HTTP code, and lock private/internal visibility plus raw visibility non-leakage with executable regressions. Co-authored-by: Seongho Bae --- .github/workflows/security-scan.yml | 2 +- ARCHITECTURE.md | 6 +- CHANGELOG.md | 2 +- .../dependency-review-fail-closed.md | 12 ++-- .../test_required_workflow_queue_contract.py | 65 +++++++++++++++++++ 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 5199bd52f..76dd06ae9 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -294,8 +294,8 @@ jobs: set -e case "$status" in + 000|"") http_status="unavailable" ;; [0-9][0-9][0-9]) http_status="$status" ;; - "") http_status="unavailable" ;; *) http_status="malformed" ;; esac diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eb9954f84..1345f7192 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -109,9 +109,9 @@ sequenceDiagram The central `Security Scan` job treats GitHub's exact `BASE_SHA...HEAD_SHA` comparison as a hard supply-chain evidence boundary. Only transport exit `0` plus HTTP `200` may reach the immutably pinned dependency-review action. A -`403`, `404`, timeout, truncated transfer, or malformed status fails closed -and records allowlisted repository visibility with the exact revisions. Other -scanners are complementary; they are not substitutes. +`403`, `404`, timeout, truncated transfer, curl `000` sentinel, or malformed +status fails closed and records allowlisted repository visibility with the +exact revisions. Other scanners are complementary; they are not substitutes. ```mermaid flowchart TD diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b0944ad..a0543074a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ Semantic Versioning where the repository publishes a release. ### Security - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. -- Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, or truncated compare) instead of treating HTTP 403/404 as a clean skip; the probe records allowlisted repository visibility with the exact head SHA and never prints the API body. +- Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, truncated compare, or curl's `000` no-status sentinel) instead of treating HTTP 403/404 as a clean skip; the probe records allowlisted repository visibility with the exact head SHA and never prints the API body or raw visibility strings. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 521ab01c8..778641f53 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -4,11 +4,11 @@ Status: `active_pr` until the matching workflow and regression contract are pres ## Decision -Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, empty or malformed status, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. +Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, empty or malformed status, curl `000` sentinel, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, allowlisted visibility (`public`, `private`, `internal`, or `unknown`), exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials, response bodies, and raw untrusted visibility strings are never diagnostic output. After a successful probe the pinned action is not independently skippable. -RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. +RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). curl's `%{http_code}` write-out is the numeric status from the last retrieved transfer; when no HTTP status was received it emits `000` (Stenberg, n.d.). That sentinel is unavailable evidence, not an HTTP status. NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. GitHub documents `403` as the private-repository response when GitHub Advanced Security is not enabled, or when the comparison targets a fork (GitHub, n.d.). Record the allowlisted visibility and exact revisions, then verify dependency-graph or Advanced Security configuration. Do not infer `not-applicable` from `403`. ## Identity and authority @@ -19,9 +19,9 @@ Checks, status contexts, review submissions, and merge authorization remain sepa ## Failure classification and remediation - Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. -- Any other result: fail the job and retain exact repository, allowlisted visibility, base/head, status, and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. Do not infer a root cause from HTTP `403` or `404`. +- Any other result: fail the job and retain exact repository, allowlisted visibility, base/head, status, and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. curl `000` is recorded as `unavailable`. Do not infer a root cause from HTTP `403` or `404`. - Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. -- Private or internal exception: require a separately reviewed organization policy with explicit entitlement evidence and compensating controls. Never infer `not-applicable` from an unavailable response. +- HTTP `403` on a private or internal repository: verify whether GitHub Advanced Security / dependency review is entitled for that repository. Keep the job failed until a separately reviewed organization exception with compensating controls exists. Never infer `not-applicable` from an unavailable response. Retries are operator-initiated only after the capability or service condition changes. Do not rerun unchanged evidence repeatedly and do not convert an unavailable endpoint into a green skip. @@ -42,12 +42,14 @@ Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* GitHub. (n.d.). *Dependency review*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-review -GitHub. (n.d.). *REST API endpoints for dependency review*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/rest/dependency-graph/dependency-review +GitHub. (n.d.). *REST API endpoints for dependency review*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/rest/dependency-graph/dependency-review GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/code-security/concepts/supply-chain-security/dependency-graph GitHub. (n.d.). *Webhook events and payloads*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/webhooks/webhook-events-and-payloads#repository +Stenberg, D. (n.d.). *curl -- write out variables*. curl. Retrieved August 16, 2026, from https://curl.se/docs/manpage.html + National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST SP 800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 6d75dbdea..d37ab6787 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -927,6 +927,7 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N ) assert "public|private|internal)" in support_probe assert "visibility ${visibility}" in support_probe + assert '000|"") http_status="unavailable"' in support_probe assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow assert "--connect-timeout 10" in workflow assert "--max-time 30" in workflow @@ -1075,6 +1076,70 @@ def test_dependency_review_records_unknown_visibility_when_unset( ) +@pytest.mark.parametrize("visibility", ["private", "internal"]) +def test_dependency_review_records_allowlisted_visibility( + tmp_path: Path, + visibility: str, +) -> None: + """Private and internal 403s must keep the allowlisted visibility label.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '403'\nexit 0\n", + repository_visibility=visibility, + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="403", + expected_curl_exit="0", + expected_visibility=visibility, + ) + + +def test_dependency_review_does_not_echo_raw_visibility( + tmp_path: Path, +) -> None: + """Untrusted visibility strings must not appear in probe diagnostics.""" + + raw_visibility = "Public; curl https://evil.example" + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '403'\nexit 0\n", + repository_visibility=raw_visibility, + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="403", + expected_curl_exit="0", + expected_visibility="unknown", + ) + combined = f"{result.stdout}{result.stderr}" + assert raw_visibility not in combined + assert "evil.example" not in combined + + +def test_dependency_review_curl_000_status_is_unavailable( + tmp_path: Path, +) -> None: + """curl's 000 write-out sentinel is not a completed HTTP exchange.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '000'\nexit 6\n", + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="unavailable", + expected_curl_exit="6", + ) + + def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: workflow = workflow_text("security-scan.yml") From d4ab623d1d456472b1b7e54954b8888d22bb1b6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:07:18 +0000 Subject: [PATCH 6/7] fix(security): reject named refs before dependency-review compare GitHub's compare API resolves named revisions to moving HEADs. Require exact 40- or 64-character hexadecimal object IDs and an owner/name repository identity before any compare request. Co-authored-by: Seongho Bae --- .github/workflows/security-scan.yml | 10 ++ AGENTS.md | 2 +- ARCHITECTURE.md | 10 +- CHANGELOG.md | 1 + .../dependency-review-fail-closed.md | 24 ++- .../test_required_workflow_queue_contract.py | 137 +++++++++++++++++- 6 files changed, 171 insertions(+), 13 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 76dd06ae9..44b2d3dd1 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -280,6 +280,16 @@ jobs: public|private|internal) visibility="${REPOSITORY_VISIBILITY}" ;; *) visibility="unknown" ;; esac + 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 for the allowlisted repository (visibility ${visibility}): 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 (visibility ${visibility}): owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi set +e status="$( curl -sS --connect-timeout 10 --max-time 30 \ diff --git a/AGENTS.md b/AGENTS.md index 6aab567ee..3b08e0daa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,4 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). -Dependency-review unavailability fails closed; diagnostics include allowlisted repository visibility. See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). +Dependency-review unavailability fails closed; diagnostics include allowlisted repository visibility. Named refs and non-`owner/name` repository values are rejected before the compare request. See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1345f7192..f64e5ef82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,19 +107,25 @@ sequenceDiagram ## Dependency-review evidence The central `Security Scan` job treats GitHub's exact `BASE_SHA...HEAD_SHA` -comparison as a hard supply-chain evidence boundary. Only transport exit `0` -plus HTTP `200` may reach the immutably pinned dependency-review action. A +comparison as a hard supply-chain evidence boundary. The probe rejects named +refs and non-`owner/name` repository values before it calls the compare API, +because GitHub would otherwise resolve `main` to a moving HEAD. Only an exact +40- or 64-character hexadecimal object ID pair plus transport exit `0` plus +HTTP `200` may reach the immutably pinned dependency-review action. A `403`, `404`, timeout, truncated transfer, curl `000` sentinel, or malformed status fails closed and records allowlisted repository visibility with the exact revisions. Other scanners are complementary; they are not substitutes. ```mermaid flowchart TD + Identity{"owner/name and 40- or 64-hex SHAs?"} Probe["Exact base/head compare probe"] Transport{"curl exit 0 and HTTP 200?"} Action["Pinned dependency-review action"] Fail["Fail closed with repo, visibility, SHAs, status"] + Identity -->|"yes"| Probe + Identity -->|"no"| Fail Probe --> Transport Transport -->|"yes"| Action Transport -->|"no"| Fail diff --git a/CHANGELOG.md b/CHANGELOG.md index a0543074a..6a459d747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Semantic Versioning where the repository publishes a release. - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. - Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, truncated compare, or curl's `000` no-status sentinel) instead of treating HTTP 403/404 as a clean skip; the probe records allowlisted repository visibility with the exact head SHA and never prints the API body or raw visibility strings. +- Reject named Git revisions and non-`owner/name` repository values before the dependency-review compare request so GitHub cannot resolve `main` to a moving HEAD and so path injection cannot reach the compare URL. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 778641f53..1cfc16982 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -4,20 +4,23 @@ Status: `active_pr` until the matching workflow and regression contract are pres ## Decision -Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, empty or malformed status, curl `000` sentinel, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. +Dependency review is a hard supply-chain gate. The central workflow accepts only HTTP `200` from GitHub's exact `BASE_SHA...HEAD_SHA` comparison before invoking the immutably pinned dependency-review action. A `403`, `404`, `400`, `500`, `503`, empty or malformed status, curl `000` sentinel, timeout, transport failure, truncated exchange, or other unexpected outcome is unavailable evidence and fails closed. -The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, allowlisted visibility (`public`, `private`, `internal`, or `unknown`), exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials, response bodies, and raw untrusted visibility strings are never diagnostic output. After a successful probe the pinned action is not independently skippable. +The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, allowlisted visibility (`public`, `private`, `internal`, or `unknown`), exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials, response bodies, raw untrusted visibility strings, named refs, and raw invalid repository paths are never diagnostic output. After a successful probe the pinned action is not independently skippable. + +Before any compare request, the probe requires an `owner/name` repository identity and exact Git object IDs: 40 hexadecimal characters for SHA-1 or 64 hexadecimal characters for SHA-256 (Chacon & Straub, 2014; National Institute of Standards and Technology, 2015). GitHub's compare API resolves named revisions such as `main` to the current HEAD of that name (GitHub, n.d.). That moving target is not the pull-request head and is unavailable evidence. Rejecting it before interpolation also prevents path injection into `/repos/{owner}/{repo}/dependency-graph/compare/{basehead}` (MITRE, 2026). RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). curl's `%{http_code}` write-out is the numeric status from the last retrieved transfer; when no HTTP status was received it emits `000` (Stenberg, n.d.). That sentinel is unavailable evidence, not an HTTP status. NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. GitHub documents `403` as the private-repository response when GitHub Advanced Security is not enabled, or when the comparison targets a fork (GitHub, n.d.). Record the allowlisted visibility and exact revisions, then verify dependency-graph or Advanced Security configuration. Do not infer `not-applicable` from `403`. ## Identity and authority -The dependency-review job checks out the pull request's explicit head repository and immutable head SHA with persisted credentials disabled. The API comparison independently binds the event's exact base and head revisions. The job retains `contents: read` and `pull-requests: read`; it receives no write, OIDC, model, release, package, or deployment authority. +The dependency-review job checks out the pull request's explicit head repository and immutable head SHA with persisted credentials disabled. The API comparison independently binds the event's exact base and head Git object IDs after those values pass the hexadecimal length check. The job retains `contents: read` and `pull-requests: read`; it receives no write, OIDC, model, release, package, or deployment authority. Checks, status contexts, review submissions, and merge authorization remain separate evidence classes. OSV, Trivy, CodeQL, Semgrep, Secret Scan, Scorecard, and Dependabot are complementary controls and are not semantic substitutes for dependency review. ## Failure classification and remediation +- Identity rejected (named ref, empty or non-hex revision, or non-`owner/name` repository): fail the job before curl. Use the pull-request event's exact hexadecimal SHAs and `owner/name`, then rerun. - Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. - Any other result: fail the job and retain exact repository, allowlisted visibility, base/head, status, and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. curl `000` is recorded as `unavailable`. Do not infer a root cause from HTTP `403` or `404`. - Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. @@ -31,12 +34,15 @@ ContextualWisdomLab/EgressWeave#66, Security Scan run `31108241013`, job `926389 ## Acceptance and rollback -Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. +Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, reject named refs and non-`owner/name` repository values before any compare request, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. -Rollback requires an independently reviewed revert and fresh exact-head evidence. A rollback must not restore the `403`/`404` success path or print an API response body. +Rollback requires an independently reviewed revert and fresh exact-head evidence. A rollback must not restore the `403`/`404` success path, accept named revisions as compare evidence, or print an API response body. ## References +Chacon, S., & Straub, B. (2014). *Pro Git* (2nd ed.). Apress. +https://git-scm.com/book/en/v2/Git-Internals-Git-Objects + Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 @@ -48,7 +54,11 @@ GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 9, 2026, from GitHub. (n.d.). *Webhook events and payloads*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/webhooks/webhook-events-and-payloads#repository -Stenberg, D. (n.d.). *curl -- write out variables*. curl. Retrieved August 16, 2026, from https://curl.se/docs/manpage.html +MITRE. (2026). *CWE-20: Improper input validation*. +https://cwe.mitre.org/data/definitions/20.html + +National Institute of Standards and Technology. (2015). *Secure hash +standard (SHS)* (FIPS 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST SP @@ -56,3 +66,5 @@ privacy controls for information systems and organizations* (NIST SP SLSA. (2023). *SLSA v1.0: Supply-chain Levels for Software Artifacts*. Open Source Security Foundation. https://slsa.dev/spec/v1.0/ + +Stenberg, D. (n.d.). *curl -- write out variables*. curl. Retrieved August 16, 2026, from https://curl.se/docs/manpage.html diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index d37ab6787..4f927854a 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -37,12 +37,18 @@ def run_dependency_review_support_probe( *, curl_script: str, repository_visibility: str = "public", + base_sha: str = PROBE_BASE_SHA, + head_sha: str = PROBE_HEAD_SHA, + repository: str = "ContextualWisdomLab/.github", ) -> subprocess.CompletedProcess: """Run the workflow support probe against a controlled curl binary. The helper places a fake ``curl`` first on ``PATH`` so the extracted workflow script cannot call the real binary, then supplies only the - synthetic environment variables the support step reads. + synthetic environment variables the support step reads. Optional SHA + and repository overrides let identity-validation regressions prove + that a named ref or path-injection value cannot reach the compare + request. """ fake_bin = tmp_path / "bin" @@ -66,9 +72,9 @@ def run_dependency_review_support_probe( "GITHUB_API_URL": "https://api.example.invalid", "GITHUB_OUTPUT": str(tmp_path / "github-output"), "GH_TOKEN": PROBE_TOKEN, - "BASE_SHA": PROBE_BASE_SHA, - "HEAD_SHA": PROBE_HEAD_SHA, - "REPOSITORY": "ContextualWisdomLab/.github", + "BASE_SHA": base_sha, + "HEAD_SHA": head_sha, + "REPOSITORY": repository, "REPOSITORY_VISIBILITY": repository_visibility, }, capture_output=True, @@ -928,6 +934,21 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N assert "public|private|internal)" in support_probe assert "visibility ${visibility}" in support_probe assert '000|"") http_status="unavailable"' in support_probe + assert '^[0-9a-f]{40}([0-9a-f]{24})?$' in support_probe + assert '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' in support_probe + assert support_probe.index("^[0-9a-f]{40}") < support_probe.index("curl -sS") + assert support_probe.index("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") < support_probe.index( + "curl -sS" + ) + assert "continue-on-error:" not in support_probe + action_lines = [] + for line in workflow.split(" - name: Dependency review\n", 1)[1].splitlines(): + if line and not line.startswith(" "): + break + action_lines.append(line) + action_step = "\n".join(action_lines) + assert "if:" not in action_step + assert "continue-on-error:" not in action_step assert 'if [ "$curl_status" -ne 0 ] || [ "$http_status" != "200" ]; then' in workflow assert "--connect-timeout 10" in workflow assert "--max-time 30" in workflow @@ -1140,6 +1161,114 @@ def test_dependency_review_curl_000_status_is_unavailable( ) +def test_dependency_review_curl_000_with_exit_0_is_unavailable( + tmp_path: Path, +) -> None: + """A proxy that prints 000 and exits 0 still has no HTTP status.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '000'\nexit 0\n", + ) + + assert_dependency_review_probe_failed( + result, + tmp_path, + expected_http="unavailable", + expected_curl_exit="0", + ) + + +def _successful_probe_curl_script(tmp_path: Path) -> str: + """Return a fake curl that would make the probe go green if reached.""" + + marker = tmp_path / "curl-invoked" + return ( + "#!/usr/bin/env bash\n" + f"printf 'invoked' >{shlex.quote(str(marker))}\n" + "printf '200'\n" + "exit 0\n" + ) + + +def test_dependency_review_named_head_revision_cannot_go_green( + tmp_path: Path, +) -> None: + """GitHub resolves named revisions to moving HEADs; that is not evidence.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + head_sha="main", + ) + + combined = f"{result.stdout}{result.stderr}" + assert result.returncode == 1 + assert not (tmp_path / "curl-invoked").exists() + assert not (tmp_path / "github-output").exists() + assert "exact 40- or 64-character hexadecimal" in result.stdout + assert "main" not in combined + assert PROBE_TOKEN not in combined + + +def test_dependency_review_empty_base_sha_cannot_go_green( + tmp_path: Path, +) -> None: + """An empty base revision must not become a compare request.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + base_sha="", + ) + + assert result.returncode == 1 + assert not (tmp_path / "curl-invoked").exists() + assert not (tmp_path / "github-output").exists() + assert "exact 40- or 64-character hexadecimal" in result.stdout + + +def test_dependency_review_sha256_object_id_may_reach_compare( + tmp_path: Path, +) -> None: + """A 64-character Git object ID is exact evidence and may be compared.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + base_sha="c" * 64, + head_sha="d" * 64, + ) + + assert result.returncode == 0 + assert (tmp_path / "curl-invoked").exists() + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "supported=true\n" + ) + + +def test_dependency_review_rejects_repository_path_injection( + tmp_path: Path, +) -> None: + """owner/name is required before the compare path is interpolated.""" + + raw_repository = "ContextualWisdomLab/../evil" + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + repository=raw_repository, + ) + + combined = f"{result.stdout}{result.stderr}" + assert result.returncode == 1 + assert not (tmp_path / "curl-invoked").exists() + assert not (tmp_path / "github-output").exists() + assert "owner/name repository identity" in result.stdout + assert raw_repository not in combined + assert "../evil" not in combined + assert PROBE_TOKEN not in combined + + def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: workflow = workflow_text("security-scan.yml") From 948de32e869e1656e7ae1ba770b16c0b652f4c29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:12:51 +0000 Subject: [PATCH 7/7] fix(security): reject dot path components before dependency-review compare A single-slash owner/name whose owner or name is . or .. still matched the previous identity regex, so ../.github and ContextualWisdomLab/.. could reach curl. RFC 3986 remove-dot-segments would collapse those into a different compare URL. The organization .github repository remains a legal name. Related to #810. Prefer this branch over #1042 and #1048 for integration. Do not close #810 until an EgressWeave canary proves a non-200 cannot green the job. Co-authored-by: Seongho Bae --- .github/workflows/security-scan.yml | 6 ++ AGENTS.md | 2 +- ARCHITECTURE.md | 11 ++-- CHANGELOG.md | 2 +- .../dependency-review-fail-closed.md | 16 +++-- .../test_required_workflow_queue_contract.py | 61 +++++++++++++++++++ 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 44b2d3dd1..ac7eddb2e 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -290,6 +290,12 @@ jobs: echo "::error::Dependency review evidence unavailable (visibility ${visibility}): 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 (visibility ${visibility}): 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 \ diff --git a/AGENTS.md b/AGENTS.md index 3b08e0daa..30ebc4620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,4 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). -Dependency-review unavailability fails closed; diagnostics include allowlisted repository visibility. Named refs and non-`owner/name` repository values are rejected before the compare request. See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). +Dependency-review unavailability fails closed; diagnostics include allowlisted repository visibility. Named refs, non-`owner/name` repository values, and `.`/`..` path components are rejected before the compare request. See [`docs/doctoring/dependency-review-fail-closed.md`](docs/doctoring/dependency-review-fail-closed.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f64e5ef82..7adfeeaec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -108,17 +108,18 @@ sequenceDiagram The central `Security Scan` job treats GitHub's exact `BASE_SHA...HEAD_SHA` comparison as a hard supply-chain evidence boundary. The probe rejects named -refs and non-`owner/name` repository values before it calls the compare API, -because GitHub would otherwise resolve `main` to a moving HEAD. Only an exact -40- or 64-character hexadecimal object ID pair plus transport exit `0` plus -HTTP `200` may reach the immutably pinned dependency-review action. A +refs, non-`owner/name` repository values, and `.`/`..` path components before +it calls the compare API, because GitHub would otherwise resolve `main` to a +moving HEAD and RFC 3986 would collapse `../.github` out of `/repos`. Only an +exact 40- or 64-character hexadecimal object ID pair plus transport exit `0` +plus HTTP `200` may reach the immutably pinned dependency-review action. A `403`, `404`, timeout, truncated transfer, curl `000` sentinel, or malformed status fails closed and records allowlisted repository visibility with the exact revisions. Other scanners are complementary; they are not substitutes. ```mermaid flowchart TD - Identity{"owner/name and 40- or 64-hex SHAs?"} + Identity{"owner/name, no . or .., and 40- or 64-hex SHAs?"} Probe["Exact base/head compare probe"] Transport{"curl exit 0 and HTTP 200?"} Action["Pinned dependency-review action"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a459d747..d8e6cc5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ Semantic Versioning where the repository publishes a release. - Keep the Quarantine Sandbox Runtime caller read-only and model-secret-free, grant only job-scoped OIDC to the reusable scheduler, and preserve the product boundary in which the sandbox returns artifact-analysis evidence while hosts retain WAF/IDS, admission, final verdict, incident, and retention authority. - Fail closed when GitHub dependency-review evidence is unavailable (non-200, transport failure, truncated compare, or curl's `000` no-status sentinel) instead of treating HTTP 403/404 as a clean skip; the probe records allowlisted repository visibility with the exact head SHA and never prints the API body or raw visibility strings. -- Reject named Git revisions and non-`owner/name` repository values before the dependency-review compare request so GitHub cannot resolve `main` to a moving HEAD and so path injection cannot reach the compare URL. +- Reject named Git revisions, non-`owner/name` repository values, and `.`/`..` path components before the dependency-review compare request so GitHub cannot resolve `main` to a moving HEAD and so RFC 3986 remove-dot-segments cannot turn `../.github` into a compare URL. The organization `.github` special repository remains a legal name. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 1cfc16982..6d29a4e16 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -8,7 +8,7 @@ Dependency review is a hard supply-chain gate. The central workflow accepts only The support probe has a 10-second connection limit and 30-second total limit. It preserves curl's transport exit code separately from the bounded HTTP status and requires transport exit `0` plus exact HTTP `200`. It discards the response body and logs only repository identity, allowlisted visibility (`public`, `private`, `internal`, or `unknown`), exact base/head revisions, the normalized HTTP status, and the numeric transport exit. Credentials, response bodies, raw untrusted visibility strings, named refs, and raw invalid repository paths are never diagnostic output. After a successful probe the pinned action is not independently skippable. -Before any compare request, the probe requires an `owner/name` repository identity and exact Git object IDs: 40 hexadecimal characters for SHA-1 or 64 hexadecimal characters for SHA-256 (Chacon & Straub, 2014; National Institute of Standards and Technology, 2015). GitHub's compare API resolves named revisions such as `main` to the current HEAD of that name (GitHub, n.d.). That moving target is not the pull-request head and is unavailable evidence. Rejecting it before interpolation also prevents path injection into `/repos/{owner}/{repo}/dependency-graph/compare/{basehead}` (MITRE, 2026). +Before any compare request, the probe requires an `owner/name` repository identity and exact Git object IDs: 40 hexadecimal characters for SHA-1 or 64 hexadecimal characters for SHA-256 (Chacon & Straub, 2014; National Institute of Standards and Technology, 2015). GitHub's compare API resolves named revisions such as `main` to the current HEAD of that name (GitHub, n.d.). That moving target is not the pull-request head and is unavailable evidence. A single-slash `owner/name` whose owner or name is `.` or `..` is still refused: RFC 3986 remove-dot-segments would turn `/repos/../.github/...` into `/.github/...` (Berners-Lee et al., 2005; MITRE, 2026a, 2026b). The organization `.github` special repository remains a legal name; only the path-segment sentinels are rejected. Rejecting these values before interpolation prevents path injection into `/repos/{owner}/{repo}/dependency-graph/compare/{basehead}`. RFC 9110 §15.3.1 defines `200` as a completed successful representation, not as a status that can be inferred after a truncated transfer (Fielding et al., 2022). curl's `%{http_code}` write-out is the numeric status from the last retrieved transfer; when no HTTP status was received it emits `000` (Stenberg, n.d.). That sentinel is unavailable evidence, not an HTTP status. NIST SP 800-53 Rev. 5 RA-5 and SA-12 require that vulnerability and supply-chain evidence be obtained, not assumed absent (National Institute of Standards and Technology, 2020). SLSA v1.0 likewise treats missing provenance as unverified rather than passing (SLSA, 2023). An HTTP `403` or `404` is therefore unavailable evidence, not a clean skip. GitHub documents `403` as the private-repository response when GitHub Advanced Security is not enabled, or when the comparison targets a fork (GitHub, n.d.). Record the allowlisted visibility and exact revisions, then verify dependency-graph or Advanced Security configuration. Do not infer `not-applicable` from `403`. @@ -20,7 +20,7 @@ Checks, status contexts, review submissions, and merge authorization remain sepa ## Failure classification and remediation -- Identity rejected (named ref, empty or non-hex revision, or non-`owner/name` repository): fail the job before curl. Use the pull-request event's exact hexadecimal SHAs and `owner/name`, then rerun. +- Identity rejected (named ref, empty or non-hex revision, non-`owner/name` repository, or a `.`/`..` path component): fail the job before curl. Use the pull-request event's exact hexadecimal SHAs and `owner/name`, then rerun. Do not retry a named ref or a dotted path component. - Transport exit `0` plus HTTP `200`: proceed to the pinned dependency-review action. - Any other result: fail the job and retain exact repository, allowlisted visibility, base/head, status, and transport-exit evidence. An HTTP `200` emitted by a failed or partial transfer is unavailable evidence. curl `000` is recorded as `unavailable`. Do not infer a root cause from HTTP `403` or `404`. - Public repository failure: verify dependency graph and security configuration, organization policy, token read access, and GitHub service health. @@ -34,12 +34,16 @@ ContextualWisdomLab/EgressWeave#66, Security Scan run `31108241013`, job `926389 ## Acceptance and rollback -Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, reject named refs and non-`owner/name` repository values before any compare request, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. +Acceptance requires the permanent queue contract to reject the former `supported=false` path, require bounded probing and discarded bodies, require exact-head checkout, reject named refs, non-`owner/name` repository values, and `.`/`..` path components before any compare request, and prove that only `200` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. Rollback requires an independently reviewed revert and fresh exact-head evidence. A rollback must not restore the `403`/`404` success path, accept named revisions as compare evidence, or print an API response body. ## References +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource +Identifier (URI): Generic syntax* (RFC 3986). Internet Engineering Task +Force. https://doi.org/10.17487/RFC3986 + Chacon, S., & Straub, B. (2014). *Pro Git* (2nd ed.). Apress. https://git-scm.com/book/en/v2/Git-Internals-Git-Objects @@ -54,9 +58,13 @@ GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 9, 2026, from GitHub. (n.d.). *Webhook events and payloads*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/webhooks/webhook-events-and-payloads#repository -MITRE. (2026). *CWE-20: Improper input validation*. +MITRE. (2026a). *CWE-20: Improper input validation*. https://cwe.mitre.org/data/definitions/20.html +MITRE. (2026b). *CWE-22: Improper limitation of a pathname to a restricted +directory ('Path Traversal')*. +https://cwe.mitre.org/data/definitions/22.html + National Institute of Standards and Technology. (2015). *Secure hash standard (SHS)* (FIPS 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 4f927854a..2e6f2be19 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -940,6 +940,13 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N assert support_probe.index("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") < support_probe.index( "curl -sS" ) + assert 'repository_owner="${REPOSITORY%%/*}"' in support_probe + assert 'repository_name="${REPOSITORY#*/}"' in support_probe + assert '[ "${repository_owner}" = "." ]' in support_probe + assert '[ "${repository_name}" = ".." ]' in support_probe + assert support_probe.index('repository_owner="${REPOSITORY%%/*}"') < ( + support_probe.index("curl -sS") + ) assert "continue-on-error:" not in support_probe action_lines = [] for line in workflow.split(" - name: Dependency review\n", 1)[1].splitlines(): @@ -1269,6 +1276,60 @@ def test_dependency_review_rejects_repository_path_injection( assert PROBE_TOKEN not in combined +@pytest.mark.parametrize( + "raw_repository", + [ + "../.github", + "ContextualWisdomLab/..", + "ContextualWisdomLab/.", + "./.github", + ], +) +def test_dependency_review_rejects_dot_path_components( + tmp_path: Path, + raw_repository: str, +) -> None: + """A single-slash owner/name whose owner or name is ``.`` or ``..`` is still injection. + + RFC 3986 remove-dot-segments would turn ``/repos/../.github/...`` into + ``/.github/...`` (Berners-Lee et al., 2005). The ``.github`` product + repository must remain legal; only the path-segment sentinels are refused. + """ + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + repository=raw_repository, + ) + + combined = f"{result.stdout}{result.stderr}" + assert result.returncode == 1 + assert not (tmp_path / "curl-invoked").exists() + assert not (tmp_path / "github-output").exists() + assert "owner/name repository identity" in result.stdout + assert "Dot or parent-directory path components" in result.stdout + assert raw_repository not in combined + assert PROBE_TOKEN not in combined + + +def test_dependency_review_allows_dot_github_repository_name( + tmp_path: Path, +) -> None: + """The organization ``.github`` special repository remains a legal compare target.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=_successful_probe_curl_script(tmp_path), + repository="ContextualWisdomLab/.github", + ) + + assert result.returncode == 0 + assert (tmp_path / "curl-invoked").exists() + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "supported=true\n" + ) + + def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: workflow = workflow_text("security-scan.yml")