From 974213080224b29194d3a0568e17b8c38d895cdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:23:42 +0900 Subject: [PATCH 01/43] docs: plan source-authoritative Actions evidence verifier --- ...4-source-authoritative-actions-evidence.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md diff --git a/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md b/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md new file mode 100644 index 00000000..8b307ddb --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md @@ -0,0 +1,224 @@ +# Source-Authoritative GitHub Actions Evidence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one production-grade detector vertical slice that independently acquires a GitHub Actions run and job, verifies their source identity, classifies the security outcome, and emits reproducible evidence instead of trusting a caller-provided Boolean or log label. + +**Architecture:** A new dependency-free `appguardrail_core.github_actions_evidence` module owns strict GitHub REST acquisition, schema and identity validation, canonical source hashing, duplicate protection, and a CLI entrypoint. The existing IssueOps collector remains the organization-wide publisher; the new entrypoint is a bounded production verifier for exact run/job evidence and provides the contract that later collector integration can reuse. All network requests remain pinned to `https://api.github.com`, reject redirects, cap response size, and never emit the token. + +**Tech Stack:** Python 3.11+, standard-library `argparse`, `dataclasses`, `datetime`, `hashlib`, `json`, `urllib`; pytest; setuptools console scripts. + +## Global Constraints + +- Production statement coverage and branch coverage for the new module must be 100%. +- Every public module, class, function, method, and error type must have a complete docstring. +- Database and persistent object names, when introduced, must contain at least two words and use `snake_case`; this slice introduces no database object. +- GitHub source URLs and API origin are HTTPS-only and fail closed on redirect or identity mismatch. +- The verifier must preserve authorized evidence metadata without indiscriminate PII masking; it emits only bounded Actions metadata and never the bearer token or raw cross-repository logs. +- No external runtime dependency may be added. +- Historical issues #815, #813, and #763 are regression shapes, not self-authenticating oracles. + +--- + +### Task 1: Define the source-evidence contract with failing tests + +**Files:** +- Create: `tests/test_github_actions_evidence.py` +- Create later: `appguardrail_core/github_actions_evidence.py` + +**Interfaces:** +- Produces: wished-for `verify_actions_job(repository, run, job, observed_at, max_age, seen_source_digests)` and `EvidenceValidationError` interfaces. +- Consumes: `appguardrail_core.issueops.is_failure` and `appguardrail_core.issueops.is_security_name`. + +- [ ] **Step 1: Write a failing historical-failure test** + +Create a #815-shaped run/job fixture and assert that verification returns `detector_state == "failure"`, exact run/job identity, `probe_ref == "github_actions_job_v1"`, `acquirer_ref == "github_rest_api_v2022_11_28"`, and a 64-character SHA-256 source digest. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `pytest -q tests/test_github_actions_evidence.py::test_verifies_source_authoritative_failure` + +Expected: collection failure because `appguardrail_core.github_actions_evidence` does not exist. + +- [ ] **Step 3: Add identity and fail-closed tests** + +Cover success, cancelled security run, non-security job, mismatched run/job IDs, malformed SHA, wrong-origin URLs, unfinished jobs, stale evidence, duplicate digest, non-object payload, oversized IDs, and a stable digest across mapping key order. + +- [ ] **Step 4: Re-run and retain RED evidence** + +Run: `pytest -q tests/test_github_actions_evidence.py` + +Expected: collection failure for the missing production module. + +- [ ] **Step 5: Commit the RED tests** + +```bash +git add tests/test_github_actions_evidence.py docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md +git commit -m "test(actions): define source-authoritative evidence contract" +``` + +### Task 2: Implement deterministic source verification + +**Files:** +- Create: `appguardrail_core/github_actions_evidence.py` +- Test: `tests/test_github_actions_evidence.py` + +**Interfaces:** +- Produces: `ActionsJobEvidence`, `EvidenceValidationError`, `verify_actions_job`, and `ensure_unique_source_digest`. +- Consumes: exact GitHub run and job mappings acquired by Task 3. + +- [ ] **Step 1: Implement strict scalar and URL validation** + +Validate repository names as exactly two bounded GitHub name segments; require positive run/job IDs of at most 20 digits; require exact GitHub run/job HTTPS URLs; require a 40-character hexadecimal head SHA; require timezone-aware GitHub timestamps; and reject incomplete or unknown conclusions. + +- [ ] **Step 2: Implement canonical evidence projection and hashing** + +Project only bounded run/job fields plus step number/name/status/conclusion, encode with sorted compact JSON, and compute SHA-256. Never include authorization headers, tokens, annotations, or raw logs. + +- [ ] **Step 3: Implement classification and freshness** + +Classify GitHub failure conclusions as `failure`, completed `success` as `pass`, and reject unsupported conclusions. Require security-relevant workflow/job names and enforce `updated_at <= observed_at` plus the configured maximum age. + +- [ ] **Step 4: Implement immutable result serialization and duplicate rejection** + +Return a frozen dataclass with `to_dict()` and reject a digest already present in the caller-supplied digest set. + +- [ ] **Step 5: Run the focused suite and verify GREEN** + +Run: `pytest -q tests/test_github_actions_evidence.py` + +Expected: all tests pass. + +- [ ] **Step 6: Commit the verifier** + +```bash +git add appguardrail_core/github_actions_evidence.py tests/test_github_actions_evidence.py +git commit -m "feat(actions): verify source-authoritative job evidence" +``` + +### Task 3: Add bounded GitHub REST acquisition and CLI execution + +**Files:** +- Modify: `appguardrail_core/github_actions_evidence.py` +- Modify: `tests/test_github_actions_evidence.py` +- Modify: `pyproject.toml` + +**Interfaces:** +- Produces: `GitHubApiClient`, `acquire_actions_job`, and `main`. +- Consumes: `verify_actions_job` from Task 2. + +- [ ] **Step 1: Write RED client tests** + +Use a fake opener/response to prove the client rejects redirects, non-JSON content, payloads larger than 2 MiB, non-object JSON, and source identity mismatch; prove the token is sent only in the Authorization request header and never appears in output. + +- [ ] **Step 2: Run the new client tests and verify RED** + +Run: `pytest -q tests/test_github_actions_evidence.py -k "client or cli or acquire"` + +Expected: failures because the acquisition and CLI interfaces are missing. + +- [ ] **Step 3: Implement the pinned REST client and acquisition** + +GET `/repos/{repository}/actions/runs/{run_id}` and `/repos/{repository}/actions/jobs/{job_id}` through a no-redirect opener, a 30-second timeout, GitHub API version `2022-11-28`, and a 2 MiB response cap. Pass both mappings to `verify_actions_job`. + +- [ ] **Step 4: Implement the CLI** + +Require `--repository`, `--run-id`, and `--job-id`; read only `APPGUARDRAIL_GITHUB_TOKEN`; support `--max-age-hours` and `--seen-source-digest`; emit sorted JSON. Exit 0 for verified pass, 1 for verified security failure, and 2 for acquisition or validation failure. + +- [ ] **Step 5: Register the console script** + +Add `appguardrail-actions-evidence = "appguardrail_core.github_actions_evidence:main"` to `[project.scripts]`. + +- [ ] **Step 6: Run focused and full tests** + +Run: `pytest -q tests/test_github_actions_evidence.py` + +Run: `pytest -q` + +Expected: all tests pass with no warnings. + +- [ ] **Step 7: Commit the production entrypoint** + +```bash +git add appguardrail_core/github_actions_evidence.py tests/test_github_actions_evidence.py pyproject.toml +git commit -m "feat(actions): add evidence acquisition CLI" +``` + +### Task 4: Add audit, threat, operations, and traceability documentation + +**Files:** +- Create: `docs/github-actions-source-evidence.md` +- Create: `docs/adr/0013-source-authoritative-actions-evidence.md` +- Create: `CHANGELOG.d/938-source-authoritative-actions-evidence.md` +- Modify: `ARCHITECTURE.md` +- Modify: `docs/TEST_STRATEGY.md` +- Modify: `docs/THREAT_MODEL.md` +- Modify: `docs/OPERABILITY.md` +- Modify: `docs/TRACEABILITY.md` + +**Interfaces:** +- Documents: the exact CLI, evidence schema, exit codes, trust boundary, replay procedure, and buyer-visible acceptance evidence. + +- [ ] **Step 1: Document the evidence flow** + +Show `repository/run/job identity → pinned GitHub REST acquisition → strict validation → canonical digest → pass/failure decision → JSON evidence`, and distinguish the GitHub API response from historical AppGuardrail issue text. + +- [ ] **Step 2: Document threats and controls** + +Cover confused-deputy repository substitution, cross-origin redirects, ID mismatch, stale/replayed evidence, oversized payloads, token disclosure, untrusted job names, and incomplete jobs. + +- [ ] **Step 3: Document testing and operations** + +Provide exact commands for historical replay, token scope, exit-code handling, duplicate-ledger integration, incident diagnosis, and rollback. + +- [ ] **Step 4: Add APA 7th references** + +Cite GitHub REST API documentation, NIST SP 800-53 Rev. 5 evidence/audit controls, NIST SP 800-218 SSDF, SLSA v1.2, and RFC 8259 where each decision is applied. + +- [ ] **Step 5: Update traceability and changelog** + +Map issue #938 acceptance criteria to production symbols and tests; describe the feature as one verified vertical slice rather than complete coverage of every AppGuardrail issue family. + +- [ ] **Step 6: Commit documentation** + +```bash +git add ARCHITECTURE.md docs CHANGELOG.d/938-source-authoritative-actions-evidence.md +git commit -m "docs(actions): trace source-authoritative evidence slice" +``` + +### Task 5: Enforce exact quality gates and open the PR + +**Files:** +- Modify only if required: `.github/workflows/tests.yml` +- Verify: all files changed in Tasks 1-4. + +**Interfaces:** +- Produces: exact-head CI evidence and a reviewable PR linked to issue #938. + +- [ ] **Step 1: Run module coverage and docstring gates** + +Run: `coverage run --branch -m pytest -q tests/test_github_actions_evidence.py` + +Run: `coverage report --include='appguardrail_core/github_actions_evidence.py' --fail-under=100` + +Run: `interrogate -vv appguardrail_core/github_actions_evidence.py` + +Expected: 100% statement/branch coverage and 100% docstrings. + +- [ ] **Step 2: Run repository validation** + +Run: `python -m compileall -q appguardrail_core scanner scripts tests` + +Run: `pytest -q` + +Run: `python -m build` + +Expected: all commands succeed without warnings. + +- [ ] **Step 3: Open a bounded PR** + +Use title `feat(actions): verify source-authoritative job evidence` and link `Closes #938`. Keep auto-merge disabled until independent review and exact-head checks pass. + +- [ ] **Step 4: Process review and merge gates** + +Resolve only verified findings, rerun exact-head checks, obtain approval from someone other than the latest pusher, enable auto-merge, and confirm the protected `develop` commit after merge. From 8abbf795f66c5e12d23a8a4cd28fbcdea71063d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:25:29 +0900 Subject: [PATCH 02/43] test(actions): define source-authoritative evidence contract --- tests/test_github_actions_evidence.py | 466 ++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/test_github_actions_evidence.py diff --git a/tests/test_github_actions_evidence.py b/tests/test_github_actions_evidence.py new file mode 100644 index 00000000..db1ea35f --- /dev/null +++ b/tests/test_github_actions_evidence.py @@ -0,0 +1,466 @@ +"""Tests for source-authoritative GitHub Actions job evidence.""" + +from __future__ import annotations + +import io +import json +import urllib.error +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from appguardrail_core.github_actions_evidence import ( + ActionsJobEvidence, + EvidenceAcquisitionError, + EvidenceValidationError, + GitHubApiClient, + acquire_actions_job, + main, + verify_actions_job, +) + +OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) +REPOSITORY = "ContextualWisdomLab/.github" +RUN_ID = 30_769_144_488 +JOB_ID = 91_553_355_284 +HEAD_SHA = "2a83043b0239ba827153c934f87e469dba4f96f0" + + +def run_payload(**overrides): + """Return a #815-shaped GitHub Actions run payload.""" + payload = { + "id": RUN_ID, + "name": ( + "OpenCode Review Dispatch " + "ContextualWisdomLab/.github#701@3a15867168d39a248b92c14f6db0e63584e8dc22" + ), + "html_url": f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "main", + "event": "repository_dispatch", + "status": "completed", + "conclusion": "failure", + "created_at": "2026-08-02T23:40:00Z", + "updated_at": "2026-08-02T23:44:00Z", + "pull_requests": [], + } + payload.update(overrides) + return payload + + +def job_payload(**overrides): + """Return a #815-shaped GitHub Actions job payload.""" + payload = { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "opencode-review", + "workflow_name": "OpenCode Review Dispatch", + "html_url": ( + f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "failure", + "started_at": "2026-08-02T23:41:00Z", + "completed_at": "2026-08-02T23:43:30Z", + "steps": [ + { + "number": 18, + "name": "Run bounded review", + "status": "completed", + "conclusion": "success", + }, + { + "number": 19, + "name": "Publish review", + "status": "completed", + "conclusion": "failure", + }, + ], + } + payload.update(overrides) + return payload + + +def verify(run=None, job=None, **kwargs): + """Verify one fixture with deterministic observation time and freshness.""" + return verify_actions_job( + REPOSITORY, + run_payload() if run is None else run, + job_payload() if job is None else job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + **kwargs, + ) + + +def test_verifies_source_authoritative_failure(): + """Bind a real-world failure shape to immutable source identity.""" + evidence = verify() + + assert isinstance(evidence, ActionsJobEvidence) + assert evidence.detector_state == "failure" + assert evidence.repository == REPOSITORY + assert evidence.run_id == RUN_ID + assert evidence.job_id == JOB_ID + assert evidence.head_sha == HEAD_SHA + assert evidence.failed_step_numbers == (19,) + assert evidence.probe_ref == "github_actions_job_v1" + assert evidence.acquirer_ref == "github_rest_api_v2022_11_28" + assert len(evidence.source_digest_sha256) == 64 + assert evidence.to_dict()["failed_step_numbers"] == [19] + + +def test_verifies_pass_and_digest_is_mapping_order_independent(): + """Return pass for a successful security job and hash canonical content.""" + run = run_payload(conclusion="success") + job = job_payload( + conclusion="success", + steps=[ + { + "conclusion": "success", + "status": "completed", + "name": "Publish review", + "number": 19, + } + ], + ) + reversed_run = dict(reversed(list(run.items()))) + reversed_job = dict(reversed(list(job.items()))) + + first = verify(run=run, job=job) + second = verify(run=reversed_run, job=reversed_job) + + assert first.detector_state == "pass" + assert first.source_digest_sha256 == second.source_digest_sha256 + + +def test_cancelled_security_job_is_a_verified_failure(): + """Treat a completed cancelled scanner job as a non-passing security result.""" + run = run_payload( + name="Strix Security Scan ContextualWisdomLab/newsdom-api#501@" + HEAD_SHA, + conclusion="cancelled", + ) + job = job_payload( + name="strix", + workflow_name="Strix Security Scan", + conclusion="cancelled", + ) + + assert verify(run=run, job=job).detector_state == "failure" + + +@pytest.mark.parametrize( + ("repository", "run", "job", "error"), + [ + ("ContextualWisdomLab/../evil", run_payload(), job_payload(), "repository"), + ( + REPOSITORY, + run_payload(id=RUN_ID + 1), + job_payload(), + "run id", + ), + ( + REPOSITORY, + run_payload(), + job_payload(run_id=RUN_ID + 1), + "job run id", + ), + ( + REPOSITORY, + run_payload(head_sha="abc123"), + job_payload(), + "head SHA", + ), + ( + REPOSITORY, + run_payload( + html_url=f"https://evil.example/{REPOSITORY}/actions/runs/{RUN_ID}" + ), + job_payload(), + "run URL", + ), + ( + REPOSITORY, + run_payload(), + job_payload( + html_url=f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/1" + ), + "job URL", + ), + ( + REPOSITORY, + run_payload(status="queued"), + job_payload(), + "run status", + ), + ( + REPOSITORY, + run_payload(), + job_payload(status="in_progress"), + "job status", + ), + ( + REPOSITORY, + run_payload(conclusion="neutral"), + job_payload(conclusion="neutral"), + "conclusion", + ), + ( + REPOSITORY, + run_payload(name="Documentation"), + job_payload(name="build", workflow_name="Documentation"), + "security-relevant", + ), + ], +) +def test_rejects_identity_and_state_ambiguity(repository, run, job, error): + """Fail closed when the exact source identity or terminal state is ambiguous.""" + with pytest.raises(EvidenceValidationError, match=error): + verify_actions_job( + repository, + run, + job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + +@pytest.mark.parametrize("payload_name", ["run", "job"]) +def test_rejects_non_object_source_payload(payload_name): + """Reject JSON arrays and scalars instead of interpreting caller assertions.""" + run = [] if payload_name == "run" else run_payload() + job = [] if payload_name == "job" else job_payload() + + with pytest.raises(EvidenceValidationError, match=f"{payload_name} payload"): + verify(run=run, job=job) + + +def test_rejects_future_and_stale_evidence(): + """Do not accept future-dated or replayed source evidence silently.""" + with pytest.raises(EvidenceValidationError, match="future"): + verify( + run=run_payload(updated_at="2026-08-03T00:00:01Z"), + job=job_payload(completed_at="2026-08-03T00:00:01Z"), + ) + + with pytest.raises(EvidenceValidationError, match="stale"): + verify_actions_job( + REPOSITORY, + run_payload(updated_at="2026-07-01T00:00:00Z"), + job_payload(completed_at="2026-07-01T00:00:00Z"), + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + with pytest.raises(EvidenceValidationError, match="max_age"): + verify_actions_job( + REPOSITORY, + run_payload(), + job_payload(), + observed_at=OBSERVED_AT, + max_age=timedelta(0), + ) + + +def test_rejects_duplicate_source_digest(): + """Prevent a verified source artifact from being ingested twice.""" + evidence = verify() + + with pytest.raises(EvidenceValidationError, match="duplicate"): + verify(seen_source_digests={evidence.source_digest_sha256}) + + +class FakeResponse: + """Bounded in-memory HTTP response used by the REST client tests.""" + + def __init__(self, payload, content_type="application/json", status=200): + """Encode payload and expose the subset of HTTPResponse used in production.""" + self.body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.headers = {"content-type": content_type} + self.status = status + + def read(self, amount=-1): + """Read at most the requested byte count.""" + return self.body if amount < 0 else self.body[:amount] + + def __enter__(self): + """Enter the response context.""" + return self + + def __exit__(self, exc_type, exc, traceback): + """Exit without suppressing errors.""" + return False + + +class FakeOpener: + """Record requests and return queued responses or exceptions.""" + + def __init__(self, *results): + """Store deterministic results for subsequent open calls.""" + self.results = list(results) + self.requests = [] + + def open(self, request, timeout): + """Return or raise the next configured result.""" + self.requests.append((request, timeout)) + result = self.results.pop(0) + if isinstance(result, BaseException): + raise result + return result + + +def test_client_fetches_bounded_json_with_scoped_auth_header(): + """Acquire JSON only from the pinned API without exposing the token.""" + opener = FakeOpener(FakeResponse(run_payload())) + client = GitHubApiClient("secret-token", opener=opener) + + payload = client.get_json(f"/repos/{REPOSITORY}/actions/runs/{RUN_ID}") + + request, timeout = opener.requests[0] + assert payload["id"] == RUN_ID + assert request.full_url == ( + f"https://api.github.com/repos/{REPOSITORY}/actions/runs/{RUN_ID}" + ) + assert request.get_header("Authorization") == "Bearer secret-token" + assert request.get_header("X-github-api-version") == "2022-11-28" + assert timeout == 30 + assert "secret-token" not in json.dumps(payload) + + +@pytest.mark.parametrize( + ("response", "error"), + [ + (FakeResponse({}, content_type="text/plain"), "JSON content type"), + (FakeResponse([], content_type="application/json"), "JSON object"), + (FakeResponse(b"x" * (2 * 1024 * 1024 + 1)), "response limit"), + (FakeResponse(b"{"), "invalid JSON"), + ], +) +def test_client_rejects_untrusted_response_shapes(response, error): + """Reject non-JSON, oversized, invalid, and non-object API responses.""" + client = GitHubApiClient("token", opener=FakeOpener(response)) + + with pytest.raises(EvidenceAcquisitionError, match=error): + client.get_json(f"/repos/{REPOSITORY}/actions/runs/{RUN_ID}") + + +def test_client_rejects_invalid_configuration_and_network_errors(): + """Pin origin and paths and return sanitized acquisition failures.""" + with pytest.raises(ValueError, match="token"): + GitHubApiClient(" ") + with pytest.raises(ValueError, match="API root"): + GitHubApiClient("token", api_root="https://example.com") + + client = GitHubApiClient("token", opener=FakeOpener(FakeResponse({}))) + with pytest.raises(ValueError, match="path"): + client.get_json("https://evil.example/api") + + http_error = urllib.error.HTTPError( + "https://api.github.com", 403, "Forbidden", {}, io.BytesIO(b"secret-token") + ) + client = GitHubApiClient("secret-token", opener=FakeOpener(http_error)) + with pytest.raises(EvidenceAcquisitionError, match="HTTP 403") as exc_info: + client.get_json(f"/repos/{REPOSITORY}/actions/runs/{RUN_ID}") + assert "secret-token" not in str(exc_info.value) + + client = GitHubApiClient( + "secret-token", opener=FakeOpener(urllib.error.URLError("secret-token")) + ) + with pytest.raises(EvidenceAcquisitionError, match="network failure") as exc_info: + client.get_json(f"/repos/{REPOSITORY}/actions/runs/{RUN_ID}") + assert "secret-token" not in str(exc_info.value) + + +def test_acquire_actions_job_uses_exact_source_endpoints(): + """Fetch the authoritative run and job before evaluating the outcome.""" + opener = FakeOpener(FakeResponse(run_payload()), FakeResponse(job_payload())) + client = GitHubApiClient("token", opener=opener) + + evidence = acquire_actions_job( + client, + REPOSITORY, + RUN_ID, + JOB_ID, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + assert evidence.detector_state == "failure" + assert [request.full_url for request, _timeout in opener.requests] == [ + f"https://api.github.com/repos/{REPOSITORY}/actions/runs/{RUN_ID}", + f"https://api.github.com/repos/{REPOSITORY}/actions/jobs/{JOB_ID}", + ] + + +def test_cli_emits_evidence_and_uses_decision_exit_codes(monkeypatch, capsys): + """Expose pass/failure as machine-readable output and stable exit codes.""" + failure = verify() + passed = ActionsJobEvidence( + **{**failure.__dict__, "detector_state": "pass", "job_conclusion": "success"} + ) + observed = [] + + class FakeClient: + def __init__(self, token): + observed.append(token) + + monkeypatch.setenv("APPGUARDRAIL_GITHUB_TOKEN", "scoped-token") + monkeypatch.setattr( + "appguardrail_core.github_actions_evidence.GitHubApiClient", FakeClient + ) + monkeypatch.setattr( + "appguardrail_core.github_actions_evidence.acquire_actions_job", + lambda *_args, **_kwargs: passed, + ) + + argv = [ + "--repository", + REPOSITORY, + "--run-id", + str(RUN_ID), + "--job-id", + str(JOB_ID), + "--max-age-hours", + "48", + ] + assert main(argv) == 0 + output = json.loads(capsys.readouterr().out) + assert output["detector_state"] == "pass" + assert observed == ["scoped-token"] + + monkeypatch.setattr( + "appguardrail_core.github_actions_evidence.acquire_actions_job", + lambda *_args, **_kwargs: failure, + ) + assert main(argv) == 1 + assert json.loads(capsys.readouterr().out)["detector_state"] == "failure" + + +def test_cli_fails_closed_without_token_or_on_validation_error(monkeypatch, capsys): + """Return exit 2 with bounded JSON errors for unavailable source evidence.""" + monkeypatch.delenv("APPGUARDRAIL_GITHUB_TOKEN", raising=False) + argv = [ + "--repository", + REPOSITORY, + "--run-id", + str(RUN_ID), + "--job-id", + str(JOB_ID), + ] + assert main(argv) == 2 + assert json.loads(capsys.readouterr().err)["error_code"] == "missing_token" + + monkeypatch.setenv("APPGUARDRAIL_GITHUB_TOKEN", "token") + monkeypatch.setattr( + "appguardrail_core.github_actions_evidence.acquire_actions_job", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EvidenceValidationError("source identity mismatch") + ), + ) + assert main(argv) == 2 + error = json.loads(capsys.readouterr().err) + assert error == { + "error_code": "source_evidence_unavailable", + "message": "source identity mismatch", + } From 73900895bb1d36accad57a03228a2f750b7312fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:29:55 +0900 Subject: [PATCH 03/43] feat(actions): verify source-authoritative job evidence --- appguardrail_core/github_actions_evidence.py | 588 +++++++++++++++++++ 1 file changed, 588 insertions(+) create mode 100644 appguardrail_core/github_actions_evidence.py diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py new file mode 100644 index 00000000..67756747 --- /dev/null +++ b/appguardrail_core/github_actions_evidence.py @@ -0,0 +1,588 @@ +"""Acquire and verify source-authoritative GitHub Actions job evidence. + +The verifier deliberately accepts GitHub run and job API objects rather than a +caller-provided Boolean decision. It binds their exact repository, run, job, +commit, terminal state, and freshness into a deterministic SHA-256 evidence +identity suitable for audit and duplicate prevention. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Mapping + +from appguardrail_core.issueops import is_failure, is_security_name + +API_ROOT = "https://api.github.com" +API_VERSION = "2022-11-28" +PROBE_REF = "github_actions_job_v1" +ACQUIRER_REF = "github_rest_api_v2022_11_28" +SCHEMA_VERSION = "1.0" +DEFAULT_TIMEOUT_SECONDS = 30 +DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +MAX_IDENTIFIER_DIGITS = 20 +_REPOSITORY_RE = re.compile( + r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}\Z" +) +_HEAD_SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z") +_SOURCE_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z") +_ALLOWED_CONCLUSIONS = { + "success", + "failure", + "cancelled", + "timed_out", + "action_required", +} +_ALLOWED_STEP_CONCLUSIONS = _ALLOWED_CONCLUSIONS | { + "skipped", + "neutral", + "stale", + "startup_failure", + "", +} + + +class EvidenceValidationError(ValueError): + """Raised when acquired source evidence is ambiguous, stale, or invalid.""" + + +class EvidenceAcquisitionError(RuntimeError): + """Raised when the authoritative GitHub source cannot be acquired safely.""" + + +@dataclass(frozen=True) +class ActionsJobEvidence: + """Immutable normalized evidence for one verified GitHub Actions job.""" + + schema_version: str + probe_ref: str + acquirer_ref: str + repository: str + workflow_name: str + job_name: str + run_id: int + job_id: int + head_sha: str + branch_name: str + event_name: str + run_url: str + job_url: str + job_conclusion: str + detector_state: str + failed_step_numbers: tuple[int, ...] + source_updated_at: str + observed_at: str + source_digest_sha256: str + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-compatible evidence representation.""" + payload = asdict(self) + payload["failed_step_numbers"] = list(self.failed_step_numbers) + return payload + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Reject redirects so authenticated requests cannot change origins.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + """Return no redirected request and let urllib surface the response.""" + return None + + +class GitHubApiClient: + """Bounded GitHub REST client pinned to the public API origin.""" + + def __init__( + self, + token: str, + *, + api_root: str = API_ROOT, + opener: Any | None = None, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + ) -> None: + """Initialize a client with one scoped token and bounded I/O limits.""" + if not isinstance(token, str) or not token.strip(): + raise ValueError("GitHub token must be non-empty") + if api_root.rstrip("/") != API_ROOT: + raise ValueError("GitHub API root must be https://api.github.com") + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + if max_response_bytes <= 0: + raise ValueError("max_response_bytes must be positive") + self._token = token.strip() + self._api_root = API_ROOT + self._opener = opener or urllib.request.build_opener(_NoRedirect) + self._timeout_seconds = timeout_seconds + self._max_response_bytes = max_response_bytes + + def get_json(self, path: str) -> dict[str, Any]: + """Fetch one bounded JSON object from an allowed GitHub Actions path.""" + if not isinstance(path, str) or not re.fullmatch( + r"/repos/[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}/actions/(?:runs|jobs)/[1-9][0-9]{0,19}", + path, + ): + raise ValueError( + "GitHub API path is not an allowed Actions resource path" + ) + request = urllib.request.Request( # noqa: S310 - exact fixed GitHub origin + f"{self._api_root}{path}", + method="GET", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self._token}", + "User-Agent": "appguardrail-actions-evidence", + "X-GitHub-Api-Version": API_VERSION, + }, + ) + try: + with self._opener.open( + request, timeout=self._timeout_seconds + ) as response: # noqa: S310 - exact fixed GitHub origin + status = int(getattr(response, "status", 200)) + if status != 200: + raise EvidenceAcquisitionError( + f"GitHub API returned unexpected HTTP {status}" + ) + content_type = _content_type(response.headers) + if "application/json" not in content_type.lower(): + raise EvidenceAcquisitionError( + "GitHub API response did not use a JSON content type" + ) + body = response.read(self._max_response_bytes + 1) + except urllib.error.HTTPError as exc: + raise EvidenceAcquisitionError( + f"GitHub API request failed with HTTP {exc.code}" + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise EvidenceAcquisitionError("GitHub API network failure") from exc + if len(body) > self._max_response_bytes: + raise EvidenceAcquisitionError( + "GitHub API response exceeded the response limit" + ) + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise EvidenceAcquisitionError("GitHub API returned invalid JSON") from exc + if not isinstance(payload, dict): + raise EvidenceAcquisitionError( + "GitHub API response must be a JSON object" + ) + return payload + + +def verify_actions_job( + repository: str, + run: Mapping[str, Any], + job: Mapping[str, Any], + *, + observed_at: datetime | None = None, + max_age: timedelta = timedelta(hours=48), + seen_source_digests: Iterable[str] = (), +) -> ActionsJobEvidence: + """Validate and classify exact GitHub Actions run and job API objects.""" + normalized_repository = _validate_repository(repository) + if not isinstance(run, Mapping): + raise EvidenceValidationError("run payload must be a JSON object") + if not isinstance(job, Mapping): + raise EvidenceValidationError("job payload must be a JSON object") + if max_age <= timedelta(0): + raise EvidenceValidationError("max_age must be positive") + + run_id = _positive_identifier(run.get("id"), "run id") + job_id = _positive_identifier(job.get("id"), "job id") + job_run_id = _positive_identifier(job.get("run_id"), "job run id") + if job_run_id != run_id: + raise EvidenceValidationError("job run id does not match run id") + + run_url = _required_text(run.get("html_url"), "run URL", 500) + expected_run_url = ( + f"https://github.com/{normalized_repository}/actions/runs/{run_id}" + ) + if run_url != expected_run_url: + raise EvidenceValidationError("run id and run URL do not match") + + job_url = _required_text(job.get("html_url"), "job URL", 600) + expected_job_url = f"{expected_run_url}/job/{job_id}" + if job_url != expected_job_url: + raise EvidenceValidationError( + "job URL does not match repository, run, and job ids" + ) + + head_sha = _required_text(run.get("head_sha"), "head SHA", 40).lower() + if not _HEAD_SHA_RE.fullmatch(head_sha): + raise EvidenceValidationError( + "head SHA must contain exactly 40 hexadecimal characters" + ) + + run_status = _required_text(run.get("status"), "run status", 40).lower() + if run_status != "completed": + raise EvidenceValidationError("run status must be completed") + job_status = _required_text(job.get("status"), "job status", 40).lower() + if job_status != "completed": + raise EvidenceValidationError("job status must be completed") + + run_conclusion = _required_text( + run.get("conclusion"), "run conclusion", 40 + ).lower() + job_conclusion = _required_text( + job.get("conclusion"), "job conclusion", 40 + ).lower() + if run_conclusion not in _ALLOWED_CONCLUSIONS: + raise EvidenceValidationError("run conclusion is unsupported") + if job_conclusion not in _ALLOWED_CONCLUSIONS: + raise EvidenceValidationError("job conclusion is unsupported") + + workflow_name = _required_text( + run.get("name") or job.get("workflow_name"), "workflow name", 500 + ) + job_name = _required_text(job.get("name"), "job name", 500) + if not is_security_name( + workflow_name, job.get("workflow_name"), job_name + ): + raise EvidenceValidationError("job is not security-relevant") + + observed = _normalize_observed_at(observed_at) + run_updated_at = _parse_github_time( + run.get("updated_at"), "run updated_at" + ) + job_completed_at = _parse_github_time( + job.get("completed_at"), "job completed_at" + ) + source_updated_at = max(run_updated_at, job_completed_at) + if source_updated_at > observed: + raise EvidenceValidationError("source evidence is future-dated") + if observed - source_updated_at > max_age: + raise EvidenceValidationError("source evidence is stale") + + steps = _normalize_steps(job.get("steps")) + source_projection = { + "repository": normalized_repository, + "run": { + "id": run_id, + "name": workflow_name, + "html_url": run_url, + "head_sha": head_sha, + "head_branch": _optional_text(run.get("head_branch"), 255), + "event": _required_text(run.get("event"), "event name", 100), + "status": run_status, + "conclusion": run_conclusion, + "updated_at": _format_timestamp(run_updated_at), + "pull_request_numbers": _pull_request_numbers( + run.get("pull_requests") + ), + }, + "job": { + "id": job_id, + "run_id": job_run_id, + "name": job_name, + "workflow_name": _optional_text( + job.get("workflow_name"), 500 + ), + "html_url": job_url, + "status": job_status, + "conclusion": job_conclusion, + "completed_at": _format_timestamp(job_completed_at), + "steps": steps, + }, + } + canonical = json.dumps( + source_projection, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + source_digest = hashlib.sha256(canonical).hexdigest() + normalized_seen = { + _normalize_digest(value) for value in seen_source_digests + } + if source_digest in normalized_seen: + raise EvidenceValidationError("duplicate source evidence digest") + + failed_steps = tuple( + step["number"] + for step in steps + if is_failure(step["conclusion"]) + ) + detector_state = "failure" if is_failure(job_conclusion) else "pass" + return ActionsJobEvidence( + schema_version=SCHEMA_VERSION, + probe_ref=PROBE_REF, + acquirer_ref=ACQUIRER_REF, + repository=normalized_repository, + workflow_name=workflow_name, + job_name=job_name, + run_id=run_id, + job_id=job_id, + head_sha=head_sha, + branch_name=_optional_text(run.get("head_branch"), 255), + event_name=_required_text(run.get("event"), "event name", 100), + run_url=run_url, + job_url=job_url, + job_conclusion=job_conclusion, + detector_state=detector_state, + failed_step_numbers=failed_steps, + source_updated_at=_format_timestamp(source_updated_at), + observed_at=_format_timestamp(observed), + source_digest_sha256=source_digest, + ) + + +def acquire_actions_job( + client: GitHubApiClient, + repository: str, + run_id: int, + job_id: int, + *, + observed_at: datetime | None = None, + max_age: timedelta = timedelta(hours=48), + seen_source_digests: Iterable[str] = (), +) -> ActionsJobEvidence: + """Acquire an exact run/job pair from GitHub and verify its source identity.""" + normalized_repository = _validate_repository(repository) + normalized_run_id = _positive_identifier(run_id, "run id") + normalized_job_id = _positive_identifier(job_id, "job id") + run = client.get_json( + f"/repos/{normalized_repository}/actions/runs/{normalized_run_id}" + ) + job = client.get_json( + f"/repos/{normalized_repository}/actions/jobs/{normalized_job_id}" + ) + if _positive_identifier(run.get("id"), "run id") != normalized_run_id: + raise EvidenceValidationError( + "acquired run id does not match requested run id" + ) + if _positive_identifier(job.get("id"), "job id") != normalized_job_id: + raise EvidenceValidationError( + "acquired job id does not match requested job id" + ) + return verify_actions_job( + normalized_repository, + run, + job, + observed_at=observed_at, + max_age=max_age, + seen_source_digests=seen_source_digests, + ) + + +def main(argv: list[str] | None = None) -> int: + """Run the source-evidence verifier and return a stable decision exit code.""" + parser = argparse.ArgumentParser( + prog="appguardrail-actions-evidence", + description="Acquire and verify one exact GitHub Actions security job.", + ) + parser.add_argument("--repository", required=True) + parser.add_argument("--run-id", required=True, type=int) + parser.add_argument("--job-id", required=True, type=int) + parser.add_argument("--max-age-hours", type=float, default=48.0) + parser.add_argument("--seen-source-digest", action="append", default=[]) + args = parser.parse_args(argv) + + token = os.environ.get("APPGUARDRAIL_GITHUB_TOKEN", "").strip() + if not token: + _write_error( + "missing_token", "APPGUARDRAIL_GITHUB_TOKEN is required" + ) + return 2 + try: + client = GitHubApiClient(token) + evidence = acquire_actions_job( + client, + args.repository, + args.run_id, + args.job_id, + max_age=timedelta(hours=args.max_age_hours), + seen_source_digests=args.seen_source_digest, + ) + except ( + EvidenceAcquisitionError, + EvidenceValidationError, + ValueError, + ) as exc: + _write_error("source_evidence_unavailable", str(exc)) + return 2 + print( + json.dumps( + evidence.to_dict(), sort_keys=True, ensure_ascii=False + ) + ) + return 1 if evidence.detector_state == "failure" else 0 + + +def _content_type(headers: Any) -> str: + if hasattr(headers, "get_content_type"): + return str(headers.get_content_type()) + if hasattr(headers, "get"): + return str(headers.get("content-type", "")) + return "" + + +def _validate_repository(repository: Any) -> str: + if not isinstance(repository, str) or not _REPOSITORY_RE.fullmatch( + repository + ): + raise EvidenceValidationError( + "repository must be an exact owner/name identifier" + ) + owner, name = repository.split("/", 1) + if owner in {".", ".."} or name in {".", ".."}: + raise EvidenceValidationError( + "repository contains an invalid path segment" + ) + return repository + + +def _positive_identifier(value: Any, label: str) -> int: + if isinstance(value, bool): + raise EvidenceValidationError( + f"{label} must be a positive integer" + ) + try: + normalized = int(value) + except (TypeError, ValueError) as exc: + raise EvidenceValidationError( + f"{label} must be a positive integer" + ) from exc + if normalized <= 0 or len(str(normalized)) > MAX_IDENTIFIER_DIGITS: + raise EvidenceValidationError( + f"{label} must be a positive integer of at most " + f"{MAX_IDENTIFIER_DIGITS} digits" + ) + return normalized + + +def _required_text(value: Any, label: str, max_length: int) -> str: + if not isinstance(value, str): + raise EvidenceValidationError(f"{label} must be text") + normalized = value.strip() + if ( + not normalized + or len(normalized) > max_length + or any( + character in normalized + for character in ("\x00", "\r", "\n") + ) + ): + raise EvidenceValidationError( + f"{label} is empty, oversized, or contains controls" + ) + return normalized + + +def _optional_text(value: Any, max_length: int) -> str: + if value is None: + return "" + return _required_text(value, "optional text", max_length) + + +def _parse_github_time(value: Any, label: str) -> datetime: + text = _required_text(value, label, 64) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise EvidenceValidationError( + f"{label} is not a valid ISO timestamp" + ) from exc + if parsed.tzinfo is None: + raise EvidenceValidationError(f"{label} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def _normalize_observed_at(value: datetime | None) -> datetime: + observed = value or datetime.now(timezone.utc) + if not isinstance(observed, datetime) or observed.tzinfo is None: + raise EvidenceValidationError( + "observed_at must be timezone-aware" + ) + return observed.astimezone(timezone.utc) + + +def _format_timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_steps(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + if not isinstance(value, list): + raise EvidenceValidationError("job steps must be a list") + normalized: list[dict[str, Any]] = [] + seen_numbers: set[int] = set() + for raw_step in value: + if not isinstance(raw_step, Mapping): + raise EvidenceValidationError( + "each job step must be a JSON object" + ) + number = _positive_identifier(raw_step.get("number"), "step number") + if number in seen_numbers: + raise EvidenceValidationError( + "job step numbers must be unique" + ) + seen_numbers.add(number) + status = _required_text( + raw_step.get("status"), "step status", 40 + ).lower() + conclusion = str(raw_step.get("conclusion") or "").strip().lower() + if conclusion not in _ALLOWED_STEP_CONCLUSIONS: + raise EvidenceValidationError( + "step conclusion is unsupported" + ) + normalized.append( + { + "number": number, + "name": _required_text( + raw_step.get("name"), "step name", 500 + ), + "status": status, + "conclusion": conclusion, + } + ) + return sorted(normalized, key=lambda step: step["number"]) + + +def _pull_request_numbers(value: Any) -> list[int]: + if value is None: + return [] + if not isinstance(value, list): + raise EvidenceValidationError("pull_requests must be a list") + numbers: set[int] = set() + for item in value: + if not isinstance(item, Mapping): + raise EvidenceValidationError( + "each pull request must be a JSON object" + ) + number = item.get("number") + if number is not None: + numbers.add( + _positive_identifier(number, "pull request number") + ) + return sorted(numbers) + + +def _normalize_digest(value: Any) -> str: + if not isinstance(value, str): + raise EvidenceValidationError("source digest must be text") + normalized = value.strip().lower() + if not _SOURCE_DIGEST_RE.fullmatch(normalized): + raise EvidenceValidationError( + "source digest must be 64 hexadecimal characters" + ) + return normalized + + +def _write_error(error_code: str, message: str) -> None: + payload = {"error_code": error_code, "message": message} + print( + json.dumps(payload, sort_keys=True, ensure_ascii=False), + file=sys.stderr, + ) From e070342e160b5b4c84610ccb54c274fc20f91c3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:36:44 +0900 Subject: [PATCH 04/43] test(actions): cover source-evidence trust boundaries --- tests/test_github_actions_evidence_edges.py | 428 ++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 tests/test_github_actions_evidence_edges.py diff --git a/tests/test_github_actions_evidence_edges.py b/tests/test_github_actions_evidence_edges.py new file mode 100644 index 00000000..6b0ddca8 --- /dev/null +++ b/tests/test_github_actions_evidence_edges.py @@ -0,0 +1,428 @@ +"""Defensive branch coverage for GitHub Actions source evidence.""" + +from __future__ import annotations + +import io +import json +import urllib.error +from datetime import datetime, timedelta, timezone + +import pytest + +import appguardrail_core.github_actions_evidence as gae + +OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) +REPOSITORY = "ContextualWisdomLab/.github" +RUN_ID = 30_769_144_488 +JOB_ID = 91_553_355_284 +HEAD_SHA = "2a83043b0239ba827153c934f87e469dba4f96f0" + + +def run_payload(**overrides): + """Return a bounded security workflow run fixture.""" + payload = { + "id": RUN_ID, + "name": "OpenCode Review Dispatch current-head", + "html_url": f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "main", + "event": "repository_dispatch", + "status": "completed", + "conclusion": "failure", + "updated_at": "2026-08-02T23:44:00Z", + "pull_requests": [], + } + payload.update(overrides) + return payload + + +def job_payload(**overrides): + """Return a bounded security workflow job fixture.""" + payload = { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "opencode-review", + "workflow_name": "OpenCode Review Dispatch", + "html_url": ( + f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "failure", + "completed_at": "2026-08-02T23:43:30Z", + "steps": [ + { + "number": 19, + "name": "Publish review", + "status": "completed", + "conclusion": "failure", + } + ], + } + payload.update(overrides) + return payload + + +def verify(*, run=None, job=None, **kwargs): + """Verify one fixture with deterministic time bounds.""" + return gae.verify_actions_job( + REPOSITORY, + run_payload() if run is None else run, + job_payload() if job is None else job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + **kwargs, + ) + + +class FakeResponse: + """In-memory response implementing the production client's HTTP subset.""" + + def __init__(self, payload, content_type="application/json", status=200): + """Encode a JSON-like payload or preserve supplied bytes.""" + self.body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.headers = {"content-type": content_type} + self.status = status + + def read(self, amount=-1): + """Return at most the requested bytes.""" + return self.body if amount < 0 else self.body[:amount] + + def __enter__(self): + """Enter the response context.""" + return self + + def __exit__(self, exc_type, exc, traceback): + """Exit without suppressing errors.""" + return False + + +class FakeOpener: + """Record requests and return or raise deterministic queued results.""" + + def __init__(self, *results): + """Store results for subsequent requests.""" + self.results = list(results) + self.requests = [] + + def open(self, request, timeout): + """Return or raise the next result.""" + self.requests.append((request, timeout)) + result = self.results.pop(0) + if isinstance(result, BaseException): + raise result + return result + + +def test_redirect_and_client_constructor_edges(): + """Reject redirects and invalid transport limits before acquisition.""" + assert ( + gae._NoRedirect().redirect_request( + None, None, 302, "redirect", {}, "https://evil.example" + ) + is None + ) + for token in (None, " "): + with pytest.raises(ValueError, match="token"): + gae.GitHubApiClient(token) + with pytest.raises(ValueError, match="API root"): + gae.GitHubApiClient("token", api_root="https://example.com") + with pytest.raises(ValueError, match="timeout"): + gae.GitHubApiClient("token", timeout_seconds=0) + with pytest.raises(ValueError, match="max_response"): + gae.GitHubApiClient("token", max_response_bytes=0) + assert gae.GitHubApiClient( + "token", api_root="https://api.github.com/" + )._opener + + +def test_get_json_transport_edges(): + """Cover path, status, network, encoding, and content-type failures.""" + client = gae.GitHubApiClient("token", opener=FakeOpener(FakeResponse({}))) + for path in ( + None, + "https://evil.example/api", + "/repos/a/b/actions/runs/0", + ): + with pytest.raises(ValueError, match="path"): + client.get_json(path) + + with pytest.raises(gae.EvidenceAcquisitionError, match="unexpected HTTP 201"): + gae.GitHubApiClient( + "token", opener=FakeOpener(FakeResponse({}, status=201)) + ).get_json("/repos/a/b/actions/runs/1") + with pytest.raises(gae.EvidenceAcquisitionError, match="network failure"): + gae.GitHubApiClient( + "token", opener=FakeOpener(urllib.error.URLError("private detail")) + ).get_json("/repos/a/b/actions/runs/1") + with pytest.raises(gae.EvidenceAcquisitionError, match="invalid JSON"): + gae.GitHubApiClient( + "token", opener=FakeOpener(FakeResponse(b"\xff")) + ).get_json("/repos/a/b/actions/runs/1") + + class ContentTypeHeaders: + """Expose the email-message content-type interface.""" + + @staticmethod + def get_content_type(): + """Return a trusted JSON media type.""" + return "application/json" + + response = FakeResponse({}) + response.headers = ContentTypeHeaders() + assert gae.GitHubApiClient( + "token", opener=FakeOpener(response) + ).get_json("/repos/a/b/actions/runs/1") == {} + + response = FakeResponse({}) + response.headers = object() + with pytest.raises(gae.EvidenceAcquisitionError, match="JSON content type"): + gae.GitHubApiClient( + "token", opener=FakeOpener(response) + ).get_json("/repos/a/b/actions/runs/1") + + +def test_verify_additional_fail_closed_edges(): + """Reject unresolved payload, age, URL, and terminal-state ambiguity.""" + with pytest.raises(gae.EvidenceValidationError, match="job payload"): + gae.verify_actions_job( + REPOSITORY, + run_payload(), + [], + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + with pytest.raises(gae.EvidenceValidationError, match="max_age"): + gae.verify_actions_job( + REPOSITORY, + run_payload(), + job_payload(), + observed_at=OBSERVED_AT, + max_age=timedelta(0), + ) + with pytest.raises(gae.EvidenceValidationError, match="run URL"): + verify( + run=run_payload( + html_url="https://github.com/other/repo/actions/runs/1" + ) + ) + with pytest.raises(gae.EvidenceValidationError, match="job URL"): + verify( + job=job_payload( + html_url="https://github.com/other/repo/actions/runs/1/job/2" + ) + ) + with pytest.raises(gae.EvidenceValidationError, match="job conclusion"): + verify( + run=run_payload(conclusion="success"), + job=job_payload(conclusion="neutral"), + ) + + fallback = verify( + run=run_payload(name=""), + job=job_payload(workflow_name="OpenCode Review Dispatch"), + ) + assert fallback.workflow_name == "OpenCode Review Dispatch" + + current_time = datetime.now(timezone.utc) - timedelta(seconds=1) + dynamic = gae.verify_actions_job( + REPOSITORY, + run_payload(updated_at=current_time.isoformat()), + job_payload(completed_at=current_time.isoformat()), + max_age=timedelta(hours=1), + ) + assert dynamic.observed_at.endswith("Z") + + for observed_at in ("not-a-datetime", datetime(2026, 8, 3)): + with pytest.raises(gae.EvidenceValidationError, match="observed_at"): + gae.verify_actions_job( + REPOSITORY, + run_payload(), + job_payload(), + observed_at=observed_at, + max_age=timedelta(hours=48), + ) + + empty_optional = gae.verify_actions_job( + REPOSITORY, + run_payload(head_branch=None, pull_requests=None), + job_payload(workflow_name=None, steps=None), + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + assert empty_optional.branch_name == "" + assert empty_optional.failed_step_numbers == () + + duplicate_prs = verify( + run=run_payload( + pull_requests=[ + {"number": 2}, + {"number": 1}, + {"number": 2}, + {"number": None}, + ] + ) + ) + assert len(duplicate_prs.source_digest_sha256) == 64 + + +def test_scalar_and_collection_validation_edges(): + """Exercise every bounded scalar, step, PR, and digest validation branch.""" + for repository in (None, "owner", "./repo", "owner/.", "owner/.."): + with pytest.raises( + gae.EvidenceValidationError, match="repository|path segment" + ): + gae._validate_repository(repository) + + assert gae._positive_identifier("2", "id") == 2 + for value in (True, None, "not-an-id", 0, 10**20): + with pytest.raises(gae.EvidenceValidationError, match="id"): + gae._positive_identifier(value, "id") + + for value in (None, 3): + with pytest.raises(gae.EvidenceValidationError, match="text"): + gae._required_text(value, "field", 4) + for value in ("", "12345", "x\x00", "x\n"): + with pytest.raises(gae.EvidenceValidationError, match="field"): + gae._required_text(value, "field", 4) + + assert gae._optional_text(None, 4) == "" + assert gae._optional_text("ok", 4) == "ok" + with pytest.raises(gae.EvidenceValidationError, match="valid ISO"): + gae._parse_github_time("bad", "time") + with pytest.raises(gae.EvidenceValidationError, match="timezone"): + gae._parse_github_time("2026-08-03T00:00:00", "time") + assert gae._format_timestamp(OBSERVED_AT) == "2026-08-03T00:00:00Z" + + invalid_steps = ( + "not-a-list", + [1], + [ + { + "number": 1, + "name": "first", + "status": "completed", + "conclusion": "success", + }, + { + "number": 1, + "name": "duplicate", + "status": "completed", + "conclusion": "success", + }, + ], + [ + { + "number": 1, + "name": "unknown", + "status": "completed", + "conclusion": "mystery", + } + ], + ) + for steps in invalid_steps: + with pytest.raises( + gae.EvidenceValidationError, match="steps|step|conclusion" + ): + gae._normalize_steps(steps) + assert gae._normalize_steps(None) == [] + sorted_steps = gae._normalize_steps( + [ + { + "number": 2, + "name": "second", + "status": "completed", + "conclusion": None, + }, + { + "number": 1, + "name": "first", + "status": "completed", + "conclusion": "success", + }, + ] + ) + assert [step["number"] for step in sorted_steps] == [1, 2] + + for pull_requests in ("not-a-list", [1]): + with pytest.raises( + gae.EvidenceValidationError, match="pull_request|pull request" + ): + gae._pull_request_numbers(pull_requests) + assert gae._pull_request_numbers(None) == [] + + for digest in (None, "bad"): + with pytest.raises(gae.EvidenceValidationError, match="digest"): + gae._normalize_digest(digest) + assert gae._normalize_digest("A" * 64) == "a" * 64 + + +def test_acquisition_rejects_returned_identity_mismatch(): + """Bind requested and returned IDs before classifying API content.""" + + class SequenceClient: + """Return two deterministic payloads in request order.""" + + def __init__(self, run, job): + """Store the run and job responses.""" + self.items = [run, job] + + def get_json(self, path): + """Return the next response.""" + return self.items.pop(0) + + with pytest.raises(gae.EvidenceValidationError, match="acquired run id"): + gae.acquire_actions_job( + SequenceClient(run_payload(id=RUN_ID + 1), job_payload()), + REPOSITORY, + RUN_ID, + JOB_ID, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + with pytest.raises(gae.EvidenceValidationError, match="acquired job id"): + gae.acquire_actions_job( + SequenceClient(run_payload(), job_payload(id=JOB_ID + 1)), + REPOSITORY, + RUN_ID, + JOB_ID, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + +def test_cli_pass_and_validation_error_paths(monkeypatch, capsys): + """Return stable pass and fail-closed error exit codes.""" + failure = verify() + passed = gae.ActionsJobEvidence( + **{ + **failure.__dict__, + "detector_state": "pass", + "job_conclusion": "success", + } + ) + monkeypatch.setenv("APPGUARDRAIL_GITHUB_TOKEN", "token") + monkeypatch.setattr( + gae, "acquire_actions_job", lambda *_args, **_kwargs: passed + ) + argv = [ + "--repository", + REPOSITORY, + "--run-id", + str(RUN_ID), + "--job-id", + str(JOB_ID), + "--seen-source-digest", + "a" * 64, + ] + assert gae.main(argv) == 0 + assert json.loads(capsys.readouterr().out)["detector_state"] == "pass" + + def raise_validation(*_args, **_kwargs): + raise gae.EvidenceValidationError("source identity mismatch") + + monkeypatch.setattr(gae, "acquire_actions_job", raise_validation) + assert gae.main(argv) == 2 + assert json.loads(capsys.readouterr().err) == { + "error_code": "source_evidence_unavailable", + "message": "source identity mismatch", + } From e4e33801c44f09c2908d6b6bad9a69b82858a031 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:39:23 +0900 Subject: [PATCH 05/43] fix(actions): reject control characters before normalization --- appguardrail_core/github_actions_evidence.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index 67756747..2280f67f 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -464,18 +464,17 @@ def _positive_identifier(value: Any, label: str) -> int: def _required_text(value: Any, label: str, max_length: int) -> str: if not isinstance(value, str): raise EvidenceValidationError(f"{label} must be text") - normalized = value.strip() - if ( - not normalized - or len(normalized) > max_length - or any( - character in normalized - for character in ("\x00", "\r", "\n") - ) + if any( + character in value for character in ("\x00", "\r", "\n") ): raise EvidenceValidationError( f"{label} is empty, oversized, or contains controls" ) + normalized = value.strip() + if not normalized or len(normalized) > max_length: + raise EvidenceValidationError( + f"{label} is empty, oversized, or contains controls" + ) return normalized From ebdc4f568dd8d628301e6ac38f6a4af84d606855 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:40:12 +0900 Subject: [PATCH 06/43] feat(actions): register evidence verifier CLI --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 57423204..0fb80e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ Changelog = "https://github.com/ContextualWisdomLab/appguardrail/blob/develop/CH [project.scripts] appguardrail = "scanner.cli.appguardrail:main" +appguardrail-actions-evidence = "appguardrail_core.github_actions_evidence:main" appguardrail-openssf-evidence = "appguardrail_core.openssf_evidence:main" [tool.setuptools.dynamic] From ab28ab9526a7f4f0938c1e3480d8d66d494fa9e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:48:54 +0900 Subject: [PATCH 07/43] fix(actions): harden bounded source-evidence validation --- appguardrail_core/github_actions_evidence.py | 210 +++++++------------ 1 file changed, 72 insertions(+), 138 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index 2280f67f..e7c386f4 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -11,6 +11,7 @@ import argparse import hashlib import json +import math import os import re import sys @@ -30,25 +31,14 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 MAX_IDENTIFIER_DIGITS = 20 +MAX_AGE_HOURS = 24 * 365 * 10 _REPOSITORY_RE = re.compile( r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}\Z" ) _HEAD_SHA_RE = re.compile(r"[0-9a-fA-F]{40}\Z") _SOURCE_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z") -_ALLOWED_CONCLUSIONS = { - "success", - "failure", - "cancelled", - "timed_out", - "action_required", -} -_ALLOWED_STEP_CONCLUSIONS = _ALLOWED_CONCLUSIONS | { - "skipped", - "neutral", - "stale", - "startup_failure", - "", -} +_ALLOWED_CONCLUSIONS = {"success", "failure", "cancelled", "timed_out", "action_required"} +_ALLOWED_STEP_CONCLUSIONS = _ALLOWED_CONCLUSIONS | {"skipped", "neutral", "stale", "startup_failure", ""} class EvidenceValidationError(ValueError): @@ -131,9 +121,7 @@ def get_json(self, path: str) -> dict[str, Any]: r"/repos/[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}/actions/(?:runs|jobs)/[1-9][0-9]{0,19}", path, ): - raise ValueError( - "GitHub API path is not an allowed Actions resource path" - ) + raise ValueError("GitHub API path is not an allowed Actions resource path") request = urllib.request.Request( # noqa: S310 - exact fixed GitHub origin f"{self._api_root}{path}", method="GET", @@ -166,17 +154,13 @@ def get_json(self, path: str) -> dict[str, Any]: except (urllib.error.URLError, TimeoutError, OSError) as exc: raise EvidenceAcquisitionError("GitHub API network failure") from exc if len(body) > self._max_response_bytes: - raise EvidenceAcquisitionError( - "GitHub API response exceeded the response limit" - ) + raise EvidenceAcquisitionError("GitHub API response exceeded the response limit") try: payload = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise EvidenceAcquisitionError("GitHub API returned invalid JSON") from exc if not isinstance(payload, dict): - raise EvidenceAcquisitionError( - "GitHub API response must be a JSON object" - ) + raise EvidenceAcquisitionError("GitHub API response must be a JSON object") return payload @@ -195,8 +179,8 @@ def verify_actions_job( raise EvidenceValidationError("run payload must be a JSON object") if not isinstance(job, Mapping): raise EvidenceValidationError("job payload must be a JSON object") - if max_age <= timedelta(0): - raise EvidenceValidationError("max_age must be positive") + if not isinstance(max_age, timedelta) or max_age <= timedelta(0): + raise EvidenceValidationError("max_age must be a positive timedelta") run_id = _positive_identifier(run.get("id"), "run id") job_id = _positive_identifier(job.get("id"), "job id") @@ -214,15 +198,11 @@ def verify_actions_job( job_url = _required_text(job.get("html_url"), "job URL", 600) expected_job_url = f"{expected_run_url}/job/{job_id}" if job_url != expected_job_url: - raise EvidenceValidationError( - "job URL does not match repository, run, and job ids" - ) + raise EvidenceValidationError("job URL does not match repository, run, and job ids") head_sha = _required_text(run.get("head_sha"), "head SHA", 40).lower() if not _HEAD_SHA_RE.fullmatch(head_sha): - raise EvidenceValidationError( - "head SHA must contain exactly 40 hexadecimal characters" - ) + raise EvidenceValidationError("head SHA must contain exactly 40 hexadecimal characters") run_status = _required_text(run.get("status"), "run status", 40).lower() if run_status != "completed": @@ -231,12 +211,8 @@ def verify_actions_job( if job_status != "completed": raise EvidenceValidationError("job status must be completed") - run_conclusion = _required_text( - run.get("conclusion"), "run conclusion", 40 - ).lower() - job_conclusion = _required_text( - job.get("conclusion"), "job conclusion", 40 - ).lower() + run_conclusion = _required_text(run.get("conclusion"), "run conclusion", 40).lower() + job_conclusion = _required_text(job.get("conclusion"), "job conclusion", 40).lower() if run_conclusion not in _ALLOWED_CONCLUSIONS: raise EvidenceValidationError("run conclusion is unsupported") if job_conclusion not in _ALLOWED_CONCLUSIONS: @@ -246,18 +222,12 @@ def verify_actions_job( run.get("name") or job.get("workflow_name"), "workflow name", 500 ) job_name = _required_text(job.get("name"), "job name", 500) - if not is_security_name( - workflow_name, job.get("workflow_name"), job_name - ): + if not is_security_name(workflow_name, job.get("workflow_name"), job_name): raise EvidenceValidationError("job is not security-relevant") observed = _normalize_observed_at(observed_at) - run_updated_at = _parse_github_time( - run.get("updated_at"), "run updated_at" - ) - job_completed_at = _parse_github_time( - job.get("completed_at"), "job completed_at" - ) + run_updated_at = _parse_github_time(run.get("updated_at"), "run updated_at") + job_completed_at = _parse_github_time(job.get("completed_at"), "job completed_at") source_updated_at = max(run_updated_at, job_completed_at) if source_updated_at > observed: raise EvidenceValidationError("source evidence is future-dated") @@ -277,17 +247,13 @@ def verify_actions_job( "status": run_status, "conclusion": run_conclusion, "updated_at": _format_timestamp(run_updated_at), - "pull_request_numbers": _pull_request_numbers( - run.get("pull_requests") - ), + "pull_request_numbers": _pull_request_numbers(run.get("pull_requests")), }, "job": { "id": job_id, "run_id": job_run_id, "name": job_name, - "workflow_name": _optional_text( - job.get("workflow_name"), 500 - ), + "workflow_name": _optional_text(job.get("workflow_name"), 500), "html_url": job_url, "status": job_status, "conclusion": job_conclusion, @@ -302,9 +268,7 @@ def verify_actions_job( ensure_ascii=False, ).encode("utf-8") source_digest = hashlib.sha256(canonical).hexdigest() - normalized_seen = { - _normalize_digest(value) for value in seen_source_digests - } + normalized_seen = {_normalize_digest(value) for value in seen_source_digests} if source_digest in normalized_seen: raise EvidenceValidationError("duplicate source evidence digest") @@ -358,13 +322,9 @@ def acquire_actions_job( f"/repos/{normalized_repository}/actions/jobs/{normalized_job_id}" ) if _positive_identifier(run.get("id"), "run id") != normalized_run_id: - raise EvidenceValidationError( - "acquired run id does not match requested run id" - ) + raise EvidenceValidationError("acquired run id does not match requested run id") if _positive_identifier(job.get("id"), "job id") != normalized_job_id: - raise EvidenceValidationError( - "acquired job id does not match requested job id" - ) + raise EvidenceValidationError("acquired job id does not match requested job id") return verify_actions_job( normalized_repository, run, @@ -388,12 +348,21 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--seen-source-digest", action="append", default=[]) args = parser.parse_args(argv) - token = os.environ.get("APPGUARDRAIL_GITHUB_TOKEN", "").strip() - if not token: + if ( + not math.isfinite(args.max_age_hours) + or args.max_age_hours <= 0 + or args.max_age_hours > MAX_AGE_HOURS + ): _write_error( - "missing_token", "APPGUARDRAIL_GITHUB_TOKEN is required" + "invalid_max_age", + f"max_age_hours must be finite and within (0, {MAX_AGE_HOURS}]", ) return 2 + + token = os.environ.get("APPGUARDRAIL_GITHUB_TOKEN", "").strip() + if not token: + _write_error("missing_token", "APPGUARDRAIL_GITHUB_TOKEN is required") + return 2 try: client = GitHubApiClient(token) evidence = acquire_actions_job( @@ -404,22 +373,15 @@ def main(argv: list[str] | None = None) -> int: max_age=timedelta(hours=args.max_age_hours), seen_source_digests=args.seen_source_digest, ) - except ( - EvidenceAcquisitionError, - EvidenceValidationError, - ValueError, - ) as exc: + except (EvidenceAcquisitionError, EvidenceValidationError, ValueError) as exc: _write_error("source_evidence_unavailable", str(exc)) return 2 - print( - json.dumps( - evidence.to_dict(), sort_keys=True, ensure_ascii=False - ) - ) + print(json.dumps(evidence.to_dict(), sort_keys=True, ensure_ascii=False)) return 1 if evidence.detector_state == "failure" else 0 def _content_type(headers: Any) -> str: + """Return a response media type without trusting arbitrary header objects.""" if hasattr(headers, "get_content_type"): return str(headers.get_content_type()) if hasattr(headers, "get"): @@ -428,89 +390,75 @@ def _content_type(headers: Any) -> str: def _validate_repository(repository: Any) -> str: - if not isinstance(repository, str) or not _REPOSITORY_RE.fullmatch( - repository - ): - raise EvidenceValidationError( - "repository must be an exact owner/name identifier" - ) + """Validate and return one exact bounded GitHub owner/name identifier.""" + if not isinstance(repository, str) or not _REPOSITORY_RE.fullmatch(repository): + raise EvidenceValidationError("repository must be an exact owner/name identifier") owner, name = repository.split("/", 1) if owner in {".", ".."} or name in {".", ".."}: - raise EvidenceValidationError( - "repository contains an invalid path segment" - ) + raise EvidenceValidationError("repository contains an invalid path segment") return repository def _positive_identifier(value: Any, label: str) -> int: - if isinstance(value, bool): - raise EvidenceValidationError( - f"{label} must be a positive integer" - ) - try: - normalized = int(value) - except (TypeError, ValueError) as exc: - raise EvidenceValidationError( - f"{label} must be a positive integer" - ) from exc + """Validate and return a positive bounded integer identifier.""" + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise EvidenceValidationError(f"{label} must be a positive integer") + if isinstance(value, str) and (not value.isascii() or not value.isdigit()): + raise EvidenceValidationError(f"{label} must be a positive integer") + normalized = int(value) if normalized <= 0 or len(str(normalized)) > MAX_IDENTIFIER_DIGITS: raise EvidenceValidationError( - f"{label} must be a positive integer of at most " - f"{MAX_IDENTIFIER_DIGITS} digits" + f"{label} must be a positive integer of at most {MAX_IDENTIFIER_DIGITS} digits" ) return normalized def _required_text(value: Any, label: str, max_length: int) -> str: + """Validate one bounded non-empty string without line controls.""" if not isinstance(value, str): raise EvidenceValidationError(f"{label} must be text") - if any( - character in value for character in ("\x00", "\r", "\n") - ): - raise EvidenceValidationError( - f"{label} is empty, oversized, or contains controls" - ) + if any(character in value for character in ("\x00", "\r", "\n")): + raise EvidenceValidationError(f"{label} is empty, oversized, or contains controls") normalized = value.strip() if not normalized or len(normalized) > max_length: - raise EvidenceValidationError( - f"{label} is empty, oversized, or contains controls" - ) + raise EvidenceValidationError(f"{label} is empty, oversized, or contains controls") return normalized def _optional_text(value: Any, max_length: int) -> str: + """Return a validated optional string using the empty string for null.""" if value is None: return "" return _required_text(value, "optional text", max_length) def _parse_github_time(value: Any, label: str) -> datetime: + """Parse a timezone-aware GitHub timestamp and normalize it to UTC.""" text = _required_text(value, label, 64) try: parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) except ValueError as exc: - raise EvidenceValidationError( - f"{label} is not a valid ISO timestamp" - ) from exc + raise EvidenceValidationError(f"{label} is not a valid ISO timestamp") from exc if parsed.tzinfo is None: raise EvidenceValidationError(f"{label} must include a timezone") return parsed.astimezone(timezone.utc) def _normalize_observed_at(value: datetime | None) -> datetime: + """Return a timezone-aware UTC observation timestamp.""" observed = value or datetime.now(timezone.utc) if not isinstance(observed, datetime) or observed.tzinfo is None: - raise EvidenceValidationError( - "observed_at must be timezone-aware" - ) + raise EvidenceValidationError("observed_at must be timezone-aware") return observed.astimezone(timezone.utc) def _format_timestamp(value: datetime) -> str: + """Serialize a datetime as canonical UTC ISO 8601 text.""" return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") def _normalize_steps(value: Any) -> list[dict[str, Any]]: + """Validate, sort, and normalize bounded terminal GitHub job steps.""" if value is None: return [] if not isinstance(value, list): @@ -519,29 +467,21 @@ def _normalize_steps(value: Any) -> list[dict[str, Any]]: seen_numbers: set[int] = set() for raw_step in value: if not isinstance(raw_step, Mapping): - raise EvidenceValidationError( - "each job step must be a JSON object" - ) + raise EvidenceValidationError("each job step must be a JSON object") number = _positive_identifier(raw_step.get("number"), "step number") if number in seen_numbers: - raise EvidenceValidationError( - "job step numbers must be unique" - ) + raise EvidenceValidationError("job step numbers must be unique") seen_numbers.add(number) - status = _required_text( - raw_step.get("status"), "step status", 40 - ).lower() + status = _required_text(raw_step.get("status"), "step status", 40).lower() + if status != "completed": + raise EvidenceValidationError("step status must be completed") conclusion = str(raw_step.get("conclusion") or "").strip().lower() if conclusion not in _ALLOWED_STEP_CONCLUSIONS: - raise EvidenceValidationError( - "step conclusion is unsupported" - ) + raise EvidenceValidationError("step conclusion is unsupported") normalized.append( { "number": number, - "name": _required_text( - raw_step.get("name"), "step name", 500 - ), + "name": _required_text(raw_step.get("name"), "step name", 500), "status": status, "conclusion": conclusion, } @@ -550,6 +490,7 @@ def _normalize_steps(value: Any) -> list[dict[str, Any]]: def _pull_request_numbers(value: Any) -> list[int]: + """Return sorted unique pull-request numbers from a GitHub run payload.""" if value is None: return [] if not isinstance(value, list): @@ -557,31 +498,24 @@ def _pull_request_numbers(value: Any) -> list[int]: numbers: set[int] = set() for item in value: if not isinstance(item, Mapping): - raise EvidenceValidationError( - "each pull request must be a JSON object" - ) + raise EvidenceValidationError("each pull request must be a JSON object") number = item.get("number") if number is not None: - numbers.add( - _positive_identifier(number, "pull request number") - ) + numbers.add(_positive_identifier(number, "pull request number")) return sorted(numbers) def _normalize_digest(value: Any) -> str: + """Validate and normalize one lower-case SHA-256 evidence digest.""" if not isinstance(value, str): raise EvidenceValidationError("source digest must be text") normalized = value.strip().lower() if not _SOURCE_DIGEST_RE.fullmatch(normalized): - raise EvidenceValidationError( - "source digest must be 64 hexadecimal characters" - ) + raise EvidenceValidationError("source digest must be 64 hexadecimal characters") return normalized def _write_error(error_code: str, message: str) -> None: + """Write one stable machine-readable error object to standard error.""" payload = {"error_code": error_code, "message": message} - print( - json.dumps(payload, sort_keys=True, ensure_ascii=False), - file=sys.stderr, - ) + print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) From f6e604f5e9322d6ed5e9d6c33fcabbe6eb4d3e0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:49:20 +0900 Subject: [PATCH 08/43] test(actions): cover bounded age and identifier validation --- ...ithub_actions_evidence_validation_edges.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_github_actions_evidence_validation_edges.py diff --git a/tests/test_github_actions_evidence_validation_edges.py b/tests/test_github_actions_evidence_validation_edges.py new file mode 100644 index 00000000..1da274bc --- /dev/null +++ b/tests/test_github_actions_evidence_validation_edges.py @@ -0,0 +1,126 @@ +"""Validation edges for source-authoritative GitHub Actions evidence.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +import pytest + +import appguardrail_core.github_actions_evidence as evidence_module + +OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) +REPOSITORY = "ContextualWisdomLab/.github" +RUN_ID = 30_769_144_488 +JOB_ID = 91_553_355_284 +HEAD_SHA = "2a83043b0239ba827153c934f87e469dba4f96f0" + + +def run_payload(**overrides): + """Return a bounded security workflow run fixture.""" + payload = { + "id": RUN_ID, + "name": "OpenCode Review Dispatch current-head", + "html_url": f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "main", + "event": "repository_dispatch", + "status": "completed", + "conclusion": "failure", + "updated_at": "2026-08-02T23:44:00Z", + "pull_requests": [], + } + payload.update(overrides) + return payload + + +def job_payload(**overrides): + """Return a bounded security workflow job fixture.""" + payload = { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "opencode-review", + "workflow_name": "OpenCode Review Dispatch", + "html_url": ( + f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "failure", + "completed_at": "2026-08-02T23:43:30Z", + "steps": [ + { + "number": 19, + "name": "Publish review", + "status": "completed", + "conclusion": "failure", + } + ], + } + payload.update(overrides) + return payload + + +def test_rejects_non_timedelta_age_and_unfinished_step(): + """Reject ambiguous freshness units and non-terminal step evidence.""" + with pytest.raises( + evidence_module.EvidenceValidationError, match="positive timedelta" + ): + evidence_module.verify_actions_job( + REPOSITORY, + run_payload(), + job_payload(), + observed_at=OBSERVED_AT, + max_age=1, + ) + + with pytest.raises( + evidence_module.EvidenceValidationError, match="step status" + ): + evidence_module.verify_actions_job( + REPOSITORY, + run_payload(), + job_payload( + steps=[ + { + "number": 1, + "name": "Still running", + "status": "in_progress", + "conclusion": None, + } + ] + ), + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + +def test_identifier_rejects_float_and_non_ascii_digits(): + """Prevent lossy integer coercion and Unicode digit ambiguity.""" + for value in (1.0, "2"): + with pytest.raises( + evidence_module.EvidenceValidationError, match="positive integer" + ): + evidence_module._positive_identifier(value, "id") + + +def test_cli_rejects_non_finite_or_excessive_age_before_auth(monkeypatch, capsys): + """Fail closed on invalid freshness windows without requiring a credential.""" + monkeypatch.delenv("APPGUARDRAIL_GITHUB_TOKEN", raising=False) + base = [ + "--repository", + REPOSITORY, + "--run-id", + str(RUN_ID), + "--job-id", + str(JOB_ID), + ] + for value in ( + "nan", + "inf", + "0", + str(evidence_module.MAX_AGE_HOURS + 1), + ): + assert evidence_module.main( + [*base, "--max-age-hours", value] + ) == 2 + assert json.loads(capsys.readouterr().err)["error_code"] == "invalid_max_age" From b6a6ac9b51a5646cf45ec2f82a21f80139f6a415 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:50:12 +0900 Subject: [PATCH 09/43] test(actions): enforce complete source-evidence docstrings --- ...test_github_actions_evidence_docstrings.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_github_actions_evidence_docstrings.py diff --git a/tests/test_github_actions_evidence_docstrings.py b/tests/test_github_actions_evidence_docstrings.py new file mode 100644 index 00000000..7e81af77 --- /dev/null +++ b/tests/test_github_actions_evidence_docstrings.py @@ -0,0 +1,32 @@ +"""Docstring contract for the GitHub Actions source-evidence module.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "appguardrail_core" + / "github_actions_evidence.py" +) + + +def test_every_module_class_function_and_method_has_a_docstring(): + """Require complete explanatory docstrings for every shipped symbol.""" + module = ast.parse(MODULE_PATH.read_text(encoding="utf-8")) + missing: list[str] = [] + + if not ast.get_docstring(module, clean=False): + missing.append("") + + for node in ast.walk(module): + if not isinstance( + node, + (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef), + ): + continue + if not ast.get_docstring(node, clean=False): + missing.append(f"{node.name}@{node.lineno}") + + assert missing == [], "Missing shipped-symbol docstrings: " + ", ".join(missing) From 9582ee81877f7e0f306de04f7d3d04be10f08272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:50:31 +0900 Subject: [PATCH 10/43] ci(actions): require exact evidence coverage --- .../github-actions-evidence-coverage.yml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/github-actions-evidence-coverage.yml diff --git a/.github/workflows/github-actions-evidence-coverage.yml b/.github/workflows/github-actions-evidence-coverage.yml new file mode 100644 index 00000000..e37df57c --- /dev/null +++ b/.github/workflows/github-actions-evidence-coverage.yml @@ -0,0 +1,86 @@ +name: GitHub Actions Evidence Coverage + +on: + push: + branches: [develop, main] + paths: + - appguardrail_core/github_actions_evidence.py + - tests/test_github_actions_evidence.py + - tests/test_github_actions_evidence_edges.py + - tests/test_github_actions_evidence_validation_edges.py + - tests/test_github_actions_evidence_docstrings.py + - .github/workflows/github-actions-evidence-coverage.yml + - requirements-test.txt + pull_request: + branches: [develop, main] + paths: + - appguardrail_core/github_actions_evidence.py + - tests/test_github_actions_evidence.py + - tests/test_github_actions_evidence_edges.py + - tests/test_github_actions_evidence_validation_edges.py + - tests/test_github_actions_evidence_docstrings.py + - .github/workflows/github-actions-evidence-coverage.yml + - requirements-test.txt + +permissions: + contents: read + +concurrency: + group: github-actions-evidence-coverage-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-coverage: + name: Source evidence statement, branch, and docstring coverage + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Checkout exact AppGuardrail source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install hash-locked test dependencies + run: >- + python -m pip install + --disable-pip-version-check + --no-cache-dir + --require-hashes + -r requirements-test.txt + + - name: Checkout verified Coverage.py source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: coveragepy/coveragepy + ref: 4c0e7ff425ecbb33e2b994b41118a71eb4e39021 # 7.15.4 + path: .tools/coveragepy + persist-credentials: false + + - name: Run exact statement and branch coverage + env: + PYTHONPATH: ${{ github.workspace }}/.tools/coveragepy:${{ github.workspace }} + run: | + set -euo pipefail + python -m coverage erase + python -m coverage run --branch -m pytest -q \ + tests/test_github_actions_evidence.py \ + tests/test_github_actions_evidence_edges.py \ + tests/test_github_actions_evidence_validation_edges.py \ + tests/test_github_actions_evidence_docstrings.py + python -m coverage report \ + --include='appguardrail_core/github_actions_evidence.py' \ + --precision=2 \ + --show-missing \ + --fail-under=100 + + - name: Verify complete shipped-symbol docstrings independently + run: >- + python -m pytest -q + tests/test_github_actions_evidence_docstrings.py From cd09d828c5e32e9d9a613b9adae31f4370ffe775 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:52:38 +0900 Subject: [PATCH 11/43] docs(actions): integrate source-authoritative evidence architecture --- ARCHITECTURE.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bb3a2105..b02e78bc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # AppGuardrail Architecture **Status:** Accepted as-built/target architecture with maturity labels -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-08-14 ## Architectural goal @@ -61,7 +61,32 @@ flowchart LR DET --> RES ``` -A registry maps requirement identity to executable detector family; it cannot assert the detector answer. PR #911 is active-PR implementation of this contract. +A registry maps requirement identity to executable detector family; it cannot assert the detector answer. Historical PR #911 remains a non-authoritative inventory prototype. Issue #938 and PR #939 replace its broad draft with one bounded, source-authoritative detector vertical slice. + +## Source-authoritative GitHub Actions evidence + +```mermaid +flowchart LR + INTENT[Exact repository, run ID, job ID] + API[GitHub REST API 2022-11-28] + RUN[Authoritative workflow run object] + JOB[Authoritative workflow job object] + VALID[Identity, terminal-state, freshness validation] + HASH[Canonical SHA-256 source identity] + DECISION[Verified pass/failure evidence] + + INTENT --> API + API --> RUN + API --> JOB + RUN --> VALID + JOB --> VALID + VALID --> HASH + HASH --> DECISION +``` + +`appguardrail_core.github_actions_evidence` is the first source-authoritative evidence acquirer. It does not accept a caller Boolean as detector truth. The production CLI fetches the exact GitHub run and job over a fixed HTTPS origin, rejects redirects and identity mismatch, caps response size, requires completed security-relevant states, rejects future/stale/duplicate evidence, and emits a bounded canonical digest. Raw logs, bearer credentials, and arbitrary cross-repository content are outside this evidence object. + +This slice is an acquisition-and-verification contract, not a claim that every historical issue family is directly detected. Each future detector family must supply its own authoritative source, independent oracle, mutation evidence, and production-path proof. ## SSRF architecture @@ -120,4 +145,4 @@ These modes share normalized contracts but can operate separately. ## Change control -A new detector engine, persistent schema, tenant authority, arbitrary autofix class, outbound target policy, issue-audit semantics, or automation credential boundary requires ADR and synchronized technical/security/test documentation. \ No newline at end of file +A new detector engine, evidence acquirer, persistent schema, tenant authority, arbitrary autofix class, outbound target policy, issue-audit semantics, or automation credential boundary requires ADR and synchronized technical/security/test documentation. \ No newline at end of file From 55133aa1453a44205635f5a27932669fbdb12788 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:53:16 +0900 Subject: [PATCH 12/43] docs(actions): decide source-authoritative evidence acquisition --- ...7-source-authoritative-actions-evidence.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/adr/0007-source-authoritative-actions-evidence.md diff --git a/docs/adr/0007-source-authoritative-actions-evidence.md b/docs/adr/0007-source-authoritative-actions-evidence.md new file mode 100644 index 00000000..1c34f9a5 --- /dev/null +++ b/docs/adr/0007-source-authoritative-actions-evidence.md @@ -0,0 +1,86 @@ +# ADR-0007: Source-authoritative GitHub Actions evidence + +- **Status:** Proposed +- **Date:** 2026-08-14 +- **Decision owners:** AppGuardrail maintainers +- **Related:** Issue #938, PR #939 + +## Context + +AppGuardrail historically aggregated workflow failures and issue records, but a caller-supplied Boolean, log label, or registry entry is not independent proof that a security control failed. Enterprise buyers need evidence that binds a decision to the source system that produced it, while still allowing the underlying source object to be independently replayed and audited. + +GitHub's REST API exposes exact workflow-run and workflow-job resources and recommends the versioned `application/vnd.github+json` API contract with `X-GitHub-Api-Version: 2022-11-28`. Fine-grained credentials need only repository Actions read access for private resources. NIST SP 800-53 Rev. 5 treats audit, accountability, assessment, access control, and supply-chain assurance as related control families; NIST SP 800-218 requires secure-development evidence to be integrated into the software lifecycle. SLSA v1.2 defines provenance as verifiable information describing where, when, and how an artifact or source revision was produced. + +## Decision + +AppGuardrail SHALL treat an authoritative GitHub REST run/job pair—not a caller assertion—as the source input for GitHub Actions security-outcome evidence. + +The production acquirer SHALL: + +1. accept an exact `owner/repository`, `run_id`, and `job_id` intent; +2. use only `https://api.github.com` with API version `2022-11-28`; +3. reject redirects, non-JSON responses, responses larger than 2 MiB, and non-object JSON; +4. bind returned repository URLs, run ID, job ID, run ID on the job, and 40-hex head SHA; +5. require completed security-relevant run/job states and completed step states; +6. reject future, stale, malformed, unsupported, duplicate, and non-security evidence; +7. project only bounded metadata and compute a deterministic SHA-256 source identity; +8. exclude bearer credentials, raw logs, annotations, and unrestricted cross-repository content from the evidence envelope; +9. expose stable exit codes: verified pass `0`, verified failure `1`, unavailable/invalid evidence `2`; +10. preserve authorized identifiers and evidence metadata rather than indiscriminately masking them, while relying on least-privilege token scope, purpose binding, encryption, tenant isolation, retention, immutable audit, and field-level authorization at storage and control-plane boundaries. + +## Alternatives rejected + +### Trust the caller's pass/fail value + +Rejected because it cannot distinguish an actual detector from a wrapper around a Boolean and cannot prove source identity. + +### Parse raw workflow logs as the primary contract + +Rejected for this first slice because logs are larger, less stable, more likely to contain secrets or personal data, and easier to forge through line-oriented output. Logs may become a separate acquirer only with their own size, redaction, provenance, and oracle contract. + +### Follow redirects for convenience + +Rejected because authenticated cross-origin redirects create a confused-deputy and credential-disclosure boundary. The source origin is fixed and mismatch fails closed. + +### Treat issue #911's generated registry as an oracle + +Rejected because registry content and fixtures derived from the same source do not constitute independent efficacy evidence. Minimal reusable inventory data may be reviewed separately, but each detector family needs a source-authoritative probe and independent oracle. + +## Consequences + +### Positive + +- buyers can reproduce exactly which GitHub source object produced a decision; +- evidence has a stable duplicate-prevention identity; +- token values and raw logs stay outside the portable result; +- failures, successes, stale evidence, and unavailable evidence are distinguishable; +- the contract can be reused by organization collectors without inheriting caller-assertion semantics. + +### Negative + +- callers must provide exact run and job identifiers; +- historical replay requires an explicitly larger, still bounded freshness window; +- GitHub REST availability becomes an operational dependency for live acquisition; +- the slice does not by itself prove all AppGuardrail issue families are directly detected. + +## Verification + +Merge requires: + +- true-positive, true-negative, cancelled, malformed, stale, future, duplicate, wrong-origin, wrong-identity, oversized, invalid JSON, unfinished, and token-disclosure tests; +- production statement and branch coverage of 100%; +- complete module/class/function/method docstrings; +- exact-head repository Tests, SAST, CodeQL, dependency/supply-chain checks, `appguardrail-scan`, and independent approval; +- documentation and changelog traceability to issue #938. + +## References + +GitHub. (2026). *REST API endpoints for workflow jobs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-jobs + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5, Release 5.2.0). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Supply-chain Levels for Software Artifacts. (2026). *SLSA specification version 1.2*. Linux Foundation. https://slsa.dev/spec/v1.2/ + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 From 7c4d6bad6d253315b1e755ad2209610a4d3c5f5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:53:43 +0900 Subject: [PATCH 13/43] docs(actions): index source-evidence ADR --- docs/adr/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 984420a9..bb6a38dc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,9 +10,10 @@ | [0004](0004-tenant-network-boundaries.md) | Tenant authority and outbound destinations are explicit security boundaries | Accepted | | [0005](0005-remediation-authority.md) | Deterministic autofix is limited to proven semantics-preserving transforms | Accepted | | [0006](0006-automation-authority.md) | Autonomous development remains separate from independent merge/release authority | Accepted | +| [0007](0007-source-authoritative-actions-evidence.md) | GitHub Actions decisions bind to independently acquired run/job source evidence | Proposed | ## ADR triggers -Create or update an ADR when changing detector truth semantics, issue obligation coverage, built-in versus external execution, autofix authority, persistent tenant schema/authz, outbound webhook/DAST egress, normalized finding/SARIF identity, or autonomous/release credentials. +Create or update an ADR when changing detector truth semantics, evidence acquisition, issue obligation coverage, built-in versus external execution, autofix authority, persistent tenant schema/authz, outbound webhook/DAST egress, normalized finding/SARIF identity, or autonomous/release credentials. Implementation PRs must reconcile PRD/TRD/Architecture/UML/ERD/Threat/Test/Operability/Traceability and CHANGELOG where those contracts move. \ No newline at end of file From 686697d9993f98f9b758ec6524856b5040b544be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:54:46 +0900 Subject: [PATCH 14/43] docs(actions): add buyer and operator source-evidence guide --- docs/github-actions-source-evidence.md | 209 +++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/github-actions-source-evidence.md diff --git a/docs/github-actions-source-evidence.md b/docs/github-actions-source-evidence.md new file mode 100644 index 00000000..664adb46 --- /dev/null +++ b/docs/github-actions-source-evidence.md @@ -0,0 +1,209 @@ +# GitHub Actions Source-Authoritative Evidence + +**Maturity:** bounded production vertical slice +**Implementation:** `appguardrail_core.github_actions_evidence` +**CLI:** `appguardrail-actions-evidence` +**Related:** issue #938, PR #939, ADR-0007 + +## What the buyer can verify + +Use this interface when the security decision must prove which GitHub Actions run and job produced it. The verifier contacts GitHub directly and returns a source-bound result; it does not accept a caller's `passed=true` or `failed=true` field as detector truth. + +```mermaid +flowchart LR + A[Repository + run ID + job ID] + B[Pinned GitHub REST acquisition] + C[Run/job identity validation] + D[Terminal security-state validation] + E[Freshness and duplicate validation] + F[Canonical SHA-256 evidence] + G[Verified pass/failure JSON] + + A --> B --> C --> D --> E --> F --> G +``` + +Each successful evidence object identifies: + +- the exact repository; +- workflow and job names; +- run and job IDs; +- head commit SHA; +- branch and triggering event; +- exact GitHub run/job URLs; +- terminal job conclusion; +- failed step numbers; +- source update and observation times; +- a deterministic source digest; +- the probe and acquirer contract versions. + +## Run it + +Set a least-privilege token with **Actions: read** for the target private repository. Public resources can be read without that permission, but the CLI intentionally requires an explicit token so production invocation does not silently change its authentication model. + +```bash +export APPGUARDRAIL_GITHUB_TOKEN='github_pat_...' + +appguardrail-actions-evidence \ + --repository ContextualWisdomLab/.github \ + --run-id 30769144488 \ + --job-id 91553355284 +``` + +Interpret the exit code before taking the next action: + +| Exit | Meaning | Operator action | +|---:|---|---| +| `0` | Source-verified security pass | Persist the JSON evidence and continue the protected workflow. | +| `1` | Source-verified security failure | Persist the JSON evidence, open or update the incident/remediation record, and block the affected gate. | +| `2` | Evidence unavailable or invalid | Do not infer pass or failure; fix source identity, permissions, freshness, or availability and retry. | + +A historical replay must use an explicitly expanded but still bounded freshness window: + +```bash +appguardrail-actions-evidence \ + --repository ContextualWisdomLab/.github \ + --run-id 30769144488 \ + --job-id 91553355284 \ + --max-age-hours 720 +``` + +Reject a source object already processed by the caller's immutable digest ledger: + +```bash +appguardrail-actions-evidence \ + --repository ContextualWisdomLab/.github \ + --run-id 30769144488 \ + --job-id 91553355284 \ + --seen-source-digest "$ALREADY_RECORDED_SHA256" +``` + +## Evidence schema example + +```json +{ + "schema_version": "1.0", + "probe_ref": "github_actions_job_v1", + "acquirer_ref": "github_rest_api_v2022_11_28", + "repository": "ContextualWisdomLab/.github", + "workflow_name": "OpenCode Review Dispatch current-head", + "job_name": "opencode-review", + "run_id": 30769144488, + "job_id": 91553355284, + "head_sha": "2a83043b0239ba827153c934f87e469dba4f96f0", + "branch_name": "main", + "event_name": "repository_dispatch", + "run_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/30769144488", + "job_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/30769144488/job/91553355284", + "job_conclusion": "failure", + "detector_state": "failure", + "failed_step_numbers": [19], + "source_updated_at": "2026-08-02T23:44:00Z", + "observed_at": "2026-08-03T00:00:00Z", + "source_digest_sha256": "<64 lowercase hexadecimal characters>" +} +``` + +The digest covers a canonical projection of the authoritative run and job objects. It deliberately excludes `observed_at`, bearer credentials, raw logs, annotations, runner names, and unrestricted response fields. Re-observing unchanged source data therefore yields the same source digest. + +## Security and privacy boundary + +### Controls applied by the acquirer + +- exact HTTPS API origin: `https://api.github.com`; +- no redirect following; +- exact versioned API header: `2022-11-28`; +- allowed Actions resource paths only; +- 30-second request timeout; +- 2 MiB response maximum; +- JSON media type and JSON object required; +- exact repository/run/job URL and identifier binding; +- 40-hex head SHA binding; +- completed run, job, and step states only; +- security-relevant workflow or job name required; +- future, stale, malformed, unsupported, and duplicate evidence rejected; +- sanitized acquisition errors that do not repeat the token or response body. + +### PII without indiscriminate masking + +AppGuardrail does not treat blanket masking as the default control because removing actor, repository, or audit identity can make authorized incident response unusable. Apply these controls at the persistence and control-plane layer instead: + +1. isolate evidence by tenant and authorized repository scope; +2. use least-privilege Actions-read credentials and short credential lifetimes; +3. encrypt evidence in transit and at rest with tenant-aware key policy; +4. bind collection to a documented security or audit purpose; +5. authorize fields and exports by role; +6. retain only bounded run/job metadata required for the decision; +7. log immutable access, export, deletion, and replay events; +8. apply legal hold and retention policy without changing the evidence digest; +9. avoid raw logs unless a separate, reviewed log-evidence acquirer is used. + +## Failure diagnosis + +| Error pattern | Likely cause | Next action | +|---|---|---| +| `missing_token` | CLI credential absent | Provide an Actions-read token through the environment, not the command line. | +| `HTTP 403` | token lacks access or policy denies access | Confirm repository scope and Actions-read permission. | +| run/job URL mismatch | wrong repository or identifier | Copy the exact run and job IDs from the same GitHub run. | +| non-security evidence | selected build is not classified as a security workflow/job | Use the actual security job or extend the reviewed security-name taxonomy separately. | +| future-dated evidence | source clock exceeds the trusted observation clock | Correct clock synchronization or supply a verified observation time through the library interface. | +| stale evidence | freshness window too narrow for the intended replay | Increase `--max-age-hours` only for the approved replay period. | +| duplicate digest | source object was already ingested | Reuse the prior evidence record instead of creating a second decision. | +| response limit or invalid JSON | upstream proxy/error body or unexpected API behavior | Diagnose network controls; do not treat the result as pass. | + +## Test and release evidence + +The exact-head gate runs: + +```bash +python -m pytest -q \ + tests/test_github_actions_evidence.py \ + tests/test_github_actions_evidence_edges.py \ + tests/test_github_actions_evidence_validation_edges.py \ + tests/test_github_actions_evidence_docstrings.py +``` + +Coverage.py 7.15.4 is checked out at verified source commit `4c0e7ff425ecbb33e2b994b41118a71eb4e39021` and runs without an unpinned PyPI resolution: + +```bash +PYTHONPATH="$PWD/.tools/coveragepy:$PWD" \ + python -m coverage run --branch -m pytest -q + +PYTHONPATH="$PWD/.tools/coveragepy:$PWD" \ + python -m coverage report \ + --include='appguardrail_core/github_actions_evidence.py' \ + --precision=2 \ + --show-missing \ + --fail-under=100 +``` + +The merge gate requires 100% statements, 100% branches, complete shipped-symbol docstrings, the full repository test matrix, `appguardrail-scan`, SAST, CodeQL, dependency/supply-chain checks, and an independent current-head approval. + +## Requirement traceability + +| Issue #938 requirement | Production symbol | Verification | +|---|---|---| +| authoritative acquisition | `GitHubApiClient.get_json`, `acquire_actions_job` | client and exact-endpoint tests | +| explicit probe/acquirer | `PROBE_REF`, `ACQUIRER_REF` | failure fixture assertion | +| source identity | `verify_actions_job` | run/job URL, ID, SHA mismatch tests | +| fail closed | validation and acquisition error types | malformed, stale, future, duplicate, unfinished, wrong-origin tests | +| independent historical shape | #815-shaped run/job fixture | source projection and expected outcome assertions | +| production CLI | `main`, console script | pass/failure/error exit-code tests | +| token protection | `GitHubApiClient` bounded output/errors | token non-disclosure tests | +| 100% coverage/docstrings | dedicated exact-head workflow | coverage and AST docstring gates | +| architecture and operations | ADR-0007, this guide, `ARCHITECTURE.md` | documentation review | + +## Scope boundary + +This slice proves one reusable GitHub Actions source-evidence contract. It does not claim direct detector efficacy for every historical issue or scanner family. Add the next detector only after identifying its authoritative source, creating an independent oracle, proving true/false/adversarial cases, and integrating it through a real production entrypoint. + +## References + +GitHub. (2026). *REST API endpoints for workflow jobs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-jobs + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5, Release 5.2.0). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Supply-chain Levels for Software Artifacts. (2026). *SLSA specification version 1.2*. Linux Foundation. https://slsa.dev/spec/v1.2/ + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 From 492e223cc5ecd3198607fe1f68983dad06175f5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:55:03 +0900 Subject: [PATCH 15/43] docs(actions): record source-authoritative evidence slice --- .../938-source-authoritative-actions-evidence.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CHANGELOG.d/938-source-authoritative-actions-evidence.md diff --git a/CHANGELOG.d/938-source-authoritative-actions-evidence.md b/CHANGELOG.d/938-source-authoritative-actions-evidence.md new file mode 100644 index 00000000..02c88017 --- /dev/null +++ b/CHANGELOG.d/938-source-authoritative-actions-evidence.md @@ -0,0 +1,12 @@ +## Added + +- Added `appguardrail-actions-evidence`, a source-authoritative GitHub Actions verifier that acquires exact workflow-run and workflow-job objects from the pinned GitHub REST API instead of accepting a caller-provided pass/fail assertion. +- Added immutable `ActionsJobEvidence` output with repository, run, job, commit, terminal outcome, failed-step, freshness, probe/acquirer, and SHA-256 source identity. +- Added fail-closed validation for wrong-origin URLs, identifier mismatch, malformed SHA/timestamps, incomplete states, non-security jobs, future or stale evidence, duplicates, oversized/non-JSON responses, and unavailable source data. +- Added a dedicated exact-head statement/branch coverage and complete-docstring workflow, with Coverage.py 7.15.4 pinned to verified source commit `4c0e7ff425ecbb33e2b994b41118a71eb4e39021`. +- Added ADR-0007, architecture integration, buyer/operator runbook, threat controls, PII-preserving alternatives, requirement traceability, and APA 7th references. + +## Security + +- GitHub API acquisition is fixed to `https://api.github.com`, rejects redirects, caps responses at 2 MiB, emits sanitized errors, and excludes bearer tokens and raw logs from portable evidence. +- Invalid or unavailable evidence returns an explicit inconclusive exit path instead of being interpreted as success. \ No newline at end of file From ca553c61e6df9574f1994df28f456104134c3591 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:58:22 +0900 Subject: [PATCH 16/43] test(actions): kill source-evidence predicate mutations --- .../test_github_actions_evidence_mutations.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/test_github_actions_evidence_mutations.py diff --git a/tests/test_github_actions_evidence_mutations.py b/tests/test_github_actions_evidence_mutations.py new file mode 100644 index 00000000..ff93498b --- /dev/null +++ b/tests/test_github_actions_evidence_mutations.py @@ -0,0 +1,199 @@ +"""Mutation evidence for source-authoritative GitHub Actions predicates.""" + +from __future__ import annotations + +import sys +import types +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +import appguardrail_core.github_actions_evidence as evidence_module + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "appguardrail_core" + / "github_actions_evidence.py" +) +OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) +REPOSITORY = "ContextualWisdomLab/.github" +RUN_ID = 30_769_144_488 +JOB_ID = 91_553_355_284 +HEAD_SHA = "2a83043b0239ba827153c934f87e469dba4f96f0" + + +def run_payload(**overrides): + """Return an independently specified security workflow run fixture.""" + payload = { + "id": RUN_ID, + "name": "OpenCode Review Dispatch current-head", + "html_url": f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "main", + "event": "repository_dispatch", + "status": "completed", + "conclusion": "failure", + "updated_at": "2026-08-02T23:44:00Z", + "pull_requests": [], + } + payload.update(overrides) + return payload + + +def job_payload(**overrides): + """Return an independently specified security workflow job fixture.""" + payload = { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "opencode-review", + "workflow_name": "OpenCode Review Dispatch", + "html_url": ( + f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "failure", + "completed_at": "2026-08-02T23:43:30Z", + "steps": [], + } + payload.update(overrides) + return payload + + +def load_mutant(old: str, new: str): + """Load exactly one textual production-predicate mutation in isolation.""" + source = MODULE_PATH.read_text(encoding="utf-8") + assert source.count(old) == 1, f"Mutation target drifted: {old}" + mutated_source = source.replace(old, new) + module_name = "appguardrail_core._github_actions_evidence_mutant" + mutant = types.ModuleType(module_name) + mutant.__file__ = str(MODULE_PATH) + mutant.__package__ = "appguardrail_core" + sys.modules[module_name] = mutant + try: + exec( + compile(mutated_source, str(MODULE_PATH), "exec"), + mutant.__dict__, + ) + finally: + sys.modules.pop(module_name, None) + return mutant + + +def verify(module, *, run=None, job=None): + """Invoke one module's verifier with the fixed independent oracle time.""" + return module.verify_actions_job( + REPOSITORY, + run_payload() if run is None else run, + job_payload() if job is None else job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + +def source_identity_oracle(module): + """Require valid identity to pass and mismatched job/run identity to fail.""" + assert verify(module).detector_state == "failure" + try: + verify(module, job=job_payload(run_id=RUN_ID + 1)) + except module.EvidenceValidationError: + return + raise AssertionError("mismatched job/run identity was accepted") + + +def security_obligation_oracle(module): + """Require a non-security workflow/job pair to be rejected.""" + try: + verify( + module, + run=run_payload(name="Documentation"), + job=job_payload(name="build", workflow_name="Documentation"), + ) + except module.EvidenceValidationError: + return + raise AssertionError("non-security evidence was accepted") + + +def outcome_mapping_oracle(module): + """Require a failed source conclusion to map to detector failure.""" + assert verify(module).detector_state == "failure" + + +def acquisition_identity_oracle(module): + """Require exact requested and acquired run/job identifiers.""" + + class Client: + """Return one authoritative run and one authoritative job in order.""" + + def __init__(self, run, job): + """Store the two deterministic source responses.""" + self.items = [run, job] + + def get_json(self, path): + """Return the next exact source object.""" + return self.items.pop(0) + + assert module.acquire_actions_job( + Client(run_payload(), job_payload()), + REPOSITORY, + RUN_ID, + JOB_ID, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ).detector_state == "failure" + try: + module.acquire_actions_job( + Client(run_payload(id=RUN_ID + 1), job_payload()), + REPOSITORY, + RUN_ID, + JOB_ID, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + except module.EvidenceValidationError: + return + raise AssertionError("acquired run identity mismatch was accepted") + + +def test_kills_source_identity_inversion(): + """Prove the independent identity oracle kills an inverted comparison.""" + source_identity_oracle(evidence_module) + mutant = load_mutant( + "if job_run_id != run_id:", + "if job_run_id == run_id:", + ) + with pytest.raises((AssertionError, mutant.EvidenceValidationError)): + source_identity_oracle(mutant) + + +def test_kills_security_obligation_bypass(): + """Prove the security obligation oracle kills a relevance bypass.""" + security_obligation_oracle(evidence_module) + mutant = load_mutant( + 'if not is_security_name(workflow_name, job.get("workflow_name"), job_name):', + "if False:", + ) + with pytest.raises(AssertionError, match="non-security"): + security_obligation_oracle(mutant) + + +def test_kills_outcome_mapping_inversion(): + """Prove the outcome oracle kills failure/pass inversion.""" + outcome_mapping_oracle(evidence_module) + mutant = load_mutant( + 'detector_state = "failure" if is_failure(job_conclusion) else "pass"', + 'detector_state = "pass" if is_failure(job_conclusion) else "failure"', + ) + with pytest.raises(AssertionError): + outcome_mapping_oracle(mutant) + + +def test_kills_acquired_identifier_check_inversion(): + """Prove the acquisition oracle kills requested/returned ID inversion.""" + acquisition_identity_oracle(evidence_module) + mutant = load_mutant( + 'if _positive_identifier(run.get("id"), "run id") != normalized_run_id:', + 'if _positive_identifier(run.get("id"), "run id") == normalized_run_id:', + ) + with pytest.raises((AssertionError, mutant.EvidenceValidationError)): + acquisition_identity_oracle(mutant) From 895f63eadc2147c2d8c7520db9e30bd70f2c6722 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:58:59 +0900 Subject: [PATCH 17/43] ci(actions): include source-evidence mutation proof --- .../workflows/github-actions-evidence-coverage.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-evidence-coverage.yml b/.github/workflows/github-actions-evidence-coverage.yml index e37df57c..725033e9 100644 --- a/.github/workflows/github-actions-evidence-coverage.yml +++ b/.github/workflows/github-actions-evidence-coverage.yml @@ -9,6 +9,7 @@ on: - tests/test_github_actions_evidence_edges.py - tests/test_github_actions_evidence_validation_edges.py - tests/test_github_actions_evidence_docstrings.py + - tests/test_github_actions_evidence_mutations.py - .github/workflows/github-actions-evidence-coverage.yml - requirements-test.txt pull_request: @@ -19,6 +20,7 @@ on: - tests/test_github_actions_evidence_edges.py - tests/test_github_actions_evidence_validation_edges.py - tests/test_github_actions_evidence_docstrings.py + - tests/test_github_actions_evidence_mutations.py - .github/workflows/github-actions-evidence-coverage.yml - requirements-test.txt @@ -31,7 +33,7 @@ concurrency: jobs: exact-coverage: - name: Source evidence statement, branch, and docstring coverage + name: Source evidence statement, branch, docstring, and mutation coverage runs-on: ubuntu-latest timeout-minutes: 20 env: @@ -73,7 +75,8 @@ jobs: tests/test_github_actions_evidence.py \ tests/test_github_actions_evidence_edges.py \ tests/test_github_actions_evidence_validation_edges.py \ - tests/test_github_actions_evidence_docstrings.py + tests/test_github_actions_evidence_docstrings.py \ + tests/test_github_actions_evidence_mutations.py python -m coverage report \ --include='appguardrail_core/github_actions_evidence.py' \ --precision=2 \ @@ -84,3 +87,8 @@ jobs: run: >- python -m pytest -q tests/test_github_actions_evidence_docstrings.py + + - name: Verify production-predicate mutation resistance independently + run: >- + python -m pytest -q + tests/test_github_actions_evidence_mutations.py From eb6f6c78ab371ff2ce5ca9544da0240b70f4ad78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:59:38 +0900 Subject: [PATCH 18/43] ci(actions): isolate mutation execution from coverage measurement --- .github/workflows/github-actions-evidence-coverage.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-evidence-coverage.yml b/.github/workflows/github-actions-evidence-coverage.yml index 725033e9..86766bb4 100644 --- a/.github/workflows/github-actions-evidence-coverage.yml +++ b/.github/workflows/github-actions-evidence-coverage.yml @@ -75,8 +75,7 @@ jobs: tests/test_github_actions_evidence.py \ tests/test_github_actions_evidence_edges.py \ tests/test_github_actions_evidence_validation_edges.py \ - tests/test_github_actions_evidence_docstrings.py \ - tests/test_github_actions_evidence_mutations.py + tests/test_github_actions_evidence_docstrings.py python -m coverage report \ --include='appguardrail_core/github_actions_evidence.py' \ --precision=2 \ From 8ca51eaa28f97e76f0e0d46a70aa933cc554bdf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:01:02 +0900 Subject: [PATCH 19/43] docs(actions): add source-evidence test contract --- docs/TEST_STRATEGY.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 2dcf63f5..c32064c2 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -1,7 +1,7 @@ # AppGuardrail Test and Detector Validation Strategy **Status:** Accepted quality baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-08-14 ## Mandatory gates @@ -33,7 +33,24 @@ Fixtures must contain input evidence, not an expected-answer field consumed by t The issue/claim inventory must be independently generated or authenticated and then mapped to obligations. Tests verify every retained detectable claim maps to a detector family and that every obligation executes actual detector code. Duplicate historical incidents may share a detector family but cannot be silently dropped. -PR #911 is active-PR evidence; until merged, no-exclusions issue coverage is not a protected-branch claim. +Historical PR #911 is preserved only as an inventory prototype; it is not protected-branch evidence. Issue #938 and PR #939 establish one bounded source-authoritative vertical slice before any broader issue-coverage claim is reconsidered. + +## Source-authoritative evidence tests + +Every evidence acquirer must prove its source identity independently of the caller. The GitHub Actions slice requires: + +- exact repository/run/job URL and identifier binding; +- exact head SHA and versioned API contract; +- true failure, true pass, cancelled failure, and non-security negative cases; +- malformed, wrong-origin, unfinished, future, stale, duplicate, oversized, non-JSON, and unavailable cases; +- token non-disclosure and no raw response-body leakage in errors; +- deterministic digest across mapping insertion order; +- production CLI pass/failure/inconclusive exit codes; +- an AST-based complete-docstring gate; +- 100% statement and branch coverage measured without mutation execution contaminating the coverage result; +- independent mutation oracles that kill source-identity inversion, security-obligation bypass, outcome inversion, and requested/acquired identifier inversion. + +The #815-shaped test object is a historical source shape, not a generated detector answer. Expected outcomes are asserted by independent test logic. Exact runbook, threat boundary, and traceability are in `docs/github-actions-source-evidence.md`. ## SSRF tests @@ -85,4 +102,4 @@ Verify immutable action refs, RCA-first feasibility, no provider/reviewer secret ## Release acceptance -A release requires one exact integrated protected head satisfying detector positive/negative obligations, tenant/network security, exact coverage, packaging/SBOM/provenance, migration/rollback where state changes, independent review, and post-publish smoke. \ No newline at end of file +A release requires one exact integrated protected head satisfying detector positive/negative obligations, source-authoritative acquisition where claimed, tenant/network security, exact coverage, packaging/SBOM/provenance, migration/rollback where state changes, independent review, and post-publish smoke. \ No newline at end of file From c489ffbd1657a19e01ad2269493b29873a76d6a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:01:56 +0900 Subject: [PATCH 20/43] docs(actions): model source-evidence threats --- docs/THREAT_MODEL.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 8aeba721..1949d82d 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -1,11 +1,11 @@ # AppGuardrail Threat Model **Status:** Accepted baseline -**Last reviewed:** 2026-08-09 +**Last reviewed:** 2026-08-14 ## Scope -Covers built-in scanning, optional external engines, findings/SARIF/reporting, deterministic fixes, GitHub monitor workflows, the current SQLite control plane, webhook egress, issue-to-detector assurance, and autonomous-development authority. +Covers built-in scanning, optional external engines, findings/SARIF/reporting, deterministic fixes, GitHub monitor workflows, source-authoritative Actions evidence, the current SQLite control plane, webhook egress, issue-to-detector assurance, and autonomous-development authority. ## Trust boundaries @@ -14,6 +14,8 @@ flowchart LR CODE[Untrusted target code/config] SCAN[Scanner/external engines] FIND[Findings] + SOURCE[Authoritative external evidence source] + ACQ[Bounded evidence acquirer] CP[Control plane] OUT[Webhook/ZAP/network target] DEV[Autonomous developer] @@ -21,6 +23,8 @@ flowchart LR CODE --> SCAN SCAN --> FIND + SOURCE --> ACQ + ACQ --> FIND FIND --> CP CP --> OUT DEV --> REVIEW @@ -31,6 +35,15 @@ flowchart LR | Threat | Impact | Controls | |---|---|---| | detector fixture asserts its own answer | false issue-coverage confidence | independent inventory + answer-free evidence + actual detector execution | +| caller Boolean or log label treated as source truth | forged pass/failure evidence | acquire exact source object directly; no caller decision field in contract | +| repository/run/job substitution | decision bound to the wrong source | exact owner/repository, run ID, job ID, URL, run-on-job ID, and head SHA validation | +| authenticated redirect to another origin | token disclosure/confused deputy | fixed `https://api.github.com` origin and redirect rejection | +| oversized or non-JSON API response | memory/resource abuse or parser confusion | 2 MiB cap, JSON media type, JSON object requirement, bounded scalar fields | +| future, stale, or replayed source evidence | time-travel or duplicate decisions | trusted observation time, bounded max age, canonical source SHA-256 ledger | +| unfinished job/step interpreted as pass/fail | premature gate decision | completed run, job, and step states required; otherwise inconclusive | +| bearer token reflected in output/error | credential disclosure | token only in Authorization header; sanitized error classes; raw body excluded | +| raw workflow logs copied into portable evidence | secret/PII leakage and unstable identity | bounded metadata projection only; logs require a separate reviewed acquirer | +| indiscriminate PII masking removes incident identity | authorized response becomes unusable | tenant isolation, least privilege, encryption, purpose binding, field authorization, immutable audit, retention controls | | unsupported structural rule presented as built-in | false negative/marketing error | explicit built-in vs external-engine capability/maturity | | scanner/tool unavailable treated as clean | false security assurance | explicit unavailable/inconclusive classification | | secret extraction/reflection | credential disclosure | redacted/fingerprinted findings; bounded logs/reports | @@ -45,6 +58,12 @@ flowchart LR | tampered SBOM/report evidence | acquisition/security misstatement | deterministic source/lock provenance and manifest hashes | | autonomous model self-approval | governance bypass | developer/reviewer/merge/release authority separation | +## Source-authoritative Actions abuse case + +An attacker can present a legitimate-looking run URL from another repository, pair a valid run with an unrelated job, replay an old failure, publish a line containing `security failed`, return a proxy HTML error body, or force an authenticated redirect. The acquirer therefore fixes the API origin, validates every source identifier and public URL, requires terminal source states, bounds size and freshness, computes a canonical digest, and returns inconclusive for any unavailable or ambiguous condition. Historical issue text and generated registries are context, not source authority. + +The portable evidence object retains only the bounded metadata required to reproduce the decision. Raw logs, runner identity, arbitrary annotations, and credentials remain outside this object. When authorized human or repository identity is required, preserve it through access and retention controls rather than deleting it through blanket masking. + ## Stored SSRF abuse case A URL may be safe syntactically but unsafe after DNS resolution, redirect, or later execution. Stored-destination security therefore spans source trust, canonical URL/scheme/port policy, DNS/IP classification, redirect behavior, persistence, and execution-time revalidation/egress. A storage guard and a scanner rule are separate controls. @@ -55,8 +74,8 @@ A retained historical issue can tempt an audit to “cover” itself by mapping ## Residual risk -No static scanner proves application security. Dynamic/runtime/business-logic vulnerabilities may require external tools or human review. AppGuardrail must state toolset/evidence limits and avoid `Clean Scan` claims when a selected required engine could not run. +No static scanner proves application security. Dynamic/runtime/business-logic vulnerabilities may require external tools or human review. GitHub Actions metadata proves the source workflow outcome, not that the workflow's internal detector is scientifically or operationally valid; that detector still needs its own positive/negative/adversarial oracle. AppGuardrail must state toolset/evidence limits and avoid `Clean Scan` claims when a selected required engine or source could not run. ## Review triggers -Revisit when adding a structural matcher, new external engine, behavior-changing autofix, new network/egress path, persistent tenant schema, new issue-evidence source, or changed autonomous/release credential boundary. \ No newline at end of file +Revisit when adding a structural matcher, new external engine, behavior-changing autofix, new network/egress path, persistent tenant schema, new evidence acquirer or log source, broader source fields, changed retention/export behavior, or changed autonomous/release credential boundary. \ No newline at end of file From 0fb757fd76b58bfcafd7290f89c5f0f99be7acd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:02:53 +0900 Subject: [PATCH 21/43] docs(actions): add source-evidence operations --- docs/OPERABILITY.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 6183d5cc..6ca585de 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -1,11 +1,11 @@ # AppGuardrail Operability, Recovery, and Release Guide **Status:** Accepted operating baseline -**Last reviewed:** 2026-08-09 +**Last reviewed:** 2026-08-14 ## Operating model -AppGuardrail can run as a local/CI scanner, optional multi-tenant control plane, continuous GitHub monitor, and organization evidence aggregator. The product remains useful when optional external scanners or the control plane are absent; capability/unavailability must be explicit in evidence. +AppGuardrail can run as a local/CI scanner, optional multi-tenant control plane, continuous GitHub monitor, source-authoritative evidence verifier, and organization evidence aggregator. The product remains useful when optional external scanners or the control plane are absent; capability/unavailability must be explicit in evidence. ## Scan health states @@ -25,7 +25,10 @@ Do not collapse non-completion into “clean.” - scan completion/findings/blocker counts by engine/family; - detector false-positive/false-negative benchmark where maintained; -- issue-obligation executable coverage after PR #911 integration; +- source-authoritative acquisition pass/failure/inconclusive counts; +- stale, duplicate, identity-mismatch, wrong-origin, and upstream API-error counts; +- evidence age and acquisition latency by source repository; +- detector-family executable obligation coverage after bounded vertical-slice integration; - engine unavailable/failure rate; - scan/control-plane latency and finding volume; - new blocker drift count; @@ -41,6 +44,18 @@ Do not collapse non-completion into “clean.” Fix the first owning detector/adapter or input-classification boundary, add a regression, then rescan the exact target. Do not suppress a finding merely because a fix is inconvenient. +### Source-authoritative GitHub Actions evidence + +Use the stable CLI exit code before deciding the next action: + +- `0`: persist the verified pass evidence and continue the protected workflow; +- `1`: persist the verified failure evidence, block the affected gate, and open/update remediation; +- `2`: treat the result as inconclusive; repair authentication, source identity, freshness, API availability, or malformed data and retry. + +For HTTP 403, confirm the token has Actions-read access to the exact private repository. For an identity mismatch, copy the run and job IDs from the same run and verify the owner/repository pair. For stale replay, increase `--max-age-hours` only to the approved historical interval; do not disable freshness. For duplicate digest, reuse the existing immutable evidence record. For API/proxy errors, preserve the bounded error code but never log the credential or response body. + +The acquirer intentionally has no internal retry loop. The caller may apply bounded retry only for transient transport or 5xx failures after distinguishing them from deterministic 4xx, identity, freshness, and validation failures. Every retry reacquires both run and job from the fixed API origin and revalidates the full contract. + ### External engine Verify installation/version/config/authorization and distinguish provider/tool infrastructure from target vulnerability. One transient rerun may be appropriate after RCA; repeated retries are not a substitute for fixing deterministic failure. @@ -63,18 +78,18 @@ Webhook destination validation must be revisited when DNS resolution/redirect co ## Issue-to-detector audit operation -After PR #911 merges, run the executable audit from authenticated retained issue inventory and closed evidence corpus. Fail if a detectable obligation lacks a detector family or detector execution is inconclusive. Historical issue count alone is not a success metric; obligation execution and evidence provenance are. +Historical PR #911 remains an inventory prototype and must not be run as if it were protected-branch executable coverage. Issue #938/PR #939 establishes one bounded source-authoritative Actions evidence contract. Extend coverage only by adding a new authoritative acquirer/probe, independent oracle, mutation evidence, production entrypoint, and exact-head gates for each detector family. ## Upgrade and rollback -1. review CHANGELOG/ADR/detector changes; -2. run full detector/security/control-plane suite; -3. compare finding set on representative benchmark repositories; +1. review CHANGELOG/ADR/detector/acquirer changes; +2. run full detector/security/control-plane and source-evidence suites; +3. compare finding/evidence sets on representative benchmark repositories; 4. rehearse persistent schema migration/rollback if changed; 5. canary continuous monitor/control plane where deployed; 6. retain previous package/image/db backup until new evidence is accepted; -7. rollback software/config on regression and re-run the benchmark scan. +7. rollback software/config on regression and re-run the benchmark scan and evidence replay. ## Release gate -Release only from exact protected head with all required CI/security/review, 100% production coverage/docs, detector-obligation evidence, package/SBOM/provenance, persistent-state migration/recovery, control-plane auth/network security, CHANGELOG/version, and post-publish smoke. A merged detector PR is not a release by itself. +Release only from exact protected head with all required CI/security/review, 100% production statement/branch coverage and docs, detector-obligation and source-authority evidence, package/SBOM/provenance, persistent-state migration/recovery, control-plane auth/network security, CHANGELOG/version, and post-publish smoke. A merged detector or acquirer PR is not a release by itself. From 4769c00479edb71c9d366eb90d702ce99f2b30fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:03:42 +0900 Subject: [PATCH 22/43] docs(actions): promote source-evidence traceability --- docs/TRACEABILITY.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b9757a95..2ca3822b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,7 +1,7 @@ # AppGuardrail Requirements, Detection, and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-08-14 | Requirement / security class | Detector/control boundary | Evidence maturity | |---|---|---| @@ -16,8 +16,9 @@ | CycloneDX SBOM | SBOM module | implemented-main | | organization buyer evidence | org evidence aggregator | implemented-main | | RCA-first feasibility scheduler | CI/agent policy | implemented-main | -| every retained issue claim mapped to executable detector obligation | issue-detection audit | PR #911 active-PR | -| authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR | +| broad every-issue executable obligation coverage | issue-detection audit | not implemented; historical PR #911 closed as an inventory prototype | +| source-authoritative GitHub Actions run/job evidence | `appguardrail_core.github_actions_evidence`, CLI, exact coverage/mutation workflow | PR #939 active-PR; bounded vertical slice | +| authenticated workflow-result source identity | fixed GitHub REST origin, exact run/job/repository/SHA/freshness/digest validation | PR #939 active-PR | | automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope | | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | @@ -28,10 +29,26 @@ - External-engine capability must name the engine and availability; normalization does not convert it into a built-in detector. - A prevention/hardening change does not automatically promote the matching scanner-detection row; PR #924 and PR #910 were verified and promoted independently. - An issue registry mapping cannot promote an obligation unless actual detector execution derives its result from independent/closed evidence. +- A source-evidence acquirer does not promote the scientific or operational efficacy of the underlying workflow detector; that detector still needs its own independent oracle. -## Issue #911 traceability contract +## Issue #938 / PR #939 traceability contract -When PR #911 is accepted, the authoritative obligation system should preserve issue number/claim identity, detector family, evidence fixture/workflow provenance, execution result, and detector rule/finding evidence. Deduplicating equivalent incidents into one detector family is allowed; dropping a retained claim through an exclusion/waiver list is not. +| Requirement | Production path | Exact verification | +|---|---|---| +| no caller Boolean as truth | `acquire_actions_job` fetches run and job before `verify_actions_job` | exact endpoint and fake-client tests | +| explicit probe/acquirer identity | `PROBE_REF`, `ACQUIRER_REF` | evidence envelope assertions | +| exact source identity | repository, run/job IDs and URLs, run-on-job ID, head SHA | mismatch matrix and acquisition identity tests | +| bounded trusted transport | `GitHubApiClient` | origin/path/redirect/content-type/size/timeout/network tests | +| terminal security outcome | completed run/job/steps and security-name taxonomy | pass/failure/cancelled/non-security/unfinished tests | +| temporal and replay safety | `observed_at`, positive bounded `max_age`, digest ledger | future/stale/non-finite/excessive/duplicate tests | +| deterministic source identity | canonical bounded JSON projection + SHA-256 | mapping-order stability and duplicate tests | +| token/raw-log exclusion | header-only token and bounded projection | token non-disclosure and sanitized-error tests | +| production interface | console script and `main` | pass `0`, failure `1`, inconclusive `2` tests | +| predicate robustness | isolated source mutation execution | identity, obligation, outcome, acquisition mutation oracles | +| quality contract | dedicated exact-head workflow | 100% statements, 100% branches, complete docstrings | +| architecture/operations | ADR-0007, `ARCHITECTURE.md`, source-evidence runbook | documentation review | + +Historical issue #815 supplies a reproducible GitHub Actions failure shape, but the expected outcome remains independently asserted in tests. PR #939 intentionally does not import the 20k-line PR #911 registry or present self-derived registry fixtures as independent evidence. ## SSRF traceability contract @@ -49,8 +66,10 @@ Current protected-branch evidence keeps those controls distinct: PR #924 supplie ## Standards/research +The GitHub Actions evidence slice applies GitHub's versioned REST Actions job contract, NIST SP 800-53 Rev. 5 Release 5.2.0 audit/assessment/access/supply-chain control families, NIST SP 800-218 SSDF 1.1, SLSA 1.2 provenance concepts, and RFC 8259 JSON constraints. APA 7th entries and decision mapping are recorded in ADR-0007 and `docs/github-actions-source-evidence.md`. + Existing repository docs/doctoring/security evidence remain the bibliography/source-of-truth for standards such as SARIF, CycloneDX, GitHub security interfaces, and applicable OWASP/CWE classes. Material new detector classes should add authoritative standard/CWE/OWASP references and APA 7 citations in doctoring where research/standards materially drive implementation. ## Change rule -Every new issue-class detector or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. \ No newline at end of file +Every new issue-class detector, evidence acquirer, or product security boundary should add/update a row and its concrete test/evidence path. Stale/queued/cancelled/rate-limited/predecessor checks cannot promote evidence maturity. \ No newline at end of file From 14810609169495daae7d9f3b15c270cbc2a0742e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:09:23 +0900 Subject: [PATCH 23/43] docs(prd): demote closed issue-registry prototype --- docs/PRD.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 77c43ade..8314314a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,7 +1,7 @@ # AppGuardrail Product Requirements Document -**Status:** Accepted cross-cutting product baseline for protected `develop` at `77e3e0c5867b1143970fcdce80962bda8a8fc80f` -**Last reviewed:** 2026-08-12 +**Status:** Accepted cross-cutting product baseline; protected-`develop` facts refetched against `a68b57d4ccad4f895d7a3d9f909fffbc4653b17e` +**Last reviewed:** 2026-08-14 ## 1. Product purpose @@ -29,7 +29,8 @@ The product goal is not merely to prevent one defect after a review. Security de - PR #924 is **implemented-main** prevention at the control-plane webhook write boundary: malformed bodies, non-string values, and unsafe destinations fail closed before persistence. - PR #910 is **implemented-main** scanner detection for the separate stored-webhook coding pattern through the packaged `python-stored-ssrf-webhook-url` rule. Its supported scope is the tested Python `set_webhook` direct and one-hop flows, including conditional/non-enforcing guard regressions; it is not a claim of universal interprocedural SSRF detection. -- PR #911 proposes a no-exclusions registry mapping the repository's retained issue history to executable detector-family obligations and authenticated workflow-result evidence. It remains an active-PR product capability until merged and independently verified. +- PR #911 is **closed unmerged**. Its no-exclusions registry remains historical inventory/prototype evidence, not current or active-PR capability. Broad issue-history coverage must be rebuilt through bounded source-authoritative detector/evidence slices rather than promoted from that draft. +- PR #939 is an **active-PR** bounded source-authoritative GitHub Actions run/job evidence slice. It becomes current only after protected-branch integration and fresh protected-head verification. - Open UX/performance Jules PRs remain active-PR and must not be described as current release behavior before integration. ## 4. Primary users @@ -123,4 +124,4 @@ AppGuardrail SHALL produce deterministic component inventory with lockfile prove ## 10. Release acceptance -A release requires one exact protected head with full detector/test coverage, control-plane/security regressions, exact CI/security/review, packaging/SBOM/provenance, migration/recovery evidence for changed persistent state, updated CHANGELOG/version/artifacts, and post-publish smoke. PR #910 scanner detection and PR #924 write-boundary prevention are current protected-branch controls; PR #911 claims become current only after protected-branch integration and fresh protected-head verification. +A release requires one exact protected head with full detector/test coverage, control-plane/security regressions, exact CI/security/review, packaging/SBOM/provenance, migration/recovery evidence for changed persistent state, updated CHANGELOG/version/artifacts, and post-publish smoke. PR #910 scanner detection and PR #924 write-boundary prevention are current protected-branch controls. PR #911 remains historical closed-unmerged inventory work; successor slices such as PR #939 become current only after protected-branch integration and fresh protected-head verification. From 45e810240ff5b0f81d69d43e9f40babd4fb523cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:09:57 +0900 Subject: [PATCH 24/43] test(docs): track closed issue-registry prototype truth --- tests/test_documentation_contract.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_documentation_contract.py b/tests/test_documentation_contract.py index 2ffa7719..d7756549 100644 --- a/tests/test_documentation_contract.py +++ b/tests/test_documentation_contract.py @@ -87,8 +87,9 @@ def test_integrated_ssrf_controls_are_promoted_but_distinct() -> None: assert "scanner detection" in detector_claim assert "python-stored-ssrf-webhook-url" in detector_claim - active_issue_claim = _single_line_with(prd, "PR #911", "active-PR") - assert "no-exclusions registry" in active_issue_claim + historical_issue_claim = _single_line_with(prd, "PR #911", "closed unmerged") + assert "historical inventory/prototype evidence" in historical_issue_claim + assert "active-PR capability" in historical_issue_claim detector_trace = _single_line_with( traceability, @@ -100,10 +101,11 @@ def test_integrated_ssrf_controls_are_promoted_but_distinct() -> None: issue_trace = _single_line_with( traceability, - "every retained issue claim mapped to executable detector obligation", - "PR #911 active-PR", + "broad every-issue executable obligation coverage", + "historical PR #911 closed as an inventory prototype", ) assert "issue-detection audit" in issue_trace + assert "not implemented" in issue_trace assert "separate controls" in architecture From d7c8944e5cc6827b3d55dc886a60f6ca1b2b07de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:52:10 +0900 Subject: [PATCH 25/43] test(actions): remove unused namespace import --- tests/test_github_actions_evidence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_github_actions_evidence.py b/tests/test_github_actions_evidence.py index db1ea35f..85f35823 100644 --- a/tests/test_github_actions_evidence.py +++ b/tests/test_github_actions_evidence.py @@ -6,7 +6,6 @@ import json import urllib.error from datetime import datetime, timedelta, timezone -from types import SimpleNamespace import pytest From 0e18941315c0f8bdc450cdaabd1c60a4a27efaca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 00:13:07 +0900 Subject: [PATCH 26/43] test(actions): remove unused io import --- tests/test_github_actions_evidence_edges.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_github_actions_evidence_edges.py b/tests/test_github_actions_evidence_edges.py index 6b0ddca8..c15d2aa0 100644 --- a/tests/test_github_actions_evidence_edges.py +++ b/tests/test_github_actions_evidence_edges.py @@ -2,7 +2,6 @@ from __future__ import annotations -import io import json import urllib.error from datetime import datetime, timedelta, timezone From 99722d8542503bd383faf0ed486ca66d096f6307 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:00:38 +0900 Subject: [PATCH 27/43] test(actions): require terminal step evidence --- ..._github_actions_evidence_required_steps.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/test_github_actions_evidence_required_steps.py diff --git a/tests/test_github_actions_evidence_required_steps.py b/tests/test_github_actions_evidence_required_steps.py new file mode 100644 index 00000000..cac77847 --- /dev/null +++ b/tests/test_github_actions_evidence_required_steps.py @@ -0,0 +1,74 @@ +"""Fail-closed regressions for required GitHub Actions step evidence.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +import appguardrail_core.github_actions_evidence as gae + +REPOSITORY = "ContextualWisdomLab/appguardrail" +RUN_ID = 31879016068 +JOB_ID = 95001996967 +HEAD_SHA = "4d3747e931723f07160994a3f3f879eef8dc9fe0" +OBSERVED_AT = datetime(2026, 8, 15, 11, 0, tzinfo=timezone.utc) + + +def _run_payload() -> dict[str, object]: + """Return a completed security workflow run fixture.""" + return { + "id": RUN_ID, + "name": "OpenCode Review", + "html_url": f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "feature/source-authoritative-actions-evidence-938", + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-15T10:55:45Z", + "pull_requests": [{"number": 939}], + } + + +def _job_payload() -> dict[str, object]: + """Return a completed security job fixture with one terminal step.""" + return { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "coverage-evidence", + "workflow_name": "OpenCode Review", + "html_url": ( + f"https://github.com/{REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "success", + "completed_at": "2026-08-15T10:55:45Z", + "steps": [ + { + "number": 1, + "name": "Verify coverage evidence", + "status": "completed", + "conclusion": "success", + } + ], + } + + +@pytest.mark.parametrize("step_evidence", [None, []]) +def test_verify_actions_job_rejects_missing_or_empty_steps(step_evidence): + """Reject a terminal job whose authoritative step collection has no evidence.""" + job = _job_payload() + if step_evidence is None: + job.pop("steps") + else: + job["steps"] = step_evidence + + with pytest.raises(gae.EvidenceValidationError, match="steps"): + gae.verify_actions_job( + REPOSITORY, + _run_payload(), + job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=1), + ) From 3929a2236284f56dc049887c5a734d8d075066c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:02:13 +0900 Subject: [PATCH 28/43] fix(actions): fail closed without job steps --- appguardrail_core/github_actions_evidence.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index e7c386f4..114eff42 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -398,7 +398,6 @@ def _validate_repository(repository: Any) -> str: raise EvidenceValidationError("repository contains an invalid path segment") return repository - def _positive_identifier(value: Any, label: str) -> int: """Validate and return a positive bounded integer identifier.""" if isinstance(value, bool) or not isinstance(value, (int, str)): @@ -458,11 +457,11 @@ def _format_timestamp(value: datetime) -> str: def _normalize_steps(value: Any) -> list[dict[str, Any]]: - """Validate, sort, and normalize bounded terminal GitHub job steps.""" - if value is None: - return [] + """Validate, sort, and normalize non-empty terminal GitHub job steps.""" if not isinstance(value, list): - raise EvidenceValidationError("job steps must be a list") + raise EvidenceValidationError("job steps must be a non-empty list") + if not value: + raise EvidenceValidationError("job steps must contain at least one completed step") normalized: list[dict[str, Any]] = [] seen_numbers: set[int] = set() for raw_step in value: @@ -518,4 +517,4 @@ def _normalize_digest(value: Any) -> str: def _write_error(error_code: str, message: str) -> None: """Write one stable machine-readable error object to standard error.""" payload = {"error_code": error_code, "message": message} - print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) + print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) \ No newline at end of file From a28bc26f43ec300e9fe7f87e809c9212eb168918 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:04:35 +0900 Subject: [PATCH 29/43] test(actions): align fail-closed step coverage --- tests/test_github_actions_evidence_edges.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/test_github_actions_evidence_edges.py b/tests/test_github_actions_evidence_edges.py index c15d2aa0..55715422 100644 --- a/tests/test_github_actions_evidence_edges.py +++ b/tests/test_github_actions_evidence_edges.py @@ -244,12 +244,21 @@ def test_verify_additional_fail_closed_edges(): empty_optional = gae.verify_actions_job( REPOSITORY, run_payload(head_branch=None, pull_requests=None), - job_payload(workflow_name=None, steps=None), + job_payload(workflow_name=None), observed_at=OBSERVED_AT, max_age=timedelta(hours=48), ) assert empty_optional.branch_name == "" - assert empty_optional.failed_step_numbers == () + assert empty_optional.failed_step_numbers == (19,) + + with pytest.raises(gae.EvidenceValidationError, match="steps"): + gae.verify_actions_job( + REPOSITORY, + run_payload(), + job_payload(steps=None), + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) duplicate_prs = verify( run=run_payload( @@ -293,6 +302,8 @@ def test_scalar_and_collection_validation_edges(): assert gae._format_timestamp(OBSERVED_AT) == "2026-08-03T00:00:00Z" invalid_steps = ( + None, + [], "not-a-list", [1], [ @@ -323,7 +334,6 @@ def test_scalar_and_collection_validation_edges(): gae.EvidenceValidationError, match="steps|step|conclusion" ): gae._normalize_steps(steps) - assert gae._normalize_steps(None) == [] sorted_steps = gae._normalize_steps( [ { From c1be89ca896a0f8e5d170f62dc3cf28ac8a9682a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:06:11 +0900 Subject: [PATCH 30/43] refactor(actions): reuse normalized evidence fields --- appguardrail_core/github_actions_evidence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index 114eff42..6b2b90c2 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -235,6 +235,8 @@ def verify_actions_job( raise EvidenceValidationError("source evidence is stale") steps = _normalize_steps(job.get("steps")) + branch_name = _optional_text(run.get("head_branch"), 255) + event_name = _required_text(run.get("event"), "event name", 100) source_projection = { "repository": normalized_repository, "run": { @@ -242,8 +244,8 @@ def verify_actions_job( "name": workflow_name, "html_url": run_url, "head_sha": head_sha, - "head_branch": _optional_text(run.get("head_branch"), 255), - "event": _required_text(run.get("event"), "event name", 100), + "head_branch": branch_name, + "event": event_name, "status": run_status, "conclusion": run_conclusion, "updated_at": _format_timestamp(run_updated_at), @@ -288,8 +290,8 @@ def verify_actions_job( run_id=run_id, job_id=job_id, head_sha=head_sha, - branch_name=_optional_text(run.get("head_branch"), 255), - event_name=_required_text(run.get("event"), "event name", 100), + branch_name=branch_name, + event_name=event_name, run_url=run_url, job_url=job_url, job_conclusion=job_conclusion, @@ -398,6 +400,7 @@ def _validate_repository(repository: Any) -> str: raise EvidenceValidationError("repository contains an invalid path segment") return repository + def _positive_identifier(value: Any, label: str) -> int: """Validate and return a positive bounded integer identifier.""" if isinstance(value, bool) or not isinstance(value, (int, str)): @@ -517,4 +520,4 @@ def _normalize_digest(value: Any) -> str: def _write_error(error_code: str, message: str) -> None: """Write one stable machine-readable error object to standard error.""" payload = {"error_code": error_code, "message": message} - print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) \ No newline at end of file + print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) From b3085be410b9b06eeadf090d388f1bab78835d0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:07:03 +0900 Subject: [PATCH 31/43] test(actions): cover required-step mutation oracle --- .../test_github_actions_evidence_mutations.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/test_github_actions_evidence_mutations.py b/tests/test_github_actions_evidence_mutations.py index ff93498b..22b58a60 100644 --- a/tests/test_github_actions_evidence_mutations.py +++ b/tests/test_github_actions_evidence_mutations.py @@ -54,7 +54,14 @@ def job_payload(**overrides): "status": "completed", "conclusion": "failure", "completed_at": "2026-08-02T23:43:30Z", - "steps": [], + "steps": [ + { + "number": 19, + "name": "Publish review", + "status": "completed", + "conclusion": "failure", + } + ], } payload.update(overrides) return payload @@ -71,7 +78,7 @@ def load_mutant(old: str, new: str): mutant.__package__ = "appguardrail_core" sys.modules[module_name] = mutant try: - exec( + exec( # noqa: S102 - executes only this repository's own module source compile(mutated_source, str(MODULE_PATH), "exec"), mutant.__dict__, ) @@ -119,6 +126,16 @@ def outcome_mapping_oracle(module): assert verify(module).detector_state == "failure" +def required_steps_oracle(module): + """Require terminal source evidence to contain at least one job step.""" + for steps in (None, []): + try: + verify(module, job=job_payload(steps=steps)) + except module.EvidenceValidationError: + continue + raise AssertionError("missing or empty step evidence was accepted") + + def acquisition_identity_oracle(module): """Require exact requested and acquired run/job identifiers.""" @@ -188,6 +205,17 @@ def test_kills_outcome_mapping_inversion(): outcome_mapping_oracle(mutant) +def test_kills_required_step_evidence_bypass(): + """Prove the step oracle kills an empty-step validation bypass.""" + required_steps_oracle(evidence_module) + mutant = load_mutant( + "if not value:\n raise EvidenceValidationError(\"job steps must contain at least one completed step\")", + "if False:\n raise EvidenceValidationError(\"job steps must contain at least one completed step\")", + ) + with pytest.raises(AssertionError, match="step evidence"): + required_steps_oracle(mutant) + + def test_kills_acquired_identifier_check_inversion(): """Prove the acquisition oracle kills requested/returned ID inversion.""" acquisition_identity_oracle(evidence_module) From 64bc7e1ac97c9f84dedf5c59d0b1aa991e6faf48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:07:42 +0900 Subject: [PATCH 32/43] test(actions): make Unicode rejection lint-safe --- tests/test_github_actions_evidence_validation_edges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_github_actions_evidence_validation_edges.py b/tests/test_github_actions_evidence_validation_edges.py index 1da274bc..e6d7da1e 100644 --- a/tests/test_github_actions_evidence_validation_edges.py +++ b/tests/test_github_actions_evidence_validation_edges.py @@ -96,7 +96,7 @@ def test_rejects_non_timedelta_age_and_unfinished_step(): def test_identifier_rejects_float_and_non_ascii_digits(): """Prevent lossy integer coercion and Unicode digit ambiguity.""" - for value in (1.0, "2"): + for value in (1.0, "\uff12"): # FULLWIDTH DIGIT TWO with pytest.raises( evidence_module.EvidenceValidationError, match="positive integer" ): From b6a076ccf1922d946b93f807817ed1b444865b80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:08:22 +0900 Subject: [PATCH 33/43] docs(actions): specify required step evidence tests --- docs/TEST_STRATEGY.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c32064c2..aa3e513b 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -1,7 +1,7 @@ # AppGuardrail Test and Detector Validation Strategy **Status:** Accepted quality baseline -**Last reviewed:** 2026-08-14 +**Last reviewed:** 2026-08-15 ## Mandatory gates @@ -41,16 +41,17 @@ Every evidence acquirer must prove its source identity independently of the call - exact repository/run/job URL and identifier binding; - exact head SHA and versioned API contract; +- at least one completed job step; missing, null, or empty `steps` evidence must fail closed before classification; - true failure, true pass, cancelled failure, and non-security negative cases; - malformed, wrong-origin, unfinished, future, stale, duplicate, oversized, non-JSON, and unavailable cases; - token non-disclosure and no raw response-body leakage in errors; - deterministic digest across mapping insertion order; -- production CLI pass/failure/inconclusive exit codes; +- production CLI pass/failure/inconclusive exit codes, with missing or empty step evidence mapped to inconclusive exit code 2; - an AST-based complete-docstring gate; - 100% statement and branch coverage measured without mutation execution contaminating the coverage result; -- independent mutation oracles that kill source-identity inversion, security-obligation bypass, outcome inversion, and requested/acquired identifier inversion. +- independent mutation oracles that kill source-identity inversion, security-obligation bypass, outcome inversion, required-step bypass, and requested/acquired identifier inversion. -The #815-shaped test object is a historical source shape, not a generated detector answer. Expected outcomes are asserted by independent test logic. Exact runbook, threat boundary, and traceability are in `docs/github-actions-source-evidence.md`. +The required-step regression was introduced RED before the production guard and is exercised both through the production verifier and an independent mutation oracle. The #815-shaped test object is a historical source shape, not a generated detector answer. Expected outcomes are asserted by independent test logic. Exact runbook, threat boundary, and traceability are in `docs/github-actions-source-evidence.md`. ## SSRF tests @@ -102,4 +103,4 @@ Verify immutable action refs, RCA-first feasibility, no provider/reviewer secret ## Release acceptance -A release requires one exact integrated protected head satisfying detector positive/negative obligations, source-authoritative acquisition where claimed, tenant/network security, exact coverage, packaging/SBOM/provenance, migration/rollback where state changes, independent review, and post-publish smoke. \ No newline at end of file +A release requires one exact integrated protected head satisfying detector positive/negative obligations, source-authoritative acquisition where claimed, tenant/network security, exact coverage, packaging/SBOM/provenance, migration/rollback where state changes, independent review, and post-publish smoke. From 13793434454a93bd26b3a226593a8ca8f504b9a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:09:19 +0900 Subject: [PATCH 34/43] docs(actions): sync plan with implemented evidence gates --- .../2026-08-14-source-authoritative-actions-evidence.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md b/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md index 8b307ddb..36dc4805 100644 --- a/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md +++ b/docs/superpowers/plans/2026-08-14-source-authoritative-actions-evidence.md @@ -148,7 +148,7 @@ git commit -m "feat(actions): add evidence acquisition CLI" **Files:** - Create: `docs/github-actions-source-evidence.md` -- Create: `docs/adr/0013-source-authoritative-actions-evidence.md` +- Create: `docs/adr/0007-source-authoritative-actions-evidence.md` - Create: `CHANGELOG.d/938-source-authoritative-actions-evidence.md` - Modify: `ARCHITECTURE.md` - Modify: `docs/TEST_STRATEGY.md` @@ -190,7 +190,7 @@ git commit -m "docs(actions): trace source-authoritative evidence slice" **Files:** - Modify only if required: `.github/workflows/tests.yml` -- Verify: all files changed in Tasks 1-4. +- Verify: `.github/workflows/github-actions-evidence-coverage.yml` and all files changed in Tasks 1-4. **Interfaces:** - Produces: exact-head CI evidence and a reviewable PR linked to issue #938. @@ -213,7 +213,7 @@ Run: `pytest -q` Run: `python -m build` -Expected: all commands succeed without warnings. +Expected: all commands succeed without warnings, and the dedicated `GitHub Actions Evidence Coverage` workflow is required on the exact PR head. - [ ] **Step 3: Open a bounded PR** From 763497e078fdae0da330cd384fb79a4cb6017c05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:44:21 +0900 Subject: [PATCH 35/43] ci(actions): cover required-step regression --- .github/workflows/github-actions-evidence-coverage.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/github-actions-evidence-coverage.yml b/.github/workflows/github-actions-evidence-coverage.yml index 86766bb4..ff844976 100644 --- a/.github/workflows/github-actions-evidence-coverage.yml +++ b/.github/workflows/github-actions-evidence-coverage.yml @@ -7,6 +7,7 @@ on: - appguardrail_core/github_actions_evidence.py - tests/test_github_actions_evidence.py - tests/test_github_actions_evidence_edges.py + - tests/test_github_actions_evidence_required_steps.py - tests/test_github_actions_evidence_validation_edges.py - tests/test_github_actions_evidence_docstrings.py - tests/test_github_actions_evidence_mutations.py @@ -18,6 +19,7 @@ on: - appguardrail_core/github_actions_evidence.py - tests/test_github_actions_evidence.py - tests/test_github_actions_evidence_edges.py + - tests/test_github_actions_evidence_required_steps.py - tests/test_github_actions_evidence_validation_edges.py - tests/test_github_actions_evidence_docstrings.py - tests/test_github_actions_evidence_mutations.py @@ -74,6 +76,7 @@ jobs: python -m coverage run --branch -m pytest -q \ tests/test_github_actions_evidence.py \ tests/test_github_actions_evidence_edges.py \ + tests/test_github_actions_evidence_required_steps.py \ tests/test_github_actions_evidence_validation_edges.py \ tests/test_github_actions_evidence_docstrings.py python -m coverage report \ From ed0c56548a58d624475008d2b8c8204d38d81d11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:15:43 +0900 Subject: [PATCH 36/43] test(actions): reject HTTP control characters in tokens --- .../test_github_actions_evidence_validation_edges.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_github_actions_evidence_validation_edges.py b/tests/test_github_actions_evidence_validation_edges.py index e6d7da1e..a1298d89 100644 --- a/tests/test_github_actions_evidence_validation_edges.py +++ b/tests/test_github_actions_evidence_validation_edges.py @@ -103,6 +103,17 @@ def test_identifier_rejects_float_and_non_ascii_digits(): evidence_module._positive_identifier(value, "id") +def test_client_rejects_crlf_in_bearer_token_before_header_construction(): + """Reject HTTP field-line injection material at the credential boundary.""" + for token in ( + "valid-prefix\rX-Injected: yes", + "valid-prefix\nX-Injected: yes", + "valid-prefix\x00suffix", + ): + with pytest.raises(ValueError, match="control characters"): + evidence_module.GitHubApiClient(token) + + def test_cli_rejects_non_finite_or_excessive_age_before_auth(monkeypatch, capsys): """Fail closed on invalid freshness windows without requiring a credential.""" monkeypatch.delenv("APPGUARDRAIL_GITHUB_TOKEN", raising=False) From 03618054056e6cc2998599ae1cb092a1c429b3b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:17:55 +0900 Subject: [PATCH 37/43] fix(actions): reject control characters in bearer tokens --- appguardrail_core/github_actions_evidence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index 6b2b90c2..a8a778da 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -103,6 +103,8 @@ def __init__( """Initialize a client with one scoped token and bounded I/O limits.""" if not isinstance(token, str) or not token.strip(): raise ValueError("GitHub token must be non-empty") + if any(ord(character) < 32 or ord(character) == 127 for character in token): + raise ValueError("GitHub token must not contain HTTP control characters") if api_root.rstrip("/") != API_ROOT: raise ValueError("GitHub API root must be https://api.github.com") if timeout_seconds <= 0: From 8a8c0ef1376ee79cea5f5e9dce0541c969ae635f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:18:49 +0900 Subject: [PATCH 38/43] docs(actions): record HTTP credential field boundary --- docs/adr/0007-source-authoritative-actions-evidence.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/adr/0007-source-authoritative-actions-evidence.md b/docs/adr/0007-source-authoritative-actions-evidence.md index 1c34f9a5..6dcf637b 100644 --- a/docs/adr/0007-source-authoritative-actions-evidence.md +++ b/docs/adr/0007-source-authoritative-actions-evidence.md @@ -9,7 +9,7 @@ AppGuardrail historically aggregated workflow failures and issue records, but a caller-supplied Boolean, log label, or registry entry is not independent proof that a security control failed. Enterprise buyers need evidence that binds a decision to the source system that produced it, while still allowing the underlying source object to be independently replayed and audited. -GitHub's REST API exposes exact workflow-run and workflow-job resources and recommends the versioned `application/vnd.github+json` API contract with `X-GitHub-Api-Version: 2022-11-28`. Fine-grained credentials need only repository Actions read access for private resources. NIST SP 800-53 Rev. 5 treats audit, accountability, assessment, access control, and supply-chain assurance as related control families; NIST SP 800-218 requires secure-development evidence to be integrated into the software lifecycle. SLSA v1.2 defines provenance as verifiable information describing where, when, and how an artifact or source revision was produced. +GitHub's REST API exposes exact workflow-run and workflow-job resources and recommends the versioned `application/vnd.github+json` API contract with `X-GitHub-Api-Version: 2022-11-28`. Fine-grained credentials need only repository Actions read access for private resources. RFC 9110 defines HTTP field values containing CR, LF, or NUL as invalid and dangerous and requires recipients to reject or normalize them; AppGuardrail rejects HTTP control characters at its bearer-credential boundary before constructing the `Authorization` field. NIST SP 800-53 Rev. 5 treats audit, accountability, assessment, access control, and supply-chain assurance as related control families; NIST SP 800-218 requires secure-development evidence to be integrated into the software lifecycle. SLSA v1.2 defines provenance as verifiable information describing where, when, and how an artifact or source revision was produced. ## Decision @@ -19,7 +19,7 @@ The production acquirer SHALL: 1. accept an exact `owner/repository`, `run_id`, and `job_id` intent; 2. use only `https://api.github.com` with API version `2022-11-28`; -3. reject redirects, non-JSON responses, responses larger than 2 MiB, and non-object JSON; +3. reject redirects, non-JSON responses, responses larger than 2 MiB, HTTP control characters in bearer credentials, and non-object JSON; 4. bind returned repository URLs, run ID, job ID, run ID on the job, and 40-hex head SHA; 5. require completed security-relevant run/job states and completed step states; 6. reject future, stale, malformed, unsupported, duplicate, and non-security evidence; @@ -53,6 +53,7 @@ Rejected because registry content and fixtures derived from the same source do n - buyers can reproduce exactly which GitHub source object produced a decision; - evidence has a stable duplicate-prevention identity; - token values and raw logs stay outside the portable result; +- malformed header-control material is rejected before credentials can enter an HTTP field; - failures, successes, stale evidence, and unavailable evidence are distinguishable; - the contract can be reused by organization collectors without inheriting caller-assertion semantics. @@ -67,7 +68,7 @@ Rejected because registry content and fixtures derived from the same source do n Merge requires: -- true-positive, true-negative, cancelled, malformed, stale, future, duplicate, wrong-origin, wrong-identity, oversized, invalid JSON, unfinished, and token-disclosure tests; +- true-positive, true-negative, cancelled, malformed, stale, future, duplicate, wrong-origin, wrong-identity, oversized, invalid JSON, unfinished, bearer-control-character, and token-disclosure tests; - production statement and branch coverage of 100%; - complete module/class/function/method docstrings; - exact-head repository Tests, SAST, CodeQL, dependency/supply-chain checks, `appguardrail-scan`, and independent approval; @@ -84,3 +85,5 @@ Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development fr Supply-chain Levels for Software Artifacts. (2026). *SLSA specification version 1.2*. Linux Foundation. https://slsa.dev/spec/v1.2/ Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 From ae88ed15bf12c2025a5af601f6445c1b6b480ba2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:19:19 +0900 Subject: [PATCH 39/43] chore(actions): document bearer header hardening --- CHANGELOG.d/938-source-authoritative-actions-evidence.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/938-source-authoritative-actions-evidence.md b/CHANGELOG.d/938-source-authoritative-actions-evidence.md index 02c88017..9d2f433d 100644 --- a/CHANGELOG.d/938-source-authoritative-actions-evidence.md +++ b/CHANGELOG.d/938-source-authoritative-actions-evidence.md @@ -8,5 +8,5 @@ ## Security -- GitHub API acquisition is fixed to `https://api.github.com`, rejects redirects, caps responses at 2 MiB, emits sanitized errors, and excludes bearer tokens and raw logs from portable evidence. -- Invalid or unavailable evidence returns an explicit inconclusive exit path instead of being interpreted as success. \ No newline at end of file +- GitHub API acquisition is fixed to `https://api.github.com`, rejects redirects, caps responses at 2 MiB, rejects HTTP control characters in bearer credentials before header construction, emits sanitized errors, and excludes bearer tokens and raw logs from portable evidence. +- Invalid or unavailable evidence returns an explicit inconclusive exit path instead of being interpreted as success. From 0b2774b56eee268b1fb7d23194d309a8dcdc2437 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 18:58:13 +0900 Subject: [PATCH 40/43] test(actions): reproduce repository case binding false negative --- ...github_actions_evidence_repository_case.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_github_actions_evidence_repository_case.py diff --git a/tests/test_github_actions_evidence_repository_case.py b/tests/test_github_actions_evidence_repository_case.py new file mode 100644 index 00000000..ad75e0d8 --- /dev/null +++ b/tests/test_github_actions_evidence_repository_case.py @@ -0,0 +1,61 @@ +"""Regression for GitHub repository identity case normalization.""" + +from datetime import datetime, timedelta, timezone + +from appguardrail_core.github_actions_evidence import verify_actions_job + + +RUN_ID = 30_769_144_488 +JOB_ID = 91_553_355_284 +HEAD_SHA = "2a83043b0239ba827153c934f87e469dba4f96f0" +CANONICAL_REPOSITORY = "ContextualWisdomLab/.github" +REQUESTED_REPOSITORY = "contextualwisdomlab/.github" +OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) + + +def test_repository_identity_accepts_github_canonical_case_urls() -> None: + """Treat repository identity as case-insensitive while binding exact IDs.""" + run = { + "id": RUN_ID, + "name": "OpenCode Review Dispatch", + "html_url": f"https://github.com/{CANONICAL_REPOSITORY}/actions/runs/{RUN_ID}", + "head_sha": HEAD_SHA, + "head_branch": "main", + "event": "repository_dispatch", + "status": "completed", + "conclusion": "failure", + "updated_at": "2026-08-02T23:44:00Z", + "pull_requests": [], + } + job = { + "id": JOB_ID, + "run_id": RUN_ID, + "name": "opencode-review", + "workflow_name": "OpenCode Review Dispatch", + "html_url": ( + f"https://github.com/{CANONICAL_REPOSITORY}/actions/runs/{RUN_ID}/job/{JOB_ID}" + ), + "status": "completed", + "conclusion": "failure", + "completed_at": "2026-08-02T23:43:30Z", + "steps": [ + { + "number": 19, + "name": "Publish review", + "status": "completed", + "conclusion": "failure", + } + ], + } + + evidence = verify_actions_job( + REQUESTED_REPOSITORY, + run, + job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + assert evidence.run_id == RUN_ID + assert evidence.job_id == JOB_ID + assert evidence.detector_state == "failure" From e08163a53a27c562e9ac55b4fdb4a663e9d8bd04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:00:32 +0900 Subject: [PATCH 41/43] fix(actions): bind repository identity case-insensitively --- appguardrail_core/github_actions_evidence.py | 31 +++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index a8a778da..a1a86e9c 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -191,15 +191,11 @@ def verify_actions_job( raise EvidenceValidationError("job run id does not match run id") run_url = _required_text(run.get("html_url"), "run URL", 500) - expected_run_url = ( - f"https://github.com/{normalized_repository}/actions/runs/{run_id}" - ) - if run_url != expected_run_url: + if not _matches_actions_url(run_url, normalized_repository, run_id): raise EvidenceValidationError("run id and run URL do not match") job_url = _required_text(job.get("html_url"), "job URL", 600) - expected_job_url = f"{expected_run_url}/job/{job_id}" - if job_url != expected_job_url: + if not _matches_actions_url(job_url, normalized_repository, run_id, job_id): raise EvidenceValidationError("job URL does not match repository, run, and job ids") head_sha = _required_text(run.get("head_sha"), "head SHA", 40).lower() @@ -403,6 +399,27 @@ def _validate_repository(repository: Any) -> str: return repository +def _matches_actions_url( + url: str, + repository: str, + run_id: int, + job_id: int | None = None, +) -> bool: + """Bind a GitHub Actions URL to a case-insensitive repository and exact IDs.""" + match = re.fullmatch( + r"https://github\.com/([A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100})/actions/runs/([1-9][0-9]{0,19})(?:/job/([1-9][0-9]{0,19}))?", + url, + ) + if match is None or match.group(1).casefold() != repository.casefold(): + return False + if int(match.group(2)) != run_id: + return False + actual_job_id = match.group(3) + if job_id is None: + return actual_job_id is None + return actual_job_id is not None and int(actual_job_id) == job_id + + def _positive_identifier(value: Any, label: str) -> int: """Validate and return a positive bounded integer identifier.""" if isinstance(value, bool) or not isinstance(value, (int, str)): @@ -522,4 +539,4 @@ def _normalize_digest(value: Any) -> str: def _write_error(error_code: str, message: str) -> None: """Write one stable machine-readable error object to standard error.""" payload = {"error_code": error_code, "message": message} - print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) + print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) \ No newline at end of file From ccaf79a909041aeaee858e720d56f21eaeaeab53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:06:58 +0900 Subject: [PATCH 42/43] test(actions): reproduce casing replay bypass --- ...github_actions_evidence_repository_case.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/test_github_actions_evidence_repository_case.py b/tests/test_github_actions_evidence_repository_case.py index ad75e0d8..cd12ae1a 100644 --- a/tests/test_github_actions_evidence_repository_case.py +++ b/tests/test_github_actions_evidence_repository_case.py @@ -2,7 +2,12 @@ from datetime import datetime, timedelta, timezone -from appguardrail_core.github_actions_evidence import verify_actions_job +import pytest + +from appguardrail_core.github_actions_evidence import ( + EvidenceValidationError, + verify_actions_job, +) RUN_ID = 30_769_144_488 @@ -13,9 +18,9 @@ OBSERVED_AT = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc) -def test_repository_identity_accepts_github_canonical_case_urls() -> None: - """Treat repository identity as case-insensitive while binding exact IDs.""" - run = { +def _source_payloads() -> tuple[dict[str, object], dict[str, object]]: + """Return one exact authoritative run/job pair using GitHub canonical casing.""" + run: dict[str, object] = { "id": RUN_ID, "name": "OpenCode Review Dispatch", "html_url": f"https://github.com/{CANONICAL_REPOSITORY}/actions/runs/{RUN_ID}", @@ -27,7 +32,7 @@ def test_repository_identity_accepts_github_canonical_case_urls() -> None: "updated_at": "2026-08-02T23:44:00Z", "pull_requests": [], } - job = { + job: dict[str, object] = { "id": JOB_ID, "run_id": RUN_ID, "name": "opencode-review", @@ -47,6 +52,12 @@ def test_repository_identity_accepts_github_canonical_case_urls() -> None: } ], } + return run, job + + +def test_repository_identity_accepts_github_canonical_case_urls() -> None: + """Treat requested identity as case-insensitive while binding exact IDs.""" + run, job = _source_payloads() evidence = verify_actions_job( REQUESTED_REPOSITORY, @@ -56,6 +67,29 @@ def test_repository_identity_accepts_github_canonical_case_urls() -> None: max_age=timedelta(hours=48), ) + assert evidence.repository == CANONICAL_REPOSITORY assert evidence.run_id == RUN_ID assert evidence.job_id == JOB_ID assert evidence.detector_state == "failure" + + +def test_repository_case_variants_cannot_bypass_source_digest_deduplication() -> None: + """Canonicalize equivalent requested casing before hashing source evidence.""" + run, job = _source_payloads() + first = verify_actions_job( + CANONICAL_REPOSITORY, + run, + job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + ) + + with pytest.raises(EvidenceValidationError, match="duplicate source evidence digest"): + verify_actions_job( + REQUESTED_REPOSITORY, + run, + job, + observed_at=OBSERVED_AT, + max_age=timedelta(hours=48), + seen_source_digests=[first.source_digest_sha256], + ) From 3edce66b403d7eff904d6b810016cac99af50605 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:10:14 +0900 Subject: [PATCH 43/43] fix(actions): canonicalize repository evidence identity --- appguardrail_core/github_actions_evidence.py | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/appguardrail_core/github_actions_evidence.py b/appguardrail_core/github_actions_evidence.py index a1a86e9c..10deefba 100644 --- a/appguardrail_core/github_actions_evidence.py +++ b/appguardrail_core/github_actions_evidence.py @@ -191,11 +191,14 @@ def verify_actions_job( raise EvidenceValidationError("job run id does not match run id") run_url = _required_text(run.get("html_url"), "run URL", 500) - if not _matches_actions_url(run_url, normalized_repository, run_id): + canonical_repository = _actions_url_repository( + run_url, normalized_repository, run_id + ) + if canonical_repository is None: raise EvidenceValidationError("run id and run URL do not match") job_url = _required_text(job.get("html_url"), "job URL", 600) - if not _matches_actions_url(job_url, normalized_repository, run_id, job_id): + if not _matches_actions_url(job_url, canonical_repository, run_id, job_id): raise EvidenceValidationError("job URL does not match repository, run, and job ids") head_sha = _required_text(run.get("head_sha"), "head SHA", 40).lower() @@ -236,7 +239,7 @@ def verify_actions_job( branch_name = _optional_text(run.get("head_branch"), 255) event_name = _required_text(run.get("event"), "event name", 100) source_projection = { - "repository": normalized_repository, + "repository": canonical_repository, "run": { "id": run_id, "name": workflow_name, @@ -282,7 +285,7 @@ def verify_actions_job( schema_version=SCHEMA_VERSION, probe_ref=PROBE_REF, acquirer_ref=ACQUIRER_REF, - repository=normalized_repository, + repository=canonical_repository, workflow_name=workflow_name, job_name=job_name, run_id=run_id, @@ -399,25 +402,38 @@ def _validate_repository(repository: Any) -> str: return repository -def _matches_actions_url( +def _actions_url_repository( url: str, repository: str, run_id: int, job_id: int | None = None, -) -> bool: - """Bind a GitHub Actions URL to a case-insensitive repository and exact IDs.""" +) -> str | None: + """Return authoritative repository spelling when an Actions URL binds exactly.""" match = re.fullmatch( r"https://github\.com/([A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100})/actions/runs/([1-9][0-9]{0,19})(?:/job/([1-9][0-9]{0,19}))?", url, ) if match is None or match.group(1).casefold() != repository.casefold(): - return False + return None if int(match.group(2)) != run_id: - return False + return None actual_job_id = match.group(3) if job_id is None: - return actual_job_id is None - return actual_job_id is not None and int(actual_job_id) == job_id + if actual_job_id is not None: + return None + elif actual_job_id is None or int(actual_job_id) != job_id: + return None + return match.group(1) + + +def _matches_actions_url( + url: str, + repository: str, + run_id: int, + job_id: int | None = None, +) -> bool: + """Bind a GitHub Actions URL to a case-insensitive repository and exact IDs.""" + return _actions_url_repository(url, repository, run_id, job_id) is not None def _positive_identifier(value: Any, label: str) -> int: @@ -539,4 +555,4 @@ def _normalize_digest(value: Any) -> str: def _write_error(error_code: str, message: str) -> None: """Write one stable machine-readable error object to standard error.""" payload = {"error_code": error_code, "message": message} - print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr) \ No newline at end of file + print(json.dumps(payload, sort_keys=True, ensure_ascii=False), file=sys.stderr)