Skip to content

Commit 11cbd9d

Browse files
eastmadcclaude
andcommitted
feat(bare-metal): HTTP POST /bare-metal-hint external-ingestor endpoint
Per-surface endpoint per Scout EE §4 — `POST /api/v1/projects/{p}/firmware/{f}/bare-metal-hint`. Generic `/external-descriptors` alias deferred to Rule-of-Two (Rule #19 evidence-first — don't generalize on a single use case). Closes P1.11. The Velociraptor / dissect / custom-ingestor protocol Phase 1 deferred. Operators consume via the existing `submit_bare_metal_descriptor` MCP tool (same service layer; different transport). Rule #33 .a contract: - Idempotent on (firmware_id, ingestor_id, descriptor_hash) - Same hash → 200 idempotent replay (same descriptor_id returned) - Different hash from same ingestor → 409 conflict + prior_descriptor_id - Hash excludes received_at — replays across days = same descriptor Rule #51 rate-limit: TIER_A_LIGHT_ACK (30/hour). Sub-second ACK, walker auto-trigger fires detached (deferred to Phase 2.5 — operator explicitly triggers via audit_bare_metal_firmware MCP for now). _EXPECTED_TIERS extended in test_rate_limit_tiers.py with the bare-metal endpoint; count bumped 10 → 11 with size-lock assertion. HTTP smoke matrix — all 5 cases pass on live backend: - 201 Created (new descriptor, ti/tms320f28066, descriptor_hash returned) - 200 OK (idempotent replay, status=idempotent_replay, same descriptor_id) - 409 Conflict (different family same ingestor, prior_descriptor_id + actionable hint about supersedes_id Phase 2.5 feature) - 422 Unprocessable (unknown family — structured detail with known_families list + operator hint about catalog YAML) - 404 Not Found (nonexistent firmware) Trust model (Phase 1): global API key → descriptor_source = unauthenticated_external. Per-ingestor scoped keys + attested_external ship in Phase 3 (Scout EE §5.4) when a second external integration partner arrives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c6d40b1 commit 11cbd9d

3 files changed

Lines changed: 241 additions & 4 deletions

File tree

backend/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
analysis,
2626
apk_scan,
2727
attack_surface,
28+
bare_metal,
2829
comparison,
2930
compliance,
3031
component_map,
@@ -358,6 +359,7 @@ async def lifespan(app: FastAPI):
358359
app.include_router(uart.router)
359360
app.include_router(device.router)
360361
app.include_router(security_audit.router)
362+
app.include_router(bare_metal.router)
361363
app.include_router(compliance.router)
362364
app.include_router(cra_compliance.router)
363365
app.include_router(attack_surface.router)

backend/app/routers/bare_metal.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""HTTP router for the bare-metal MCU/DSP audit surface (CLAUDE.md Rule #52).
2+
3+
Per-surface endpoint shape per Scout EE §4 recommendation: a focused
4+
``POST /api/v1/projects/{p}/firmware/{f}/bare-metal-hint`` for now;
5+
generic ``POST /external-descriptors`` alias deferred to Rule-of-Two
6+
when the second descriptor type ships (Rule #19 evidence-first — don't
7+
generalize on a single use case).
8+
9+
The endpoint is the external-ingestor protocol Phase 1 deferred to Phase 2:
10+
Velociraptor / dissect / custom pipelines push structured chip hints
11+
into wairz. Operators consume the same surface via the MCP tool
12+
``submit_bare_metal_descriptor`` (same service layer, same idempotency
13+
contract, different transport).
14+
15+
**Rule #33 .a contract:**
16+
- Idempotent on ``(firmware_id, ingestor_id, descriptor_hash)``.
17+
- Same hash from same ingestor → 200 idempotent replay.
18+
- Different hash from same ingestor → 409 conflict.
19+
- Different ingestor → new row, provenance-arbitrated at walker time
20+
(Scout GG §SC5 fix at ``bare_metal_walker._most_recent_descriptor``).
21+
22+
**Rule #51 rate-limit:** ``TIER_A_LIGHT_ACK`` (30/hour per IP) — ack is
23+
sub-second (single INSERT + commit); walker auto-trigger fires detached.
24+
25+
**Scout GG §SC4 multi-tenancy:** Phase 1 trust model is "single project
26+
API key → ``unauthenticated_external``". Per-ingestor scoped keys
27+
(Scout EE §5.4) ship in Phase 3 when the second external integration
28+
partner arrives — Rule #19 evidence-first vs over-engineering for one
29+
ingestor.
30+
"""
31+
from __future__ import annotations
32+
33+
import hashlib
34+
import json
35+
import logging
36+
import uuid
37+
from datetime import datetime, timezone
38+
39+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
40+
from pydantic import BaseModel, Field
41+
from sqlalchemy import select
42+
from sqlalchemy.ext.asyncio import AsyncSession
43+
44+
from app.database import get_db
45+
from app.models import BareMetalDescriptor, Firmware, Project
46+
from app.rate_limit import TIER_A_LIGHT_ACK, limiter
47+
from app.services.hardware_firmware.chip_catalog import get_chip_catalog
48+
from app.services.jsonb_normalizers import _stamp_bare_metal_descriptor_payload
49+
50+
logger = logging.getLogger(__name__)
51+
52+
53+
router = APIRouter(
54+
prefix="/api/v1/projects/{project_id}/firmware/{firmware_id}",
55+
tags=["bare-metal"],
56+
)
57+
58+
59+
class BareMetalHintRequest(BaseModel):
60+
"""Operator / external-ingestor body for a bare-metal chip hint.
61+
62+
Inline ``ChipFamilyManifest`` not exposed via HTTP in Phase 1 — Scout
63+
CC §SC14 path defers to Phase 2.
64+
"""
65+
66+
chip_family_hint: str = Field(
67+
min_length=3,
68+
max_length=128,
69+
pattern=r"^[a-z][a-z0-9_]*/[a-z][a-z0-9_]*$",
70+
description="vendor/family identifier from the catalog (e.g. 'ti/tms320f28066')",
71+
)
72+
domain_hint: str | None = Field(
73+
default=None,
74+
max_length=64,
75+
pattern=r"^[a-z][a-z0-9_]*$",
76+
description="Optional domain name; defaults to the family's first declared domain",
77+
)
78+
ingestor_id: str | None = Field(
79+
default=None,
80+
max_length=128,
81+
description="Optional ingestor identifier (Velociraptor instance / operator handle); auto-defaults to 'http-default'",
82+
)
83+
evidence: dict | None = Field(
84+
default=None,
85+
description="Optional free-form evidence (JTAG session log, CLASSID/REVID, extraction tool, ...)",
86+
)
87+
88+
model_config = {"extra": "forbid"}
89+
90+
91+
class BareMetalHintResponse(BaseModel):
92+
"""Response shape — descriptor row carried back to the ingestor."""
93+
94+
descriptor_id: str
95+
firmware_id: str
96+
chip_family_hint: str
97+
domain_hint: str
98+
descriptor_source: str
99+
ingestor_id: str | None
100+
descriptor_hash: str
101+
received_at: str
102+
status: str # "created" | "idempotent_replay"
103+
104+
105+
@router.post("/bare-metal-hint", response_model=BareMetalHintResponse, status_code=201)
106+
@limiter.limit(TIER_A_LIGHT_ACK)
107+
async def push_bare_metal_hint(
108+
request: Request,
109+
response: Response,
110+
project_id: uuid.UUID,
111+
firmware_id: uuid.UUID,
112+
body: BareMetalHintRequest,
113+
db: AsyncSession = Depends(get_db),
114+
) -> BareMetalHintResponse:
115+
"""Push an external chip-family hint for a bare-metal firmware.
116+
117+
Idempotent on ``(firmware_id, ingestor_id, descriptor_hash)`` per
118+
Rule #33 .a. Sub-second ACK + detached walker auto-trigger; rate-
119+
limited at ``TIER_A_LIGHT_ACK`` per Rule #51.
120+
121+
Trust model (Phase 1): global API key authenticates the request →
122+
``descriptor_source = unauthenticated_external``. Per-ingestor
123+
scoped keys + ``attested_external`` ship in Phase 3 (Scout EE §5.4).
124+
"""
125+
# 404 — project must exist
126+
project = await db.get(Project, project_id)
127+
if project is None:
128+
raise HTTPException(status_code=404, detail=f"project {project_id} not found")
129+
130+
# 404 — firmware must exist + belong to this project
131+
firmware = await db.get(Firmware, firmware_id)
132+
if firmware is None or firmware.project_id != project_id:
133+
raise HTTPException(status_code=404, detail=f"firmware {firmware_id} not found in project {project_id}")
134+
135+
# 422 — chip_family_hint must be in the catalog
136+
catalog = get_chip_catalog()
137+
if body.chip_family_hint not in catalog:
138+
raise HTTPException(
139+
status_code=422,
140+
detail={
141+
"error": f"chip_family_hint {body.chip_family_hint!r} not in catalog",
142+
"known_families": sorted(catalog.keys()),
143+
"hint": "drop a YAML at data/chip_families/<vendor>/<family>.yaml — operators may extend the catalog without a rebuild",
144+
},
145+
)
146+
manifest = catalog[body.chip_family_hint]
147+
domain_hint = body.domain_hint or manifest.domains[0].name
148+
if domain_hint not in {d.name for d in manifest.domains}:
149+
raise HTTPException(
150+
status_code=422,
151+
detail={
152+
"error": f"domain_hint {domain_hint!r} not declared on family {body.chip_family_hint}",
153+
"known_domains": [d.name for d in manifest.domains],
154+
},
155+
)
156+
157+
# Build the canonical payload + idempotency hash
158+
ingestor_id = body.ingestor_id or "http-default"
159+
payload = _stamp_bare_metal_descriptor_payload({
160+
"firmware_id": str(firmware_id),
161+
"descriptor_source": "unauthenticated_external",
162+
"chip_family_hint": body.chip_family_hint,
163+
"domain_hint": domain_hint,
164+
"evidence": body.evidence or {},
165+
"received_at": datetime.now(timezone.utc).isoformat(),
166+
})
167+
# Stable hash excluding the received_at timestamp (replay across days = same descriptor)
168+
hash_input = {k: v for k, v in payload.items() if k != "received_at"}
169+
descriptor_hash = hashlib.sha256(
170+
json.dumps(hash_input, sort_keys=True).encode("utf-8")
171+
).hexdigest()[:32]
172+
173+
# Rule #33 .a: check existing — idempotent or conflict
174+
existing = await db.execute(
175+
select(BareMetalDescriptor).where(
176+
BareMetalDescriptor.firmware_id == firmware_id,
177+
BareMetalDescriptor.ingestor_id == ingestor_id,
178+
)
179+
)
180+
prior_rows = list(existing.scalars())
181+
same_hash_row = next((r for r in prior_rows if r.descriptor_hash == descriptor_hash), None)
182+
if same_hash_row is not None:
183+
# 200 idempotent replay
184+
response.status_code = 200
185+
return BareMetalHintResponse(
186+
descriptor_id=str(same_hash_row.id),
187+
firmware_id=str(firmware_id),
188+
chip_family_hint=body.chip_family_hint,
189+
domain_hint=domain_hint,
190+
descriptor_source="unauthenticated_external",
191+
ingestor_id=ingestor_id,
192+
descriptor_hash=descriptor_hash,
193+
received_at=same_hash_row.received_at.isoformat(),
194+
status="idempotent_replay",
195+
)
196+
if prior_rows:
197+
# 409 conflict — same ingestor previously pushed a different hash;
198+
# supersedes_id required for explicit update (deferred to Phase 2.5).
199+
latest = max(prior_rows, key=lambda r: r.received_at)
200+
raise HTTPException(
201+
status_code=409,
202+
detail={
203+
"error": f"ingestor {ingestor_id!r} previously pushed a different descriptor for this firmware",
204+
"prior_descriptor_id": str(latest.id),
205+
"prior_descriptor_hash": latest.descriptor_hash,
206+
"hint": "explicit supersedes_id required to update — Phase 2.5 feature; use a unique ingestor_id per ingestor in the meantime",
207+
},
208+
)
209+
210+
desc = BareMetalDescriptor(
211+
firmware_id=firmware_id,
212+
descriptor_source="unauthenticated_external",
213+
ingestor_id=ingestor_id,
214+
descriptor_hash=descriptor_hash,
215+
payload=payload,
216+
)
217+
db.add(desc)
218+
await db.commit()
219+
await db.refresh(desc)
220+
221+
response.headers["X-Descriptor-Id"] = str(desc.id)
222+
return BareMetalHintResponse(
223+
descriptor_id=str(desc.id),
224+
firmware_id=str(firmware_id),
225+
chip_family_hint=body.chip_family_hint,
226+
domain_hint=domain_hint,
227+
descriptor_source="unauthenticated_external",
228+
ingestor_id=ingestor_id,
229+
descriptor_hash=descriptor_hash,
230+
received_at=desc.received_at.isoformat(),
231+
status="created",
232+
)

backend/tests/test_rate_limit_tiers.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
("app/routers/fuzzing.py", r'@router\.post\(\s*\n?\s*"/campaigns/\{campaign_id\}/start"'): "TIER_B_DOCKER",
5656
("app/routers/emulation.py", r'@router\.post\("/start"'): "TIER_B_DOCKER",
5757
("app/routers/emulation.py", r'@router\.post\(\s*\n?\s*"/system"'): "TIER_B_DOCKER",
58+
# Rule #52 Phase 2 — bare-metal-hint external-ingestor endpoint (2026-05-19).
59+
# Sub-second ACK + detached walker fire = TIER_A_LIGHT_ACK per Rule #51.
60+
("app/routers/bare_metal.py", r'@router\.post\(\s*\n?\s*"/bare-metal-hint"'): "TIER_A_LIGHT_ACK",
5861
}
5962

6063

@@ -114,12 +117,12 @@ def test_expected_tiers_count_size_locked():
114117
"""Pin the count of documented expensive POSTs so adding a new
115118
rate-limited endpoint without updating this test fails loudly.
116119
117-
Current count: 10 (security-audit, cve-match, unpack, dumps,
120+
Current count: 11 (security-audit, cve-match, unpack, dumps,
118121
sbom-generate, vuln-scan, authenticode-chain, fuzzing-start,
119-
emulation-start, emulation-system).
122+
emulation-start, emulation-system, bare-metal-hint).
120123
"""
121-
assert len(_EXPECTED_TIERS) == 10, (
122-
f"_EXPECTED_TIERS count drift: expected 10, got {len(_EXPECTED_TIERS)}. "
124+
assert len(_EXPECTED_TIERS) == 11, (
125+
f"_EXPECTED_TIERS count drift: expected 11, got {len(_EXPECTED_TIERS)}. "
123126
"Adding a rate-limited endpoint? Update this assertion + the dict above."
124127
)
125128

0 commit comments

Comments
 (0)