feat(sbom): add authoritative Nexus client
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
135b1647d7
commit
b068e9da42
5 changed files with 446 additions and 4 deletions
|
|
@ -89,9 +89,9 @@
|
||||||
| task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T06 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
| task | RMGR-WP-0010-T07 | done | — | workplans/RMGR-WP-0010-authoritative-workload-references.md |
|
||||||
| task | RMGR-WP-0011-T01 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T01 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T02 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T02 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T03 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T03 | done | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| task | RMGR-WP-0011-T04 | todo | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
| task | RMGR-WP-0011-T04 | progress | — | workplans/RMGR-WP-0011-sbom-nexus-production-client.md |
|
||||||
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0001 | open | — | intakes/intakes.md |
|
||||||
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
| intake | RMGR-IN-0002 | open | — | intakes/intakes.md |
|
||||||
| decision | RMGR-DEC-2026-001 | resolved | — | decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md |
|
| decision | RMGR-DEC-2026-001 | resolved | — | decisions/RMGR-DEC-2026-001-whynot-identifier-batch.md |
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,44 @@ Calls use a bounded timeout and surface deterministic transport, HTTP, and
|
||||||
contract errors. Credentials enter only through the platform runtime path and
|
contract errors. Credentials enter only through the platform runtime path and
|
||||||
must never appear in files, command output, or logs.
|
must never appear in files, command output, or logs.
|
||||||
|
|
||||||
|
## Authoritative client usage
|
||||||
|
|
||||||
|
The Python client requires `SBOM_NEXUS_URL`. `SBOM_NEXUS_TIMEOUT_SECONDS`
|
||||||
|
defaults to 30 seconds and is bounded to 0.1–300 seconds. An optional
|
||||||
|
`SBOM_NEXUS_TOKEN` may be injected at runtime; its value is excluded from
|
||||||
|
configuration representations and all structured errors.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from repo_manager.sbom_client import SBOMNexusClient, SBOMNexusConfig
|
||||||
|
|
||||||
|
client = SBOMNexusClient(SBOMNexusConfig.from_environment())
|
||||||
|
client.upsert_repository(
|
||||||
|
"example",
|
||||||
|
nexus_checkout_path="/srv/controlled/example/abc123",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
receipt = client.ingest_repository(
|
||||||
|
"example",
|
||||||
|
expected_source_revision="abc123",
|
||||||
|
operation_id="stable-operation-id",
|
||||||
|
)
|
||||||
|
latest = client.latest_snapshot("example")
|
||||||
|
report = client.licence_report()
|
||||||
|
```
|
||||||
|
|
||||||
|
The client URL-escapes repository slugs, accepts only object responses with the
|
||||||
|
pinned route-specific fields, and adds an `authoritative-service` context. It
|
||||||
|
does not retry mutations automatically: a timeout or server failure can occur
|
||||||
|
after Nexus commits. Structured errors therefore expose
|
||||||
|
`mutation_may_have_committed`; callers must resolve the stable operation id or
|
||||||
|
read the resulting snapshot before deciding whether to retry.
|
||||||
|
|
||||||
|
When an expected source revision is supplied, an `ingested` receipt for any
|
||||||
|
other revision fails closed as `source_revision_mismatch` and explicitly notes
|
||||||
|
that the mutation may already have committed. The checkout path sent to Nexus
|
||||||
|
is Nexus-local controlled-source identity, never authorization to mount a
|
||||||
|
workstation path into the cluster.
|
||||||
|
|
||||||
## Repository source identity and provenance
|
## Repository source identity and provenance
|
||||||
|
|
||||||
Repo Manager remains authoritative for repository slug, active state,
|
Repo Manager remains authoritative for repository slug, active state,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
|
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
|
||||||
SNAPSHOT_REQUIRED_FIELDS = frozenset(
|
SNAPSHOT_REQUIRED_FIELDS = frozenset(
|
||||||
|
|
@ -32,12 +36,234 @@ PREVIEW_CONTEXT = {
|
||||||
"advances_last_success_at": False,
|
"advances_last_success_at": False,
|
||||||
"creates_snapshot_history": False,
|
"creates_snapshot_history": False,
|
||||||
}
|
}
|
||||||
|
SERVICE_CONTEXT = {
|
||||||
|
"mode": "authoritative-service",
|
||||||
|
"authoritative": True,
|
||||||
|
"product_owner": "sbom-nexus",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SBOMContractError(ValueError):
|
class SBOMContractError(ValueError):
|
||||||
"""Raised when a Nexus response is outside the pinned consumer contract."""
|
"""Raised when a Nexus response is outside the pinned consumer contract."""
|
||||||
|
|
||||||
|
|
||||||
|
class SBOMServiceError(RuntimeError):
|
||||||
|
"""A sanitized, deterministic failure from the authoritative service client."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
mutation_may_have_committed: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.method = method
|
||||||
|
self.path = path
|
||||||
|
self.status_code = status_code
|
||||||
|
self.mutation_may_have_committed = mutation_may_have_committed
|
||||||
|
status = f" HTTP {status_code}" if status_code is not None else ""
|
||||||
|
super().__init__(f"SBOM Nexus {method} {path} failed ({code}{status})")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"method": self.method,
|
||||||
|
"path": self.path,
|
||||||
|
"status_code": self.status_code,
|
||||||
|
"mutation_may_have_committed": self.mutation_may_have_committed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SBOMNexusConfig:
|
||||||
|
"""Explicit service configuration; credentials are deliberately repr-hidden."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
timeout_seconds: float = 30.0
|
||||||
|
bearer_token: str | None = field(default=None, repr=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
url = httpx.URL(self.base_url)
|
||||||
|
if url.scheme not in {"http", "https"} or not url.host:
|
||||||
|
raise ValueError("SBOM Nexus base_url must be an absolute HTTP(S) URL")
|
||||||
|
if url.username or url.password or url.query or url.fragment:
|
||||||
|
raise ValueError("SBOM Nexus base_url must not contain credentials, query, or fragment")
|
||||||
|
if not 0.1 <= self.timeout_seconds <= 300.0:
|
||||||
|
raise ValueError("SBOM Nexus timeout_seconds must be between 0.1 and 300")
|
||||||
|
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls) -> SBOMNexusConfig:
|
||||||
|
base_url = os.getenv("SBOM_NEXUS_URL")
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("SBOM_NEXUS_URL is required for authoritative service mode")
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("SBOM_NEXUS_TIMEOUT_SECONDS", "30"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("SBOM_NEXUS_TIMEOUT_SECONDS must be numeric") from exc
|
||||||
|
return cls(
|
||||||
|
base_url=base_url,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
bearer_token=os.getenv("SBOM_NEXUS_TOKEN") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SBOMNexusClient:
|
||||||
|
"""Bounded client for Nexus-owned authoritative repository SBOM state."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: SBOMNexusConfig,
|
||||||
|
*,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._transport = transport
|
||||||
|
|
||||||
|
def upsert_repository(
|
||||||
|
self,
|
||||||
|
repo_slug: str,
|
||||||
|
*,
|
||||||
|
nexus_checkout_path: str | None,
|
||||||
|
active: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Project identity/path; the path must resolve inside Nexus's controlled source plane."""
|
||||||
|
return self._request_object(
|
||||||
|
"PUT",
|
||||||
|
f"/repositories/{quote(repo_slug, safe='')}",
|
||||||
|
body={"checkout_path": nexus_checkout_path, "active": active},
|
||||||
|
operation="repository-projection",
|
||||||
|
writes_state=True,
|
||||||
|
required_fields=frozenset({"slug", "active"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def ingest_repository(
|
||||||
|
self,
|
||||||
|
repo_slug: str,
|
||||||
|
*,
|
||||||
|
expected_source_revision: str | None = None,
|
||||||
|
operation_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Persist one Nexus scan outcome without automatic mutation retries."""
|
||||||
|
headers = {"Idempotency-Key": operation_id} if operation_id else None
|
||||||
|
result = self._request_object(
|
||||||
|
"POST",
|
||||||
|
f"/sbom/{quote(repo_slug, safe='')}/ingest",
|
||||||
|
headers=headers,
|
||||||
|
operation="ingest",
|
||||||
|
writes_state=True,
|
||||||
|
required_fields=frozenset(
|
||||||
|
{"repo_slug", "snapshot_id", "status", "entry_count", "snapshot_at"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if expected_source_revision and result.get("status") == "ingested":
|
||||||
|
actual = result.get("source_revision")
|
||||||
|
if actual != expected_source_revision:
|
||||||
|
raise SBOMServiceError(
|
||||||
|
"source_revision_mismatch",
|
||||||
|
"POST",
|
||||||
|
f"/sbom/{quote(repo_slug, safe='')}/ingest",
|
||||||
|
mutation_may_have_committed=True,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def latest_snapshot(self, repo_slug: str) -> dict[str, Any]:
|
||||||
|
return self._request_object(
|
||||||
|
"GET",
|
||||||
|
f"/sbom/{quote(repo_slug, safe='')}",
|
||||||
|
operation="latest-snapshot",
|
||||||
|
writes_state=False,
|
||||||
|
required_fields=frozenset(
|
||||||
|
{
|
||||||
|
"repo_slug",
|
||||||
|
"last_attempt_at",
|
||||||
|
"last_success_at",
|
||||||
|
"last_status",
|
||||||
|
"entry_count",
|
||||||
|
"snapshot_id",
|
||||||
|
"entries",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def licence_report(self) -> dict[str, Any]:
|
||||||
|
return self._request_object(
|
||||||
|
"GET",
|
||||||
|
"/sbom/report/licences/",
|
||||||
|
operation="licence-report",
|
||||||
|
writes_state=False,
|
||||||
|
required_fields=frozenset({"groups", "copyleft_direct_prod", "copyleft_direct_count"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _request_object(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
operation: str,
|
||||||
|
writes_state: bool,
|
||||||
|
required_fields: frozenset[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if self.config.bearer_token:
|
||||||
|
request_headers["Authorization"] = f"Bearer {self.config.bearer_token}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(
|
||||||
|
base_url=self.config.base_url,
|
||||||
|
timeout=self.config.timeout_seconds,
|
||||||
|
headers=request_headers,
|
||||||
|
transport=self._transport,
|
||||||
|
) as client:
|
||||||
|
response = client.request(method, path, json=body)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise SBOMServiceError("timeout", method, path) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise SBOMServiceError("transport_error", method, path) from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SBOMServiceError(
|
||||||
|
"http_error",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
status_code=response.status_code,
|
||||||
|
mutation_may_have_committed=writes_state and response.status_code >= 500,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SBOMServiceError(
|
||||||
|
"invalid_json",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
mutation_may_have_committed=writes_state,
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise SBOMServiceError(
|
||||||
|
"invalid_response_type",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
mutation_may_have_committed=writes_state,
|
||||||
|
)
|
||||||
|
if required_fields - payload.keys():
|
||||||
|
raise SBOMServiceError(
|
||||||
|
"contract_error",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
mutation_may_have_committed=writes_state,
|
||||||
|
)
|
||||||
|
payload["repo_manager_context"] = {
|
||||||
|
**SERVICE_CONTEXT,
|
||||||
|
"operation": operation,
|
||||||
|
"writes_state": writes_state,
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
|
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
|
||||||
"""Accept additive fields but reject unknown schemas or missing required fields."""
|
"""Accept additive fields but reject unknown schemas or missing required fields."""
|
||||||
if payload.get("schema") != SNAPSHOT_SCHEMA:
|
if payload.get("schema") != SNAPSHOT_SCHEMA:
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from repo_manager.cli import main
|
from repo_manager.cli import main
|
||||||
from repo_manager.sbom_client import (
|
from repo_manager.sbom_client import (
|
||||||
SBOMContractError,
|
SBOMContractError,
|
||||||
|
SBOMNexusClient,
|
||||||
|
SBOMNexusConfig,
|
||||||
|
SBOMServiceError,
|
||||||
licence_report_from_snapshot,
|
licence_report_from_snapshot,
|
||||||
scan_repository_via_nexus,
|
scan_repository_via_nexus,
|
||||||
validate_snapshot_contract,
|
validate_snapshot_contract,
|
||||||
|
|
@ -174,3 +178,157 @@ def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, ca
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert json.loads(output.read_text())["schema"] == "sbom-nexus.snapshot.v1"
|
assert json.loads(output.read_text())["schema"] == "sbom-nexus.snapshot.v1"
|
||||||
assert json.loads(capsys.readouterr().out)["product_owner"] == "sbom-nexus"
|
assert json.loads(capsys.readouterr().out)["product_owner"] == "sbom-nexus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_config_is_explicit_bounded_and_redacts_token(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("SBOM_NEXUS_URL", "https://nexus.example.test/")
|
||||||
|
monkeypatch.setenv("SBOM_NEXUS_TIMEOUT_SECONDS", "12.5")
|
||||||
|
monkeypatch.setenv("SBOM_NEXUS_TOKEN", "do-not-print-this")
|
||||||
|
|
||||||
|
config = SBOMNexusConfig.from_environment()
|
||||||
|
|
||||||
|
assert config.base_url == "https://nexus.example.test"
|
||||||
|
assert config.timeout_seconds == 12.5
|
||||||
|
assert "do-not-print-this" not in repr(config)
|
||||||
|
with pytest.raises(ValueError, match="must not contain credentials"):
|
||||||
|
SBOMNexusConfig("https://user:secret@nexus.example.test")
|
||||||
|
with pytest.raises(ValueError, match="between 0.1 and 300"):
|
||||||
|
SBOMNexusConfig("https://nexus.example.test", timeout_seconds=301)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_client_calls_pinned_routes_and_marks_service_context() -> None:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls.append(request)
|
||||||
|
if request.url.path.startswith("/repositories/"):
|
||||||
|
return httpx.Response(200, json={"slug": "demo", "active": True})
|
||||||
|
if request.method == "POST":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"repo_slug": "demo",
|
||||||
|
"snapshot_id": "snapshot-1",
|
||||||
|
"status": "ingested",
|
||||||
|
"entry_count": 2,
|
||||||
|
"snapshot_at": "2026-08-22T20:00:00Z",
|
||||||
|
"source_revision": "abc123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.url.path == "/sbom/report/licences/":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"groups": [],
|
||||||
|
"copyleft_direct_prod": [],
|
||||||
|
"copyleft_direct_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"repo_slug": "demo",
|
||||||
|
"last_attempt_at": "2026-08-22T20:00:00Z",
|
||||||
|
"last_success_at": "2026-08-22T20:00:00Z",
|
||||||
|
"last_status": "ingested",
|
||||||
|
"entry_count": 2,
|
||||||
|
"snapshot_id": "snapshot-1",
|
||||||
|
"entries": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = SBOMNexusClient(
|
||||||
|
SBOMNexusConfig("https://nexus.example.test", bearer_token="runtime-secret"),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
projected = client.upsert_repository(
|
||||||
|
"demo",
|
||||||
|
nexus_checkout_path="/srv/controlled/demo/abc123",
|
||||||
|
)
|
||||||
|
ingested = client.ingest_repository(
|
||||||
|
"demo",
|
||||||
|
expected_source_revision="abc123",
|
||||||
|
operation_id="operation-1",
|
||||||
|
)
|
||||||
|
latest = client.latest_snapshot("demo")
|
||||||
|
report = client.licence_report()
|
||||||
|
|
||||||
|
assert [request.url.path for request in calls] == [
|
||||||
|
"/repositories/demo",
|
||||||
|
"/sbom/demo/ingest",
|
||||||
|
"/sbom/demo",
|
||||||
|
"/sbom/report/licences/",
|
||||||
|
]
|
||||||
|
assert all(request.headers["Authorization"] == "Bearer runtime-secret" for request in calls)
|
||||||
|
assert calls[1].headers["Idempotency-Key"] == "operation-1"
|
||||||
|
assert projected["repo_manager_context"]["writes_state"] is True
|
||||||
|
assert ingested["repo_manager_context"]["mode"] == "authoritative-service"
|
||||||
|
assert latest["repo_manager_context"]["authoritative"] is True
|
||||||
|
assert report["repo_manager_context"]["operation"] == "licence-report"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_client_fails_closed_on_source_revision_mismatch() -> None:
|
||||||
|
transport = httpx.MockTransport(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"repo_slug": "demo",
|
||||||
|
"snapshot_id": "snapshot-1",
|
||||||
|
"status": "ingested",
|
||||||
|
"entry_count": 1,
|
||||||
|
"snapshot_at": "2026-08-22T20:00:00Z",
|
||||||
|
"source_revision": "different",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = SBOMNexusClient(SBOMNexusConfig("https://nexus.example.test"), transport=transport)
|
||||||
|
|
||||||
|
with pytest.raises(SBOMServiceError) as raised:
|
||||||
|
client.ingest_repository("demo", expected_source_revision="expected")
|
||||||
|
|
||||||
|
assert raised.value.code == "source_revision_mismatch"
|
||||||
|
assert raised.value.mutation_may_have_committed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_client_has_sanitized_timeout_and_http_failures() -> None:
|
||||||
|
secret = "never-echo-this-token"
|
||||||
|
|
||||||
|
def timeout_handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ReadTimeout("upstream timed out", request=request)
|
||||||
|
|
||||||
|
timeout_client = SBOMNexusClient(
|
||||||
|
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
|
||||||
|
transport=httpx.MockTransport(timeout_handler),
|
||||||
|
)
|
||||||
|
with pytest.raises(SBOMServiceError) as timeout:
|
||||||
|
timeout_client.latest_snapshot("demo")
|
||||||
|
assert timeout.value.to_dict()["code"] == "timeout"
|
||||||
|
assert secret not in str(timeout.value)
|
||||||
|
assert secret not in json.dumps(timeout.value.to_dict())
|
||||||
|
|
||||||
|
http_client = SBOMNexusClient(
|
||||||
|
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
|
||||||
|
transport=httpx.MockTransport(lambda request: httpx.Response(503, json={"detail": secret})),
|
||||||
|
)
|
||||||
|
with pytest.raises(SBOMServiceError) as failed:
|
||||||
|
http_client.ingest_repository("demo")
|
||||||
|
assert failed.value.status_code == 503
|
||||||
|
assert failed.value.mutation_may_have_committed is True
|
||||||
|
assert secret not in str(failed.value)
|
||||||
|
assert secret not in json.dumps(failed.value.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoritative_client_rejects_malformed_success_without_exposing_body() -> None:
|
||||||
|
client = SBOMNexusClient(
|
||||||
|
SBOMNexusConfig("https://nexus.example.test"),
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda request: httpx.Response(200, json={"unexpected": "sensitive body"})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SBOMServiceError) as failed:
|
||||||
|
client.latest_snapshot("demo")
|
||||||
|
|
||||||
|
assert failed.value.code == "contract_error"
|
||||||
|
assert "sensitive body" not in str(failed.value)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ workstation filesystem must never be mounted into the cluster.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0011-T02
|
id: RMGR-WP-0011-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "280cfa84-1561-5cb9-8943-aa0775c57be6"
|
state_hub_task_id: "280cfa84-1561-5cb9-8943-aa0775c57be6"
|
||||||
```
|
```
|
||||||
|
|
@ -70,6 +70,19 @@ projection, ingest, latest-snapshot, and licence-report surfaces needed by Repo
|
||||||
Manager. Preserve bounded timeouts and actionable failures; credentials, when
|
Manager. Preserve bounded timeouts and actionable failures; credentials, when
|
||||||
introduced through the platform path, must never enter files, output, or logs.
|
introduced through the platform path, must never enter files, output, or logs.
|
||||||
|
|
||||||
|
**Result (2026-08-22):** `SBOMNexusClient` provides the four pinned service
|
||||||
|
operations with explicit `SBOM_NEXUS_URL`, a configurable 0.1–300 second bound,
|
||||||
|
route-specific success validation, URL-safe slugs, and sanitized deterministic
|
||||||
|
timeout/transport/HTTP/JSON/contract errors. Optional runtime bearer
|
||||||
|
credentials are representation-hidden and never copied into failures.
|
||||||
|
|
||||||
|
Mutation calls are not retried implicitly. Ingest accepts a stable
|
||||||
|
`Idempotency-Key`, and failures state whether the operation may already have
|
||||||
|
committed. Callers may require an exact source revision; a mismatched ingested
|
||||||
|
receipt fails closed while preserving that commit-uncertainty signal. The
|
||||||
|
repository projection parameter is explicitly Nexus-local
|
||||||
|
`nexus_checkout_path`, subject to the controlled-source boundary in T01.
|
||||||
|
|
||||||
## Make local scanning an explicit preview
|
## Make local scanning an explicit preview
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -96,7 +109,7 @@ contract tests carry the same semantics.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0011-T04
|
id: RMGR-WP-0011-T04
|
||||||
status: todo
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "81fed060-3431-5d9b-819b-fcc7d629c364"
|
state_hub_task_id: "81fed060-3431-5d9b-819b-fcc7d629c364"
|
||||||
```
|
```
|
||||||
|
|
@ -106,6 +119,13 @@ prove by source inspection that Repo Manager has no scanner implementation,
|
||||||
snapshot store, freshness evaluation, catch-up policy, or licence classifier.
|
snapshot store, freshness evaluation, catch-up policy, or licence classifier.
|
||||||
Capture the exact production handoff evidence required by SBOM-WP-0002.
|
Capture the exact production handoff evidence required by SBOM-WP-0002.
|
||||||
|
|
||||||
|
**Progress (2026-08-22):** contract tests now cover additive preview schemas,
|
||||||
|
unknown schema rejection, all four authoritative routes, bounded configuration,
|
||||||
|
credential redaction, malformed success responses, timeouts, server failures,
|
||||||
|
commit uncertainty, idempotency headers, and revision mismatch. Remaining is
|
||||||
|
the production consumer handoff and final source/ownership inspection after the
|
||||||
|
controlled source-input topology is available.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- Authoritative mode talks to SBOM Nexus and returns its pinned snapshot
|
- Authoritative mode talks to SBOM Nexus and returns its pinned snapshot
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue