Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ae5ab23
test(supply-chain): add RED lock provenance contracts
seonghobae Aug 16, 2026
912be00
feat(supply-chain): implement offline Python lock provenance receipt
seonghobae Aug 16, 2026
027d227
ci(supply-chain): publish Python lock provenance receipt
seonghobae Aug 16, 2026
c59f7d6
test(supply-chain): harden lock provenance branch coverage
seonghobae Aug 16, 2026
05b2cc0
fix(supply-chain): fail closed on incomplete lock generators
seonghobae Aug 16, 2026
f5b75fe
docs(supply-chain): record Python lock provenance evidence boundary
seonghobae Aug 16, 2026
df3efe5
fix(supply-chain): fail closed when lock hashes disappear
seonghobae Aug 16, 2026
3a7acca
test(supply-chain): cover provenance review regressions
seonghobae Aug 16, 2026
59aa696
fix(supply-chain): contain lock provenance reads
seonghobae Aug 16, 2026
2f245bb
fix(ci): publish failed lock provenance receipts
seonghobae Aug 16, 2026
1b99119
docs(supply-chain): document contained provenance reads
seonghobae Aug 16, 2026
dcd0e35
test(supply-chain): specify registry hash provenance contract
seonghobae Aug 16, 2026
f816166
feat(supply-chain): validate locked hashes against PyPI releases
seonghobae Aug 16, 2026
d9dfedf
ci(supply-chain): verify PyPI hashes before install
seonghobae Aug 16, 2026
cfcf064
docs(supply-chain): record PyPI hash provenance boundary
seonghobae Aug 16, 2026
fa6063b
test(supply-chain): cover PyPI provenance failure boundaries
seonghobae Aug 16, 2026
3dbdf3b
test(supply-chain): reject PyPI metadata origin redirects
seonghobae Aug 16, 2026
3ff0bf9
fix(supply-chain): keep PyPI metadata reads on trusted origin
seonghobae Aug 16, 2026
712e8e1
test(supply-chain): keep registry edge suite lint-clean
seonghobae Aug 16, 2026
ebb91a7
test(supply-chain): reject vacuous PyPI provenance receipts
seonghobae Aug 16, 2026
ecb492c
fix(supply-chain): require non-vacuous registry evidence
seonghobae Aug 16, 2026
6fdb12d
test(supply-chain): expose recursive requirements include bypass
seonghobae Aug 16, 2026
65f5a88
fix(supply-chain): validate recursive requirements includes
seonghobae Aug 16, 2026
bb8e349
docs(supply-chain): record recursive include boundary
seonghobae Aug 16, 2026
80454ee
Merge live provenance parent into registry slice
seonghobae Aug 16, 2026
f9f7f6a
Merge branch 'develop' into feat/dependency-lock-provenance-receipt
seonghobae Aug 17, 2026
d6c8030
Merge branch 'feat/dependency-lock-provenance-receipt' into feat/pyth…
seonghobae Aug 17, 2026
18cb855
Merge branch 'develop' into feat/dependency-lock-provenance-receipt
seonghobae Aug 17, 2026
4313e25
Merge branch 'develop' into feat/dependency-lock-provenance-receipt
opencode-agent[bot] Aug 18, 2026
20f1a5e
Merge branch 'feat/dependency-lock-provenance-receipt' into feat/pyth…
opencode-agent[bot] Aug 20, 2026
4ad12b9
fix(ci): reject unvalidated PyPI redirects
seonghobae Aug 20, 2026
1a6ac60
Merge remote-tracking branch 'origin/feat/python-lock-registry-proven…
seonghobae Aug 20, 2026
e5e99b4
fix(http): reject explicit zero loopback ports (#1337)
seonghobae Aug 25, 2026
4535698
merge(develop): reconcile supply-chain hash verification with lock pr…
seonghobae Aug 25, 2026
a1f89eb
merge(supply-chain): adopt evolved lock provenance attestation into r…
seonghobae Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
exit "$status"

- name: Validate PyPI release hash provenance
run: |
status=0
receipt="$(python scripts/ci/python_lock_registry_provenance.py --json)" || status=$?
printf '%s\n' "$receipt"
{
echo '### PyPI release hash provenance'
echo '```json'
printf '%s\n' "$receipt"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit "$status"
Comment on lines +62 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New CI gate makes backend job depend on live PyPI reachability for ~120 pinned packages with no retry

The new step (\.github/workflows/app-ci.yml:62-73) runs python_lock_registry_provenance.py, which discovers every requirements*.txt hash lock in the repo (backend/requirements-hashes.txt ~105 pins, backend/requirements-agent.txt, connector/requirements-hashes.txt, requirements-strix-ci-hashes.txt, requirements-bandit-ci-hashes.txt) and issues one live GET pypi.org/pypi/<name>/<ver>/json per unique (project,version). fetch_pypi_release (python_lock_registry_provenance.py) makes a single attempt with a 15s timeout and no retry; cached_fetch (python_lock_registry_provenance.py) caches failures so a single transient 5xx/timeout on any one of ~120 sequential requests permanently emits registry-metadata-fetch-failed and fails the whole backend job (no continue-on-error). The docs describe the gate as intentionally fail-closed, but conflating a transient network error with a provenance failure is a real CI-flakiness source that runs on every PR to develop/master and every push. Consider bounded retries/backoff for transport errors distinct from genuine provenance mismatches.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


- name: Install backend dependencies
run: |
python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt
Expand Down
6 changes: 5 additions & 1 deletion backend/core/local_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ def validate_loopback_http_origin(value: str) -> LocalHTTPOrigin:
safe_hostname = address.compressed

try:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
port = (
parsed.port
if parsed.port is not None
else (443 if parsed.scheme == "https" else 80)
)
except ValueError as exc:
raise LocalHTTPValidationError("local HTTP origin port is invalid") from exc
if not 1 <= port <= 65535:
Expand Down
70 changes: 70 additions & 0 deletions backend/tests/test_local_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ def test_loopback_origin_is_canonicalized() -> None:
hostname="::1",
port=18080,
)
assert validate_loopback_http_origin("http://localhost") == LocalHTTPOrigin(
origin="http://localhost",
scheme="http",
hostname="localhost",
port=80,
)
assert validate_loopback_http_origin("https://127.0.0.1:443/") == LocalHTTPOrigin(
origin="https://127.0.0.1",
scheme="https",
hostname="127.0.0.1",
port=443,
)


@pytest.mark.parametrize(
Expand All @@ -32,6 +44,64 @@ def test_loopback_origin_normalizes_malformed_parser_errors(value: str) -> None:
validate_loopback_http_origin(value)


@pytest.mark.parametrize(
"value",
[
"http://localhost:80\x00/",
"http://\nlocalhost/",
],
)
def test_loopback_origin_rejects_control_characters(value: str) -> None:
with pytest.raises(LocalHTTPValidationError, match="control characters"):
validate_loopback_http_origin(value)


@pytest.mark.parametrize(
"value",
[
"ftp://localhost/",
"http://user:pass@localhost/",
"http://localhost/path",
"http://localhost/?query=1",
"http://localhost/#frag",
"http:///", # No hostname
],
)
def test_loopback_origin_rejects_invalid_components(value: str) -> None:
with pytest.raises(
LocalHTTPValidationError, match=r"must be a loopback HTTP\(S\) origin"
):
validate_loopback_http_origin(value)


@pytest.mark.parametrize(
"value",
[
"http://example.com/",
"http://192.168.1.1/",
"http://[2001:db8::1]/",
"http://invalid.localhost/",
],
)
def test_loopback_origin_rejects_non_allowlisted_hosts(value: str) -> None:
with pytest.raises(LocalHTTPValidationError, match="host is not allowlisted"):
validate_loopback_http_origin(value)


@pytest.mark.parametrize(
"value",
[
"http://localhost:-1/",
"http://localhost:65536/",
"http://localhost:abc/",
"http://localhost:0/",
],
)
def test_loopback_origin_rejects_invalid_ports(value: str) -> None:
with pytest.raises(LocalHTTPValidationError, match="port is invalid"):
validate_loopback_http_origin(value)


def test_local_request_target_preserves_safe_path_and_query() -> None:
assert (
validate_local_request_target("/api/emails?limit=10") == "/api/emails?limit=10"
Expand Down
114 changes: 114 additions & 0 deletions backend/tests/test_python_lock_registry_non_vacuous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Non-vacuous evidence contracts for PyPI lock provenance."""

from __future__ import annotations

import importlib.util
import json
import runpy
import sys
import urllib.request
from pathlib import Path
from typing import Any

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "scripts" / "ci" / "python_lock_registry_provenance.py"
_spec = importlib.util.spec_from_file_location("python_lock_registry_non_vacuous", SCRIPT_PATH)
assert _spec is not None and _spec.loader is not None
registry_provenance = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = registry_provenance
_spec.loader.exec_module(registry_provenance)


def _sha() -> str:
"""Return one deterministic SHA-256 fixture digest."""
return "a" * 64


def _codes(receipt: dict[str, object]) -> set[str]:
"""Return stable top-level violation codes from a repository receipt."""
return {str(item["code"]) for item in receipt["violations"]}


def test_repository_without_hash_locks_fails_non_vacuously(tmp_path: Path) -> None:
"""A green registry receipt must represent at least one discovered hash lock."""
(tmp_path / "requirements.txt").write_text("example==1.0\n", encoding="utf-8")

receipt = registry_provenance.validate_repository_registry(
tmp_path,
fetch_release=lambda project, version: {},
)

assert receipt["status"] == "failed"
assert receipt["lock_files"] == []
assert _codes(receipt) == {"registry-no-hash-locks"}


class _Response:
"""Minimal exact-origin PyPI response used by the script-entrypoint test."""

def __init__(self, url: str) -> None:
self.url = url
self.headers = {"Content-Type": "application/json"}
self.payload = json.dumps(
{
"info": {"name": "example", "version": "1.0"},
"urls": [
{
"packagetype": "sdist",
"yanked": False,
"digests": {"sha256": _sha()},
}
],
}
).encode("utf-8")

def __enter__(self) -> "_Response":
return self

def __exit__(self, *args: Any) -> None:
return None

def geturl(self) -> str:
"""Return the unchanged trusted request URL."""
return self.url

def read(self, size: int) -> bytes:
"""Return a bounded response body."""
return self.payload[:size]


def test_script_main_guard_runs_registry_validation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Executing the script as __main__ publishes a passing JSON receipt and exits zero."""
lock = tmp_path / "requirements-hashes.txt"
lock.write_text(
f"example==1.0 \\\n --hash=sha256:{_sha()}\n",
encoding="utf-8",
)

class _Opener:
def open(self, request: urllib.request.Request, timeout: float) -> _Response:
return _Response(request.full_url)

monkeypatch.setattr(urllib.request, "build_opener", lambda *handlers: _Opener())
monkeypatch.setattr(
sys,
"argv",
[
str(SCRIPT_PATH),
"--repository-root",
str(tmp_path),
"--json",
],
)

with pytest.raises(SystemExit) as exit_info:
runpy.run_path(str(SCRIPT_PATH), run_name="__main__")

assert exit_info.value.code == 0
assert json.loads(capsys.readouterr().out)["status"] == "passed"
Loading