diff --git a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx index 8d7e3a011..1c94b1432 100644 --- a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx +++ b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx @@ -43,6 +43,14 @@ type Copy = { audit: string; aiTrace: string; sources: string; + supplyChain: string; + runtimeImages: string; + digestPinned: string; + forbiddenSources: string; + imagePolicy: string; + compliant: string; + repair: string; + notEvidenced: string; latestScan: string; freshness: string; runAssets: string; @@ -75,6 +83,14 @@ const COPY: Record<"zh" | "en", Copy> = { audit: "稽核軌跡", aiTrace: "AI 軌跡", sources: "資料源", + supplyChain: "Runtime 供應鏈", + runtimeImages: "Runtime 映像", + digestPinned: "Digest 鎖定", + forbiddenSources: "禁用來源", + imagePolicy: "映像政策", + compliant: "符合", + repair: "需修復", + notEvidenced: "未盤點", latestScan: "最新盤點", freshness: "新鮮度", runAssets: "本輪資產", @@ -105,6 +121,14 @@ const COPY: Record<"zh" | "en", Copy> = { audit: "Audit trail", aiTrace: "AI trace", sources: "Sources", + supplyChain: "Runtime supply chain", + runtimeImages: "Runtime images", + digestPinned: "Digest pinned", + forbiddenSources: "Forbidden sources", + imagePolicy: "Image policy", + compliant: "PASS", + repair: "REPAIR", + notEvidenced: "NO DATA", latestScan: "Latest scan", freshness: "Freshness", runAssets: "Run assets", @@ -178,14 +202,21 @@ function TruthCell({ value, icon: Icon, tone = "neutral", + wideOnMobile = false, }: { label: string; value: string | number; icon: typeof ShieldCheck; tone?: "neutral" | "healthy" | "critical"; + wideOnMobile?: boolean; }) { return ( -
+
0 ? "critical" : "healthy"} + wideOnMobile />
+
+
+
+
+ + + + +
+
+ {payload ? (
diff --git a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts index fd0d85906..3aeff3e4d 100644 --- a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts +++ b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts @@ -27,6 +27,8 @@ function payload(): SecurityAssetControlPlanePayload { stale_asset_count: 0, orphan_asset_count: 0, relationship_count: 40, + package_digest_unpinned_count: 0, + forbidden_github_supply_chain_asset_count: 0, automation_coverage_green_percent: 85, automation_coverage_unknown_count: 0, compliance_violation_count: 0, @@ -99,6 +101,18 @@ function payload(): SecurityAssetControlPlanePayload { accepted_ai_trace_count_24h: 2, immutable_external_audit_store_evidenced: false, }, + supply_chain: { + package_asset_count: 12, + package_inventory_evidenced: true, + package_digest_pinned_count: 12, + package_digest_unpinned_count: 0, + forbidden_github_supply_chain_asset_count: 0, + github_freeze_compliant: true, + runtime_image_policy_compliant: true, + raw_image_identity_returned: false, + registry_api_called: false, + github_api_used: false, + }, source_health: [ { source_id: "asset_inventory", status: "ready", reason_code: null }, ], @@ -144,6 +158,15 @@ describe("security asset control plane projection", () => { boundaries: { ...safe.boundaries, raw_asset_identity_returned: true }, }; expect(isSecurityAssetControlPlanePayload(unsafe)).toBe(false); + + const unsafeSupplyChain = { + ...safe, + supply_chain: { + ...safe.supply_chain, + raw_image_identity_returned: true, + }, + }; + expect(isSecurityAssetControlPlanePayload(unsafeSupplyChain)).toBe(false); }); it("does not show healthy before same-run closure is proven", () => { @@ -157,6 +180,20 @@ describe("security asset control plane projection", () => { expect(securityControlPlaneTone(value)).toBe("critical"); }); + it("keeps runtime image supply-chain gaps critical", () => { + const value = payload(); + value.ai_automation.same_run_closed_loop_proven = true; + expect(securityControlPlaneTone(value)).toBe("healthy"); + + value.supply_chain!.package_digest_pinned_count = 0; + value.supply_chain!.package_digest_unpinned_count = 12; + value.supply_chain!.runtime_image_policy_compliant = false; + expect(securityControlPlaneTone(value)).toBe("critical"); + + value.supply_chain = undefined; + expect(securityControlPlaneTone(value)).toBe("critical"); + }); + it("keeps P0 work ahead of P1 regardless of API order", () => { const items = topSecurityControlPlaneWorkItems(payload(), 2); expect(items.map((item) => item.work_item_id)).toEqual([ diff --git a/apps/web/src/lib/security-asset-control-plane.ts b/apps/web/src/lib/security-asset-control-plane.ts index 41ba011e1..3630688e7 100644 --- a/apps/web/src/lib/security-asset-control-plane.ts +++ b/apps/web/src/lib/security-asset-control-plane.ts @@ -14,6 +14,8 @@ export type SecurityControlPlaneSummary = { stale_asset_count: number; orphan_asset_count: number; relationship_count: number; + package_digest_unpinned_count?: number; + forbidden_github_supply_chain_asset_count?: number; automation_coverage_green_percent: number; automation_coverage_unknown_count: number; compliance_violation_count: number; @@ -69,6 +71,19 @@ export type SecurityControlPlaneWorkItem = { next_action: string; }; +export type SecurityControlPlaneSupplyChain = { + package_asset_count: number; + package_inventory_evidenced: boolean; + package_digest_pinned_count: number; + package_digest_unpinned_count: number; + forbidden_github_supply_chain_asset_count: number; + github_freeze_compliant: boolean; + runtime_image_policy_compliant: boolean; + raw_image_identity_returned: false; + registry_api_called: false; + github_api_used: false; +}; + export type SecurityAssetControlPlanePayload = { schema_version: "iwooos_security_asset_control_plane_v1"; generated_at: string; @@ -140,6 +155,7 @@ export type SecurityAssetControlPlanePayload = { accepted_ai_trace_count_24h: number; immutable_external_audit_store_evidenced: boolean; }; + supply_chain?: SecurityControlPlaneSupplyChain; source_health: Array<{ source_id: string; status: "ready" | "unavailable"; @@ -178,6 +194,24 @@ export function isSecurityAssetControlPlanePayload( if (!isRecord(value.ai_automation) || !Array.isArray(value.ai_automation.stages)) { return false; } + if (value.supply_chain !== undefined) { + if (!isRecord(value.supply_chain)) return false; + if ( + typeof value.supply_chain.package_asset_count !== "number" || + typeof value.supply_chain.package_inventory_evidenced !== "boolean" || + typeof value.supply_chain.package_digest_pinned_count !== "number" || + typeof value.supply_chain.package_digest_unpinned_count !== "number" || + typeof value.supply_chain.forbidden_github_supply_chain_asset_count !== + "number" || + typeof value.supply_chain.github_freeze_compliant !== "boolean" || + typeof value.supply_chain.runtime_image_policy_compliant !== "boolean" || + value.supply_chain.raw_image_identity_returned !== false || + value.supply_chain.registry_api_called !== false || + value.supply_chain.github_api_used !== false + ) { + return false; + } + } return ( typeof value.summary.managed_asset_count === "number" && typeof value.summary.source_count === "number" && @@ -202,6 +236,15 @@ export function securityControlPlaneTone( payload: SecurityAssetControlPlanePayload ): SecurityControlPlaneTone { if (payload.status !== "ready" || !payload.discovery.fresh) return "critical"; + if ( + !payload.supply_chain || + !payload.supply_chain.package_inventory_evidenced || + payload.supply_chain.package_digest_unpinned_count > 0 || + payload.supply_chain.forbidden_github_supply_chain_asset_count > 0 || + !payload.supply_chain.runtime_image_policy_compliant + ) { + return "critical"; + } if ( payload.summary.compliance_violation_count > 0 || payload.summary.nist_controlled_percent < 50 ||