diff --git a/.github/workflows/dependency-review-ab-canary.yml b/.github/workflows/dependency-review-ab-canary.yml new file mode 100644 index 0000000000..4874b04961 --- /dev/null +++ b/.github/workflows/dependency-review-ab-canary.yml @@ -0,0 +1,116 @@ +name: Dependency Review A-B Canary + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: dependency-review-ab-canary-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + compare-auth-context: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact canary head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Verify exact canary head + env: + EXPECTED_CHECKOUT_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_CHECKOUT_SHA" ]; then + echo "::error::Dependency Review A/B canary checkout identity mismatch for ${EXPECTED_CHECKOUT_REPOSITORY}: expected ${EXPECTED_CHECKOUT_SHA}, actual ${actual_sha}." + exit 1 + fi + echo "DEPENDENCY_REVIEW_CANARY_CHECKOUT repository=${EXPECTED_CHECKOUT_REPOSITORY} expected_sha=${EXPECTED_CHECKOUT_SHA} actual_sha=${actual_sha}" + - name: Run dependency review A/B canary + id: dependency_review_support + env: + GH_TOKEN: ${{ github.token }} + 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}" + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then + echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + + set +e + anonymous_status="$( + curl -sS --connect-timeout 10 --max-time 30 \ + -o /dev/null \ + -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" + )" + anonymous_curl_status=$? + token_status="$( + curl -sS --connect-timeout 10 --max-time 30 \ + -o /dev/null \ + -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" + )" + token_curl_status=$? + set -e + + normalize_http_status() { + case "$1" in + [0-9][0-9][0-9]) printf '%s' "$1" ;; + "") printf '%s' unavailable ;; + *) printf '%s' malformed ;; + esac + } + + anonymous_http_status="$(normalize_http_status "$anonymous_status")" + token_http_status="$(normalize_http_status "$token_status")" + + case "${REPOSITORY_VISIBILITY:-}" in + public | private | internal) repository_visibility="$REPOSITORY_VISIBILITY" ;; + *) repository_visibility="unknown" ;; + esac + + echo "DEPENDENCY_REVIEW_CANARY repository=${REPOSITORY} visibility=${repository_visibility} base_sha=${BASE_SHA} head_sha=${HEAD_SHA} anonymous_http_status=${anonymous_http_status} anonymous_curl_exit=${anonymous_curl_status} token_http_status=${token_http_status} token_curl_exit=${token_curl_status} token_permissions=contents:read,pull-requests:read" + + if [ "$token_curl_status" -ne 0 ] || [ "$token_http_status" != "200" ]; then + echo "::error::Dependency review evidence unavailable for ${REPOSITORY} at exact base ${BASE_SHA} and head ${HEAD_SHA}: job-token HTTP ${token_http_status}; curl exit ${token_curl_status}. Anonymous probe was HTTP ${anonymous_http_status}; curl exit ${anonymous_curl_status}. Use this A/B result to identify the authorization or platform availability boundary. Failing closed." + exit 1 + fi + + echo "supported=true" >>"$GITHUB_OUTPUT" + - name: Explain A/B interpretation + if: always() + run: | + echo "::notice::This temporary owner-path canary compares the same immutable public dependency-review pair anonymously and with the job token. It never promotes anonymous success to authority and never substitutes another scanner for Dependency Review. Remove the canary after issue #810 has captured decisive owner-path evidence." diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 860d861544..40d9776d12 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -307,6 +307,23 @@ jobs: set -euo pipefail api_url="${GITHUB_API_URL:-https://api.github.com}" + git_object_id='^[0-9a-f]{40}([0-9a-f]{24})?$' + repository_identity='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + if ! [[ "${BASE_SHA}" =~ $git_object_id ]] || ! [[ "${HEAD_SHA}" =~ $git_object_id ]]; then + echo "::error::Dependency review evidence unavailable: exact 40- or 64-character hexadecimal base and head revisions are required before any compare request. Named refs are not evidence. Verify the pull-request event SHAs, then rerun. Failing closed." + exit 1 + fi + if ! [[ "${REPOSITORY}" =~ $repository_identity ]]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + repository_owner="${REPOSITORY%%/*}" + repository_name="${REPOSITORY#*/}" + if [ "${repository_owner}" = "." ] || [ "${repository_owner}" = ".." ] || [ "${repository_name}" = "." ] || [ "${repository_name}" = ".." ]; then + echo "::error::Dependency review evidence unavailable: owner/name repository identity is required before any compare request. Dot or parent-directory path components are not evidence. Verify the pull-request repository, then rerun. Failing closed." + exit 1 + fi + set +e status="$( curl -sS --connect-timeout 10 --max-time 30 \ diff --git a/docs/doctoring/dependency-review-fail-closed.md b/docs/doctoring/dependency-review-fail-closed.md index 81681d3f0c..8c984183d1 100644 --- a/docs/doctoring/dependency-review-fail-closed.md +++ b/docs/doctoring/dependency-review-fail-closed.md @@ -6,20 +6,23 @@ 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. +Before any compare request, the support probe also validates the evidence identity. Base and head must be immutable 40- or 64-character hexadecimal Git object IDs, and the repository must be exactly one `owner/name` pair. The owner and name path components may not be the RFC 3986 dot-segment sentinels `.` or `..`; `ContextualWisdomLab/.github` remains valid because `.github` is an ordinary repository name, not a dot segment. This prevents a named ref or path-normalized repository value from changing what object the comparison actually addresses. + 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. +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). RFC 3986 §5.2.4 defines dot-segment removal, so accepting `.` or `..` as a repository path component would make URL interpolation ambiguous even when a superficial single-slash shape check passes (Berners-Lee et al., 2005). 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. +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. Before URL construction, the workflow rejects named revisions, malformed object IDs, repository strings that are not exactly `owner/name`, and `.`/`..` path components. These checks are executable regressions: invalid identity must fail before the fake HTTP client is reached, while the `ContextualWisdomLab/.github` product repository must still reach an otherwise successful compare. -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. +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 +- Invalid evidence identity (named/non-hex revision, non-`owner/name` repository value, or a `.`/`..` component): fail before curl. Correct the event identity; do not retry a moving or path-normalized target. - 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 transport/status 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. @@ -27,24 +30,22 @@ Retries are operator-initiated only after the capability or service condition ch ## 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 revisions and malformed repository identities before transport, and prove that only transport success plus HTTP `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 moving/nonnormalized comparison identities, 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 +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 + +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.). *Dependency review*. GitHub Docs. Retrieved September 2, 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 September 2, 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.). *Dependency graph*. GitHub Docs. Retrieved September 2, 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 +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/ +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_dependency_review_repository_identity_regression.py b/tests/test_dependency_review_repository_identity_regression.py new file mode 100644 index 0000000000..e838d4e994 --- /dev/null +++ b/tests/test_dependency_review_repository_identity_regression.py @@ -0,0 +1,164 @@ +"""Regressions for dependency-review immutable identity validation and A/B canary.""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _support_probe_script() -> str: + """Return the executable shell body of the dependency-review A/B canary.""" + workflow = ( + REPO_ROOT / ".github" / "workflows" / "dependency-review-ab-canary.yml" + ).read_text(encoding="utf-8") + step = " - name: Run dependency review A/B canary\n" + start = workflow.index(step) + end = workflow.index("\n - name:", start + len(step)) + block = workflow[start:end] + run_marker = " run: |\n" + run_start = block.index(run_marker) + len(run_marker) + return textwrap.dedent(block[run_start:]) + + +def _run_probe( + tmp_path: Path, + repository: str, + *, + base_sha: str = "a" * 40, + head_sha: str = "b" * 40, + anonymous_status: str = "200", + token_status: str = "200", +) -> tuple[subprocess.CompletedProcess[str], Path, Path]: + """Execute the canary with a fake curl and return process plus evidence paths.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + curl_marker = tmp_path / "curl-called" + fake_curl = fake_bin / "curl" + fake_curl.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "mode=anonymous\n" + "for arg in \"$@\"; do\n" + " if [[ \"$arg\" == Authorization:* ]]; then mode=token; fi\n" + "done\n" + "printf '%s\\n' \"$mode\" >>\"${CURL_MARKER}\"\n" + "if [ \"$mode\" = token ]; then\n" + " printf '%s' \"${TOKEN_STATUS:-200}\"\n" + "else\n" + " printf '%s' \"${ANONYMOUS_STATUS:-200}\"\n" + "fi\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + output = tmp_path / "github-output" + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env.get('PATH', '')}", + "GH_TOKEN": "test-token", + "BASE_SHA": base_sha, + "HEAD_SHA": head_sha, + "REPOSITORY": repository, + "REPOSITORY_VISIBILITY": "public", + "GITHUB_API_URL": "https://api.github.invalid", + "GITHUB_OUTPUT": str(output), + "CURL_MARKER": str(curl_marker), + "ANONYMOUS_STATUS": anonymous_status, + "TOKEN_STATUS": token_status, + } + ) + result = subprocess.run( + ["bash", "-c", _support_probe_script()], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + return result, curl_marker, output + + +def test_dependency_review_rejects_dot_path_components_before_curl(tmp_path: Path) -> None: + """Reject dot-segment repository identities before either diagnostic request.""" + for index, repository in enumerate( + ("../.github", "ContextualWisdomLab/..", "ContextualWisdomLab/.", "./.github") + ): + case_dir = tmp_path / f"dot-{index}" + case_dir.mkdir() + result, curl_marker, _output = _run_probe(case_dir, repository) + assert result.returncode != 0, repository + assert not curl_marker.exists(), repository + assert "repository identity" in result.stdout.lower(), repository + + +def test_dependency_review_rejects_non_owner_name_identity_before_curl(tmp_path: Path) -> None: + """Reject repository values that are not exactly one owner/name pair.""" + for index, repository in enumerate( + ("ContextualWisdomLab", "ContextualWisdomLab/Orgmetra/extra", "/Orgmetra") + ): + case_dir = tmp_path / f"shape-{index}" + case_dir.mkdir() + result, curl_marker, _output = _run_probe(case_dir, repository) + assert result.returncode != 0, repository + assert not curl_marker.exists(), repository + assert "repository identity" in result.stdout.lower(), repository + + +def test_dependency_review_rejects_named_revisions_before_curl(tmp_path: Path) -> None: + """Require immutable 40- or 64-hex Git object ids before comparison.""" + cases = (("main", "b" * 40), ("a" * 40, "develop"), ("a" * 39, "b" * 40)) + for index, (base_sha, head_sha) in enumerate(cases): + case_dir = tmp_path / f"revision-{index}" + case_dir.mkdir() + result, curl_marker, _output = _run_probe( + case_dir, + "ContextualWisdomLab/Orgmetra", + base_sha=base_sha, + head_sha=head_sha, + ) + assert result.returncode != 0, (base_sha, head_sha) + assert not curl_marker.exists(), (base_sha, head_sha) + assert "exact 40- or 64-character hexadecimal" in result.stdout.lower() + + +def test_dependency_review_allows_dotgithub_product_repository(tmp_path: Path) -> None: + """Keep the organization .github product name valid while rejecting sentinels.""" + result, curl_marker, output = _run_probe(tmp_path, "ContextualWisdomLab/.github") + assert result.returncode == 0, result.stdout + result.stderr + assert curl_marker.read_text(encoding="utf-8") == "anonymous\ntoken\n" + assert output.read_text(encoding="utf-8") == "supported=true\n" + + +def test_dependency_review_records_anonymous_and_job_token_canary(tmp_path: Path) -> None: + """Distinguish public endpoint availability from the reusable-workflow token boundary.""" + result, curl_marker, output = _run_probe( + tmp_path, + "ContextualWisdomLab/ConceptWeave", + anonymous_status="403", + token_status="200", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert curl_marker.read_text(encoding="utf-8") == "anonymous\ntoken\n" + assert output.read_text(encoding="utf-8") == "supported=true\n" + assert "anonymous_http_status=403" in result.stdout + assert "token_http_status=200" in result.stdout + + +def test_dependency_review_job_token_result_remains_authoritative(tmp_path: Path) -> None: + """Fail closed when the job token cannot establish the exact comparison.""" + result, curl_marker, _output = _run_probe( + tmp_path, + "ContextualWisdomLab/ConceptWeave", + anonymous_status="200", + token_status="403", + ) + assert result.returncode != 0 + assert curl_marker.read_text(encoding="utf-8") == "anonymous\ntoken\n" + assert "anonymous_http_status=200" in result.stdout + assert "token_http_status=403" in result.stdout + assert "failing closed" in result.stdout.lower()