diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index c3b8fa5db..c803439d3 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -16,10 +16,14 @@ # 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 with curl exit 0 may reach +# the pinned hard gate. Named refs, `.`/`..` repository segments, and other +# malformed identity fail closed before the network call and are not echoed. +# Curl's `000` sentinel is unavailable evidence. HTTP 403/404, empty or +# malformed status, or transport failure also fails closed. Diagnostics +# record allowlisted visibility; they never print 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 +261,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 @@ -268,36 +274,53 @@ 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}" - response_file="$(mktemp)" + case "${REPOSITORY_VISIBILITY}" in + public|private|internal) visibility="${REPOSITORY_VISIBILITY}" ;; + *) visibility="unknown" ;; + esac + revision_pattern='^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$' + repository_pattern='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if ! [[ "${BASE_SHA}" =~ $revision_pattern ]] || ! [[ "${HEAD_SHA}" =~ $revision_pattern ]]; then + echo "::error::Dependency review evidence unavailable for the allowlisted repository (visibility ${visibility}): HTTP unavailable; curl exit uncalled. Malformed revision. Supply the pull request's exact 40- or 64-character hex base and head SHAs, then rerun. Named refs are not evidence. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_pattern ]] || [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable (visibility ${visibility}): HTTP unavailable; curl exit uncalled. Malformed repository. Use the canonical owner/name without . or .. path segments, then rerun. Failing closed." + exit 1 + fi + 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 + 000|"") http_status="unavailable" ;; + [0-9][0-9][0-9]) http_status="$status" ;; + *) 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} (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 "::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 with: fail-on-severity: moderate 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 7bf8ad766..57c8f7cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Semantic Versioning where the repository publishes a release. ### Security +- Fail closed when GitHub dependency-review evidence is unavailable (HTTP 403/404, empty or malformed status, curl `000`, or transport failure, including curl exit 18 with a printed 200) instead of treating those outcomes as a clean skip; reject empty/named/`../` identity before the compare call, record allowlisted visibility with the exact base and head SHAs after a real probe, and never print 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..f38a0d5a2 --- /dev/null +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -0,0 +1,57 @@ +# 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, with curl transport exit `0`, 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 validates identity before any network call. Both revisions must be 40- or 64-character hex object ids; named refs are not evidence. The repository must be canonical `owner/name` without `.` or `..` path segments (the special `.github` repository name remains legal). Malformed identity fails closed with HTTP `unavailable` and curl exit `uncalled` and does not echo the raw values. + +The 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`. Curl's `000` sentinel is unavailable evidence. 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. + +## 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` after validated identity: proceed to the pinned dependency-review action. +- Malformed revision or repository: fail before curl with HTTP `unavailable` and curl exit `uncalled`. Do not echo the raw identity. +- 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, and curl's `000` sentinel, are 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. + +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` with curl exit `0` reaches the action. Exact-head CI/security evidence, current review, protected integration, and a real protected-main consumer run remain required. + +Do not close ContextualWisdomLab/.github#810 until a protected-main public-repository consumer run (for example ContextualWisdomLab/EgressWeave) proves a non-200 or failed-transfer comparison cannot produce a green Dependency Review gate. + +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 16, 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 16, 2026, from https://docs.github.com/en/rest/dependency-graph/dependency-review + +GitHub. (n.d.). *Dependency graph*. GitHub Docs. Retrieved August 16, 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 + +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 233c08584..e887b15b5 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -11,6 +11,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +PROBE_TOKEN = "synthetic-read-token" +PROBE_BASE_SHA = "a" * 40 +PROBE_HEAD_SHA = "b" * 40 def workflow_text(name: str) -> str: @@ -27,6 +30,132 @@ 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", + repository: str = "ContextualWisdomLab/.github", + base_sha: str = PROBE_BASE_SHA, + head_sha: str = PROBE_HEAD_SHA, +) -> 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, records the exact argv + the probe used, then supplies only the synthetic environment + variables the support step reads. + """ + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + argv_path = tmp_path / "curl-argv" + shebang, separator, rest = curl_script.partition("\n") + if not shebang.startswith("#!"): + shebang = "#!/usr/bin/env bash" + rest = curl_script + elif not separator: + rest = "" + recorder = f"printf '%s\\0' \"$0\" \"$@\" > {shlex.quote(str(argv_path))}\n" + fake_curl.write_text(f"{shebang}\n{recorder}{rest}\n", 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] + ) + bash = shutil.which("bash") + assert bash is not None + return subprocess.run( + [bash, "-c", script], + env={ + "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"), + "GH_TOKEN": PROBE_TOKEN, + "BASE_SHA": base_sha, + "HEAD_SHA": head_sha, + "REPOSITORY": repository, + "REPOSITORY_VISIBILITY": repository_visibility, + }, + capture_output=True, + text=True, + check=False, + ) + + +def assert_dependency_review_compare_invoked( + tmp_path: Path, + *, + repository: str = "ContextualWisdomLab/.github", + base_sha: str = PROBE_BASE_SHA, + head_sha: str = PROBE_HEAD_SHA, +) -> None: + """Require the probe to request the exact base/head compare URL.""" + + argv = [ + part.decode("utf-8") + for part in (tmp_path / "curl-argv").read_bytes().split(b"\0") + if part + ] + expected = ( + f"https://api.example.invalid/repos/{repository}/" + f"dependency-graph/compare/{base_sha}...{head_sha}" + ) + assert expected in argv + assert "-o" in argv + assert "/dev/null" in argv + + +def assert_dependency_review_probe_failed( + result: subprocess.CompletedProcess, + tmp_path: Path, + *, + expected_http: str, + expected_curl_exit: str, + expected_visibility: str = "public", + expected_base_sha: str = PROBE_BASE_SHA, + expected_head_sha: str = PROBE_HEAD_SHA, +) -> 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 {expected_base_sha} and head {expected_head_sha}" in result.stdout + ) + assert PROBE_TOKEN not in combined + assert "Authorization:" not in combined + assert not (tmp_path / "github-output").exists() + + +def assert_dependency_review_identity_rejected( + result: subprocess.CompletedProcess, + tmp_path: Path, + *, + expected_visibility: str = "public", + forbidden_substrings: tuple[str, ...] = (), +) -> None: + """Require identity rejection before curl, without echoing raw values.""" + + combined = f"{result.stdout}{result.stderr}" + assert result.returncode == 1 + assert "HTTP unavailable; curl exit uncalled" in result.stdout + assert f"visibility {expected_visibility}" in result.stdout + assert PROBE_TOKEN not in combined + assert "Authorization:" not in combined + assert not (tmp_path / "github-output").exists() + assert not (tmp_path / "curl-argv").exists() + for needle in forbidden_substrings: + assert needle + assert needle not in combined + + def test_merge_scheduler_dispatches_one_review_by_default() -> None: workflow = workflow_text("pr-review-merge-scheduler.yml") @@ -826,16 +955,372 @@ 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") + 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 '"$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 ( + "REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}" + in support_probe + ) + assert "public|private|internal)" in support_probe + assert "visibility ${visibility}" in support_probe + assert "revision_pattern=" in support_probe + assert "repository_pattern=" in support_probe + assert "repository_owner=" in support_probe + assert "repository_name=" in support_probe + assert '[ "${repository_owner}" = ".." ]' in support_probe + assert '[ "${repository_name}" = ".." ]' in support_probe + assert "Malformed revision" in support_probe + assert "Malformed repository" in support_probe + assert "Named refs are not evidence" in support_probe + assert '000|"") http_status="unavailable"' in support_probe + assert ( + "Dependency review evidence unavailable for the allowlisted repository" + in support_probe + ) + assert "at exact base ${BASE_SHA} and head ${HEAD_SHA}" not in support_probe.split( + "set +e", + 1, + )[0] + 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 'cat "$response_file"' not in support_probe + assert "steps.dependency_review_support.outputs.supported == 'true'" not in workflow + assert "fail-on-severity: moderate" in workflow + assert action_first_line.startswith( + " uses: actions/dependency-review-action@" + ) + action_step = workflow.split(" - name: Dependency review\n", 1)[1].split( + "\n trivy-fs:", + 1, + )[0] + assert "if:" not in action_step + 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 + assert_dependency_review_compare_invoked(tmp_path) + + +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. + + This is the #897 probe bug: ``|| true`` plus a printed ``200`` from a + curl exit 18 (partial transfer) could previously mark ``supported=true``. + """ + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 18\n", + ) + + 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 '404'\nexit 0\n", "404"), + ("#!/usr/bin/env bash\nprintf ''\nexit 0\n", "unavailable"), + ("#!/usr/bin/env bash\nprintf '000'\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 404, empty, curl 000, and malformed statuses must fail closed.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script=curl_script, + ) + + 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: + """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", + ) + + 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 + assert_dependency_review_compare_invoked(tmp_path) + + +def test_dependency_review_accepts_sha256_object_ids(tmp_path: Path) -> None: + """A 64-character hex object id is a legal Git revision and may be compared.""" + + base_sha = "c" * 64 + head_sha = "d" * 64 + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + base_sha=base_sha, + head_sha=head_sha, + ) + + assert result.returncode == 0 + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "supported=true\n" + ) + assert_dependency_review_compare_invoked( + tmp_path, + base_sha=base_sha, + head_sha=head_sha, + ) + + +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_dependency_review_probe_failed( + result, + tmp_path, + expected_http="403", + expected_curl_exit="0", + expected_visibility="unknown", + ) + assert_dependency_review_compare_invoked(tmp_path) + + +@pytest.mark.parametrize("visibility", ["public", "private", "internal"]) +def test_dependency_review_records_allowlisted_visibility( + tmp_path: Path, + visibility: str, +) -> None: + """Allowlisted visibility values must appear in fail-closed diagnostics.""" + + 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, + ) + assert_dependency_review_compare_invoked(tmp_path) + + +@pytest.mark.parametrize( + ("base_sha", "head_sha"), + [ + ("", PROBE_HEAD_SHA), + (PROBE_BASE_SHA, ""), + ("not-a-revision", PROBE_HEAD_SHA), + (PROBE_BASE_SHA, "../" + ("c" * 37)), + ("d" * 39, PROBE_HEAD_SHA), + ], +) +def test_dependency_review_rejects_malformed_revision_before_network( + tmp_path: Path, + base_sha: str, + head_sha: str, +) -> None: + """Empty or non-hex revisions must fail closed without calling curl.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + base_sha=base_sha, + head_sha=head_sha, + ) + + forbidden = tuple( + value + for value in (base_sha, head_sha) + if value and value not in {PROBE_BASE_SHA, PROBE_HEAD_SHA} + ) + assert_dependency_review_identity_rejected( + result, + tmp_path, + forbidden_substrings=forbidden, + ) + assert "Malformed revision" in result.stdout + assert "Named refs are not evidence" in result.stdout + + +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="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + head_sha="main", + ) + + assert_dependency_review_identity_rejected( + result, + tmp_path, + forbidden_substrings=("main",), + ) + assert "Malformed revision" in result.stdout + + +def test_dependency_review_rejects_malformed_repository_before_network( + tmp_path: Path, +) -> None: + """A repository name that cannot form a compare URL must not reach curl.""" + + raw_repository = "../evil/repo" + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + repository=raw_repository, + ) + + assert_dependency_review_identity_rejected( + result, + tmp_path, + forbidden_substrings=(raw_repository, "../evil"), + ) + assert "Malformed repository" in result.stdout + + +@pytest.mark.parametrize( + "repository", + [ + "ContextualWisdomLab/..", + "../.github", + "ContextualWisdomLab/.", + "./.github", + ], +) +def test_dependency_review_rejects_dot_repository_segments( + tmp_path: Path, + repository: str, +) -> None: + """`.` and `..` path segments must not reach the compare URL.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + repository=repository, + ) + + assert_dependency_review_identity_rejected( + result, + tmp_path, + forbidden_substrings=(repository,), + ) + assert "Malformed repository" in result.stdout + + +def test_dependency_review_accepts_dot_github_repository_name( + tmp_path: Path, +) -> None: + """The special `.github` repository name is canonical owner/name, not `..`.""" + + result = run_dependency_review_support_probe( + tmp_path, + curl_script="#!/usr/bin/env bash\nprintf '200'\nexit 0\n", + repository="ContextualWisdomLab/.github", + ) + + assert result.returncode == 0 + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "supported=true\n" + ) + assert_dependency_review_compare_invoked(tmp_path) def test_security_scan_allows_repositories_without_supported_lockfiles() -> None: