diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 722f498..e5fe755 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -162,12 +162,18 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: def _price_health_payload() -> dict[str, Any]: try: - snapshots = list(app.extensions["price_store"].load_manifest().get("snapshots", {}).values()) + price_store = app.extensions["price_store"] + snapshots = list(price_store.load_manifest().get("snapshots", {}).values()) except PriceSourceError as exc: return {"available": False, "status": "error", "error": str(exc), "snapshot_count": 0} if not snapshots: return {"available": False, "status": "missing", "point_in_time": False, "snapshot_count": 0} latest = max(snapshots, key=lambda item: str(item.get("retrieved_at", ""))) + try: + snapshot = price_store.load_snapshot(str(latest["snapshot_id"])) + except (FileNotFoundError, PriceSourceError, KeyError) as exc: + return {"available": False, "status": "error", "error": f"price snapshot integrity validation failed: {exc}", "snapshot_count": len(snapshots)} + source = snapshot.get("source", {}) return { "available": True, "status": "available", @@ -176,8 +182,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: "retrieved_at": latest.get("retrieved_at"), "period_start": latest.get("period_start"), "period_end": latest.get("period_end"), - "quality": latest.get("quality"), - "point_in_time": bool(latest.get("point_in_time")), + "quality": source.get("quality"), + "point_in_time": source.get("point_in_time"), "symbols": latest.get("symbols", []), } diff --git a/backend/app/prices.py b/backend/app/prices.py index af86c14..998c874 100644 --- a/backend/app/prices.py +++ b/backend/app/prices.py @@ -32,6 +32,7 @@ DEFAULT_SYMBOL_MAP = { } PRICE_SCHEMA_VERSION = 1 PRICE_PARSER_VERSION = "yahoo-chart-v1" +NORMALIZED_HASH_ALGORITHM = "sha256-json-canonical-v1" MAX_PAYLOAD_BYTES = 5_000_000 _PROVIDER_SYMBOL_RE = re.compile(r"^[A-Za-z0-9^._-]{1,64}$") @@ -57,6 +58,15 @@ def _combined_hash(raw_payloads: Mapping[str, bytes]) -> str: return digest.hexdigest() +def _normalized_snapshot_hash(snapshot: dict[str, Any]) -> str: + payload = copy.deepcopy(snapshot) + source = payload.get("source") + if isinstance(source, dict): + source.pop("normalized_snapshot_hash", None) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _raw_filename(provider_symbol: str) -> str: if not _PROVIDER_SYMBOL_RE.fullmatch(provider_symbol): raise PriceSourceError("invalid provider symbol") @@ -249,6 +259,11 @@ class PriceSnapshotStore: source = snapshot.get("source") if not isinstance(source, dict) or source.get("snapshot_id") != snapshot_id: raise PriceSourceError("price snapshot identity mismatch") + if not isinstance(source.get("point_in_time"), bool): + raise PriceSourceError("price snapshot metadata point_in_time must be boolean") + declared_snapshot_hash = source.get("normalized_snapshot_hash") + if source.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM or not isinstance(declared_snapshot_hash, str) or _normalized_snapshot_hash(snapshot) != declared_snapshot_hash: + raise PriceSourceError("price normalized snapshot hash mismatch") raw_files = source.get("raw_payload_files") if not isinstance(raw_files, dict) or not raw_files: raise PriceSourceError("price snapshot raw payload manifest is missing") @@ -267,7 +282,13 @@ class PriceSnapshotStore: if actual_hash != source.get("raw_payload_hash"): raise PriceSourceError("price raw payload hash mismatch") manifest_entry = self.load_manifest().get("snapshots", {}).get(snapshot_id) - if isinstance(manifest_entry, dict) and manifest_entry.get("raw_payload_hash") != actual_hash: + if not isinstance(manifest_entry, dict): + raise PriceSourceError("price manifest entry is missing") + if manifest_entry.get("source_id") != source.get("source_id") or manifest_entry.get("quality") != source.get("quality"): + raise PriceSourceError("price manifest metadata mismatch") + if manifest_entry.get("point_in_time") is not source.get("point_in_time"): + raise PriceSourceError("price manifest metadata mismatch") + if manifest_entry.get("raw_payload_hash") != actual_hash or manifest_entry.get("normalized_snapshot_hash") != declared_snapshot_hash or manifest_entry.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM: raise PriceSourceError("price manifest hash mismatch") return snapshot @@ -288,6 +309,8 @@ class PriceSnapshotStore: raw_hash = _combined_hash(raw_payloads) if source.get("raw_payload_hash") and source["raw_payload_hash"] != raw_hash: raise PriceSourceError("raw payload hash does not match price snapshot") + if "point_in_time" in source and not isinstance(source["point_in_time"], bool): + raise PriceSourceError("price snapshot metadata point_in_time must be boolean") stored = copy.deepcopy(snapshot) stored_source = stored["source"] snapshot_id = str(stored_source["snapshot_id"]) @@ -296,6 +319,8 @@ class PriceSnapshotStore: stored_source["raw_payload_hash"] = raw_hash stored_source["raw_payload_files"] = {symbol: _raw_filename(symbol) for symbol in raw_payloads} stored_source["snapshot_file"] = f"{snapshot_id}.json" + stored_source["normalized_hash_algorithm"] = NORMALIZED_HASH_ALGORITHM + stored_source["normalized_snapshot_hash"] = _normalized_snapshot_hash(stored) for provider_symbol, payload in raw_payloads.items(): _atomic_write(self.raw_dir / snapshot_id / _raw_filename(provider_symbol), payload) _atomic_write(self.snapshot_dir / stored_source["snapshot_file"], (json.dumps(stored, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")) @@ -307,6 +332,8 @@ class PriceSnapshotStore: "period_start": stored_source.get("period_start"), "period_end": stored_source.get("period_end"), "raw_payload_hash": raw_hash, + "normalized_snapshot_hash": stored_source["normalized_snapshot_hash"], + "normalized_hash_algorithm": NORMALIZED_HASH_ALGORITHM, "parser_version": stored_source.get("parser_version"), "quality": stored_source.get("quality"), "point_in_time": stored_source.get("point_in_time"), diff --git a/backend/app/research.py b/backend/app/research.py index 30f4eea..c93fc2b 100644 --- a/backend/app/research.py +++ b/backend/app/research.py @@ -133,7 +133,7 @@ def _validate_config(windows: Sequence[int], cost_bps: float, min_events: int, e def _price_gate(price_entry: Mapping[str, Any] | None) -> dict[str, Any]: if not price_entry: return {"status": "blocked", "reason": "price_snapshot_missing", "snapshot_count": 0} - if not bool(price_entry.get("point_in_time")): + if price_entry.get("point_in_time") is not True: return { "status": "blocked", "reason": "price_series_not_point_in_time", @@ -200,7 +200,28 @@ def run_tourism_research( except (VintageStoreError, PriceSourceError) as exc: raise ResearchRunError(str(exc)) from exc vintage_gate = assess_backtest_readiness(vintages, min_events=min_events) - price_gate = _price_gate(price_entry) + validated_vintage_snapshots: dict[str, dict[str, Any]] = {} + try: + for entry in vintages: + vintage_id = str(entry["vintage_id"]) + validated_vintage_snapshots[vintage_id] = vintage_store.load_snapshot(vintage_id) + except (FileNotFoundError, VintageStoreError, KeyError) as exc: + raise ResearchRunError(f"vintage snapshot integrity validation failed: {exc}") from exc + price_snapshot: dict[str, Any] | None = None + price_gate_entry = price_entry + if price_entry: + try: + price_snapshot = price_store.load_snapshot(str(price_entry["snapshot_id"])) + except (FileNotFoundError, PriceSourceError, KeyError) as exc: + raise ResearchRunError(f"price snapshot integrity validation failed: {exc}") from exc + price_source = price_snapshot.get("source", {}) + price_gate_entry = { + **price_entry, + "point_in_time": price_source.get("point_in_time"), + "quality": price_source.get("quality"), + "normalized_snapshot_hash": price_source.get("normalized_snapshot_hash"), + } + price_gate = _price_gate(price_gate_entry) input_vintages = [ { "vintage_id": entry.get("vintage_id"), @@ -213,7 +234,7 @@ def run_tourism_research( } for entry in vintages ] - input_price = copy.deepcopy(price_entry) if price_entry else None + input_price = copy.deepcopy(price_gate_entry) if price_gate_entry else None config = { "min_events": min_events, "windows": windows, @@ -244,14 +265,8 @@ def run_tourism_research( if price_gate["status"] != "ready": report["reason"] = price_gate["reason"] return run_store.persist(report) - if not price_entry: + if not price_entry or price_snapshot is None: raise ResearchRunError("price snapshot disappeared after readiness check") - try: - price_snapshot = price_store.load_snapshot(str(price_entry["snapshot_id"])) - except (FileNotFoundError, PriceSourceError) as exc: - report["reason"] = "price_snapshot_unreadable" - report["gates"]["prices"] = {**price_gate, "status": "blocked", "reason": report["reason"], "error": str(exc)} - return run_store.persist(report) price_source = price_snapshot.get("source", {}) price_field = str(price_source.get("return_price_field", "close")) series = price_snapshot.get("series") @@ -261,7 +276,7 @@ def run_tourism_research( events: list[dict[str, Any]] = [] try: for entry in vintages: - snapshot = vintage_store.load_snapshot(str(entry["vintage_id"])) + snapshot = validated_vintage_snapshots[str(entry["vintage_id"])] result = compute_tourism_signal(snapshot) events.append({ "event_id": entry["vintage_id"], diff --git a/backend/app/vintages.py b/backend/app/vintages.py index 4873380..23ddea2 100644 --- a/backend/app/vintages.py +++ b/backend/app/vintages.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Iterable MANIFEST_SCHEMA_VERSION = 1 +NORMALIZED_HASH_ALGORITHM = "sha256-json-canonical-v1" _VINTAGE_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") _FILENAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,191}\.(?:html|json)") @@ -29,6 +30,15 @@ def _parse_timestamp(value: str) -> datetime: return parsed +def _normalized_snapshot_hash(snapshot: dict[str, Any]) -> str: + payload = copy.deepcopy(snapshot) + source = payload.get("source") + if isinstance(source, dict): + source.pop("normalized_snapshot_hash", None) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _safe_vintage_id(vintage_id: str) -> str: if not _VINTAGE_ID_RE.fullmatch(vintage_id): raise VintageStoreError("invalid vintage_id") @@ -122,7 +132,9 @@ class VintageStore: ) revision_status = existing.get("revision_status") if existing else ("revised" if same_release else "initial") source["revision_status"] = revision_status + source["normalized_hash_algorithm"] = NORMALIZED_HASH_ALGORITHM stored_snapshot["source"] = source + source["normalized_snapshot_hash"] = _normalized_snapshot_hash(stored_snapshot) _atomic_write_bytes(self.raw_dir / source["raw_snapshot_file"], raw_bytes) _atomic_write_json(self.snapshot_dir / source["snapshot_file"], stored_snapshot) @@ -138,6 +150,8 @@ class VintageStore: "release_status": source.get("release_status"), "revision_status": revision_status, "raw_payload_hash": raw_hash, + "normalized_snapshot_hash": source["normalized_snapshot_hash"], + "normalized_hash_algorithm": NORMALIZED_HASH_ALGORITHM, "parser_version": source.get("parser_version"), "raw_snapshot_file": source["raw_snapshot_file"], "snapshot_file": source["snapshot_file"], @@ -162,6 +176,25 @@ class VintageStore: snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise VintageStoreError("vintage snapshot is unreadable") from exc - if snapshot.get("source", {}).get("vintage_id") != vintage_id: + source = snapshot.get("source") + if not isinstance(source, dict) or source.get("vintage_id") != vintage_id: raise VintageStoreError("vintage snapshot identity mismatch") + declared_snapshot_hash = source.get("normalized_snapshot_hash") + if source.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM or not isinstance(declared_snapshot_hash, str) or _normalized_snapshot_hash(snapshot) != declared_snapshot_hash: + raise VintageStoreError("vintage normalized snapshot hash mismatch") + raw_filename = _safe_filename(str(source.get("raw_snapshot_file", "")), ".html") + raw_path = (self.raw_dir / raw_filename).resolve() + if raw_path.parent != self.raw_dir.resolve() or not raw_path.is_file(): + raise VintageStoreError("vintage raw payload is missing") + if hashlib.sha256(raw_path.read_bytes()).hexdigest() != source.get("raw_payload_hash"): + raise VintageStoreError("vintage raw payload hash mismatch") + manifest_entry = self.load_manifest().get("vintages", {}).get(vintage_id) + if not isinstance(manifest_entry, dict): + raise VintageStoreError("vintage manifest entry is missing") + if manifest_entry.get("source_id") != source.get("source_id"): + raise VintageStoreError("vintage manifest metadata mismatch") + if _parse_timestamp(str(manifest_entry.get("published_at"))) != _parse_timestamp(str(source.get("published_at"))): + raise VintageStoreError("vintage manifest metadata mismatch") + if manifest_entry.get("raw_payload_hash") != source.get("raw_payload_hash") or manifest_entry.get("normalized_snapshot_hash") != declared_snapshot_hash or manifest_entry.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM: + raise VintageStoreError("vintage manifest hash mismatch") return snapshot diff --git a/backend/tests/test_research.py b/backend/tests/test_research.py index d821194..34a610d 100644 --- a/backend/tests/test_research.py +++ b/backend/tests/test_research.py @@ -4,8 +4,8 @@ import tempfile import unittest from pathlib import Path -from app.prices import PriceSnapshotStore -from app.research import ResearchRunStore, run_tourism_research +from app.prices import PriceSnapshotStore, PriceSourceError +from app.research import ResearchRunError, ResearchRunStore, run_tourism_research from app.vintages import VintageStore @@ -69,6 +69,19 @@ class ResearchRunTests(unittest.TestCase): self.assertEqual(loaded["source"]["snapshot_id"], "prices-test") self.assertEqual(loaded["source"]["raw_payload_hash"], store.load_manifest()["snapshots"]["prices-test"]["raw_payload_hash"]) + def test_price_store_rejects_manifest_metadata_tampering(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + store = PriceSnapshotStore(root) + snapshot, raw = price_snapshot(point_in_time=False) + store.persist(snapshot, raw) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["snapshots"]["prices-test"]["point_in_time"] = True + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex(PriceSourceError, "metadata"): + store.load_snapshot("prices-test") + def test_runner_persists_blocked_report_when_vintage_count_is_insufficient(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -125,6 +138,25 @@ class ResearchRunTests(unittest.TestCase): self.assertEqual(len(report["inputs"]["vintages"]), 2) self.assertIn("v1-revised", {entry["vintage_id"] for entry in report["inputs"]["vintages"]}) + def test_runner_does_not_replay_cached_ready_report_after_price_tampering(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + vintage_store = VintageStore(root / "tourism") + for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")): + vintage_store.persist(f"tourism-{index}".encode(), tourism_snapshot(f"v{index}", published_at)) + price_store = PriceSnapshotStore(root / "prices") + snapshot, raw = price_snapshot(point_in_time=True) + price_store.persist(snapshot, raw) + run_store = ResearchRunStore(root / "runs") + report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,)) + self.assertEqual(report["status"], "ready") + manifest_path = root / "prices" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["snapshots"]["prices-test"]["point_in_time"] = False + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex(ResearchRunError, "integrity"): + run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,)) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_vintages.py b/backend/tests/test_vintages.py index 3910a9a..5784651 100644 --- a/backend/tests/test_vintages.py +++ b/backend/tests/test_vintages.py @@ -115,6 +115,19 @@ class VintageStoreTests(unittest.TestCase): with self.assertRaises(ValueError): store.load_snapshot("../secret") + def test_snapshot_load_rejects_tampered_normalized_payload(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + store = VintageStore(root) + snapshot = sample_snapshot() + store.persist(REPORT_HTML.encode("utf-8"), snapshot) + snapshot_path = root / "snapshots" / f"{snapshot['source']['vintage_id']}.json" + payload = json.loads(snapshot_path.read_text(encoding="utf-8")) + payload["observations"][0]["value"] = 999999 + snapshot_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "hash"): + store.load_snapshot(snapshot["source"]["vintage_id"]) + def test_collector_returns_manifested_snapshot_summary(self): with tempfile.TemporaryDirectory() as temp_dir: result = collect_bot_vintage( diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 828633c..1bdd72a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,8 +4,8 @@ - Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` - Branch: `main` -- Verified code commit: `7f7a614` — `[verified] add SET price snapshot adapter` (M2.5 pending commit) -- Current milestone: M2.5 research runner and durable paper workflow complete; validated backtest blocked +- Verified code commit: `f55ff69` — `[verified] add frozen research runner and durable paper ledger` (integrity hardening pending commit) +- Current milestone: M2.5 integrity hardening complete; validated backtest blocked - Mode: research + paper only - Frontend: Vue 3 + Vite - Backend: Flask `0.5.0` @@ -29,6 +29,7 @@ - Frozen research runner: `POST /api/v1/research/tourism/run` and `GET /api/v1/research/tourism/latest`. - Headless runner: `backend/scripts/run_tourism_research.py`. - Durable atomic paper ledger under `backend/data/paper/ledger.json` when configured. +- Raw and normalized snapshot integrity binding with explicit `sha256-json-canonical-v1` metadata. - Ranked target weights and LONG/SHORT/NEUTRAL classification. - English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields. - HttpOnly paper session and internal paper ledger. @@ -98,10 +99,12 @@ Paper writes use a server-side token exchange and HttpOnly `paper_session` cooki - One independent source release is not enough for a valid event study; current historical rows are not treated as point-in-time vintages. - The event-study engine is deterministic and tested. Yahoo price history is connected for plumbing, but it is revised vendor history, not point-in-time data. - Research runner is operational and replayable, but correctly emits a blocked report until both evidence gates pass. +- Snapshot loads fail closed when raw files, normalized payloads or manifest metadata do not match their recorded hashes. +- The hash boundary protects local artifacts against corruption/partial writes; hostile host-level rewrite of code, manifests and runtime environment is outside this local threat model. - Revisions are deduplicated by canonical `(source_id, published_at)`; the latest observed revision is selected once. - Event-study default execution is the next trading session, including weekend/holiday event dates. - Browser screenshot verification remains blocked by the Chrome remote-debugging permission prompt; served HTML/source, live API, fresh Vite build and replay integrity were verified instead. ## Exact next action -Collect independent BOT releases over time and replace/supplement revised vendor history with a point-in-time daily price source. The operator can run the frozen research check now; only raise the gate when both requirements pass. Add another metric only when its historical release coverage is real. +Collect independent BOT releases over time and replace/supplement revised vendor history with a point-in-time daily price source. The operator can run the frozen research check now; only raise the gate when both requirements pass. Add a trusted deployment signing key before treating the app as a multi-user or hostile-host service. diff --git a/docs/engineering-log.md b/docs/engineering-log.md index e599d52..2c4ec06 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -10,6 +10,7 @@ | M2.3 event-study gate | complete/blocked | 30 tests, pure engine and truthful 409 readiness API | add point-in-time price provider | | M2.4 price snapshot adapter | complete/blocked | 38 tests, live 9-symbol Yahoo snapshot, revised-history gate | evaluate point-in-time price source | | M2.5 research runner + durable paper ledger | complete/blocked | 54 tests, immutable blocked report, restart-safe paper path, live API/UI workflow | collect independent releases and point-in-time prices | +| M2.5 integrity hardening | complete/blocked | 57 tests, normalized/raw hash binding, manifest cross-checks, live re-collection | protect point-in-time gate with a trusted deployment secret if threat model expands | | Tourism deterministic signal | complete | live foreign-arrivals YoY surprise | add occupancy/airport metric | | Internal paper ledger | complete | atomic local JSON persistence and restart test | shared store before multi-worker deployment | | Dashboard | complete | Vite build + served source check with live-sign copy | visual browser capture after permission is available | @@ -27,7 +28,7 @@ ## Verification -- Backend: 54 unittest tests pass. +- Backend: 57 unittest tests pass. - Independent M1 review: **PASSED**; no concrete security or logic blockers. - Reviewer suggestions: set `PAPER_COOKIE_SECURE=1` outside local HTTP; replace in-memory sessions before multi-worker deployment. - M1 reviewer backlog: add schema-drift, duplicate/reordered-row, and malformed-vintage regression fixtures. @@ -51,4 +52,8 @@ - Paper ledger persists atomically under ignored `backend/data/paper/ledger.json` when configured. - Live API `0.5.0` created and replayed the same blocked Tourism research report; UI served the research-run panel and human-readable gate reason. - Independent M2.5 review: **PASSED**; no concrete security or logic blockers. +- Integrity hardening: normalized snapshots and raw payloads are bound to manifest metadata; malformed boolean flags, stale manifests, and cached-ready replay after tampering are rejected. +- Canonical hash algorithm is explicit: `sha256-json-canonical-v1` using sorted-key compact UTF-8 JSON after excluding only the normalized hash field. +- Integrity scope is local artifact/corruption detection. A hostile machine owner who can rewrite code, manifests, raw files and runtime environment is outside this local research app's threat model. +- Independent integrity-hardening review: **PASSED** under the stated local single-user threat model. - Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked. diff --git a/docs/engineering-log/2026-08-23-integrity-hardening.md b/docs/engineering-log/2026-08-23-integrity-hardening.md new file mode 100644 index 0000000..ef05be0 --- /dev/null +++ b/docs/engineering-log/2026-08-23-integrity-hardening.md @@ -0,0 +1,74 @@ +# 2026-08-23 — snapshot integrity hardening + +## Plan status + +- Root-cause fix for fail-open point-in-time metadata: complete. +- Raw payload and normalized snapshot binding: complete. +- Manifest/source metadata cross-checks: complete. +- Cached research report revalidation: complete. +- Trusted host/signing boundary: documented as local-only; multi-user deployment deferred. + +## Changed files + +- `backend/app/vintages.py` — canonical normalized hash, raw payload verification, manifest/source cross-checks, timezone-equivalent revision handling. +- `backend/app/prices.py` — canonical normalized hash, strict boolean point-in-time metadata, raw/manifest/source cross-checks. +- `backend/app/research.py` — validate selected snapshots before cached report lookup and derive the price gate from the verified snapshot source. +- `backend/app/__init__.py` — price health validates the latest snapshot before reporting availability. +- `backend/tests/test_vintages.py` — normalized payload tamper regression. +- `backend/tests/test_research.py` — manifest tamper and cached-ready replay regressions. + +## Root cause + +The previous implementation trusted manifest metadata too early. A truthy string such as `"false"` could pass a boolean gate, and a ready report could be returned from cache before current snapshot bytes were revalidated. The previous vintage loader also checked identity but not raw/normalized content against the manifest. + +## Fix + +All new persisted snapshots carry: + +```text +normalized_hash_algorithm: sha256-json-canonical-v1 +normalized_snapshot_hash: SHA-256(sorted-key compact UTF-8 JSON, excluding only the hash field) +raw_payload_hash: SHA-256(raw provider payload) +``` + +Loads now verify raw files, normalized content, manifest metadata, strict boolean types, and snapshot identity before the research runner can return a cached result or run an event study. + +## Live evidence + +```text +BOT source restarted and replayable=true. +Price snapshot re-collected under the new schema; 9 symbols; point_in_time=false. +GET /api/v1/prices/health → HTTP 200, available=true, revised_vendor_history. +POST /api/v1/research/tourism/run → HTTP 200, blocked 1/12 releases. +``` + +## Verification + +```text +PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v +Ran 57 tests ... OK + +npm run build +Vite build completed successfully. + +npm audit --omit=dev --audit-level=high +found 0 vulnerabilities + +python -m compileall -q backend +python /tmp/set50_platform_security_scan.py → {} +git diff --check +``` + +## Boundary + +Content hashes are a local artifact/corruption control. They are not a signature against a hostile operator who can rewrite the application, manifests, raw files and runtime environment. Add a trusted deployment signing key before multi-user or hostile-host deployment. + +## Independent review + +```text +passed: true +security_concerns: [] +logic_errors: [] +``` + +Non-blocking backlog: retain the canonicalization/hash-version contract, keep crash/partial-write coverage, and introduce signed manifests when the deployment threat model expands. diff --git a/docs/test-evidence/2026-08-23-integrity-hardening.md b/docs/test-evidence/2026-08-23-integrity-hardening.md new file mode 100644 index 0000000..efd7a6f --- /dev/null +++ b/docs/test-evidence/2026-08-23-integrity-hardening.md @@ -0,0 +1,48 @@ +# Test evidence — 2026-08-23 integrity hardening + +## Automated + +```text +PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v +Ran 57 tests ... OK + +npm run build +Vite build completed successfully. + +npm audit --omit=dev --audit-level=high +found 0 vulnerabilities + +python -m compileall -q backend +python /tmp/set50_platform_security_scan.py +{} + +git diff --check +``` + +## Regression coverage + +- Tampered normalized Tourism snapshot is rejected. +- Tampered price manifest `point_in_time` metadata is rejected. +- A cached ready report is not replayed after price manifest tampering. +- Raw payload hash mismatch is rejected. +- Timezone-equivalent releases are treated as revisions. +- Weekend/holiday events use the first trading session after the event date. + +## Live + +```text +GET /api/v1/health → HTTP 200, version=0.5.0 +GET /api/v1/prices/health → HTTP 200, available=true, point_in_time=false +GET /api/v1/data-health → HTTP 200, replayable=true +POST /api/v1/research/tourism/run → HTTP 200, blocked=1/12 +``` + +## Independent review + +```text +passed: true +security_concerns: [] +logic_errors: [] +``` + +Review passed under the stated local single-user threat model.