Protect authenticated federation sources with exact runtime URL bindings
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a137ca-5c77-7a71-b9ae-23ad9b0b5684
This commit is contained in:
parent
03cd049773
commit
b181240b35
10 changed files with 1388 additions and 3 deletions
|
|
@ -1,8 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -103,11 +105,44 @@ def _auth_headers(source: dict[str, Any]) -> dict[str, str]:
|
|||
return {header_name: token}
|
||||
|
||||
|
||||
class _RejectAuthenticatedRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never replay a source credential at a redirect target.
|
||||
|
||||
A private source must register its final URL. This also rejects same-origin
|
||||
redirects because a path change can escape the reviewed repository scope.
|
||||
"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise ConnectionError("authenticated source redirect refused")
|
||||
|
||||
|
||||
def fetch_remote_index_text(url: str, source: dict[str, Any]) -> str:
|
||||
headers = {"User-Agent": USER_AGENT, **_auth_headers(source)}
|
||||
if source.get("auth_env"):
|
||||
if urllib.parse.urlsplit(url).scheme.lower() != "https":
|
||||
raise ValueError("authenticated source requires HTTPS")
|
||||
# Registrations are writable data, not credential-routing authority.
|
||||
# Operators must bind each allowed environment name to exact URLs in
|
||||
# runtime configuration before the credential itself can be read.
|
||||
try:
|
||||
allowlist = json.loads(os.environ.get("REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST", "{}"))
|
||||
allowed_urls = allowlist.get(source["auth_env"], [])
|
||||
permitted = isinstance(allowed_urls, list) and url in allowed_urls
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
permitted = False
|
||||
if not permitted:
|
||||
raise ValueError("authenticated source URL is not runtime-authorized")
|
||||
auth_headers = _auth_headers(source)
|
||||
headers = {"User-Agent": USER_AGENT, **auth_headers}
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
# urllib's default redirect handler copies Authorization to the target.
|
||||
# Anonymous federation retains its existing redirect behavior.
|
||||
opener = (
|
||||
urllib.request.build_opener(_RejectAuthenticatedRedirect()).open
|
||||
if auth_headers
|
||||
else urllib.request.urlopen
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
with opener(request, timeout=30) as response:
|
||||
return response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ConnectionError(
|
||||
|
|
@ -313,4 +348,4 @@ def write_federated_index(
|
|||
header + yaml.safe_dump(federated, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return target, warnings
|
||||
return target, warnings
|
||||
|
|
|
|||
178
reuse_surface/private_source_verify.py
Normal file
178
reuse_surface/private_source_verify.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Owner-prepared, receipt-only native acquisition check for CUST81 revision 1.
|
||||
|
||||
Run inside the hub environment after a separately approved credential rollout:
|
||||
python -m reuse_surface.private_source_verify --proposal FILE --disclosure FILE
|
||||
--consent FILE
|
||||
No credentials are accepted as arguments. No bodies or exception text are emitted.
|
||||
This command does not register sources or use the federation cache.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from reuse_surface import federation
|
||||
from reuse_surface.registry import load_schema
|
||||
|
||||
PROPOSAL_SHA256 = "7819eb51d249f5c7aff4df9fb520afcfb76603b05a4104439499102b6c20af99"
|
||||
DISCLOSURE_SHA256 = "46ac4fe49a08054cc215aade2fbaf3b36ee334520997857ed84f530b52da9d35"
|
||||
CONSENT_SHA256 = "4a4602ad926e3af855d6ddba4731430d33e4a7432c507ba0a5193b9f43056b52"
|
||||
AUTH_ENV = "REUSE_SURFACE_FORGEJO_SOURCE_READ_TOKEN"
|
||||
REPOSITORIES = frozenset({
|
||||
"rapp-openbao", "rapp-policy-nexus", "rapp-postgres", "rapp-sbom-nexus",
|
||||
"rapp-telemetry", "rapp-tenant-engine", "rapp-user-engine",
|
||||
})
|
||||
|
||||
|
||||
def _sha(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def _load(path: Path, expected: str) -> dict[str, Any]:
|
||||
data = path.read_bytes()
|
||||
if _sha(data) != expected:
|
||||
raise ValueError("packet_digest_mismatch")
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _raw_url(repo: str, commit: str, path: str) -> str:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("invalid_commit")
|
||||
if not re.fullmatch(r"registry/(indexes|capabilities)/[a-zA-Z0-9._-]+\.(yaml|md)", path):
|
||||
raise ValueError("invalid_metadata_path")
|
||||
return (
|
||||
f"https://forgejo.coulomb.social/api/v1/repos/coulomb/{repo}/raw/"
|
||||
f"{quote(path, safe='/')}?ref={commit}"
|
||||
)
|
||||
|
||||
|
||||
def _prepared_sources(proposal_path: Path, disclosure_path: Path, consent_path: Path):
|
||||
proposal = _load(proposal_path, PROPOSAL_SHA256)
|
||||
disclosure = _load(disclosure_path, DISCLOSURE_SHA256)
|
||||
consent = _load(consent_path, CONSENT_SHA256)
|
||||
if (
|
||||
consent["consent_status"] != "approved"
|
||||
or consent["proposal_sha256"] != DISCLOSURE_SHA256
|
||||
or consent["proposal_id"] != disclosure["id"]
|
||||
or consent["proposal_revision"] != disclosure["revision"]
|
||||
or set(consent["repositories"]) != REPOSITORIES
|
||||
):
|
||||
raise ValueError("invalid_consent")
|
||||
sources = proposal["sources"]
|
||||
approved = {source["repo"]: source for source in disclosure["sources"]}
|
||||
if len(sources) != 7 or {source["repo"] for source in sources} != REPOSITORIES:
|
||||
raise ValueError("invalid_source_scope")
|
||||
for source in sources:
|
||||
owner = approved[source["repo"]]
|
||||
files = {item["path"]: item["sha256"] for item in owner["files"]}
|
||||
if (
|
||||
source["consented_commit"] != owner["commit"]
|
||||
or source["capability_ids"] != owner["capability_ids"]
|
||||
or source["index_sha256"] != files["registry/indexes/capabilities.yaml"]
|
||||
or source["auth_env"] != AUTH_ENV
|
||||
or source["native_raw_url"] != _raw_url(
|
||||
source["repo"], owner["commit"], "registry/indexes/capabilities.yaml"
|
||||
)
|
||||
):
|
||||
raise ValueError("source_not_consented")
|
||||
yield source, files
|
||||
|
||||
|
||||
def verify(proposal_path: Path, disclosure_path: Path, consent_path: Path) -> dict[str, Any]:
|
||||
receipt: dict[str, Any] = {
|
||||
"id": "CUST81-PRIVATE-NATIVE-FETCH-RECEIPT-R1",
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"proposal_sha256": PROPOSAL_SHA256,
|
||||
"disclosure_sha256": DISCLOSURE_SHA256,
|
||||
"consent_sha256": CONSENT_SHA256,
|
||||
"cache_used": False,
|
||||
"all_verified": False,
|
||||
"sources": [],
|
||||
}
|
||||
try:
|
||||
# Exhaust all packet checks before any native network request.
|
||||
sources = list(_prepared_sources(proposal_path, disclosure_path, consent_path))
|
||||
expected_urls = {
|
||||
_raw_url(source["repo"], source["consented_commit"], path)
|
||||
for source, files in sources for path in files
|
||||
}
|
||||
allowlist = json.loads(os.environ.get("REUSE_SURFACE_AUTH_SOURCE_ALLOWLIST", "{}"))
|
||||
actual_urls = allowlist.get(AUTH_ENV)
|
||||
if (
|
||||
not isinstance(actual_urls, list)
|
||||
or len(actual_urls) != 14
|
||||
or set(actual_urls) != expected_urls
|
||||
):
|
||||
raise ValueError("runtime_scope_not_exact")
|
||||
schema = Draft202012Validator(load_schema())
|
||||
except Exception:
|
||||
receipt["error"] = "packet_scope_or_schema_validation_failed"
|
||||
return receipt
|
||||
for source, files in sources:
|
||||
row = {
|
||||
"repo": source["repo"],
|
||||
"commit": source["consented_commit"],
|
||||
"index_sha256": source["index_sha256"],
|
||||
"verified": False,
|
||||
"entry_sha256": [],
|
||||
}
|
||||
receipt["sources"].append(row)
|
||||
try:
|
||||
index_text = federation.fetch_remote_index_text(source["native_raw_url"], source)
|
||||
if _sha(index_text.encode("utf-8")) != source["index_sha256"]:
|
||||
raise ValueError("index_hash_mismatch")
|
||||
index = yaml.safe_load(index_text)
|
||||
entries = index["capabilities"]
|
||||
if not isinstance(entries, list) or [e["id"] for e in entries] != source["capability_ids"]:
|
||||
raise ValueError("unexpected_capability_ids")
|
||||
for entry in entries:
|
||||
if not {"id", "name", "summary", "vector", "domain", "status", "owner", "path"} <= entry.keys():
|
||||
raise ValueError("invalid_index_row")
|
||||
if entry["owner"] != source["repo"] or entry["path"] not in files:
|
||||
raise ValueError("unconsented_entry")
|
||||
entry_url = _raw_url(source["repo"], source["consented_commit"], entry["path"])
|
||||
entry_text = federation.fetch_remote_index_text(entry_url, source)
|
||||
digest = _sha(entry_text.encode("utf-8"))
|
||||
if digest != files[entry["path"]]:
|
||||
raise ValueError("entry_hash_mismatch")
|
||||
front = re.match(r"^---\n(.*?)\n---", entry_text, re.DOTALL)
|
||||
if front is None:
|
||||
raise ValueError("missing_front_matter")
|
||||
metadata = yaml.safe_load(front.group(1))
|
||||
schema.validate(metadata)
|
||||
if metadata["id"] != entry["id"] or metadata["owner"] != source["repo"]:
|
||||
raise ValueError("entry_identity_mismatch")
|
||||
row["entry_sha256"].append(digest)
|
||||
row["verified"] = True
|
||||
except Exception:
|
||||
# Never stringify errors: HTTP handlers and parsers can embed bodies,
|
||||
# headers, credentials, or private source text in their messages.
|
||||
row["error"] = "native_fetch_or_metadata_validation_failed"
|
||||
receipt["all_verified"] = len(receipt["sources"]) == 7 and all(
|
||||
row["verified"] for row in receipt["sources"]
|
||||
)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for name in ("proposal", "disclosure", "consent"):
|
||||
parser.add_argument(f"--{name}", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
receipt = verify(args.proposal, args.disclosure, args.consent)
|
||||
print(json.dumps(receipt, sort_keys=True))
|
||||
return 0 if receipt["all_verified"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue