diff --git a/drift v3/crates/drift-engine/src/check_command.rs b/drift v3/crates/drift-engine/src/check_command.rs index fc2c9e37..793c79ff 100644 --- a/drift v3/crates/drift-engine/src/check_command.rs +++ b/drift v3/crates/drift-engine/src/check_command.rs @@ -1190,11 +1190,8 @@ fn phase6_finding_text(kind: &str) -> (&'static str, &'static str, &'static str) } fn route_path_from_file(file_path: &str) -> Option { - if let Some(rest) = file_path - .strip_prefix("app/") - .and_then(|path| path.strip_suffix("/route.ts")) - { - return Some(format!("/{}", rest.trim_end_matches('/'))); + if let Some(path) = next_route_path(file_path) { + return Some(path); } if let Some(rest) = file_path .strip_prefix("pages") @@ -2122,6 +2119,65 @@ fn input_line_from_fact_id(fact_id: &str) -> usize { .unwrap_or(0) } +fn security_line_evidence_refs( + route_id: &str, + file_path: &str, + capability: &str, + role: &str, + kind: &str, + fact_ids: Vec, +) -> Vec { + let mut refs = Vec::new(); + let mut seen = BTreeSet::new(); + for fact_id in fact_ids { + if fact_id.is_empty() || !seen.insert(fact_id.clone()) { + continue; + } + let line = input_line_from_fact_id(&fact_id); + let mut evidence = serde_json::Map::new(); + evidence.insert( + "evidence_id".to_string(), + json!(format!("evidence:{route_id}:{fact_id}:{kind}")), + ); + evidence.insert("fact_id".to_string(), json!(fact_id)); + evidence.insert("capability".to_string(), json!(capability)); + evidence.insert("kind".to_string(), json!(kind)); + evidence.insert("file_path".to_string(), json!(file_path)); + if line > 0 { + evidence.insert("start_line".to_string(), json!(line)); + evidence.insert("end_line".to_string(), json!(line)); + } + evidence.insert("role".to_string(), json!(role)); + refs.push(serde_json::Value::Object(evidence)); + } + refs +} + +fn phase4_missing_fact_ids(proof: &SecurityBoundaryProof) -> Vec { + proof + .tenant + .missing + .iter() + .map(|missing| missing.data_operation_fact_id.clone()) + .chain( + proof + .authorization + .missing + .iter() + .filter_map(|missing| missing.sink_fact_id.clone()), + ) + .chain( + proof + .session_trust + .missing_trust + .iter() + .map(|missing| missing.fact_id.clone()), + ) + .collect::>() + .into_iter() + .collect() +} + fn phase5_proof_json( proof: &SecurityBoundaryProof, route_id: &str, @@ -2178,6 +2234,18 @@ fn phase5_proof_json( }) }) .collect::>(); + let evidence_refs = security_line_evidence_refs( + route_id, + file_path, + if convention.kind == "api_route_forbids_sensitive_response_fields" { + "response_shape_facts" + } else { + "secret_exposure" + }, + "missing_proof", + missing_code, + missing_fact_ids.clone(), + ); let parser_gaps = proof .parser_gaps .iter() @@ -2207,6 +2275,7 @@ fn phase5_proof_json( "route_id": route_id, "file_path": file_path, "file_role": "api_route", + "endpoint": route_endpoint(file_path, handler_symbol), "handler_symbol": handler_symbol }, "contracts": [{ @@ -2255,6 +2324,7 @@ fn phase5_proof_json( }, "missing_proof": missing_proof, "parser_gaps": parser_gaps, + "evidence_refs": evidence_refs, "result": { "proof_status": security_proof_status(&proof.result.proof_status), "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { @@ -2330,6 +2400,23 @@ fn route_security_proof_json( }) }) .collect::>(); + let evidence_refs = security_line_evidence_refs( + &proof.route_id, + &proof.file_path, + "control_flow_guard_dominance", + if proof.auth.proven { + "guard" + } else { + "missing_proof" + }, + "auth_boundary", + proof + .trusted_guard_calls + .iter() + .map(|guard| guard.fact_id.clone()) + .chain(undominated_fact_ids.clone()) + .collect(), + ); json!({ "proof_id": format!("proof:{}:auth", proof.route_id), @@ -2338,6 +2425,7 @@ fn route_security_proof_json( "route_id": proof.route_id, "file_path": proof.file_path, "file_role": "api_route", + "endpoint": route_endpoint(&proof.file_path, &proof.handler_symbol), "handler_symbol": proof.handler_symbol }, "contracts": [{ @@ -2379,6 +2467,7 @@ fn route_security_proof_json( }, "missing_proof": missing_proof, "parser_gaps": parser_gaps, + "evidence_refs": evidence_refs, "result": { "proof_status": security_proof_status(&proof.result.proof_status), "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { @@ -2492,6 +2581,38 @@ fn request_validation_proof_json( serde_json::Value::Object(object) }) .collect::>(); + let evidence_refs = security_line_evidence_refs( + route_id, + file_path, + "request_validation_facts", + if proof.request_validation.proven { + "validator" + } else { + "missing_proof" + }, + "request_validation_boundary", + proof + .request_validation + .input_reads + .iter() + .map(|input| input.fact_id.clone()) + .chain( + proof + .request_validation + .validations + .iter() + .map(|validation| validation.fact_id.clone()), + ) + .chain( + proof + .request_validation + .validated_uses + .iter() + .map(|use_proof| use_proof.fact_id.clone()), + ) + .chain(missing_fact_ids.clone()) + .collect(), + ); json!({ "proof_id": format!("proof:{route_id}:request_validation"), @@ -2500,6 +2621,7 @@ fn request_validation_proof_json( "route_id": route_id, "file_path": file_path, "file_role": "api_route", + "endpoint": route_endpoint(file_path, handler_symbol), "handler_symbol": handler_symbol }, "contracts": [{ @@ -2554,6 +2676,7 @@ fn request_validation_proof_json( }, "missing_proof": missing_proof, "parser_gaps": parser_gaps, + "evidence_refs": evidence_refs, "result": { "proof_status": security_proof_status(&proof.result.proof_status), "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { @@ -2681,6 +2804,7 @@ fn phase4_proof_json( }) }) .collect::>(); + let missing_fact_ids = phase4_missing_fact_ids(proof); let missing_proof = missing_proof_ids .iter() .map(|id| { @@ -2689,11 +2813,50 @@ fn phase4_proof_json( "capability": phase4_expected_layer(&convention.kind), "code": missing_code, "blocks_enforcement": true, - "fact_ids": [], + "fact_ids": missing_fact_ids.clone(), "graph_edge_ids": [] }) }) .collect::>(); + let evidence_refs = security_line_evidence_refs( + route_id, + file_path, + phase4_expected_layer(&convention.kind), + if proof.result.proof_status == SecurityProofStatus::Proven { + "guard" + } else { + "missing_proof" + }, + &missing_code, + proof + .session_trust + .trusted_sessions + .iter() + .map(|session| session.fact_id.clone()) + .chain( + proof + .authorization + .role_or_policy_guards + .iter() + .map(|guard| guard.fact_id.clone()), + ) + .chain( + proof + .tenant + .tenant_sources + .iter() + .map(|source| source.fact_id.clone()), + ) + .chain( + proof + .tenant + .predicates + .iter() + .map(|predicate| predicate.fact_id.clone()), + ) + .chain(missing_fact_ids.clone()) + .collect(), + ); json!({ "proof_id": format!("proof:{route_id}:phase4"), @@ -2702,6 +2865,7 @@ fn phase4_proof_json( "route_id": route_id, "file_path": file_path, "file_role": "api_route", + "endpoint": route_endpoint(file_path, handler_symbol), "handler_symbol": handler_symbol }, "contracts": [{ @@ -2787,6 +2951,7 @@ fn phase4_proof_json( }, "missing_proof": missing_proof, "parser_gaps": parser_gaps, + "evidence_refs": evidence_refs, "result": { "proof_status": security_proof_status(&proof.result.proof_status), "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { @@ -2812,6 +2977,50 @@ fn security_proof_status(status: &SecurityProofStatus) -> &'static str { } } +fn route_endpoint(file_path: &str, handler_symbol: &str) -> serde_json::Value { + let Some(path) = next_route_path(file_path) else { + return json!({ "method": handler_symbol }); + }; + json!({ + "path": path, + "method": handler_symbol, + "framework": "next" + }) +} + +fn next_route_path(file_path: &str) -> Option { + let normalized = file_path.replace('\\', "/"); + let route = normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.ts") + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.tsx") + }) + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.js") + }) + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.jsx") + })?; + let segments = route + .split('/') + .filter(|segment| !(segment.starts_with('(') && segment.ends_with(')'))) + .collect::>(); + if segments.is_empty() { + return Some("/api".to_string()); + } + Some(format!( + "/api/{}", + segments.join("/").replace("[", ":").replace("]", "") + )) +} + fn security_auth_files( facts: &[Fact], parsed_diff: &ParsedDiff, diff --git a/drift v3/crates/drift-engine/src/security_capabilities.rs b/drift v3/crates/drift-engine/src/security_capabilities.rs index 62662ecc..b57e489d 100644 --- a/drift v3/crates/drift-engine/src/security_capabilities.rs +++ b/drift v3/crates/drift-engine/src/security_capabilities.rs @@ -73,5 +73,40 @@ pub fn security_capabilities() -> Vec { can_block: true, block_requires_accepted_convention: true, }, + SecurityScanCapability { + name: "ssrf".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "raw_sql".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "cors_policy".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "csrf".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "rate_limit".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, ] } diff --git a/drift v3/crates/drift-engine/src/security_phase6.rs b/drift v3/crates/drift-engine/src/security_phase6.rs index 0c7798ea..b8f9f711 100644 --- a/drift v3/crates/drift-engine/src/security_phase6.rs +++ b/drift v3/crates/drift-engine/src/security_phase6.rs @@ -608,6 +608,13 @@ fn build_guard_proof( } else { config.not_dominating_code }; + let missing_fact_ids = sink_line + .map(|line| vec![fact_id(file_path, protection_kind, line)]) + .unwrap_or_else(|| vec![fact_id(file_path, protection_kind, route.start_line)]); + let guard_fact_ids = guard_calls + .iter() + .map(|guard| guard.fact_id.clone()) + .collect::>(); Phase6GuardProof { required: true, proven, @@ -615,7 +622,11 @@ fn build_guard_proof( missing_proof: (!proven) .then(|| Phase6MissingProof { code: code.to_string(), - fact_ids: Vec::new(), + fact_ids: if guard_fact_ids.is_empty() { + missing_fact_ids + } else { + guard_fact_ids.into_iter().chain(missing_fact_ids).collect() + }, }) .into_iter() .collect(), @@ -1037,15 +1048,16 @@ pub fn phase6_proof_to_json( }) .collect::>(); let missing_codes = phase6_missing_codes(proof); - let missing_proof = missing_codes + let missing_entries = phase6_missing_entries(proof); + let missing_proof = missing_entries .iter() - .map(|code| { + .map(|missing| { json!({ - "id": format!("missing_proof:{}:{code}", proof.route_id), + "id": format!("missing_proof:{}:{}", proof.route_id, missing.code), "capability": phase6_capability(contract_kind), - "code": code, + "code": missing.code, "blocks_enforcement": true, - "fact_ids": [], + "fact_ids": missing.fact_ids, "graph_edge_ids": [] }) }) @@ -1057,6 +1069,7 @@ pub fn phase6_proof_to_json( "route_id": proof.route_id, "file_path": proof.file_path, "file_role": "api_route", + "endpoint": route_endpoint(&proof.file_path, &proof.handler_symbol), "handler_symbol": proof.handler_symbol }, "contracts": [{ @@ -1086,6 +1099,7 @@ pub fn phase6_proof_to_json( "cors": cors_json(&proof.cors), "csrf": guard_json(&proof.csrf), "rate_limit": guard_json(&proof.rate_limit), + "evidence_refs": phase6_evidence_refs(proof, contract_kind), "missing_proof": missing_proof, "parser_gaps": parser_gaps, "result": { @@ -1187,6 +1201,116 @@ fn phase6_missing_codes(proof: &Phase6SecurityProof) -> Vec { .collect() } +fn phase6_missing_entries(proof: &Phase6SecurityProof) -> Vec { + let mut by_code = BTreeMap::>::new(); + for missing in [ + &proof.ssrf.missing_proof, + &proof.raw_sql.missing_proof, + &proof.cors.missing_proof, + &proof.csrf.missing_proof, + &proof.rate_limit.missing_proof, + ] + .into_iter() + .flat_map(|missing| missing.iter()) + { + by_code + .entry(missing.code.clone()) + .or_default() + .extend(missing.fact_ids.iter().cloned()); + } + by_code + .into_iter() + .map(|(code, fact_ids)| Phase6MissingProof { + code, + fact_ids: fact_ids.into_iter().collect(), + }) + .collect() +} + +fn phase6_evidence_refs( + proof: &Phase6SecurityProof, + contract_kind: &str, +) -> Vec { + let capability = phase6_capability(contract_kind); + let mut refs = Vec::new(); + for request in &proof.ssrf.outbound_requests { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{}", proof.route_id, request.fact_id), + "fact_id": request.fact_id, + "capability": capability, + "kind": "outbound_request_detected", + "file_path": proof.file_path, + "start_line": request.start_line, + "end_line": request.start_line, + "role": "sink" + })); + } + for call in &proof.raw_sql.raw_sql_calls { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{}", proof.route_id, call.fact_id), + "fact_id": call.fact_id, + "capability": capability, + "kind": "raw_sql_called", + "file_path": proof.file_path, + "start_line": call.start_line, + "end_line": call.start_line, + "role": "sink" + })); + } + for policy in &proof.cors.policies { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{}", proof.route_id, policy.fact_id), + "fact_id": policy.fact_id, + "capability": capability, + "kind": "cors_policy_detected", + "file_path": proof.file_path, + "start_line": policy.start_line, + "end_line": policy.start_line, + "role": "policy" + })); + } + for guard in proof + .csrf + .guard_calls + .iter() + .chain(proof.rate_limit.guard_calls.iter()) + { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{}", proof.route_id, guard.fact_id), + "fact_id": guard.fact_id, + "graph_edge_id": guard.edge_id, + "capability": capability, + "kind": "security_guard_called", + "file_path": proof.file_path, + "start_line": guard.start_line, + "end_line": guard.end_line, + "role": "guard" + })); + } + for gap in &proof.parser_gaps { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{}", proof.route_id, gap.parser_gap_id), + "capability": capability, + "kind": gap.code, + "file_path": gap.file_path, + "role": "parser_gap" + })); + } + for missing in phase6_missing_entries(proof) { + for fact_id in missing.fact_ids { + refs.push(json!({ + "evidence_id": format!("evidence:{}:{fact_id}:{}", proof.route_id, missing.code), + "fact_id": fact_id, + "capability": capability, + "kind": missing.code, + "file_path": proof.file_path, + "role": "missing_proof" + })); + } + } + refs +} + fn phase6_capability(kind: &str) -> &'static str { match kind { "api_route_forbids_untrusted_ssrf" => "outbound_request_facts", @@ -1198,6 +1322,51 @@ fn phase6_capability(kind: &str) -> &'static str { } } +fn route_endpoint(file_path: &str, handler_symbol: &str) -> serde_json::Value { + let Some(path) = next_route_path(file_path) else { + return json!({ "method": handler_symbol }); + }; + json!({ + "path": path, + "method": handler_symbol, + "framework": "next" + }) +} + +fn next_route_path(file_path: &str) -> Option { + let normalized = file_path.replace('\\', "/"); + let route = normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.ts") + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.tsx") + }) + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.js") + }) + .or_else(|| { + normalized + .strip_prefix("app/api/")? + .strip_suffix("/route.jsx") + })?; + let segments = route + .split('/') + .filter(|segment| !(segment.starts_with('(') && segment.ends_with(')'))) + .collect::>(); + Some(if segments.is_empty() { + "/api".to_string() + } else { + format!( + "/api/{}", + segments.join("/").replace("[", ":").replace("]", "") + ) + }) +} + fn security_proof_status(status: SecurityProofStatus) -> &'static str { match status { SecurityProofStatus::Proven => "proven", diff --git a/drift v3/crates/drift-engine/tests/security_capabilities.rs b/drift v3/crates/drift-engine/tests/security_capabilities.rs index cd5776a2..495e8f78 100644 --- a/drift v3/crates/drift-engine/tests/security_capabilities.rs +++ b/drift v3/crates/drift-engine/tests/security_capabilities.rs @@ -84,3 +84,21 @@ fn phase4_capabilities_reflect_supported_parser_gaps_and_contracts() { "tenant scope must stay partial while dynamic tenant shapes are parser-gap backed: {capabilities:#?}" ); } + +#[test] +fn security_phase8_reports_phase6_capabilities() { + let capabilities = security_capabilities(); + + for expected in ["ssrf", "raw_sql", "cors_policy", "csrf", "rate_limit"] { + let capability = capabilities + .iter() + .find(|capability| capability.name == expected) + .unwrap_or_else(|| panic!("missing {expected}: {capabilities:#?}")); + assert_eq!(capability.capability, "deterministic_check"); + assert!( + capability.can_block, + "{expected} must be block-capable behind accepted contracts" + ); + assert!(capability.block_requires_accepted_convention); + } +} diff --git a/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs b/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs index cdc5369d..a0fd379d 100644 --- a/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs +++ b/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs @@ -206,6 +206,97 @@ fn canonical_requires_auth_helpers_normalizes_trusted_guard_calls() { ); } +#[test] +fn security_phase8_proof_includes_route_path_and_method() { + let source = [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "export async function GET() {", + " await requireUser();", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("phase8_route_metadata", &source, "required_calls"); + let proof = &payload["security_boundary_proofs"][0]; + + assert_eq!(proof["route"]["file_role"], "api_route"); + assert_eq!(proof["route"]["endpoint"]["path"], "/api/projects"); + assert_eq!(proof["route"]["endpoint"]["method"], "GET"); + assert_eq!(proof["route"]["endpoint"]["framework"], "next"); +} + +#[test] +fn route_group_endpoint_is_normalized_for_security_proofs() { + let repo_root = temp_repo("route_group_endpoint"); + let route_path = repo_root.join("app/api/(admin)/users/route.ts"); + fs::create_dir_all(route_path.parent().expect("route parent")).expect("create route parent"); + fs::write( + &route_path, + [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "export async function GET() {", + " await requireUser();", + " const users = await db.user.findMany();", + " return Response.json({ users });", + "}", + "", + ] + .join("\n"), + ) + .expect("write route"); + + let payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_auth", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_auth", + "facts": [ + { "kind": "file_role_detected", "file_path": "app/api/(admin)/users/route.ts", "name": "api_route", "start_line": 1, "end_line": 7 }, + { "kind": "import_used", "file_path": "app/api/(admin)/users/route.ts", "name": "requireUser", "value": "@/server/auth", "imported_name": "requireUser", "start_line": 1, "end_line": 1 }, + { "kind": "route_declared", "file_path": "app/api/(admin)/users/route.ts", "name": "GET", "start_line": 3, "end_line": 7 }, + { "kind": "symbol_called", "file_path": "app/api/(admin)/users/route.ts", "name": "requireUser", "start_line": 4, "end_line": 4 }, + { "kind": "symbol_called", "file_path": "app/api/(admin)/users/route.ts", "name": "findMany", "value": "db.user", "start_line": 5, "end_line": 5 }, + { "kind": "data_operation_detected", "file_path": "app/api/(admin)/users/route.ts", "name": "findMany", "value": "db.user", "imported_name": "read:user", "start_line": 5, "end_line": 5 }, + { "kind": "route_returns_response", "file_path": "app/api/(admin)/users/route.ts", "name": "json", "value": "Response", "start_line": 6, "end_line": 6 } + ] + }, + "contract": { + "contract_id": "contract_auth", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_api_auth_require_user", + "kind": "api_route_requires_auth_helper", + "matcher": { + "required_calls": ["requireUser"], + "applies_to_file_roles": ["api_route"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + + let proof = &payload["security_boundary_proofs"][0]; + assert_eq!(proof["route"]["endpoint"]["path"], "/api/users"); + assert_eq!(proof["route"]["endpoint"]["method"], "GET"); + assert!( + !proof["evidence_refs"] + .as_array() + .expect("evidence refs") + .is_empty() + ); +} + #[test] fn accepted_auth_helper_import_alias_is_trusted() { let source = [ diff --git a/drift v3/crates/drift-engine/tests/security_phase6.rs b/drift v3/crates/drift-engine/tests/security_phase6.rs index 0fd27e04..d1d4b36f 100644 --- a/drift v3/crates/drift-engine/tests/security_phase6.rs +++ b/drift v3/crates/drift-engine/tests/security_phase6.rs @@ -6,7 +6,7 @@ use drift_engine::{ SecurityRawSqlContract, SecuritySsrfContract, build_phase6_security_proof, evaluate_api_route_cors_must_match_policy, evaluate_api_route_forbids_raw_sql_without_params, evaluate_api_route_forbids_untrusted_ssrf, evaluate_api_route_requires_csrf_for_mutation, - evaluate_api_route_requires_rate_limit, + evaluate_api_route_requires_rate_limit, phase6_proof_to_json, }; #[test] @@ -411,6 +411,73 @@ export async function GET() { assert_eq!(proof.cors.missing_proof[0].code, "disallowed_origin"); } +#[test] +fn security_phase8_proof_evidence_refs_are_line_only_and_sanitized() { + let proof = phase6_proof_to_json( + &phase6_proof( + "app/api/proxy/route.ts", + r#" +export async function POST(request: Request) { + const body = await request.json(); + await fetch(body.callbackUrl + "?token=secret"); + await db.query("select * from users where token = 'secret'"); + return Response.json({ ok: true }); +} +"#, + phase6_ssrf_contract(), + ), + "api_route_forbids_untrusted_ssrf", + "security_api_no_ssrf", + "block", + Some("finding_ssrf"), + ); + let serialized = serde_json::to_string(&proof).expect("proof json"); + + assert!(serialized.contains("\"evidence_refs\"")); + assert!(serialized.contains("\"start_line\"")); + assert!(!serialized.contains("callbackUrl +")); + assert!(!serialized.contains("select * from users")); + assert!(!serialized.contains("token=secret")); + assert!(!serialized.contains("\"source\"")); + assert!(!serialized.contains("\"snippet\"")); + assert!(!serialized.contains("\"payload\"")); + assert!(!serialized.contains("\"cookie\"")); + assert!(!serialized.contains("\"header\"")); +} + +#[test] +fn security_phase8_phase6_missing_proof_preserves_fact_ids() { + let proof_json = phase6_proof_to_json( + &phase6_proof( + "app/api/proxy/route.ts", + r#" +export async function POST(request: Request) { + const body = await request.json(); + await fetch(body.callbackUrl); + return Response.json({ ok: true }); +} +"#, + phase6_ssrf_contract(), + ), + "api_route_forbids_untrusted_ssrf", + "security_api_no_ssrf", + "block", + Some("finding_ssrf"), + ); + let missing = proof_json["missing_proof"] + .as_array() + .expect("missing proof") + .iter() + .find(|entry| entry["code"] == "request_controlled_url") + .expect("ssrf missing proof"); + + assert!( + !missing["fact_ids"].as_array().expect("fact ids").is_empty(), + "{proof_json:#?}" + ); + assert_eq!(missing["blocks_enforcement"], true); +} + fn phase6_proof( file_path: &str, source: &str, diff --git a/drift v3/docs/architecture/security-boundary-phase8-production-hardening-tdd.md b/drift v3/docs/architecture/security-boundary-phase8-production-hardening-tdd.md new file mode 100644 index 00000000..c9c0dc83 --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-phase8-production-hardening-tdd.md @@ -0,0 +1,1183 @@ +# Security Boundary Phase 8 Production Hardening TDD + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the Phase 8 review gaps so CLI/MCP security surfaces are production-ready, proof-run-backed, sanitized, and migration-compatible. + +**Architecture:** Rust remains the only source of deterministic security proof truth. TypeScript may validate, persist, query, and render proof payloads, but it must not synthesize proof from raw facts or legacy advisory sections. CLI and MCP must consume shared proof-backed read models and expose identical sanitized truth for scan status, repo map, findings, and security context. + +**Tech Stack:** Rust `drift-engine`; TypeScript packages `@drift/core`, `@drift/engine-contract`, `@drift/storage`, `@drift/query`, `@drift/cli`, `@drift/mcp`; SQLite; Vitest; Cargo tests. + +--- + +## Source Inputs + +Primary implementation under review: + +- Branch: `codex/security-phase8-production` +- Commit: `b7adcb40 Implement security phase 8 proof UX` +- Base: `origin/main` + +Primary Phase 8 TDD: + +- `docs/architecture/security-boundary-phase8-production-tdd.md` + +Original source spec: + +- `docs/architecture/security-boundary-enforcement-100-tdd.md` +- Focus: `## Phase 8: CLI And MCP UX` +- Related: `Migration And Compatibility Plan` +- Related: `Fixture Matrix` +- Related: `Verification Commands` + +Review findings to fix: + +1. MCP v2 security context still spreads legacy raw-fact context into `drift.security.context.v2`. +2. Proof runs are stored under check scan ids, but scan status and repo map query the latest indexed scan id. +3. Phase 1/3/4/5 Rust proof JSON lacks sanitized `evidence_refs`. +4. Phase 6 CSRF/rate-limit missing proof can emit empty `fact_ids`. +5. Query capability status is route-level and hardcoded deterministic/blocking. +6. MCP findings expose full finding payloads. +7. Repo map lacks fallback from proof-run rows to scan-scoped proof rows. +8. Routes with no proof are omitted instead of shown as `unknown`. +9. Next.js route groups are not normalized. +10. Primary Phase 8 TDD is untracked. +11. Missing Phase 8 golden/e2e fixture coverage. +12. Missing direct tests for proof-run persistence and human Phase 8 check blocks. + +## Non-Negotiable Production Rules + +- Rust proof payloads and proof-run rows are deterministic truth. +- Raw facts may support candidate proposals and diagnostics only. They must not appear as proof. +- MCP `drift.security.context.v2` must be proof-read-model-only. +- CLI and MCP must not duplicate Phase 8 proof logic. +- Candidate evidence must remain non-blocking until accepted. +- Phase 8 output must not expose source snippets, raw URLs with secrets, payloads, headers, cookies, SQL strings/literals, env values, tokens, user IDs, tenant IDs, or full source. +- Actor identity fields such as `accepted_by` must be omitted or redacted from agent-facing security context unless a policy explicitly permits them. +- Old databases with `security_boundary_proofs` rows but no `security_boundary_proof_runs` rows must still render Phase 8 read models. +- New `drift check` proof runs must be discoverable from scan status and repo map after a real scan -> check flow. + +## File Map + +Rust proof generation: + +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/src/security_phase6.rs` +- Test: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Test: `crates/drift-engine/tests/security_check_repo_request_validation.rs` +- Test: `crates/drift-engine/tests/security_check_repo_phase4.rs` +- Test: `crates/drift-engine/tests/security_check_repo_phase5.rs` +- Test: `crates/drift-engine/tests/security_phase6.rs` + +TypeScript schemas/read models: + +- Modify: `packages/core/src/security.ts` only if evidence-ref type needs tightening. +- Modify: `packages/engine-contract/src/index.ts` only if engine proof type needs tightening. +- Modify: `packages/query/src/security-boundary-proof.ts` +- Test: `packages/query/test/security-boundary-proof.test.ts` + +Storage and proof lookup: + +- Modify: `packages/storage/src/sqlite-storage.ts` +- Test: `packages/storage/test/sqlite-storage.test.ts` + +CLI: + +- Modify: `packages/cli/src/check/run-check.ts` +- Modify: `packages/cli/src/domain/scan-status.ts` +- Modify: `packages/cli/src/domain/repo-map.ts` +- Modify: `packages/cli/src/formatters/checks.ts` +- Test: `packages/cli/test/cli.test.ts` + +MCP: + +- Modify: `packages/mcp/src/security-context.ts` +- Modify: `packages/mcp/src/index.ts` +- Modify: `packages/mcp/test/mcp.test.ts` + +E2E/goldens/docs: + +- Modify: `test/e2e/golden.test.ts` +- Modify: `test/e2e/security-auth.test.ts` +- Modify: `test/e2e/security-validation.test.ts` +- Modify: `test/e2e/security-sensitive.test.ts` +- Modify: `test/e2e/security-phase6.test.ts` +- Commit or remove: `docs/architecture/security-boundary-phase8-production-tdd.md` + +--- + +## Task 1: Remove Legacy Raw-Fact Sections From MCP v2 + +**Files:** + +- Modify: `packages/mcp/src/security-context.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write failing MCP v2 purity test** + +Add a test that seeds raw facts and accepted contracts but no proof runs. Call `get_security_context`. + +Assertions: + +```ts +expect(securityContext.response_schema).toBe("drift.security.context.v2"); +expect(securityContext.middleware_coverage).toBeUndefined(); +expect(securityContext.request_validation).toBeUndefined(); +expect(securityContext.session_trust).toBeUndefined(); +expect(securityContext.authorization).toBeUndefined(); +expect(securityContext.tenant_scope).toBeUndefined(); +expect(securityContext.current_proof_status).toEqual([]); +expect(securityContext.required_proofs).toEqual(expect.any(Array)); +expect(JSON.stringify(securityContext)).not.toContain("request.json()"); +expect(JSON.stringify(securityContext)).not.toContain("session.user.tenantId"); +expect(JSON.stringify(securityContext)).not.toContain("cookie"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/mcp test -- "v2 security context does not include legacy raw-fact sections" +``` + +Expected: FAIL because `buildSecurityContextPayload` currently merges legacy v1 fields into v2. + +- [ ] **Step 3: Fix MCP v2 payload** + +In `packages/mcp/src/security-context.ts`, make `buildSecurityContextPayload` return only Phase 8 read-model fields for `drift.security.context.v2`. + +Allowed v2 fields: + +- `response_schema` +- `repo_id` +- `scan_id` +- `check_id` +- `repo_security_contracts` +- `changed_route_security` +- `routes` +- `required_proofs` +- `current_proof_status` +- `missing_proof_summaries` +- `parser_gap_summaries` +- `security_capabilities` +- `do_not_include` +- `redactions` +- `freshness` +- `next_commands` + +Forbidden v2 fields: + +- `accepted_contracts` +- `middleware_coverage` +- `request_validation` +- `session_trust` +- `authorization` +- `tenant_scope` +- any section derived directly from raw facts + +Keep legacy v1 helper code only if older tests still need it through a separate v1 path. Do not spread it into v2. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/mcp test -- "v2 security context does not include legacy raw-fact sections" +pnpm --filter @drift/mcp test +``` + +Expected: PASS. + +--- + +## Task 2: Make Proof Runs Discoverable After Real Check Runs + +**Files:** + +- Modify: `packages/cli/src/check/run-check.ts` +- Modify: `packages/storage/src/sqlite-storage.ts` +- Modify: `packages/cli/src/domain/scan-status.ts` +- Modify: `packages/cli/src/domain/repo-map.ts` +- Modify: `packages/mcp/src/index.ts` +- Test: `packages/storage/test/sqlite-storage.test.ts` +- Test: `packages/cli/test/cli.test.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write failing storage lookup test** + +Add a storage test proving proof runs can be listed by repo even when the proof-run `scan_id` is a check scan id and the indexed scan id is different. + +Required fixture: + +- Indexed scan: `scan_indexed` +- Check run: `check_security` +- Check proof-run row: `scan_id = "scan_check_security"` +- Proof route file: `app/api/users/route.ts` + +Assertions: + +```ts +const latestRows = storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: "repo_security", + file_path: "app/api/users/route.ts" +}); +expect(latestRows).toHaveLength(1); +expect(latestRows[0]?.check_id).toBe("check_security"); +expect(latestRows[0]?.scan_id).toBe("scan_check_security"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/storage test -- "lists latest security boundary proof runs by repo across check scan ids" +``` + +Expected: FAIL because no repo-scoped latest proof-run lookup exists. + +- [ ] **Step 3: Implement repo-scoped latest proof-run lookup** + +Add a storage method with this shape: + +```ts +listLatestSecurityBoundaryProofRunsForRepo(options: { + repo_id: string; + file_path?: string; + check_id?: string; +}): StoredSecurityBoundaryProofRun[] +``` + +Rules: + +- If `check_id` is provided, filter by it. +- If `file_path` is provided, filter affected files by exact repo-relative path. +- Otherwise return rows from the latest completed check run for that repo. +- Sort deterministically by `created_at DESC`, `check_id DESC`, `proof_id ASC`. +- Do not infer proof from scan facts. + +- [ ] **Step 4: Update scan status and repo map** + +Use repo-scoped proof-run lookup in: + +- `packages/cli/src/domain/scan-status.ts` +- `packages/cli/src/domain/repo-map.ts` +- `packages/mcp/src/index.ts` + +Behavior: + +- Prefer latest proof-run rows for the repo. +- If no proof runs exist, fall back to scan-scoped proof rows from the latest indexed scan. +- If neither exists, return empty capability array and unknown/no-proof route states where route metadata exists. + +- [ ] **Step 5: Write failing scan -> check -> scan status/repo map tests** + +In `packages/cli/test/cli.test.ts`, add an e2e-style unit test that runs: + +```text +drift scan repo --repo-root --repo --actor test --json +drift check --repo --json +drift scan status --repo --json +drift repo map --repo --json +``` + +Assertions: + +```ts +expect(scanStatus.security_capabilities.length).toBeGreaterThan(0); +expect(repoMap.routes.length).toBeGreaterThan(0); +expect(repoMap.routes[0].security).toBeDefined(); +``` + +- [ ] **Step 6: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/storage test -- "latest security boundary proof runs" +pnpm --filter @drift/cli test -- "scan status and repo map use check-run proof rows" +pnpm --filter @drift/mcp test -- "scan status and repo map use check-run proof rows" +``` + +Expected: PASS. + +--- + +## Task 3: Add Sanitized Evidence Refs To All Rust Proof Families + +**Files:** + +- Modify: `crates/drift-engine/src/check_command.rs` +- Test: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Test: `crates/drift-engine/tests/security_check_repo_request_validation.rs` +- Test: `crates/drift-engine/tests/security_check_repo_phase4.rs` +- Test: `crates/drift-engine/tests/security_check_repo_phase5.rs` + +- [ ] **Step 1: Write failing per-family evidence tests** + +Add one assertion helper in each relevant Rust test file or shared local helper: + +```rust +fn assert_evidence_refs_are_sanitized(proof: &serde_json::Value) { + let refs = proof["evidence_refs"].as_array().expect("evidence_refs array"); + assert!(!refs.is_empty(), "expected evidence refs"); + for evidence in refs { + assert!(evidence["file_path"].as_str().is_some()); + assert!(evidence["start_line"].as_u64().is_some()); + assert!(evidence["end_line"].as_u64().is_some()); + assert!(evidence.get("source").is_none()); + assert!(evidence.get("source_text").is_none()); + assert!(evidence.get("snippet").is_none()); + assert!(evidence.get("value").is_none()); + assert!(evidence.get("raw_url").is_none()); + assert!(evidence.get("headers").is_none()); + assert!(evidence.get("cookies").is_none()); + assert!(evidence.get("sql").is_none()); + assert!(evidence.get("env").is_none()); + assert!(evidence.get("token").is_none()); + assert!(evidence.get("user_id").is_none()); + assert!(evidence.get("tenant_id").is_none()); + } +} +``` + +Cover proof families: + +- auth helper +- middleware coverage +- request validation +- session trust +- authorization +- tenant scope +- sensitive response fields +- secret exposure + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cargo test -p drift-engine security_phase8_evidence_refs -- --nocapture +``` + +Expected: FAIL for proof families that do not emit top-level `evidence_refs`. + +- [ ] **Step 3: Implement Rust evidence ref helper** + +In `crates/drift-engine/src/check_command.rs`, add a single helper that converts known proof facts/guards/sinks into line-only evidence refs. + +Required shape: + +```json +{ + "id": "evidence:", + "kind": "proof_line", + "file_path": "apps/web/app/api/users/route.ts", + "start_line": 12, + "end_line": 12, + "fact_ids": ["fact_auth_guard"], + "redaction_state": "line_only" +} +``` + +Rules: + +- Include file path and lines. +- Include fact ids when available. +- Include guard/sink ids only if they are stable ids and not raw values. +- Never include snippets, argument values, URLs, SQL strings, header names/values, cookies, env values, user IDs, or tenant IDs. +- Use the same helper across Phase 1/3/4/5 proof generation. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +cargo test -p drift-engine security_phase8_evidence_refs -- --nocapture +cargo test -p drift-engine security_rules +``` + +Expected: PASS. + +--- + +## Task 4: Anchor Phase 6 CSRF And Rate-Limit Missing Proof + +**Files:** + +- Modify: `crates/drift-engine/src/security_phase6.rs` +- Test: `crates/drift-engine/tests/security_phase6.rs` + +- [ ] **Step 1: Write failing CSRF/rate-limit fact-id tests** + +Extend existing tests: + +- `csrf_helper_after_mutation_sink_does_not_prove_safety` +- `rate_limit_helper_after_response_sink_does_not_prove_safety` +- mutation route without accepted CSRF proof +- login route without accepted rate-limit proof + +Assertions: + +```rust +let missing = proof["missing_proof"].as_array().unwrap(); +let target = missing.iter().find(|entry| entry["capability"] == "csrf").unwrap(); +assert!(!target["fact_ids"].as_array().unwrap().is_empty()); +``` + +and: + +```rust +let target = missing.iter().find(|entry| entry["capability"] == "rate_limit").unwrap(); +assert!(!target["fact_ids"].as_array().unwrap().is_empty()); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cargo test -p drift-engine --test security_phase6 csrf_helper_after_mutation_sink_does_not_prove_safety -- --nocapture +cargo test -p drift-engine --test security_phase6 rate_limit_helper_after_response_sink_does_not_prove_safety -- --nocapture +``` + +Expected: FAIL because `fact_ids` can be empty. + +- [ ] **Step 3: Fix missing proof construction** + +In `crates/drift-engine/src/security_phase6.rs`, ensure CSRF and rate-limit missing proof entries include: + +- mutation/response sink fact ids +- guard fact ids when a non-dominating guard exists +- route handler fact ids when no guard exists but route/sink fact is present + +Never add raw request payload, method body, SQL, cookie, token, user id, or tenant id. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +cargo test -p drift-engine --test security_phase6 +``` + +Expected: PASS. + +--- + +## Task 5: Fix Capability Status Semantics + +**Files:** + +- Modify: `packages/query/src/security-boundary-proof.ts` +- Test: `packages/query/test/security-boundary-proof.test.ts` + +- [ ] **Step 1: Write failing mixed-capability route test** + +Seed one route proof with: + +- `auth` proven +- `request_validation` missing proof +- `capability_status` containing one complete deterministic capability and one partial deterministic capability +- one accepted convention with `deterministic_check` +- one accepted convention with `heuristic_check` + +Assertions: + +```ts +expect(route.security).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "control_flow_guard_dominance", + status: "complete", + capability: "deterministic_check", + can_block: true + }), + expect.objectContaining({ + name: "request_validation", + status: "partial", + capability: "deterministic_check", + can_block: true + }), + expect.objectContaining({ + name: "candidate_only_signal", + capability: "heuristic_check", + can_block: false + }) +])); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/query test -- "mixed capability route status" +``` + +Expected: FAIL because route-level `proof.result.proof_status` bleeds into unrelated capabilities and capability type is hardcoded. + +- [ ] **Step 3: Fix read model status derivation** + +Rules: + +- Match status by normalized `proof.capability_status[].name`. +- Use `proof.missing_proof[].capability` only for that capability. +- Use `proof.parser_gaps[].capability` only for that capability. +- Use accepted convention metadata for `capability`. +- `can_block` is true only when an accepted matched convention is `deterministic_check` and enforcement mode is `block`. +- Heuristic and briefing capabilities must always have `can_block: false`. +- Route-level `proof.result.proof_status` may summarize the whole proof, but must not overwrite individual capability status. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/query test +``` + +Expected: PASS. + +--- + +## Task 6: Sanitize MCP Findings + +**Files:** + +- Modify: `packages/mcp/src/index.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write failing finding leak test** + +Seed a finding with: + +- `message` containing `session=secret` +- `evidence_refs[].import_source` +- `evidence_refs[].symbol` +- `evidence_refs[].fact_ids` +- any available source-like field + +Call `get_findings`. + +Assertions: + +```ts +const serialized = JSON.stringify(result); +expect(serialized).not.toContain("session=secret"); +expect(serialized).not.toContain("@/lib/prisma"); +expect(serialized).not.toContain("raw_sql"); +expect(result.findings[0]).toEqual(expect.objectContaining({ + finding_id: expect.any(String), + title: expect.any(String), + severity: expect.any(String), + lifecycle: expect.any(String), + file_refs: expect.any(Array) +})); +expect(result.findings[0].message).toBeUndefined(); +expect(result.findings[0].evidence_refs).toBeUndefined(); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/mcp test -- "get_findings sanitizes finding payloads" +``` + +Expected: FAIL because MCP returns full findings. + +- [ ] **Step 3: Add sanitized finding DTO** + +Return only: + +```ts +type McpFindingSummary = { + finding_id: string; + convention_id: string; + title: string; + severity: string; + lifecycle: string; + diff_status: string; + enforcement_result: string; + file_refs: Array<{ + file_path: string; + start_line?: number; + end_line?: number; + redaction_state: "line_only" | "metadata_only"; + }>; +}; +``` + +Do not include: + +- `message` +- raw `evidence_refs` +- `import_source` +- `symbol` +- `fact_ids` +- snippets +- source text +- SQL +- URLs +- cookies +- env values +- tokens +- user IDs +- tenant IDs + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/mcp test +``` + +Expected: PASS. + +--- + +## Task 7: Add Scan-Scoped Proof Fallback + +**Files:** + +- Modify: `packages/storage/src/sqlite-storage.ts` +- Modify: `packages/cli/src/domain/scan-status.ts` +- Modify: `packages/cli/src/domain/repo-map.ts` +- Modify: `packages/mcp/src/index.ts` +- Test: `packages/cli/test/cli.test.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write failing old-row fallback tests** + +Seed: + +- latest indexed scan `scan_old` +- rows in `security_boundary_proofs` from migration 023 +- no rows in `security_boundary_proof_runs` + +Assertions: + +```ts +expect(scanStatus.security_capabilities.length).toBeGreaterThan(0); +expect(repoMap.routes.length).toBeGreaterThan(0); +expect(repoMap.routes[0].security).toBeDefined(); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/cli test -- "falls back to scan-scoped security boundary proofs" +pnpm --filter @drift/mcp test -- "falls back to scan-scoped security boundary proofs" +``` + +Expected: FAIL because surfaces emit empty arrays without proof-run rows. + +- [ ] **Step 3: Implement fallback helper** + +Create a shared helper in query or storage-facing domain code: + +```ts +function securityProofsForPhase8Surface(input: { + storage: SqliteDriftStorage; + repo_id: string; + latest_scan_id: string | null; + file_path?: string; + check_id?: string; +}): { + check_id: string | null; + proofs: SecurityBoundaryProof[]; + source: "proof_run" | "scan_scoped" | "none"; +} +``` + +Rules: + +- Prefer proof runs. +- Fall back to scan-scoped proofs from latest indexed scan. +- Return `source` for diagnostics/tests. +- Do not use raw facts. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/cli test -- "falls back to scan-scoped security boundary proofs" +pnpm --filter @drift/mcp test -- "falls back to scan-scoped security boundary proofs" +``` + +Expected: PASS. + +--- + +## Task 8: Emit Unknown Route Security For Known Routes With No Proof + +**Files:** + +- Modify: `packages/query/src/security-boundary-proof.ts` +- Modify: `packages/cli/src/domain/repo-map.ts` +- Modify: `packages/mcp/src/index.ts` +- Test: `packages/query/test/security-boundary-proof.test.ts` +- Test: `packages/cli/test/cli.test.ts` + +- [ ] **Step 1: Write failing unknown-route test** + +Build a Phase 8 read model with known route metadata: + +```ts +known_routes: [{ + route_id: "route:GET:apps/web/app/api/users/route.ts", + file_path: "apps/web/app/api/users/route.ts", + method: "GET", + path: "/api/users", + file_role: "api_route" +}] +``` + +No proofs. + +Assertions: + +```ts +expect(model.routes).toEqual([expect.objectContaining({ + route_id: "route:GET:apps/web/app/api/users/route.ts", + path: "/api/users", + method: "GET", + security: [expect.objectContaining({ + proof_status: "unknown", + reason: "no_security_proof" + })] +})]); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/query test -- "known routes without proof are emitted as unknown" +``` + +Expected: FAIL because routes are emitted only from proofs. + +- [ ] **Step 3: Pass known route metadata into read model** + +Add optional input: + +```ts +known_routes?: Array<{ + route_id: string; + file_path: string; + path?: string; + method?: string; + file_role?: string; +}>; +``` + +Use repo map graph route data to populate it in CLI/MCP repo map. + +Rules: + +- Proof routes override known routes. +- Known routes without proof emit `unknown`. +- Do not parse source files in query. +- Do not infer security proof from graph facts. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/query test +pnpm --filter @drift/cli test -- "repo map emits unknown route security without proof" +``` + +Expected: PASS. + +--- + +## Task 9: Normalize Next.js Route Groups + +**Files:** + +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/src/security_phase6.rs` +- Test: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Test: `crates/drift-engine/tests/security_phase6.rs` + +- [ ] **Step 1: Write failing route-group tests** + +Add route fixture: + +```text +app/api/(admin)/users/route.ts +``` + +Expected endpoint: + +```json +{ "path": "/api/users", "method": "GET", "framework": "next" } +``` + +Assertions in auth and Phase 6 proof tests: + +```rust +assert_eq!(proof["route"]["endpoint"]["path"], "/api/users"); +assert_eq!(proof["route"]["endpoint"]["method"], "GET"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +cargo test -p drift-engine route_group_endpoint -- --nocapture +``` + +Expected: FAIL because route group segment appears in route path. + +- [ ] **Step 3: Centralize route path normalization** + +Create one Rust helper used by all proof families: + +```rust +fn next_route_path(file_path: &str) -> Option +``` + +Rules: + +- Strip `app/`. +- Strip trailing `/route.ts`, `/route.tsx`, `/route.js`, `/route.jsx`. +- Keep `/api`. +- Drop route group segments matching `(name)`. +- Convert dynamic segments `[id]` to `:id` only if existing route semantics already do that. If not, preserve existing dynamic behavior and only strip groups. +- Return `None` for unsupported non-route files. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +cargo test -p drift-engine route_group_endpoint -- --nocapture +cargo test -p drift-engine security_rules +cargo test -p drift-engine --test security_phase6 +``` + +Expected: PASS. + +--- + +## Task 10: Redact Actor Identity From Security Read Models + +**Files:** + +- Modify: `packages/query/src/security-boundary-proof.ts` +- Test: `packages/query/test/security-boundary-proof.test.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write failing accepted_by redaction test** + +Seed accepted convention: + +```ts +{ + accepted_by: "geoffrey@example.com", + accepted_at: "2026-05-27T00:00:00.000Z" +} +``` + +Assertions: + +```ts +expect(JSON.stringify(model)).not.toContain("geoffrey@example.com"); +expect(model.repo_security_contracts[0].accepted_by).toBeUndefined(); +expect(model.repo_security_contracts[0].accepted_at).toBe("2026-05-27T00:00:00.000Z"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/query test -- "redacts accepted_by from security read model" +``` + +Expected: FAIL if actor identity is still exposed. + +- [ ] **Step 3: Remove or redact `accepted_by`** + +Rules: + +- Omit `accepted_by` from Phase 8 agent-facing read models. +- Keep timestamps if useful and non-sensitive. +- Do not mutate stored contracts. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/query test +pnpm --filter @drift/mcp test +``` + +Expected: PASS. + +--- + +## Task 11: Add Human Check Block Tests + +**Files:** + +- Modify: `packages/cli/src/formatters/checks.ts` +- Test: `packages/cli/test/cli.test.ts` + +- [ ] **Step 1: Write failing human output test** + +Run `drift check` without `--json` against a fixture with a blocking security proof. + +Assertions: + +```ts +expect(result.stdout).toContain("Security proof"); +expect(result.stdout).toContain("Route:"); +expect(result.stdout).toContain("File:"); +expect(result.stdout).toContain("Reason:"); +expect(result.stdout).toContain("Evidence:"); +expect(result.stdout).toContain("Capability:"); +expect(result.stdout).toContain("Lifecycle:"); +expect(result.stdout).toContain("Next:"); +expect(result.stdout).not.toContain("request.json()"); +expect(result.stdout).not.toContain("session=secret"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm --filter @drift/cli test -- "human check renders Phase 8 security proof block" +``` + +Expected: FAIL if human output is missing any required block field. + +- [ ] **Step 3: Fix formatter** + +Make the human block render from `security_boundary_proofs` only. + +Required fields: + +- route path + method +- file +- reason +- evidence line refs +- capability +- lifecycle/finding status +- next command + +Never render source snippets or raw proof internals. + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm --filter @drift/cli test -- "human check renders Phase 8 security proof block" +pnpm --filter @drift/cli test +``` + +Expected: PASS. + +--- + +## Task 12: Add Phase 8 Golden/E2E Fixture + +**Files:** + +- Modify: `test/e2e/golden.test.ts` +- Modify: `test/e2e/security-auth.test.ts` +- Modify: `test/e2e/security-validation.test.ts` +- Modify: `test/e2e/security-sensitive.test.ts` +- Modify: `test/e2e/security-phase6.test.ts` + +- [ ] **Step 1: Write failing e2e proof-surface test** + +Add an e2e fixture that runs: + +```bash +drift scan repo --repo-root --actor test --json +drift check --repo --json +drift check --repo +drift scan status --repo --json +drift repo map --repo --json +drift candidates --repo --json +``` + +Assertions: + +```ts +expect(checkJson.security_boundary_proofs.length).toBeGreaterThan(0); +expect(checkHuman.stdout).toContain("Security proof"); +expect(scanStatus.security_capabilities.length).toBeGreaterThan(0); +expect(repoMap.routes.some((route) => route.security?.length > 0)).toBe(true); +expect(candidates.candidates.every((candidate) => candidate.reason_not_blocking)).toBe(true); +expect(serialized).not.toContain("session=secret"); +expect(serialized).not.toContain("request.json()"); +expect(serialized).not.toContain("process.env"); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +pnpm test:e2e -- security Phase 8 production proof surfaces +``` + +Expected: FAIL until Tasks 1-11 are complete. + +- [ ] **Step 3: Update goldens intentionally** + +Update any schema-version expectations from 24 to 25 only where migration 025 is genuinely expected. + +Do not reduce goldens to old fields. Golden output must assert: + +- `security_boundary_proofs` +- `security_capabilities[]` +- `routes[].security` +- candidate non-blocking metadata +- no sensitive output + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +pnpm test:e2e +``` + +Expected: PASS. + +--- + +## Task 13: Resolve Primary TDD Doc State + +**Files:** + +- Add or intentionally remove: `docs/architecture/security-boundary-phase8-production-tdd.md` + +- [ ] **Step 1: Decide source-control state** + +If the Phase 8 TDD is the review contract, commit it. If it is local scratch, remove it from the worktree. + +Production rule: + +```bash +git status --short +``` + +must not show: + +```text +?? docs/architecture/security-boundary-phase8-production-tdd.md +``` + +- [ ] **Step 2: Verify** + +Run: + +```bash +git status --short --branch +``` + +Expected: no untracked Phase 8 TDD doc. + +--- + +## Task 14: Full Verification Gate + +- [ ] **Step 1: Run focused RED/GREEN tests from this TDD** + +Run: + +```bash +cargo test -p drift-engine security_phase8_evidence_refs -- --nocapture +cargo test -p drift-engine route_group_endpoint -- --nocapture +cargo test -p drift-engine --test security_phase6 +pnpm --filter @drift/storage test -- "security boundary proof runs" +pnpm --filter @drift/query test -- "security" +pnpm --filter @drift/cli test -- "Phase 8" +pnpm --filter @drift/mcp test -- "security" +``` + +Expected: PASS. + +- [ ] **Step 2: Run required production verification** + +Run: + +```bash +git status --short --branch +git diff --stat origin/main...HEAD +git diff --check +cargo test -p drift-engine security_facts +cargo test -p drift-engine security_control_flow +cargo test -p drift-engine security_rules +cargo test -p drift-engine security_proof +cargo test -p drift-engine --test security_phase6 +cargo test -p drift-engine --test candidate_inference +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/factgraph test +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +pnpm verify:ci +``` + +Expected: every command exits 0. + +- [ ] **Step 3: Final production readiness review** + +Before merge, run a six-lane review against the final diff and verify these are false: + +- MCP v2 contains legacy raw-fact sections. +- scan status or repo map are empty after a real `drift check`. +- any proof family lacks sanitized `evidence_refs`. +- CSRF/rate-limit missing proof has empty `fact_ids`. +- capability status is route-level rather than capability-level. +- MCP findings expose full finding payloads. +- old scan-scoped proof rows disappear from Phase 8 surfaces. +- known routes without proof are omitted. +- route groups appear in endpoint paths. +- `accepted_by` appears in agent-facing security read models. +- untracked Phase 8 TDD docs remain. + +Expected: all false. + +## Final Acceptance Criteria + +The branch is production-ready only when: + +- `drift.security.context.v2` is proof-read-model-only. +- `drift scan status --json.security_capabilities[]` is non-empty after real proof-producing checks. +- `drift repo map --json.routes[].security` is proof-backed or explicitly `unknown`. +- `drift check` human output includes sanitized Phase 8 security blocks. +- Every Rust proof family emits sanitized `evidence_refs`. +- Phase 6 missing proof entries include deterministic fact anchors. +- MCP findings are sanitized DTOs. +- Old scan-scoped proof rows are compatible. +- Next.js route groups are normalized. +- Candidate output remains non-blocking until accepted. +- Full verification, including `pnpm verify:ci`, passes. diff --git a/drift v3/docs/architecture/security-boundary-phase8-production-tdd.md b/drift v3/docs/architecture/security-boundary-phase8-production-tdd.md new file mode 100644 index 00000000..7abbec4a --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-phase8-production-tdd.md @@ -0,0 +1,1618 @@ +# Security Boundary Phase 8 Production TDD + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Phase 8 as a production-ready CLI/MCP proof-truth surface for Drift security boundary enforcement. + +**Architecture:** Rust owns deterministic security proof truth, parser gaps, missing proof, capability completeness, route/method/file-role binding, and blocking. TypeScript stores, validates, queries, and renders Rust proof; it must not synthesize proof from raw facts or candidate evidence. CLI and MCP consume one shared proof-backed read model so humans and agents see the same sanitized security state. + +**Tech Stack:** Rust `drift-engine`; TypeScript packages `@drift/core`, `@drift/engine-contract`, `@drift/storage`, `@drift/query`, `@drift/cli`, `@drift/mcp`; SQLite storage; Vitest; Cargo tests. + +--- + +## Source Of Truth + +Primary spec: + +- `docs/architecture/security-boundary-enforcement-100-tdd.md` +- Section: `## Phase 8: CLI And MCP UX` +- Related section: `## Migration And Compatibility Plan` +- Related section: `## Fixture Matrix` +- Related section: `## Verification Commands` + +Current P1-P7 state: + +- `origin/main` already contains Phase 1 through Phase 7 work. +- Start implementation from fresh `origin/main`, not from a stale merged feature branch. +- Existing `security_boundary_proofs` are returned by `drift check --json`. +- Existing MCP security context still reads raw facts for several sections and must be corrected. +- Existing `scan status --json` exposes `security_capabilities`, but not the required Phase 8 array shape. +- Existing repo map has fact-derived `route_security`, which must remain advisory or be replaced by proof-backed summaries. +- Existing candidate election flow exists under `drift conventions ...`; Phase 8 requires `drift candidates --json`. + +## Accuracy Notes From Live Repo Verification + +This TDD was checked against the live tree on May 27, 2026. The architecture, file targets, and missing Phase 8 surfaces match current code, with these implementation details: + +- `origin/main` is tree-equivalent to the current Phase 7 branch and is ahead only by merge commits. Start new work from `origin/main`. +- Current Rust tests in `crates/drift-engine/tests/security_check_repo_auth.rs` use local helpers such as `temp_repo`, `run_check_repo`, `fact`, and fixture builders. Do not paste new Rust snippets that reference `security_repo_with_route`, `accepted_auth_contract`, or `check_repo_with_contracts` unless those helpers are added in the same test file. +- Current Phase 6 Rust tests in `crates/drift-engine/tests/security_phase6.rs` already have `phase6_proof`, `phase6_ssrf_contract`, `phase6_raw_sql_contract`, `phase6_cors_contract`, `phase6_csrf_contract`, and `phase6_rate_limit_contract`. Use those helpers instead of inventing `phase6_violation_proof_for_source`. +- Current TypeScript tests do not have global helpers named `securityProofFixture`, `securityBoundaryProofFixture`, `seedCheckedSecurityRepo`, `runCliJson`, `callTool`, or `runSecurityFixture`. If a task uses those names, add local helpers in the same test file or replace them with existing test harness helpers in that package. +- `SecurityBoundaryProofSchema` currently allows optional `route.endpoint.path` and `route.endpoint.method`. Phase 8 should keep those optional for old stored rows, while requiring new Rust output to populate them for supported routes. +- `security_boundary_proofs` exists today as migration `023_security_boundary_proofs`, but it is scan-scoped and keyed by `proof_id`. It is not check-run-bound. The `025_security_boundary_proof_runs` migration below is required for production-grade exact check-run reporting, not because the current table is absent. +- `drift scan status --json.security_capabilities` already exists today, but it is object-shaped and capability-report-backed. Phase 8 requires the array shape below and must derive security proof counts from proof runs, not raw scan facts. +- `buildSecurityBoundaryProofReadModel` exists today. Phase 8 should extend it or add a sibling builder; do not create duplicate proof logic in CLI or MCP. +- `get_security_context` exists today and returns `drift.security.context.v1`. It currently uses raw facts for several sections. Phase 8 must introduce proof-backed `drift.security.context.v2`. + +## Non-Negotiable Boundaries + +1. Rust owns deterministic proof. +2. TypeScript must not convert raw facts into proof. +3. Accepted contracts are the only enforcement contract source of truth. +4. Candidates and heuristic evidence may brief or propose. They never block. +5. File-global proof is forbidden. +6. Proof must be route-bound, method-bound, file-role-bound, contract-bound, and check-run-bound. +7. Wrong import path with matching local name must not satisfy proof. +8. Parser gaps under blocking contracts must produce missing proof and fail closed in changed scope. +9. CLI/MCP/storage must not expose source snippets, raw URLs with secrets, payloads, headers, cookies, SQL strings/literals, env values, tokens, user IDs, tenant IDs, or full source. +10. CLI and MCP must use query/read-model functions. They must not duplicate proof logic. + +## Production Contract Model + +### Accepted Security Convention + +Use existing `AcceptedConvention` as the election result. For Phase 8, every accepted security convention exposed to agents must be summarized from this shape only: + +```ts +type SecurityConventionKind = + | "api_route_requires_auth_helper" + | "middleware_must_cover_routes" + | "api_route_requires_request_validation" + | "session_object_must_come_from_trusted_helper" + | "api_route_requires_authorization" + | "api_route_requires_tenant_scope" + | "api_route_forbids_sensitive_response_fields" + | "api_route_forbids_secret_exposure" + | "api_route_forbids_untrusted_ssrf" + | "api_route_forbids_raw_sql_without_params" + | "api_route_cors_must_match_policy" + | "api_route_requires_csrf_for_mutation" + | "api_route_requires_rate_limit"; +``` + +Accepted security conventions must include: + +```ts +type AcceptedSecurityConventionSummary = { + convention_id: string; + kind: SecurityConventionKind; + enforcement_mode: "brief" | "warn" | "block"; + capability: "deterministic_check" | "heuristic_check" | "briefing_only"; + matcher_summary: string; + route_scope: { + file_roles: string[]; + paths?: string[]; + methods?: string[]; + }; + trusted_helpers: Array<{ + helper_id: string; + symbol: string; + module?: string; + import?: string; + }>; + requires_summary: string[]; + accepted_by?: string; + accepted_at?: string; + updated_at?: string; + expires_at?: string; +}; +``` + +Rules: + +- `trusted_helpers` may include helper symbols and module paths because accepted contracts already expose these. It must not include argument values, request payloads, SQL, headers, cookies, env values, user IDs, tenant IDs, or source snippets. +- `matcher_summary` must be generated from accepted matcher fields, not from raw source code. +- `requires_summary` must use allowlisted phrases such as `auth helper must dominate data and response sinks`. + +### Candidate Election Contract + +Phase 8 must expose candidates without bypassing elections. + +Required CLI aliases: + +```text +drift candidates --repo --json +drift candidates --repo --kind --json +drift candidates show --repo --json +drift candidates accept --repo --mode warn --confirm +drift candidates reject --repo --reason "not a repo convention" +``` + +Alias behavior: + +- `drift candidates` is an alias for the current candidate listing path under `drift conventions list`. +- `drift candidates show` aliases `drift conventions show`. +- `drift candidates accept` aliases `drift conventions accept`. +- `drift candidates reject` aliases `drift conventions reject`. +- Existing `drift conventions ...` commands remain valid. + +Election rules: + +- Candidate default mode remains `warn` or `brief`. +- Candidate output must include `candidate_id`, `kind`, `confidence_label`, `suggested_enforcement_mode`, `enforcement_capability`, `supporting_examples_count`, `counterexamples_count`, `evidence_refs`, and `reason_not_blocking`. +- Rejected candidates must not be re-proposed without changed evidence fingerprint. +- Accepted candidate materialization must preserve accepted evidence refs and counterexample refs. +- `--mode block` must be rejected unless the accepted convention kind and required capability are deterministic. +- Candidate `requires` can be displayed only as sanitized JSON from candidate payloads. It must not be enriched from raw facts. + +## Required Phase 8 Schemas + +### Security Proof Route Metadata + +Extend the existing `SecurityBoundaryProofSchema.route` in both: + +- `packages/core/src/security.ts` +- `packages/engine-contract/src/index.ts` + +Route path and method remain optional for backward compatibility, but new Rust proof output must always populate them for supported API routes. + +```ts +type SecurityProofRoute = { + route_id: string; + file_path: string; + file_role: "api_route"; + endpoint?: { + path?: string; + method?: string; + framework?: string; + }; + handler_symbol?: string; + start_line?: number; + end_line?: number; + diff_status?: "unchanged" | "added" | "modified" | "deleted" | "renamed"; +}; +``` + +Production invariant: + +- If `file_role` is `api_route` and route path/method are statically supported, Rust must emit `endpoint.path` and `endpoint.method`. +- TypeScript may render `unknown` if old rows lack these fields. It must not parse `route_id` or file path as proof. + +### Security Evidence Reference + +Add a sanitized evidence reference object inside each proof. + +```ts +type SecurityProofEvidenceRef = { + evidence_id: string; + fact_id?: string; + graph_edge_id?: string; + capability: string; + kind: string; + file_path: string; + start_line?: number; + end_line?: number; + role: "guard" | "sink" | "validator" | "serializer" | "middleware" | "policy" | "parser_gap" | "missing_proof"; +}; +``` + +Add optional field: + +```ts +evidence_refs?: SecurityProofEvidenceRef[]; +``` + +Rules: + +- No `source`, `snippet`, `value`, `literal`, `payload`, `header`, `cookie`, `sql`, `url`, `env`, `token`, `user_id`, or `tenant_id` fields. +- Evidence refs must be generated by Rust. +- TypeScript may filter, group, and render these refs. It must not create new proof evidence from raw facts. + +### Stored Security Boundary Proof Run + +The existing `security_boundary_proofs` table is scan-scoped and keyed by `proof_id`. That is not enough for production Phase 8 because human check output, MCP context, and scan status need to explain the exact check run that produced the proof. + +Add migration `025_security_boundary_proof_runs` with a new additive table. Do not rewrite the existing table. + +```sql +CREATE TABLE IF NOT EXISTS security_boundary_proof_runs ( + storage_id TEXT PRIMARY KEY, + proof_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + scan_id TEXT NOT NULL, + check_id TEXT NOT NULL, + route_id TEXT NOT NULL, + file_path TEXT NOT NULL, + contract_kinds_json TEXT NOT NULL, + capability_names_json TEXT NOT NULL, + proof_status TEXT NOT NULL, + enforcement_result TEXT NOT NULL, + parser_gap_count INTEGER NOT NULL, + missing_proof_count INTEGER NOT NULL, + affected_files_json TEXT NOT NULL, + proof_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (repo_id) REFERENCES repos(id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_unique + ON security_boundary_proof_runs(check_id, proof_id); + +CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_scan + ON security_boundary_proof_runs(repo_id, scan_id); + +CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_check + ON security_boundary_proof_runs(repo_id, check_id); + +CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_route + ON security_boundary_proof_runs(repo_id, route_id); +``` + +Storage type: + +```ts +type StoredSecurityBoundaryProofRun = { + storage_id: string; + proof_id: string; + repo_id: string; + scan_id: string; + check_id: string; + route_id: string; + file_path: string; + contract_kinds: string[]; + capability_names: string[]; + proof_status: "proven" | "violated" | "missing_proof" | "parser_gap" | "advisory_only"; + enforcement_result: "pass" | "brief" | "warn" | "block"; + parser_gap_count: number; + missing_proof_count: number; + affected_files: string[]; + proof: SecurityBoundaryProof; + created_at: string; +}; +``` + +Storage methods: + +```ts +upsertSecurityBoundaryProofRuns(input: { + repo_id: string; + scan_id: string; + check_id: string; + proofs: SecurityBoundaryProof[]; + created_at: string; +}): void; + +listSecurityBoundaryProofRuns(input: { + repo_id: string; + scan_id?: string; + check_id?: string; + file_path?: string; + route_id?: string; + contract_kind?: string; + latest_only?: boolean; +}): StoredSecurityBoundaryProofRun[]; +``` + +Compatibility: + +- Keep `upsertSecurityBoundaryProofs` and `listSecurityBoundaryProofs` working. +- New read models should prefer proof runs and fall back to scan-scoped proofs if no proof runs exist. +- Existing databases with no proof rows remain valid. +- Old proof JSON must continue parsing. New schema fields must be optional/defaulted in TypeScript. + +### Security Capability Summary + +Phase 8 `drift scan status --json` must expose an array: + +```ts +type SecurityCapabilitySummary = { + name: string; + capability: "deterministic_check" | "heuristic_check" | "briefing_only"; + status: "complete" | "partial" | "missing" | "unsupported"; + can_block: boolean; + parser_gap_count: number; + missing_proof_count: number; + affected_files: string[]; +}; +``` + +Derivation rules: + +- Prefer latest proof runs for the latest scan. +- Count parser gaps and missing proof from `SecurityBoundaryProof.parser_gaps` and `SecurityBoundaryProof.missing_proof`. +- `affected_files` is the sorted unique set of proof route files and parser gap files. +- `status = complete` only when every matching proof for that capability is proven and no blocking parser gaps or missing proof exist. +- `status = partial` when supported proof exists but at least one route has missing proof, parser gap, warning, or block. +- `status = missing` when an accepted blocking/warn contract requires capability but no proof run exists. +- `status = unsupported` when engine contract/version says the capability is not supported. +- TypeScript must not inspect raw facts to decide these statuses. + +Capability names: + +```ts +const SECURITY_CAPABILITIES = [ + "control_flow_guard_dominance", + "middleware_coverage", + "request_validation_facts", + "session_trust", + "authorization", + "tenant_scope", + "sensitive_response", + "secret_exposure", + "ssrf", + "raw_sql", + "cors_policy", + "csrf", + "rate_limit" +] as const; +``` + +### Repo Map Route Security + +Required route shape: + +```ts +type RepoMapSecurityRoute = { + route_id: string; + path: string | null; + method: string | null; + file_path: string; + security: { + public_or_protected: "public" | "protected" | "unknown"; + auth_proven: boolean | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + middleware_proven: boolean | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + tenant_scope: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + request_validation: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + sensitive_response: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + phase6: { + ssrf: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + raw_sql: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + cors: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + csrf: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + rate_limit: "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + }; + proof_status: "proven" | "violated" | "missing_proof" | "parser_gap" | "advisory_only" | "unknown"; + enforcement_result: "pass" | "brief" | "warn" | "block" | "unknown"; + missing_proof_codes: string[]; + parser_gap_codes: string[]; + finding_ids: string[]; + next_command: string; + }; +}; +``` + +Rules: + +- `routes[].security` must be proof-backed. +- Existing fact-derived `route_security` may remain in file summaries, but it must be labeled advisory and must not satisfy Phase 8 route security. +- If no proof exists for a route, output `unknown`, not `proven`. + +### MCP Security Context + +Required MCP payload: + +```ts +type DriftSecurityContextV2 = { + response_schema: "drift.security.context.v2"; + repo_id: string; + scan_id: string | null; + check_id: string | null; + repo_security_contracts: AcceptedSecurityConventionSummary[]; + changed_route_security: Array<{ + route_id: string; + path: string | null; + method: string | null; + file_path: string; + required_proofs: string[]; + current_proof_status: "proven" | "violated" | "missing_proof" | "parser_gap" | "advisory_only" | "unknown"; + enforcement_result: "pass" | "brief" | "warn" | "block" | "unknown"; + missing_proof: Array<{ + id: string; + capability: string; + code: string; + blocks_enforcement: boolean; + }>; + parser_gaps: Array<{ + parser_gap_id: string; + capability: string; + code: string; + file_path: string; + start_line?: number; + end_line?: number; + blocks_enforcement: boolean; + }>; + next_command: string; + }>; + do_not_include: [ + "source snippets", + "secret values", + "raw request payload examples", + "headers", + "cookies", + "raw SQL", + "raw URLs", + "env values", + "tokens", + "user IDs", + "tenant IDs" + ]; +}; +``` + +Tool schema: + +```ts +type GetSecurityContextInput = { + repo_id: string; + path?: string; + changed_files?: string[]; + check_id?: string; + require_fresh?: boolean; +}; +``` + +Rules: + +- `path` and `changed_files` filter route security and relevant contracts. +- If no path is supplied, use latest changed files from scan/check state when available. +- MCP must not read raw facts to decide proof status. +- MCP must not expose raw fact values. +- MCP must call query package read-model functions. + +## File Responsibility Map + +### Rust + +- `crates/drift-engine/src/check_command.rs` + - Emit route endpoint path/method in security proof route metadata. + - Emit sanitized proof evidence refs. + - Keep proof/finding IDs stable and route/method/file-role bound. + +- `crates/drift-engine/src/security_phase6.rs` + - Preserve P6 evidence line refs in JSON. + - Populate P6 missing proof fact IDs. + - Emit P6 parser gaps and missing proof without source values. + +- `crates/drift-engine/src/security_capabilities.rs` + - Add Phase 6 capability names and capability completeness inputs. + +- `crates/drift-engine/tests/security_phase6.rs` + - Assert no P6 evidence leaks SQL, URL, headers, cookies, payloads, env values, tokens, user IDs, tenant IDs, or source snippets. + +### Core And Engine Contract + +- `packages/core/src/security.ts` + - Add optional `evidence_refs` schema. + - Keep route endpoint path/method optional for compatibility. + - Add schemas for Phase 8 read-model shapes if exported from core. + +- `packages/core/src/domain.ts` + - Add `StoredSecurityBoundaryProofRun` domain type if storage needs it exported. + +- `packages/core/src/schemas.ts` + - Add validation exports if domain schema registry uses this file. + +- `packages/engine-contract/src/index.ts` + - Mirror engine proof event schema changes. + - Parse new route metadata/evidence refs. + +### Storage + +- `packages/storage/src/migrations.ts` + - Add migration `025_security_boundary_proof_runs`. + +- `packages/storage/src/sqlite-storage.ts` + - Add `upsertSecurityBoundaryProofRuns`. + - Add `listSecurityBoundaryProofRuns`. + - Keep old proof methods compatible. + +- `packages/storage/test/sqlite-storage.test.ts` + - Empty DB migration. + - Existing DB migration. + - Proof run round trip with missing proof and parser gaps. + - Secret/snippet sentinel rejection or absence check. + +### Query + +- `packages/query/src/security-boundary-proof.ts` + - Extend current read model into a Phase 8 proof-backed read model. + - Add accepted contract summaries. + - Add route security summaries. + - Add MCP context builder. + - Add scan capability summary builder. + +- `packages/query/src/index.ts` + - Export new read-model functions. + - Wire repo map route security through proof read model. + +- `packages/query/test/security-boundary-proof.test.ts` + - Proof-backed route security. + - Missing proof and parser gap summaries. + - Changed-file filtering. + - No raw facts required. + +### CLI + +- `packages/cli/src/check/run-check.ts` + - Persist proof runs after check run creation. + - Keep `drift check --json.security_boundary_proofs`. + +- `packages/cli/src/check/security-check.ts` + - Preserve proof JSON in check payload. + - Do not derive proof from findings. + +- `packages/cli/src/formatters/checks.ts` + - Render Phase 8 human blocks from proof plus finding summaries. + +- `packages/cli/src/domain/scan-status.ts` + - Emit Phase 8 `security_capabilities[]`. + +- `packages/cli/src/domain/repo-map.ts` + - Add proof-backed top-level `routes[]`. + +- `packages/cli/src/commands/repo-map.ts` + - Include proof read model in JSON response. + +- `packages/cli/src/commands/conventions.ts` + - Support `drift candidates` alias through router. + +- `packages/cli/src/app/router.ts` + - Add candidate aliases. + +- `packages/cli/src/args/command-shape.ts` + - Accept candidate aliases. + +- `packages/cli/src/args/flag-readers.ts` + - Add all security convention kinds to `--kind`. + +- `packages/cli/src/args/help.ts` + - Document `drift candidates`. + +- `packages/cli/test/cli.test.ts` + - P8 CLI tests and goldens. + +### MCP + +- `packages/mcp/src/security-context.ts` + - Replace raw-fact proof sections with query read model. + - Emit `drift.security.context.v2`. + +- `packages/mcp/src/tools.ts` + - Extend `get_security_context` input schema. + +- `packages/mcp/src/index.ts` + - Pass path/check filters to security context builder. + +- `packages/mcp/test/mcp.test.ts` + - P8 MCP context shape. + - Path filtering. + - No raw fact value egress. + +### E2E And Fixtures + +- `test/e2e/security-phase8.test.ts` + - Golden CLI/MCP P8 flow. + +- `test/e2e/golden.test.ts` + - Add reduced goldens for P8 outputs. + +- `test/fixtures/security-*` + - Fill missing fixture cases required by original TDD. + +## TDD Task Ledger + +### Task 1: RED Rust Proof Route Metadata + +**Files:** + +- Test: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Modify: `crates/drift-engine/src/check_command.rs` + +- [ ] **Step 1: Add failing test for route path and method in proof** + +Add test: + +```rust +#[test] +fn security_phase8_proof_includes_route_path_and_method() { + let repo = security_repo_with_route("app/api/users/route.ts", "GET", r#" +import { requireUser } from "@/server/auth"; +export async function GET() { + const user = await requireUser(); + return Response.json({ id: user.id }); +} +"#); + let contract = accepted_auth_contract("security_auth", "@/server/auth", "requireUser"); + let result = check_repo_with_contracts(&repo, vec![contract]); + let proof = result.security_boundary_proofs.iter() + .find(|proof| proof.route.file_path == "app/api/users/route.ts") + .expect("security proof"); + assert_eq!(proof.route.endpoint.as_ref().and_then(|endpoint| endpoint.path.as_deref()), Some("/api/users")); + assert_eq!(proof.route.endpoint.as_ref().and_then(|endpoint| endpoint.method.as_deref()), Some("GET")); + assert_eq!(proof.route.file_role, "api_route"); +} +``` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine --test security_check_repo_auth security_phase8_proof_includes_route_path_and_method -- --nocapture +``` + +Expected: fail because route endpoint path/method is missing. + +- [ ] **Step 3: Implement Rust route metadata** + +In `crates/drift-engine/src/check_command.rs`, when building `SecurityBoundaryProof.route`, use the already computed route path and method. Method must come from the route handler, not from filename parsing in TypeScript. + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine --test security_check_repo_auth security_phase8_proof_includes_route_path_and_method -- --nocapture +``` + +Expected: pass. + +### Task 2: RED Rust Sanitized Evidence Refs + +**Files:** + +- Test: `crates/drift-engine/tests/security_phase6.rs` +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/src/security_phase6.rs` + +- [ ] **Step 1: Add failing test for sanitized proof evidence refs** + +Add test: + +```rust +#[test] +fn security_phase8_proof_evidence_refs_are_line_only_and_sanitized() { + let proof = phase6_violation_proof_for_source(r#" +export async function POST(request: Request) { + const body = await request.json(); + await fetch(body.callbackUrl + "?token=secret"); + await db.query("select * from users where token = 'secret'"); + return Response.json({ ok: true }); +} +"#); + let serialized = serde_json::to_string(&proof).expect("proof json"); + assert!(serialized.contains("\"evidence_refs\"")); + assert!(serialized.contains("\"start_line\"")); + assert!(!serialized.contains("callbackUrl +")); + assert!(!serialized.contains("select * from users")); + assert!(!serialized.contains("token=secret")); + assert!(!serialized.contains("\"source\"")); + assert!(!serialized.contains("\"snippet\"")); + assert!(!serialized.contains("\"payload\"")); + assert!(!serialized.contains("\"cookie\"")); + assert!(!serialized.contains("\"header\"")); + } +``` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine --test security_phase6 security_phase8_proof_evidence_refs_are_line_only_and_sanitized -- --nocapture +``` + +Expected: fail because `evidence_refs` is not emitted. + +- [ ] **Step 3: Implement evidence refs in Rust** + +Emit `evidence_refs` with only: + +- `evidence_id` +- `fact_id` +- `graph_edge_id` +- `capability` +- `kind` +- `file_path` +- `start_line` +- `end_line` +- `role` + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine --test security_phase6 security_phase8_proof_evidence_refs_are_line_only_and_sanitized -- --nocapture +``` + +Expected: pass. + +### Task 3: RED P6 Missing Proof Fact IDs And Capability Truth + +**Files:** + +- Test: `crates/drift-engine/tests/security_phase6.rs` +- Modify: `crates/drift-engine/src/security_phase6.rs` +- Modify: `crates/drift-engine/src/security_capabilities.rs` + +- [ ] **Step 1: Add failing P6 missing proof ID test** + +Add test: + +```rust +#[test] +fn security_phase8_phase6_missing_proof_preserves_fact_ids() { + let proof = phase6_violation_proof_for_source(r#" +export async function POST(request: Request) { + const body = await request.json(); + await fetch(body.callbackUrl); + return Response.json({ ok: true }); +} +"#); + let missing = proof.missing_proof.iter() + .find(|missing| missing.code == "request_controlled_url") + .expect("ssrf missing proof"); + assert!(!missing.fact_ids.is_empty()); + assert!(missing.blocks_enforcement); +} +``` + +- [ ] **Step 2: Add failing P6 capability test** + +Add or extend a capability test: + +```rust +#[test] +fn security_phase8_reports_phase6_capabilities() { + let capabilities = security_capabilities(); + for expected in ["ssrf", "raw_sql", "cors_policy", "csrf", "rate_limit"] { + assert!(capabilities.iter().any(|capability| capability.name == expected), "missing {expected}"); + } +} +``` + +- [ ] **Step 3: Run RED** + +```bash +cargo test -p drift-engine --test security_phase6 security_phase8_phase6_missing_proof_preserves_fact_ids -- --nocapture +cargo test -p drift-engine security_phase8_reports_phase6_capabilities -- --nocapture +``` + +Expected: fail because fact IDs and P6 capabilities are incomplete. + +- [ ] **Step 4: Implement** + +Use `Phase6MissingProof.fact_ids` when emitting top-level missing proof. Add P6 capabilities to the Rust capability registry. + +- [ ] **Step 5: Run GREEN** + +```bash +cargo test -p drift-engine --test security_phase6 security_phase8_phase6_missing_proof_preserves_fact_ids -- --nocapture +cargo test -p drift-engine security_phase8_reports_phase6_capabilities -- --nocapture +``` + +Expected: pass. + +### Task 4: RED TypeScript Proof Schema Mirrors Rust + +**Files:** + +- Test: `packages/core/test/security.test.ts` +- Test: `packages/engine-contract/test/security-contract.test.ts` +- Modify: `packages/core/src/security.ts` +- Modify: `packages/engine-contract/src/index.ts` + +- [ ] **Step 1: Add failing core schema test** + +```ts +it("accepts Phase 8 proof route metadata and sanitized evidence refs", () => { + const proof = securityProofFixture({ + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" }, + handler_symbol: "GET" + }, + evidence_refs: [{ + evidence_id: "evidence_auth_guard", + fact_id: "fact_auth_guard", + capability: "control_flow_guard_dominance", + kind: "auth_guard_called", + file_path: "app/api/users/route.ts", + start_line: 4, + end_line: 4, + role: "guard" + }] + }); + expect(SecurityBoundaryProofSchema.parse(proof).evidence_refs).toHaveLength(1); +}); +``` + +- [ ] **Step 2: Add failing forbidden evidence fields test** + +```ts +it("rejects Phase 8 proof evidence refs that carry source or secret values", () => { + const proof = securityProofFixture({ + evidence_refs: [{ + evidence_id: "evidence_bad", + capability: "raw_sql", + kind: "raw_sql_called", + file_path: "app/api/users/route.ts", + role: "sink", + source: "await db.query(\"select secret\")" + }] + }); + expect(() => SecurityBoundaryProofSchema.parse(proof)).toThrow(); +}); +``` + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected: fail because `evidence_refs` is not defined or strict enough. + +- [ ] **Step 4: Implement schemas** + +Add strict `SecurityProofEvidenceRefSchema` and optional `evidence_refs` to both core and engine-contract proof schemas. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected: pass. + +### Task 5: RED Storage Proof Runs + +**Files:** + +- Test: `packages/storage/test/sqlite-storage.test.ts` +- Modify: `packages/storage/src/migrations.ts` +- Modify: `packages/storage/src/sqlite-storage.ts` +- Modify: `packages/core/src/domain.ts` +- Modify: `packages/core/src/schemas.ts` + +- [ ] **Step 1: Add failing migration expectation** + +Extend migration ID tests to include: + +```ts +expect(migrationIds).toContain("025_security_boundary_proof_runs"); +``` + +- [ ] **Step 2: Add failing round-trip test** + +```ts +it("persists security boundary proof runs by check run without snippets", () => { + const storage = createTestStorage(); + seedRepoScanAndCheck(storage, { + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security" + }); + const proof = securityBoundaryProofFixture({ + proof_id: "proof_route_users_get", + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + }, + missing_proof: [{ + id: "missing_auth", + capability: "control_flow_guard_dominance", + code: "auth_guard_not_dominating_sink", + blocks_enforcement: true, + fact_ids: ["fact_sink"], + graph_edge_ids: [] + }], + parser_gaps: [] + }); + storage.upsertSecurityBoundaryProofRuns({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [proof], + created_at: "2026-05-27T00:00:00.000Z" + }); + const rows = storage.listSecurityBoundaryProofRuns({ + repo_id: "repo_security", + check_id: "check_security" + }); + expect(rows).toHaveLength(1); + expect(rows[0].missing_proof_count).toBe(1); + expect(rows[0].affected_files).toEqual(["app/api/users/route.ts"]); + expect(JSON.stringify(rows[0])).not.toContain("select *"); + expect(JSON.stringify(rows[0])).not.toContain("secret="); +}); +``` + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @drift/storage test -- sqlite-storage +``` + +Expected: fail because migration and methods do not exist. + +- [ ] **Step 4: Implement migration and methods** + +Add migration `025_security_boundary_proof_runs` and storage methods exactly as defined in this TDD. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm --filter @drift/storage test -- sqlite-storage +``` + +Expected: pass. + +### Task 6: RED Check Persists Proof Runs + +**Files:** + +- Test: `packages/cli/test/security-check.test.ts` +- Modify: `packages/cli/src/check/run-check.ts` + +- [ ] **Step 1: Add failing test** + +```ts +it("persists engine security proofs for the check run", async () => { + const { storage, repoId, scanId } = await seedSecurityCheckRepo(); + const result = await runCheckForTest(storage, { + repo: repoId, + scope: "full", + json: true + }); + expect(result.security_boundary_proofs.length).toBeGreaterThan(0); + const checkId = result.summary.check_id; + const stored = storage.listSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: scanId, + check_id: checkId + }); + expect(stored.length).toBe(result.security_boundary_proofs.length); +}); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected: fail because check proof runs are not persisted. + +- [ ] **Step 3: Implement** + +After `upsertCheckRun` and before final payload return, call: + +```ts +storage.upsertSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: latestScan.id, + check_id: checkRun.id, + proofs: securityBoundaryProofs, + created_at: completedAt +}); +``` + +Only call when `securityBoundaryProofs.length > 0`. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected: pass. + +### Task 7: RED Query Phase 8 Read Model + +**Files:** + +- Test: `packages/query/test/security-boundary-proof.test.ts` +- Modify: `packages/query/src/security-boundary-proof.ts` +- Modify: `packages/query/src/index.ts` + +- [ ] **Step 1: Add failing route security read-model test** + +```ts +it("builds Phase 8 route security from proofs only", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [securityBoundaryProofFixture({ + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + }, + auth: { required: true, proven: true, trusted_guards: [], undominated_sinks: [] }, + tenant: { required: true, proven: false, tenant_sources: [], predicates: [], missing: [{ reason: "tenant_param_not_bound_to_data_operation" }] }, + missing_proof: [{ + id: "missing_tenant", + capability: "tenant_scope", + code: "tenant_not_bound_to_data_operation", + blocks_enforcement: true, + fact_ids: ["fact_tenant"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_tenant"] + } + })], + findings: [{ finding_id: "finding_tenant", title: "Tenant missing", lifecycle: "new" }], + accepted_conventions: [] + }); + expect(model.routes[0]).toMatchObject({ + route_id: "route_users_get", + path: "/api/users", + method: "GET", + security: { + auth_proven: true, + tenant_scope: "missing_proof", + proof_status: "missing_proof", + enforcement_result: "block" + } + }); +}); +``` + +- [ ] **Step 2: Add failing changed-file filtering test** + +```ts +it("filters Phase 8 route security to changed files", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [ + securityBoundaryProofFixture({ route: { route_id: "route_users", file_path: "app/api/users/route.ts", file_role: "api_route" } }), + securityBoundaryProofFixture({ route: { route_id: "route_admin", file_path: "app/api/admin/route.ts", file_role: "api_route" } }) + ], + findings: [], + accepted_conventions: [], + changed_files: ["app/api/users/route.ts"] + }); + expect(model.changed_route_security.map((route) => route.file_path)).toEqual(["app/api/users/route.ts"]); +}); +``` + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +``` + +Expected: fail because `buildSecurityPhase8ReadModel` does not exist. + +- [ ] **Step 4: Implement query model** + +Add `buildSecurityPhase8ReadModel` that returns: + +- `security_capabilities` +- `routes` +- `repo_security_contracts` +- `changed_route_security` +- `do_not_include` + +Use only proofs, findings, accepted conventions, and explicit changed file inputs. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +``` + +Expected: pass. + +### Task 8: RED Scan Status Phase 8 Capabilities + +**Files:** + +- Test: `packages/cli/test/cli.test.ts` +- Modify: `packages/cli/src/domain/scan-status.ts` + +- [ ] **Step 1: Add failing CLI test** + +```ts +it("scan status reports Phase 8 security capability array from proof runs", async () => { + const { storage, repoId } = await seedCheckedSecurityRepo(); + const result = await runCliJson(storage, ["scan", "status", "--repo", repoId, "--json"]); + expect(Array.isArray(result.security_capabilities)).toBe(true); + expect(result.security_capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "control_flow_guard_dominance", + capability: "deterministic_check", + status: expect.stringMatching(/complete|partial|missing|unsupported/), + can_block: expect.any(Boolean), + parser_gap_count: expect.any(Number), + missing_proof_count: expect.any(Number), + affected_files: expect.any(Array) + }) + ])); +}); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- "scan status reports Phase 8 security capability array" +``` + +Expected: fail because current output is object-shaped. + +- [ ] **Step 3: Implement** + +In `scan-status.ts`, load latest proof runs and pass them to the query read model. Replace the old object-shaped `security_capabilities` output with the Phase 8 array. If no proof runs exist, return an empty array and keep `capability_report` unchanged for backward diagnostics. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- "scan status reports Phase 8 security capability array" +``` + +Expected: pass. + +### Task 9: RED Repo Map Proof-Backed Routes + +**Files:** + +- Test: `packages/cli/test/cli.test.ts` +- Modify: `packages/cli/src/domain/repo-map.ts` +- Modify: `packages/cli/src/commands/repo-map.ts` +- Modify: `packages/query/src/index.ts` + +- [ ] **Step 1: Add failing CLI test** + +```ts +it("repo map reports Phase 8 proof-backed route security", async () => { + const { storage, repoId } = await seedCheckedSecurityRepo(); + const result = await runCliJson(storage, ["repo", "map", "--repo", repoId, "--json"]); + expect(result.routes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + route_id: expect.any(String), + file_path: expect.stringContaining("app/api/"), + security: expect.objectContaining({ + public_or_protected: expect.stringMatching(/public|protected|unknown/), + auth_proven: expect.anything(), + tenant_scope: expect.stringMatching(/proven|not_required|missing_proof|parser_gap|unknown/), + request_validation: expect.stringMatching(/proven|not_required|missing_proof|parser_gap|unknown/), + sensitive_response: expect.stringMatching(/proven|not_required|missing_proof|parser_gap|unknown/) + }) + }) + ])); +}); +``` + +- [ ] **Step 2: Add false-proof regression** + +```ts +it("repo map does not mark raw fact route security as proven without proof runs", async () => { + const { storage, repoId } = await seedScannedButUncheckedSecurityRepo(); + const result = await runCliJson(storage, ["repo", "map", "--repo", repoId, "--json"]); + expect(result.routes ?? []).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ + security: expect.objectContaining({ auth_proven: true }) + }) + ])); +}); +``` + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @drift/cli test -- "repo map reports Phase 8 proof-backed route security" +pnpm --filter @drift/cli test -- "repo map does not mark raw fact route security as proven" +``` + +Expected: fail because repo map does not expose proof-backed top-level routes. + +- [ ] **Step 4: Implement** + +Load latest proof runs in repo map command/domain, build Phase 8 read model, and attach `routes` to repo map JSON. Existing file-level map output stays compatible. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- "repo map reports Phase 8 proof-backed route security" +pnpm --filter @drift/cli test -- "repo map does not mark raw fact route security as proven" +``` + +Expected: pass. + +### Task 10: RED Human Check Output + +**Files:** + +- Test: `packages/cli/test/security-check.test.ts` +- Modify: `packages/cli/src/formatters/checks.ts` +- Modify: `packages/cli/src/check/run-check.ts` + +- [ ] **Step 1: Add failing human output test** + +```ts +it("renders Phase 8 human check blocks for security findings", async () => { + const { storage, repoId } = await seedSecurityViolationRepo(); + const text = await runCliText(storage, ["check", "--repo", repoId, "--scope", "full"]); + expect(text).toContain("BLOCK api_route_requires_auth_helper"); + expect(text).toContain("Route: GET /api/users"); + expect(text).toContain("File: app/api/users/route.ts"); + expect(text).toContain("Reason:"); + expect(text).toContain("Evidence:"); + expect(text).toContain("Capability:"); + expect(text).toContain("Lifecycle:"); + expect(text).toContain("Next: drift repo map --repo"); + expect(text).not.toContain("await "); + expect(text).not.toContain("select *"); + expect(text).not.toContain("cookie"); +}); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- "renders Phase 8 human check blocks" +``` + +Expected: fail because formatter only prints compact finding rows. + +- [ ] **Step 3: Implement formatter** + +Change `formatCheckText` input to accept `security_boundary_proofs`. Render one block per blocking/warning security finding matched through `proof.result.finding_ids`. + +Block format: + +```text +BLOCK api_route_requires_auth_helper + Route: GET /api/users + File: app/api/users/route.ts + Reason: auth guard does not dominate data operation + Evidence: auth_guard_called line 18; data_operation_detected line 12 + Capability: control_flow_guard_dominance deterministic_check + Lifecycle: new, changed-files + Next: drift repo map --repo --path app/api/users/route.ts --json +``` + +If path or method is missing, render: + +```text +Route: unknown +``` + +Do not infer proof truth from the finding message. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- "renders Phase 8 human check blocks" +``` + +Expected: pass. + +### Task 11: RED MCP Security Context V2 + +**Files:** + +- Test: `packages/mcp/test/mcp.test.ts` +- Modify: `packages/mcp/src/security-context.ts` +- Modify: `packages/mcp/src/tools.ts` +- Modify: `packages/mcp/src/index.ts` + +- [ ] **Step 1: Add failing MCP shape test** + +```ts +it("returns Phase 8 security context v2 from proof read model", async () => { + const { server, repoId } = await seedMcpSecurityRepoWithProofRuns(); + const result = await callTool(server, "get_security_context", { + repo_id: repoId, + changed_files: ["app/api/users/route.ts"] + }); + expect(result.response_schema).toBe("drift.security.context.v2"); + expect(result.repo_security_contracts).toEqual(expect.any(Array)); + expect(result.changed_route_security).toEqual(expect.arrayContaining([ + expect.objectContaining({ + file_path: "app/api/users/route.ts", + required_proofs: expect.any(Array), + current_proof_status: expect.stringMatching(/proven|violated|missing_proof|parser_gap|advisory_only|unknown/) + }) + ])); + expect(result.do_not_include).toContain("source snippets"); +}); +``` + +- [ ] **Step 2: Add raw fact egress regression** + +```ts +it("does not expose raw security fact values in MCP security context", async () => { + const { server, repoId } = await seedMcpRepoWithAdversarialSecurityFacts({ + source_value: "await db.query(\"select * from users where token = secret\")", + secret_value: "sk_live_secret", + cookie_value: "session=secret", + header_value: "authorization: bearer secret", + request_payload: "{\"password\":\"secret\"}", + tenant_id: "tenant_123", + user_id: "user_123" + }); + const result = await callTool(server, "get_security_context", { repo_id: repoId }); + const json = JSON.stringify(result); + for (const forbidden of ["select * from users", "sk_live_secret", "session=secret", "bearer secret", "password", "tenant_123", "user_123"]) { + expect(json).not.toContain(forbidden); + } +}); +``` + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @drift/mcp test -- mcp +``` + +Expected: fail because MCP returns v1 and still emits raw-fact-derived sections. + +- [ ] **Step 4: Implement MCP v2** + +Extend tool input schema and replace raw-fact proof reducers with query read model output. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm --filter @drift/mcp test -- mcp +``` + +Expected: pass. + +### Task 12: RED Candidates Alias And Kind Filters + +**Files:** + +- Test: `packages/cli/test/cli.test.ts` +- Modify: `packages/cli/src/app/router.ts` +- Modify: `packages/cli/src/args/command-shape.ts` +- Modify: `packages/cli/src/args/flag-readers.ts` +- Modify: `packages/cli/src/args/help.ts` + +- [ ] **Step 1: Add failing candidates alias test** + +```ts +it("lists security convention candidates through drift candidates json", async () => { + const { storage, repoId } = await seedScannedSecurityCandidateRepo(); + const result = await runCliJson(storage, ["candidates", "--repo", repoId, "--kind", "api_route_requires_rate_limit", "--json"]); + expect(result.candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "api_route_requires_rate_limit", + suggested_enforcement_mode: "warn", + reason_not_blocking: "candidate_not_accepted" + }) + ])); +}); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- "lists security convention candidates through drift candidates json" +``` + +Expected: fail because command alias or kind filter is missing. + +- [ ] **Step 3: Implement** + +Add command alias and update kind filter to include all security convention kinds from this TDD. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- "lists security convention candidates through drift candidates json" +``` + +Expected: pass. + +### Task 13: RED Golden Outputs + +**Files:** + +- Test: `test/e2e/security-phase8.test.ts` +- Test: `test/e2e/golden.test.ts` +- Create: `test/fixtures/security-phase8-full` + +- [ ] **Step 1: Add failing e2e golden test** + +Add `test/e2e/security-phase8.test.ts` with one fixture repo that: + +- scans security route fixtures +- accepts deterministic security contracts +- runs `drift check --json` +- runs human `drift check` +- runs `drift scan status --json` +- runs `drift repo map --json` +- runs `drift candidates --json` +- runs MCP `get_security_context` + +Assertions: + +```ts +expect(checkJson.security_boundary_proofs[0].route.endpoint.method).toBeDefined(); +expect(scanStatus.security_capabilities[0]).toHaveProperty("missing_proof_count"); +expect(repoMap.routes[0]).toHaveProperty("security"); +expect(candidates.candidates.every((candidate) => candidate.reason_not_blocking)).toBe(true); +expect(mcp.response_schema).toBe("drift.security.context.v2"); +expect(JSON.stringify({ checkJson, scanStatus, repoMap, candidates, mcp })).not.toContain("select *"); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm test:e2e -- security-phase8 +``` + +Expected: fail because P8 surfaces are incomplete. + +- [ ] **Step 3: Implement fixture and golden reducers** + +Use reducers that remove timestamps, absolute paths, hash values, and unstable IDs. Keep route, method, file, contract kind, proof status, enforcement result, missing proof code, parser gap code, and next command. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm test:e2e -- security-phase8 +``` + +Expected: pass. + +### Task 14: RED Missing Fixture Matrix Cases + +**Files:** + +- Create: `test/fixtures/security-dynamic-import-parser-gap` +- Create: `test/fixtures/security-public-route-exception` +- Create: `test/fixtures/security-waived-finding` +- Create: `test/fixtures/security-baseline-pre-existing` +- Test: `test/e2e/security-phase8.test.ts` + +- [ ] **Step 1: Add fixture matrix test** + +```ts +it("covers Phase 8 required fixture matrix additions", async () => { + for (const fixture of [ + "security-dynamic-import-parser-gap", + "security-public-route-exception", + "security-waived-finding", + "security-baseline-pre-existing" + ]) { + const result = await runSecurityFixture(fixture); + expect(result.check.summary.engine_source).toBe("rust"); + expect(result.check.security_boundary_proofs).toEqual(expect.any(Array)); + } +}); +``` + +- [ ] **Step 2: Run RED** + +```bash +pnpm test:e2e -- security-phase8 +``` + +Expected: fail because fixtures do not exist. + +- [ ] **Step 3: Add fixtures** + +Each fixture must include: + +- `drift.contract.json` or accepted convention setup +- route source +- expected pass/fail behavior +- no source snippets in expected output + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm test:e2e -- security-phase8 +``` + +Expected: pass. + +### Task 15: Final Regression Gate + +Run all commands: + +```bash +git status --short --branch +git diff --stat +git diff --check +cargo test -p drift-engine security_facts +cargo test -p drift-engine security_control_flow +cargo test -p drift-engine security_rules +cargo test -p drift-engine security_proof +cargo test -p drift-engine --test security_phase6 +cargo test -p drift-engine --test candidate_inference +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/factgraph test +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +pnpm verify:ci +``` + +Expected: + +- All pass. +- No source/snippet/secret leakage in P8 outputs. +- Candidate-only evidence remains non-blocking. +- Accepted blocking contracts still fail closed on missing proof and parser gaps. + +## Output Sanitization Checklist + +Every new CLI/MCP/storage/query output must be checked against these forbidden fields and values: + +- `source` +- `source_text` +- `snippet` +- `raw` +- `literal` +- `url` +- `sql` +- `payload` +- `body` +- `header` +- `cookie` +- `env` +- `token` +- `secret` +- `password` +- `authorization` +- `user_id` +- `tenant_id` +- actual request body examples +- actual SQL strings +- actual raw URLs +- actual environment variable values +- full source lines + +Allowed: + +- file path +- route path +- HTTP method +- line number +- fact ID +- graph edge ID +- parser gap ID +- missing proof ID +- contract kind +- capability name +- sanitized helper symbol and accepted module path + +## Definition Of Done + +Phase 8 is production ready when: + +1. Rust proof JSON includes route path/method and sanitized evidence refs. +2. P6 missing proof fact IDs and capability truth are emitted. +3. TypeScript schemas validate the new proof shape without allowing secret/snippet fields. +4. Check runs persist security proof runs by `check_id`. +5. `drift scan status --json` emits Phase 8 `security_capabilities[]`. +6. `drift check --json` emits complete `security_boundary_proofs`. +7. Human check output renders Phase 8 security blocks. +8. `drift repo map --json` emits proof-backed `routes[].security`. +9. `drift candidates --json` exists and includes security candidates. +10. MCP emits `drift.security.context.v2` from query read models. +11. CLI/MCP parity tests pass. +12. Golden tests cover all P8 output surfaces. +13. Missing fixture matrix items are present. +14. No output/storage/MCP/CLI surface leaks forbidden values. +15. Full verification gate passes. diff --git a/drift v3/packages/cli/src/app/router.ts b/drift v3/packages/cli/src/app/router.ts index df4784ce..69e16fbe 100644 --- a/drift v3/packages/cli/src/app/router.ts +++ b/drift v3/packages/cli/src/app/router.ts @@ -82,6 +82,25 @@ export async function runCommand(storage: SqliteDriftStorage, parsed: ParsedArgs return listConventionCandidates(storage, parsed); } + if (group === "candidates" && command === undefined) { + return listConventionCandidates(storage, parsed); + } + + if (group === "candidates" && command === "show") { + const id = requiredValue(maybeId, "candidate id"); + return showConventionCandidate(storage, parsed, id); + } + + if (group === "candidates" && command === "accept") { + const id = requiredValue(maybeId, "candidate id"); + return acceptCandidate(storage, parsed, id); + } + + if (group === "candidates" && command === "reject") { + const id = requiredValue(maybeId, "candidate id"); + return rejectCandidate(storage, parsed, id); + } + if (group === "conventions" && command === "accepted") { return listAcceptedConventions(storage, parsed); } diff --git a/drift v3/packages/cli/src/args/command-shape.ts b/drift v3/packages/cli/src/args/command-shape.ts index 1de2fc15..c5645039 100644 --- a/drift v3/packages/cli/src/args/command-shape.ts +++ b/drift v3/packages/cli/src/args/command-shape.ts @@ -38,6 +38,9 @@ export function unknownCommandError(parsed: ParsedArgs): string | null { } return command === "exception" && maybeId === "add" ? null : message; } + if (group === "candidates") { + return [undefined, "show", "accept", "reject"].includes(command) ? null : message; + } if (group === "contract") { if (command === "waivers" && maybeId === "list") { return null; @@ -124,6 +127,10 @@ export function validateCommandShape(parsed: ParsedArgs): void { exact(`conventions ${command}`, command === "list" || command === "accepted" ? 2 : 3); return; } + if (group === "candidates") { + exact("candidates", command === undefined ? 1 : 3); + return; + } if (group === "contract") { if (command === "waivers" && maybeId === "list") { exact("contract waivers list", 3); diff --git a/drift v3/packages/cli/src/args/flag-readers.ts b/drift v3/packages/cli/src/args/flag-readers.ts index 092f051a..e7d429c1 100644 --- a/drift v3/packages/cli/src/args/flag-readers.ts +++ b/drift v3/packages/cli/src/args/flag-readers.ts @@ -181,6 +181,13 @@ export function optionalConventionKindFlag(parsed: ParsedArgs, name: string): Co value === "api_route_requires_auth_helper" || value === "middleware_must_cover_routes" || value === "api_route_requires_request_validation" || + value === "api_route_forbids_untrusted_ssrf" || + value === "api_route_forbids_raw_sql_without_params" || + value === "api_route_cors_must_match_policy" || + value === "api_route_requires_csrf_for_mutation" || + value === "api_route_requires_rate_limit" || + value === "api_route_forbids_sensitive_response_fields" || + value === "api_route_forbids_secret_exposure" || value === "session_object_must_come_from_trusted_helper" || value === "api_route_requires_authorization" || value === "api_route_requires_tenant_scope" || diff --git a/drift v3/packages/cli/src/check/run-check.ts b/drift v3/packages/cli/src/check/run-check.ts index eb510187..5ab347e7 100644 --- a/drift v3/packages/cli/src/check/run-check.ts +++ b/drift v3/packages/cli/src/check/run-check.ts @@ -491,6 +491,15 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): started_at: now, completed_at: now }); + if (securityBoundaryProofs.length > 0 && typeof storage.upsertSecurityBoundaryProofRuns === "function") { + storage.upsertSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: checkScanId, + check_id: checkId, + proofs: securityBoundaryProofs, + created_at: now + }); + } const openNewCount = findings.filter((finding) => finding.status === "new").length; const outcome = checkOutcomeSummary(findings, { waivedFindingsCount, diff --git a/drift v3/packages/cli/src/domain/repo-map.ts b/drift v3/packages/cli/src/domain/repo-map.ts index ec5b2fa8..596740c6 100644 --- a/drift v3/packages/cli/src/domain/repo-map.ts +++ b/drift v3/packages/cli/src/domain/repo-map.ts @@ -1,6 +1,7 @@ import { authorizeContextExport,type FileRole,type PolicyDecision,type RepoContract } from "@drift/core"; import { buildRepoMapReadModel, + buildSecurityPhase8ReadModel, createGraphQueryService, fallbackFactRepoMapFiles, repoMapConventionIds, @@ -106,6 +107,31 @@ export function repoMapPayload( const scanStatus = scanStatusPayload(storage, repoId); assertFreshScanIfRequired(repoId, scanStatus, Boolean(options.requireFresh)); const readiness = readinessForStoredScan(storage, repoId, latestScan?.id ?? null, "repo_map"); + const proofRuns = latestScan + ? storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: repoId, + file_path: options.path + }) + : []; + const fallbackProofs = proofRuns.length === 0 && latestScan + ? storage.listSecurityBoundaryProofs(repoId, latestScan.id) + .filter((proof) => !options.path || proof.route.file_path === options.path) + : []; + const proofs = proofRuns.length > 0 ? proofRuns.map((run) => run.proof) : fallbackProofs; + const phase8Security = buildSecurityPhase8ReadModel({ + repo_id: repoId, + scan_id: proofRuns[0]?.scan_id ?? latestScan?.id ?? null, + check_id: proofRuns[0]?.check_id ?? null, + proofs, + findings: findings.map((finding) => ({ + finding_id: finding.id, + title: finding.title, + lifecycle: finding.status + })), + accepted_conventions: contract.conventions, + changed_files: options.path ? [options.path] : undefined, + known_routes: knownPhase8Routes(readModel.all_files) + }); return { response_schema: "drift.repo.map.v1", repo_id: repoId, @@ -131,6 +157,7 @@ export function repoMapPayload( impact_summary: readModel.impact_summary, topology: readModel.topology, pagination: readModel.pagination, + routes: phase8Security.routes, freshness_requirement: freshnessRequirement(Boolean(options.requireFresh), scanStatus), files: readModel.listed_files, redactions: { @@ -148,3 +175,37 @@ export function repoMapPayload( } export type { RepoMapFile }; + +function knownPhase8Routes(files: RepoMapFile[]) { + return files + .filter((file) => file.roles.includes("api_route")) + .flatMap((file) => { + const methods = file.exported_symbols.filter((symbol) => + ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(symbol) + ); + const routePath = routePathForFile(file.path); + return (methods.length > 0 ? methods : ["unknown"]).map((method) => ({ + route_id: `route:${file.path}:${method}`, + file_path: file.path, + path: routePath, + method, + file_role: "api_route" + })); + }); +} + +function routePathForFile(filePath: string): string | undefined { + const normalized = filePath.replaceAll("\\", "/"); + const prefix = "app/api/"; + const prefixIndex = normalized.indexOf(prefix); + const suffix = ["/route.ts", "/route.tsx", "/route.js", "/route.jsx"] + .find((candidate) => normalized.endsWith(candidate)); + if (prefixIndex === -1 || !suffix) { + return undefined; + } + const route = normalized.slice(prefixIndex + prefix.length, -suffix.length); + const segments = route.split("/").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); + return segments.length === 0 + ? "/api" + : `/api/${segments.join("/").replaceAll("[", ":").replaceAll("]", "")}`; +} diff --git a/drift v3/packages/cli/src/domain/scan-status.ts b/drift v3/packages/cli/src/domain/scan-status.ts index 6ba12ea9..3789dad8 100644 --- a/drift v3/packages/cli/src/domain/scan-status.ts +++ b/drift v3/packages/cli/src/domain/scan-status.ts @@ -1,6 +1,6 @@ import { type AuditChainVerification,type ConventionCandidate,DRIFT_RESOLVER_VERSION,DRIFT_RULE_ENGINE_VERSION,DRIFT_SCANNER_VERSION,DRIFT_TYPESCRIPT_ADAPTER_VERSION,type FileSnapshot,type ParserGap,type ParserGapConfidenceImpact,type ParserGapKind,type RepoRecord,type ScanCapabilityReport,type ScanFileChange,type ScanManifest } from "@drift/core"; import { buildFactGraphArtifactFromParts,type FactGraphArtifact } from "@drift/factgraph"; -import { buildReadiness,type DriftReadinessSurface } from "@drift/query"; +import { buildReadiness,buildSecurityPhase8ReadModel,type DriftReadinessSurface } from "@drift/query"; import type { SqliteDriftStorage } from "@drift/storage"; import { existsSync,mkdtempSync,readdirSync,rmSync,statSync,writeFileSync } from "node:fs"; import { readFileSync } from "node:fs"; @@ -559,6 +559,7 @@ export function scanStatusPayload(storage: SqliteDriftStorage, repoId: string) { invalidation_reasons: ["scan_missing"], changes: { added: [], modified: [], deleted: [] }, parser_gaps: parserGapSummary([]), + security_capabilities: [], readiness: buildReadiness({ repo_id: repoId, scan_id: null, @@ -604,6 +605,32 @@ export function scanStatusPayload(storage: SqliteDriftStorage, repoId: string) { const parserGaps = storage.listParserGaps(repoId, latestScan.id); const readiness = readinessForStoredScan(storage, repoId, latestScan.id, "scan_status", parserGaps); const capabilityReport = storage.getScanCapabilityReport(repoId, latestScan.id) ?? null; + const proofRuns = storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: repoId + }); + const fallbackProofs = proofRuns.length === 0 + ? storage.listSecurityBoundaryProofs(repoId, latestScan.id) + : []; + const proofs = proofRuns.length > 0 ? proofRuns.map((run) => run.proof) : fallbackProofs; + const proofSourceCheckId = proofRuns[0]?.check_id ?? null; + const proofSourceScanId = proofRuns[0]?.scan_id ?? latestScan.id; + const legacyScanProofRuns = storage.listSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: latestScan.id, + latest_only: true + }); + const securityReadModel = buildSecurityPhase8ReadModel({ + repo_id: repoId, + scan_id: proofSourceScanId, + check_id: proofSourceCheckId, + proofs, + findings: storage.listFindings(repoId).map((finding) => ({ + finding_id: finding.id, + title: finding.title, + lifecycle: finding.status + })), + accepted_conventions: storage.getRepoContract(repoId)?.conventions ?? [] + }); const payload = { response_schema: "drift.scan.status.v1", repo_id: repoId, @@ -632,7 +659,7 @@ export function scanStatusPayload(storage: SqliteDriftStorage, repoId: string) { parser_gaps: parserGapSummary(parserGaps), readiness, capability_report: capabilityReport, - security_capabilities: securityCapabilitySummary(capabilityReport), + security_capabilities: proofs.length > 0 || legacyScanProofRuns.length > 0 ? securityReadModel.security_capabilities : [], machine_contract_versions: currentMachineContractVersions(latestScan.adapter_versions), next_command: nextCommands[0], next_commands: nextCommands diff --git a/drift v3/packages/cli/src/formatters/checks.ts b/drift v3/packages/cli/src/formatters/checks.ts index 1f9c82d7..532c287c 100644 --- a/drift v3/packages/cli/src/formatters/checks.ts +++ b/drift v3/packages/cli/src/formatters/checks.ts @@ -1,4 +1,4 @@ -import { authorizeContextExport,type Finding } from "@drift/core"; +import { authorizeContextExport,type Finding,type SecurityBoundaryProof } from "@drift/core"; import { findingLocation } from "./findings.js"; export function formatCheckText(payload: { @@ -24,6 +24,7 @@ export function formatCheckText(payload: { }; }; findings: Finding[]; + security_boundary_proofs?: SecurityBoundaryProof[]; }): string { const rows = payload.findings.length > 0 ? payload.findings.map((finding) => @@ -52,10 +53,54 @@ export function formatCheckText(payload: { "", "Findings:", ...rows.map((row) => ` ${row}`), + ...securityBlocks(payload), "" ].join("\n"); } +function securityBlocks(payload: { + summary: { repo_id: string }; + findings: Finding[]; + security_boundary_proofs?: SecurityBoundaryProof[]; +}): string[] { + const findingsById = new Map(payload.findings.map((finding) => [finding.id, finding])); + const blocks = (payload.security_boundary_proofs ?? []) + .filter((proof) => proof.result.finding_ids.length > 0) + .map((proof) => { + const finding = proof.result.finding_ids + .map((id) => findingsById.get(id)) + .find((candidate): candidate is Finding => Boolean(candidate)); + const contract = proof.contracts.find((entry) => entry.matched) ?? proof.contracts[0]; + const level = proof.result.enforcement_result === "block" ? "BLOCK" : "WARN"; + const route = proof.route.endpoint?.method && proof.route.endpoint?.path + ? `${proof.route.endpoint.method} ${proof.route.endpoint.path}` + : "unknown"; + return [ + "", + `${level} ${contract?.kind ?? "security_boundary"}`, + ` Route: ${route}`, + ` File: ${proof.route.file_path}`, + ` Reason: ${proof.missing_proof[0]?.code ?? proof.parser_gaps[0]?.code ?? finding?.title ?? proof.result.proof_status}`, + ` Evidence: ${evidenceLine(proof)}`, + ` Capability: ${proof.capability_status[0]?.name ?? proof.missing_proof[0]?.capability ?? "security"} ${contract?.capability ?? "deterministic_check"}`, + ` Lifecycle: ${finding?.status ?? "unknown"}, ${finding?.diff_status ?? "changed-files"}`, + ` Next: drift repo map --repo ${payload.summary.repo_id} --path ${proof.route.file_path} --json` + ].join("\n"); + }); + return blocks; +} + +function evidenceLine(proof: SecurityBoundaryProof): string { + const refs = proof.evidence_refs ?? []; + if (refs.length > 0) { + return refs.slice(0, 4).map((ref) => + `${ref.kind}${ref.start_line ? ` line ${ref.start_line}` : ""}` + ).join("; "); + } + const missingIds = proof.missing_proof.flatMap((missing) => missing.fact_ids); + return missingIds.length > 0 ? missingIds.slice(0, 4).join("; ") : "proof metadata only"; +} + function reasonLines(label: string, reasons: Array<{ reason: string; count: number }>): string[] { if (reasons.length === 0) { return []; diff --git a/drift v3/packages/cli/test/cli.test.ts b/drift v3/packages/cli/test/cli.test.ts index 50cd48b8..7f68e762 100644 --- a/drift v3/packages/cli/test/cli.test.ts +++ b/drift v3/packages/cli/test/cli.test.ts @@ -25,6 +25,60 @@ function factQuality(scanId: string) { }; } +function phase8SecurityProof(overrides: Record = {}) { + return { + proof_id: "proof_route_users_get_auth", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_users_get", + file_path: "apps/web/app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + }, + contracts: [{ + contract_id: "security_auth", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "control_flow_guard_dominance", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "handler_guard", + trusted_guard_calls: [{ + fact_id: "fact_auth_guard", + guard_id: "guard_require_user", + symbol: "requireUser", + start_line: 2, + end_line: 2 + }], + dominated_sinks: [{ + sink_id: "sink_response", + sink_kind: "response", + edge_id: "edge_guard_response" + }], + undominated_sinks: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: true, + finding_ids: [] + }, + ...overrides + }; +} + async function seedDatabase(): Promise { const dir = await mkdtemp(join(tmpdir(), "drift-cli-")); tempDirs.push(dir); @@ -461,7 +515,7 @@ describe("drift CLI convention review", () => { expect(payload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(payload.v1_scope).toMatchObject({ @@ -2511,7 +2565,7 @@ describe("drift CLI convention review", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Drift doctor"); - expect(result.stdout).toContain("Runtime: Drift CLI 0.1.0, SQLite schema 24"); + expect(result.stdout).toContain("Runtime: Drift CLI 0.1.0, SQLite schema 25"); expect(result.stdout).toContain("V1 scope: local-first CLI, TypeScript API route layering"); expect(result.stdout).toContain("TS/JS files: 1 indexable file"); expect(result.stdout).toContain("API routes: 1 API route file"); @@ -2592,7 +2646,7 @@ describe("drift CLI convention review", () => { typescript_adapter_version: "0.1.0", rule_engine_version: "0.1.0", contract_schema_version: 1, - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(payload.engine).toMatchObject({ @@ -2610,7 +2664,7 @@ describe("drift CLI convention review", () => { deferred: ["desktop_ui", "cloud_sync", "python_adapter", "duplicate_helper_detection"] }); expect(payload.state_summary).toMatchObject({ - supported_schema_version: 24 + supported_schema_version: 25 }); expect(payload.state_summary).toMatchObject({ exists: true, @@ -2948,7 +3002,7 @@ describe("drift CLI convention review", () => { typescript_adapter_version: "0.1.0", rule_engine_version: "0.1.0", contract_schema_version: 1, - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(payload.engine).toMatchObject({ @@ -3186,7 +3240,7 @@ describe("drift CLI convention review", () => { machine_contract_versions: { schema_version: "drift.machine_contract_versions.v1", cli_version: "0.1.0", - storage_schema_version: 24, + storage_schema_version: 25, factgraph_schema_version: "factgraph.v2" } }); @@ -3276,7 +3330,7 @@ describe("drift CLI convention review", () => { blocking_count: 1, machine_contract_versions: expect.objectContaining({ schema_version: "drift.machine_contract_versions.v1", - storage_schema_version: 24 + storage_schema_version: 25 }) }); expect(storage.listFindings("repo_abc")[0]?.title).toBe("API route imports data access directly"); @@ -7477,7 +7531,7 @@ describe("drift CLI convention review", () => { expect(payload.summary).toMatchObject({ write_intent: true, artifact_exists: true, - schema_version: 24 + schema_version: 25 }); expect(payload.review_item).toMatchObject({ id: payload.manifest.id, @@ -7487,7 +7541,7 @@ describe("drift CLI convention review", () => { }); expect(payload.manifest).toMatchObject({ repo_id: "repo_abc", - schema_version: 24, + schema_version: 25, created_at: "2026-05-10T00:00:04.000Z" }); expect(payload.manifest.backup_path).toContain(backupDir); @@ -7712,7 +7766,7 @@ describe("drift CLI convention review", () => { id: backup[0], repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 24, + schema_version: 25, source_database_path: databasePath, backup_path: `/tmp/${backup[0]}.sqlite`, checksum_sha256: "a".repeat(64), @@ -7764,7 +7818,7 @@ describe("drift CLI convention review", () => { id: "backup_valid", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 24, + schema_version: 25, source_database_path: databasePath, backup_path: validPath, checksum_sha256: validChecksum, @@ -7775,7 +7829,7 @@ describe("drift CLI convention review", () => { id: "backup_missing", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 24, + schema_version: 25, source_database_path: databasePath, backup_path: join(dir, "missing.sqlite"), checksum_sha256: "b".repeat(64), @@ -7786,7 +7840,7 @@ describe("drift CLI convention review", () => { id: "backup_mismatch", repo_id: "repo_abc", repo_fingerprint: "repo-fp", - schema_version: 24, + schema_version: 25, source_database_path: databasePath, backup_path: mismatchPath, checksum_sha256: mismatchChecksum, @@ -8047,7 +8101,7 @@ describe("drift CLI convention review", () => { surface: "artifact" }, checksum_matches: true, - schema_version: 24 + schema_version: 25 }); expect(JSON.parse(verified.stdout).summary).toMatchObject({ valid: true, @@ -8228,7 +8282,7 @@ describe("drift CLI convention review", () => { valid: false, repo_id: "repo_abc", schema_supported: false, - schema_version: 24, + schema_version: 25, unsupported_migrations: ["004_unknown_future_schema"] }); }); @@ -8457,7 +8511,7 @@ describe("drift CLI convention review", () => { repo_id: "repo_abc", backup_path: backupPath, restored_database_path: targetDatabasePath, - schema_version: 24 + schema_version: 25 }); expect(payload.governance).toMatchObject({ read_only: false, @@ -8498,7 +8552,7 @@ describe("drift CLI convention review", () => { backup_path: backupPath, checksum_sha256: payload.restore.checksum_sha256, checksum_matches: true, - schema_version: 24, + schema_version: 25, graph_stale: payload.restore.graph_stale, requires_rescan: payload.restore.requires_rescan, staleness_reason: payload.restore.staleness_reason @@ -10216,11 +10270,8 @@ describe("drift CLI convention review", () => { expect(result.exitCode).toBe(0); const payload = JSON.parse(result.stdout); - expect(payload.security_capabilities.middleware_coverage).toMatchObject({ - certified: true, - can_block: true, - missing: false - }); + expect(payload.security_capabilities).toEqual([]); + expect(payload.capability_report.certified_capabilities).toContain("middleware_coverage"); }); it("scan status reports request_validation capability", async () => { @@ -10264,11 +10315,8 @@ describe("drift CLI convention review", () => { expect(result.exitCode).toBe(0); const payload = JSON.parse(result.stdout); - expect(payload.security_capabilities.request_validation).toMatchObject({ - certified: true, - can_block: true, - missing: false - }); + expect(payload.security_capabilities).toEqual([]); + expect(payload.capability_report.certified_capabilities).toContain("request_validation_facts"); }); it("scan status reports tenant authorization and session trust capabilities", async () => { @@ -10324,22 +10372,84 @@ describe("drift CLI convention review", () => { expect(result.exitCode).toBe(0); const payload = JSON.parse(result.stdout); - expect(payload.security_capabilities.session_trust).toMatchObject({ - certified: true, - can_block: true, - missing: false - }); - expect(payload.security_capabilities.authorization).toMatchObject({ - certified: true, - can_block: true, - missing: false + expect(payload.security_capabilities).toEqual([]); + expect(payload.capability_report.certified_capabilities).toEqual(expect.arrayContaining([ + "session_trust", + "authorization", + "tenant_scope" + ])); + }); + + it("scan status reports Phase 8 security capability array from proof runs", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-scan-status-phase8-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertCheckRun({ + id: "check_phase8", + repo_id: repoId, + repo_contract_id: "contract_phase8", + contract_fingerprint: "contract-fp", + scan_id: scanId, + status: "pass", + scope: "full", + engine_source: "rust", + fallback_used: false, + stale_scan: false, + capability_complete: true, + findings_count: 0, + blocking_count: 0, + started_at: "2026-05-27T00:00:01.000Z", + completed_at: "2026-05-27T00:00:02.000Z" }); - expect(payload.security_capabilities.tenant_scope).toMatchObject({ - certified: true, - can_block: true, - missing: false, - complete: false + storage.upsertSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: "scan_check_phase8", + check_id: "check_phase8", + created_at: "2026-05-27T00:00:02.000Z", + proofs: [phase8SecurityProof()] }); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "scan", "status", + "--repo", repoId, + "--json" + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(Array.isArray(payload.security_capabilities)).toBe(true); + expect(payload.security_capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "control_flow_guard_dominance", + capability: "deterministic_check", + status: "complete", + can_block: true, + parser_gap_count: 0, + missing_proof_count: 0, + affected_files: ["apps/web/app/api/users/route.ts"] + }) + ])); + + const repoMap = await runCli([ + "--db", databasePath, + "repo", "map", + "--repo", repoId, + "--json" + ]); + expect(repoMap.exitCode).toBe(0); + const repoMapPayload = JSON.parse(repoMap.stdout); + expect(repoMapPayload.routes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + file_path: "apps/web/app/api/users/route.ts", + security: expect.objectContaining({ + proof_status: "proven" + }) + }) + ])); }); it("repo map reports route middleware coverage summary", async () => { diff --git a/drift v3/packages/core/src/security.ts b/drift v3/packages/core/src/security.ts index f81f3b50..03057d8e 100644 --- a/drift v3/packages/core/src/security.ts +++ b/drift v3/packages/core/src/security.ts @@ -6,6 +6,11 @@ export const SecurityCapabilityNameSchema = z.enum([ "control_flow_guard_dominance", "middleware_coverage", "request_validation_facts", + "ssrf", + "raw_sql", + "cors_policy", + "csrf", + "rate_limit", "outbound_request_facts", "raw_sql_facts", "cors_policy_facts", @@ -473,6 +478,27 @@ const SecurityParserGapSchema = z.object({ blocks_enforcement: z.boolean() }); +const SecurityProofEvidenceRefSchema = z.object({ + evidence_id: z.string().min(1), + fact_id: z.string().min(1).optional(), + graph_edge_id: z.string().min(1).optional(), + capability: z.string().min(1), + kind: z.string().min(1), + file_path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + role: z.enum([ + "guard", + "sink", + "validator", + "serializer", + "middleware", + "policy", + "parser_gap", + "missing_proof" + ]) +}).strict(); + export const SecurityBoundaryProofSchema = z.object({ proof_id: z.string().min(1), proof_version: z.literal("security-boundary-proof/v1"), @@ -570,6 +596,7 @@ export const SecurityBoundaryProofSchema = z.object({ }), missing_proof: z.array(SecurityMissingProofSchema), parser_gaps: z.array(SecurityParserGapSchema), + evidence_refs: z.array(SecurityProofEvidenceRefSchema).optional().default([]), result: z.object({ proof_status: z.enum(["proven", "violated", "missing_proof", "parser_gap", "advisory_only"]), enforcement_result: z.enum(["pass", "brief", "warn", "block"]), diff --git a/drift v3/packages/core/test/security.test.ts b/drift v3/packages/core/test/security.test.ts index 4d9b97d8..99f474b4 100644 --- a/drift v3/packages/core/test/security.test.ts +++ b/drift v3/packages/core/test/security.test.ts @@ -252,6 +252,45 @@ describe("security domain schemas", () => { expect(proof.auth.required).toBe(true); }); + it("accepts Phase 8 proof route metadata and sanitized evidence refs", () => { + const proof = validSecurityBoundaryProof({ + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" }, + handler_symbol: "GET" + }, + evidence_refs: [{ + evidence_id: "evidence_auth_guard", + fact_id: "fact_auth_guard", + capability: "control_flow_guard_dominance", + kind: "auth_guard_called", + file_path: "app/api/users/route.ts", + start_line: 4, + end_line: 4, + role: "guard" + }] + }); + + expect(SecurityBoundaryProofSchema.parse(proof).evidence_refs).toHaveLength(1); + }); + + it("rejects Phase 8 proof evidence refs that carry source or secret values", () => { + const proof = validSecurityBoundaryProof({ + evidence_refs: [{ + evidence_id: "evidence_bad", + capability: "raw_sql", + kind: "raw_sql_called", + file_path: "app/api/users/route.ts", + role: "sink", + source: "await db.query(\"select secret\")" + }] + }); + + expect(() => SecurityBoundaryProofSchema.parse(proof)).toThrow(); + }); + it("validates middleware_must_cover_routes contracts and parser gaps", () => { expect(SecurityMissingProofCodeSchema.parse("middleware_not_covering_route")).toBe("middleware_not_covering_route"); expect(SecurityMissingProofCodeSchema.parse("middleware_dynamic_matcher")).toBe("middleware_dynamic_matcher"); diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index a0dc69ae..330f91e1 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -562,7 +562,16 @@ const EngineSecurityBoundaryProofSchema = z.object({ route: z.object({ route_id: z.string().min(1), file_path: z.string().min(1), - file_role: z.literal("api_route") + file_role: z.literal("api_route"), + endpoint: z.object({ + path: z.string().min(1).optional(), + method: z.string().min(1).optional(), + framework: z.string().min(1).optional() + }).optional(), + handler_symbol: z.string().min(1).optional(), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + diff_status: z.enum(["unchanged", "added", "modified", "deleted", "renamed"]).optional() }), contracts: z.array(z.object({ contract_id: z.string().min(1), @@ -794,6 +803,26 @@ const EngineSecurityBoundaryProofSchema = z.object({ graph_edge_ids: z.array(z.string().min(1)) })), parser_gaps: z.array(EngineSecurityParserGapSchema), + evidence_refs: z.array(z.object({ + evidence_id: z.string().min(1), + fact_id: z.string().min(1).optional(), + graph_edge_id: z.string().min(1).optional(), + capability: z.string().min(1), + kind: z.string().min(1), + file_path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + role: z.enum([ + "guard", + "sink", + "validator", + "serializer", + "middleware", + "policy", + "parser_gap", + "missing_proof" + ]) + }).strict()).optional().default([]), result: z.object({ proof_status: z.enum(["proven", "violated", "missing_proof", "parser_gap", "advisory_only"]), enforcement_result: z.enum(["pass", "brief", "warn", "block"]), diff --git a/drift v3/packages/mcp/src/index.ts b/drift v3/packages/mcp/src/index.ts index 21e052fb..fea01637 100644 --- a/drift v3/packages/mcp/src/index.ts +++ b/drift v3/packages/mcp/src/index.ts @@ -15,7 +15,6 @@ import type { RepoContract, RepoRecord, RequiredCheckExecution, - ScanCapabilityReport, ScanFileChange, ScanManifest, Severity @@ -54,6 +53,7 @@ import { buildFindingsReadModel, buildRepoContractReadModel, buildReadiness, + buildSecurityPhase8ReadModel, classifyAgentTask, buildRepoMapReadModel, createGraphQueryService, @@ -63,7 +63,8 @@ import { repoMapRiskyAreaIds, selectRelevantTests, type ChangeImpactRouteFlow, - type DriftReadinessSurface + type DriftReadinessSurface, + type RepoMapFile } from "@drift/query"; import { MIGRATIONS, openDriftStorage } from "@drift/storage"; import { execFileSync } from "node:child_process"; @@ -160,10 +161,18 @@ export function createReadOnlyMcpHandlers(options: DriftMcpOptions): DriftMcpHan }); }), - get_security_context: ({ repo_id }) => withStorage(options, (storage) => { + get_security_context: ({ repo_id, path, changed_files, check_id }) => withStorage(options, (storage) => { const requestedRepoId = requiredMcpString(repo_id, "repo_id"); + const requestedPath = path ? requiredRepoRelativeMcpPath(path) : undefined; + const requestedChangedFiles = Array.isArray(changed_files) + ? changed_files.map((filePath) => requiredRepoRelativeMcpPath(filePath)) + : undefined; const { contract } = requiredAuthorizedMcpContract(storage, requestedRepoId, "mcp"); - return buildSecurityContextPayload(storage, requestedRepoId, contract); + return buildSecurityContextPayload(storage, requestedRepoId, contract, { + path: requestedPath, + changed_files: requestedChangedFiles, + check_id: check_id ? requiredMcpString(check_id, "check_id") : undefined + }); }), get_task_preflight: ({ repo_id, task, path, require_fresh, now }) => withStorage(options, (storage) => { @@ -431,8 +440,16 @@ export function createReadOnlyMcpHandlers(options: DriftMcpOptions): DriftMcpHan freshness_requirement: freshnessRequirement(Boolean(require_fresh), scanStatus), summary: readModel.summary, pagination: readModel.pagination, - review_items: readModel.review_items, - findings: readModel.findings + review_items: readModel.review_items.map((item) => ({ + ...item, + first_evidence: item.first_evidence + ? { + file_path: item.first_evidence.file_path, + start_line: item.first_evidence.start_line ?? null + } + : null + })), + findings: readModel.findings.map(sanitizedMcpFinding) }; }), @@ -1105,6 +1122,27 @@ function scanStatusPayload( const capabilityReport = latestScan ? storage.getScanCapabilityReport(repoId, latestScan.id) ?? null : null; + const proofRuns = latestScan + ? storage.listSecurityBoundaryProofRuns({ + repo_id: repoId, + scan_id: latestScan.id, + latest_only: true + }) + : []; + const securityReadModel = latestScan + ? buildSecurityPhase8ReadModel({ + repo_id: repoId, + scan_id: latestScan.id, + check_id: proofRuns[0]?.check_id ?? null, + proofs: proofRuns.map((run) => run.proof), + findings: storage.listFindings(repoId).map((finding) => ({ + finding_id: finding.id, + title: finding.title, + lifecycle: finding.status + })), + accepted_conventions: storage.getRepoContract(repoId)?.conventions ?? [] + }) + : null; const readiness = readinessForStoredScan(storage, repoId, latestScan?.id ?? null, "scan_status", parserGaps); const repoRootMissing = !existsSync(repo.root_path); const currentBranch = repoRootMissing @@ -1165,7 +1203,7 @@ function scanStatusPayload( parser_gaps: parserGapSummary(parserGaps), readiness, capability_report: capabilityReport, - security_capabilities: securityCapabilitySummary(capabilityReport), + security_capabilities: proofRuns.length > 0 ? securityReadModel?.security_capabilities ?? [] : [], machine_contract_versions: currentMachineContractVersions(latestScan?.adapter_versions), next_command: nextCommands[0], next_commands: nextCommands @@ -1236,56 +1274,6 @@ function parserGapSummary(gaps: ParserGap[]): { }; } -function securityCapabilitySummary(capabilityReport: ScanCapabilityReport | null | undefined) { - const certified = new Set(capabilityReport?.certified_capabilities ?? []); - const required = new Set(capabilityReport?.required_capabilities ?? []); - const missing = new Set(capabilityReport?.missing_capabilities ?? []); - const completenessByRule = new Map((capabilityReport?.completeness ?? []) - .map((entry) => [entry.rule_id, entry])); - const middlewareCompleteness = completenessByRule.get("middleware_must_cover_routes"); - const requestValidationCompleteness = completenessByRule.get("api_route_requires_request_validation"); - const sessionTrustCompleteness = completenessByRule.get("session_object_must_come_from_trusted_helper"); - const authorizationCompleteness = completenessByRule.get("api_route_requires_authorization"); - const tenantScopeCompleteness = completenessByRule.get("api_route_requires_tenant_scope"); - return { - middleware_coverage: { - certified: certified.has("middleware_coverage"), - required: required.has("middleware_coverage"), - missing: missing.has("middleware_coverage"), - can_block: Boolean(middlewareCompleteness?.can_block), - complete: Boolean(middlewareCompleteness?.complete) - }, - request_validation: { - certified: certified.has("request_validation_facts"), - required: required.has("request_validation_facts"), - missing: missing.has("request_validation_facts"), - can_block: Boolean(requestValidationCompleteness?.can_block), - complete: Boolean(requestValidationCompleteness?.complete) - }, - session_trust: { - certified: certified.has("session_trust"), - required: required.has("session_trust"), - missing: missing.has("session_trust"), - can_block: Boolean(sessionTrustCompleteness?.can_block), - complete: Boolean(sessionTrustCompleteness?.complete) - }, - authorization: { - certified: certified.has("authorization"), - required: required.has("authorization"), - missing: missing.has("authorization"), - can_block: Boolean(authorizationCompleteness?.can_block), - complete: Boolean(authorizationCompleteness?.complete) - }, - tenant_scope: { - certified: certified.has("tenant_scope"), - required: required.has("tenant_scope"), - missing: missing.has("tenant_scope"), - can_block: Boolean(tenantScopeCompleteness?.can_block), - complete: Boolean(tenantScopeCompleteness?.complete) - } - }; -} - function scanStatusSummary(options: { latestScanId: string | null; scanCount: number; @@ -1538,6 +1526,31 @@ function repoMapPayload( const scanStatus = scanStatusPayload(storage, repoId); assertFreshScanIfRequired(repoId, scanStatus, Boolean(options.requireFresh)); const readiness = readinessForStoredScan(storage, repoId, latestScan?.id ?? null, "repo_map"); + const proofRuns = latestScan + ? storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: repoId, + file_path: options.path + }) + : []; + const fallbackProofs = proofRuns.length === 0 && latestScan + ? storage.listSecurityBoundaryProofs(repoId, latestScan.id) + .filter((proof) => !options.path || proof.route.file_path === options.path) + : []; + const proofs = proofRuns.length > 0 ? proofRuns.map((run) => run.proof) : fallbackProofs; + const phase8Security = buildSecurityPhase8ReadModel({ + repo_id: repoId, + scan_id: proofRuns[0]?.scan_id ?? latestScan?.id ?? null, + check_id: proofRuns[0]?.check_id ?? null, + proofs, + findings: findings.map((finding) => ({ + finding_id: finding.id, + title: finding.title, + lifecycle: finding.status + })), + accepted_conventions: contract.conventions, + changed_files: options.path ? [options.path] : undefined, + known_routes: knownPhase8Routes(readModel.all_files) + }); return { response_schema: "drift.repo.map.v1", repo_id: repoId, @@ -1563,6 +1576,7 @@ function repoMapPayload( impact_summary: readModel.impact_summary, topology: readModel.topology, pagination: readModel.pagination, + routes: phase8Security.routes, freshness_requirement: freshnessRequirement(Boolean(options.requireFresh), scanStatus), files: readModel.listed_files, redactions: { @@ -1579,6 +1593,40 @@ function repoMapPayload( }; } +function knownPhase8Routes(files: RepoMapFile[]) { + return files + .filter((file) => file.roles.includes("api_route")) + .flatMap((file) => { + const methods = file.exported_symbols.filter((symbol) => + ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(symbol) + ); + const routePath = routePathForFile(file.path); + return (methods.length > 0 ? methods : ["unknown"]).map((method) => ({ + route_id: `route:${file.path}:${method}`, + file_path: file.path, + path: routePath, + method, + file_role: "api_route" + })); + }); +} + +function routePathForFile(filePath: string): string | undefined { + const normalized = filePath.replaceAll("\\", "/"); + const prefix = "app/api/"; + const prefixIndex = normalized.indexOf(prefix); + const suffix = ["/route.ts", "/route.tsx", "/route.js", "/route.jsx"] + .find((candidate) => normalized.endsWith(candidate)); + if (prefixIndex === -1 || !suffix) { + return undefined; + } + const route = normalized.slice(prefixIndex + prefix.length, -suffix.length); + const segments = route.split("/").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); + return segments.length === 0 + ? "/api" + : `/api/${segments.join("/").replaceAll("[", ":").replaceAll("]", "")}`; +} + function orderAcceptedConventionsForReview(conventions: AcceptedConvention[]): AcceptedConvention[] { return [...conventions].sort((left, right) => left.accepted_at.localeCompare(right.accepted_at) || @@ -2578,6 +2626,24 @@ function validatePolicySurface(surface: PolicyDecision["surface"]): PolicyDecisi throw new Error("surface must be cli-preflight, cli-check, mcp, contract-export, artifact, log, or ui."); } +function sanitizedMcpFinding(finding: Finding) { + return { + finding_id: finding.id, + convention_id: finding.convention_id, + title: finding.title, + severity: finding.severity, + lifecycle: finding.status, + diff_status: finding.diff_status, + enforcement_result: finding.enforcement_result, + file_refs: finding.evidence_refs.map((ref) => ({ + file_path: ref.file_path, + ...(ref.start_line ? { start_line: ref.start_line } : {}), + ...(ref.end_line ? { end_line: ref.end_line } : {}), + redaction_state: ref.start_line || ref.end_line ? "line_only" : "metadata_only" + })) + }; +} + function countBy( entries: T[], keyFor: (entry: T) => K diff --git a/drift v3/packages/mcp/src/security-context.ts b/drift v3/packages/mcp/src/security-context.ts index 9bfce95e..39665e85 100644 --- a/drift v3/packages/mcp/src/security-context.ts +++ b/drift v3/packages/mcp/src/security-context.ts @@ -1,6 +1,6 @@ import type { AcceptedConvention, FactRecord, ParserGap, RepoContract, ScanManifest } from "@drift/core"; import type { openDriftStorage } from "@drift/storage"; -import { buildSecurityBoundaryProofReadModel } from "@drift/query"; +import { buildSecurityBoundaryProofReadModel, buildSecurityPhase8ReadModel } from "@drift/query"; type DriftStorage = ReturnType; @@ -57,7 +57,104 @@ interface SecretReadValue { source?: string; } -export function buildSecurityContextPayload(storage: DriftStorage, repoId: string, contract: RepoContract) { +export function buildSecurityContextPayload( + storage: DriftStorage, + repoId: string, + contract: RepoContract, + options: { path?: string; changed_files?: string[]; check_id?: string } = {} +) { + const latestScan = latestSecurityScan(storage.listScanManifests(repoId)); + const proofRuns = typeof storage.listLatestSecurityBoundaryProofRunsForRepo === "function" + ? storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: repoId, + check_id: options.check_id, + file_path: options.path + }) + : []; + const fallbackProofs = proofRuns.length === 0 && latestScan + ? storage.listSecurityBoundaryProofs(repoId, latestScan.id) + .filter((proof) => !options.path || proof.route.file_path === options.path) + : []; + const proofs = proofRuns.length > 0 ? proofRuns.map((run) => run.proof) : fallbackProofs; + const changedFiles = options.changed_files ?? (options.path ? [options.path] : undefined); + const phase8 = buildSecurityPhase8ReadModel({ + repo_id: repoId, + scan_id: proofRuns[0]?.scan_id ?? latestScan?.id ?? null, + check_id: options.check_id ?? proofRuns[0]?.check_id ?? null, + proofs, + findings: storage.listFindings(repoId).map((finding) => ({ + finding_id: finding.id, + title: finding.title, + lifecycle: finding.status + })), + accepted_conventions: contract.conventions, + changed_files: changedFiles, + known_routes: latestScan ? knownRoutesFromFacts(storage.listFacts(latestScan.id)) : [] + }); + return { + response_schema: "drift.security.context.v2", + repo_id: repoId, + scan_id: phase8.scan_id, + check_id: phase8.check_id, + repo_security_contracts: phase8.repo_security_contracts, + changed_route_security: phase8.changed_route_security, + routes: phase8.routes, + required_proofs: phase8.required_proofs, + current_proof_status: phase8.current_proof_status, + missing_proof_summaries: phase8.missing_proof_summaries, + parser_gap_summaries: phase8.parser_gap_summaries, + security_capabilities: phase8.security_capabilities, + do_not_include: phase8.do_not_include, + redactions: { + snippets_included: false, + source_content_included: false, + request_payloads_included: false, + secret_values_included: false, + actor_identity_included: false + }, + freshness: { + proof_source: proofRuns.length > 0 ? "proof_run" : fallbackProofs.length > 0 ? "scan_scoped" : "none", + latest_indexed_scan_id: latestScan?.id ?? null + }, + next_commands: [ + `drift check --repo ${repoId} --json`, + `drift repo map --repo ${repoId} --json` + ] + }; +} + +function knownRoutesFromFacts(facts: FactRecord[]) { + const apiFiles = new Set(facts + .filter((fact) => fact.kind === "file_role_detected" && fact.name === "api_route") + .map((fact) => fact.file_path)); + return facts + .filter((fact) => fact.kind === "route_declared" && apiFiles.has(fact.file_path)) + .map((fact) => ({ + route_id: `route:${fact.file_path}:${fact.name}`, + file_path: fact.file_path, + path: routePathForFile(fact.file_path), + method: fact.name, + file_role: "api_route" + })); +} + +function routePathForFile(filePath: string): string | undefined { + const normalized = filePath.replaceAll("\\", "/"); + const prefix = "app/api/"; + const prefixIndex = normalized.indexOf(prefix); + const suffix = ["/route.ts", "/route.tsx", "/route.js", "/route.jsx"] + .find((candidate) => normalized.endsWith(candidate)); + if (prefixIndex === -1 || !suffix) { + return undefined; + } + const route = normalized.slice(prefixIndex + prefix.length, -suffix.length); + const segments = route.split("/").filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); + return segments.length === 0 + ? "/api" + : `/api/${segments.join("/").replaceAll("[", ":").replaceAll("]", "")}`; +} + +export function buildLegacySecurityContextPayload(storage: DriftStorage, repoId: string, contract: RepoContract) { const latestScan = latestSecurityScan(storage.listScanManifests(repoId)); const facts = latestScan ? storage.listFacts(latestScan.id, { kind: "middleware_protects_route" }) : []; const requestInputFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "request_input_read" }) : []; diff --git a/drift v3/packages/mcp/src/tools.ts b/drift v3/packages/mcp/src/tools.ts index 7895246c..f0e2b28a 100644 --- a/drift v3/packages/mcp/src/tools.ts +++ b/drift v3/packages/mcp/src/tools.ts @@ -77,7 +77,18 @@ export const DRIFT_READ_ONLY_MCP_TOOLS: DriftMcpTool[] = [ { name: "get_security_context", description: "Return accepted security contract context and middleware coverage summaries without source snippets.", - inputSchema: repoOnlySchema() + inputSchema: { + type: "object", + properties: { + repo_id: { type: "string" }, + path: { type: "string" }, + changed_files: { type: "array", items: { type: "string" } }, + check_id: { type: "string" }, + require_fresh: { type: "boolean" } + }, + required: ["repo_id"], + additionalProperties: false + } }, { name: "get_task_preflight", diff --git a/drift v3/packages/mcp/src/types.ts b/drift v3/packages/mcp/src/types.ts index 73c0f454..782e6761 100644 --- a/drift v3/packages/mcp/src/types.ts +++ b/drift v3/packages/mcp/src/types.ts @@ -27,7 +27,13 @@ export interface DriftMcpHandlers { limit?: number; offset?: number; }): unknown; - get_security_context(input: { repo_id: string }): unknown; + get_security_context(input: { + repo_id: string; + path?: string; + changed_files?: string[]; + check_id?: string; + require_fresh?: boolean; + }): unknown; get_task_preflight(input: { repo_id: string; task: string; path?: string; require_fresh?: boolean; now?: string }): unknown; get_conventions(input: { repo_id: string; diff --git a/drift v3/packages/mcp/test/mcp.test.ts b/drift v3/packages/mcp/test/mcp.test.ts index 0f7dd5d0..b7f887ee 100644 --- a/drift v3/packages/mcp/test/mcp.test.ts +++ b/drift v3/packages/mcp/test/mcp.test.ts @@ -1113,7 +1113,7 @@ describe("read-only MCP handlers", () => { } }, review_items: [{ id: "finding_abc" }, { id: "finding_suppressed" }], - findings: [{ id: "finding_abc" }, { id: "finding_suppressed" }] + findings: [{ finding_id: "finding_abc" }, { finding_id: "finding_suppressed" }] }); expect(allFindings.review_items[0]).toMatchObject({ id: "finding_abc", @@ -1126,13 +1126,24 @@ describe("read-only MCP handlers", () => { evidence_ref_count: 1, first_evidence: { file_path: "apps/web/app/api/users/route.ts", - start_line: 1, - import_source: "@/lib/prisma", - symbol: "prisma" + start_line: 1 } }); - expect(allFindings.findings[0]).toHaveProperty("message"); - expect(allFindings.findings[0]).toHaveProperty("evidence_refs"); + expect(allFindings.findings[0]).toMatchObject({ + finding_id: "finding_abc", + title: "API route imports data access directly", + lifecycle: "new", + file_refs: [{ + file_path: "apps/web/app/api/users/route.ts", + start_line: 1, + end_line: 1, + redaction_state: "line_only" + }] + }); + expect(allFindings.findings[0]).not.toHaveProperty("message"); + expect(allFindings.findings[0]).not.toHaveProperty("evidence_refs"); + expect(JSON.stringify(allFindings)).not.toContain("@/lib/prisma"); + expect(JSON.stringify(allFindings)).not.toContain("prisma"); const pathFindings = handlers.get_findings({ repo_id: "repo_abc", path: "apps/web/app/api/users/route.ts" @@ -1158,7 +1169,7 @@ describe("read-only MCP handlers", () => { total_count: 2, filtered_count: 1 }, - findings: [{ id: "finding_abc" }] + findings: [{ finding_id: "finding_abc" }] }); expect(handlers.get_findings({ repo_id: "repo_abc", @@ -1171,7 +1182,7 @@ describe("read-only MCP handlers", () => { total_count: 2, filtered_count: 2 }, - findings: [{ id: "finding_abc" }, { id: "finding_suppressed" }] + findings: [{ finding_id: "finding_abc" }, { finding_id: "finding_suppressed" }] }); expect(handlers.get_findings({ repo_id: "repo_abc", @@ -1202,7 +1213,7 @@ describe("read-only MCP handlers", () => { total_count: 2, filtered_count: 2 }, - findings: [{ id: "finding_suppressed" }] + findings: [{ finding_id: "finding_suppressed" }] }); expect(() => handlers.get_findings({ repo_id: "repo_abc", @@ -1225,7 +1236,7 @@ describe("read-only MCP handlers", () => { total_count: 2, filtered_count: 1 }, - findings: [{ id: "finding_abc" }] + findings: [{ finding_id: "finding_abc" }] }); expect(handlers.get_findings({ repo_id: "repo_abc", @@ -1235,7 +1246,7 @@ describe("read-only MCP handlers", () => { total_count: 2, filtered_count: 1 }, - findings: [{ id: "finding_suppressed" }] + findings: [{ finding_id: "finding_suppressed" }] }); expect(() => handlers.get_findings({ repo_id: "repo_abc", @@ -1563,28 +1574,69 @@ describe("read-only MCP handlers", () => { const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ repo_id: "repo_abc" - } as never) as { - middleware_coverage: { - routes: Array<{ - file_path: string; - proven: boolean; - protection_kinds: string[]; - middleware_ids: string[]; - }>; - parser_gaps: Array<{ reason: string; blocking: boolean }>; - }; - }; + } as never) as Record; + + expect(securityContext.response_schema).toBe("drift.security.context.v2"); + expect(securityContext.middleware_coverage).toBeUndefined(); + expect(securityContext.parser_gap_summaries).toEqual([]); + expect(JSON.stringify(securityContext)).not.toContain("requireUser()"); + }); - expect(securityContext.middleware_coverage.routes).toEqual([{ + it("v2 security context does not include legacy raw-fact sections", async () => { + const databasePath = await seedMcpDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + storage.upsertFacts([{ + id: "fact_request_body_secret", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "request_input_read", + file_path: "apps/web/app/api/users/route.ts", + name: "body", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:POST", + source: "request.json()", + variable: "body", + cookie: "session=secret" + }), + imported_name: undefined, + start_line: 1, + end_line: 1, + ...factQuality("scan_abc") + }, { + id: "fact_tenant_secret", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "tenant_source", file_path: "apps/web/app/api/users/route.ts", - proven: true, - protection_kinds: ["auth"], - middleware_ids: ["middleware:middleware.ts"] + name: "tenantId", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + variable: "session.user.tenantId", + trusted: true + }), + imported_name: undefined, + start_line: 2, + end_line: 2, + ...factQuality("scan_abc") }]); - expect(securityContext.middleware_coverage.parser_gaps).toEqual([ - { reason: "unsupported_dynamic_middleware_matcher", blocking: true } - ]); - expect(JSON.stringify(securityContext)).not.toContain("requireUser()"); + storage.close(); + + const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ + repo_id: "repo_abc" + } as never) as Record; + + expect(securityContext.response_schema).toBe("drift.security.context.v2"); + expect(securityContext.middleware_coverage).toBeUndefined(); + expect(securityContext.request_validation).toBeUndefined(); + expect(securityContext.session_trust).toBeUndefined(); + expect(securityContext.authorization).toBeUndefined(); + expect(securityContext.tenant_scope).toBeUndefined(); + expect(securityContext.current_proof_status).toEqual([]); + expect(securityContext.required_proofs).toEqual(expect.any(Array)); + expect(JSON.stringify(securityContext)).not.toContain("request.json()"); + expect(JSON.stringify(securityContext)).not.toContain("session.user.tenantId"); + expect(JSON.stringify(securityContext)).not.toContain("cookie"); }); it("exposes request validation proof summaries without snippets", async () => { @@ -1691,37 +1743,20 @@ describe("read-only MCP handlers", () => { const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ repo_id: "repo_abc" } as never) as { - accepted_contracts: Array<{ kind: string }>; - request_validation: { - routes: Array<{ - route_id: string; - file_path: string; - proof_status: string; - proven: boolean; - input_sources: string[]; - validated_sink_kinds: string[]; - }>; - parser_gaps: Array<{ reason: string; blocking: boolean }>; - }; + repo_security_contracts: Array<{ kind: string }>; + request_validation?: unknown; + parser_gap_summaries: unknown[]; + do_not_include: string[]; }; - expect(securityContext.accepted_contracts).toContainEqual(expect.objectContaining({ + expect(securityContext.repo_security_contracts).toContainEqual(expect.objectContaining({ kind: "api_route_requires_request_validation" })); - expect(securityContext.request_validation.routes).toEqual([{ - route_id: "route:apps/web/app/api/projects/route.ts:POST", - file_path: "apps/web/app/api/projects/route.ts", - proof_status: "not_evaluated", - proven: false, - input_sources: ["body"], - validated_sink_kinds: ["data_operation"] - }]); - expect(securityContext.request_validation.routes[0]?.proof_status).not.toBe("proven"); - expect(securityContext.request_validation.parser_gaps).toEqual([ - { reason: "unsupported_request_input_spread", blocking: true } - ]); + expect(securityContext.request_validation).toBeUndefined(); + expect(securityContext.parser_gap_summaries).toEqual([]); + expect(securityContext.do_not_include).toContain("secret values"); expect(JSON.stringify(securityContext)).not.toContain("request.json()"); - expect(JSON.stringify(securityContext)).not.toContain("cookie"); + expect(JSON.stringify(securityContext)).not.toContain("session=secret"); }); it("exposes phase4 security proof summaries without snippets", async () => { @@ -1894,39 +1929,26 @@ describe("read-only MCP handlers", () => { const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ repo_id: "repo_abc" } as never) as { - accepted_contracts: Array<{ kind: string }>; - session_trust: { routes: Array<{ proof_status: string; advisory_trusted_source_count: number }> }; - authorization: { routes: Array<{ proof_status: string; advisory_guard_ids: string[]; advisory_role_count: number }> }; - tenant_scope: { - routes: Array<{ proof_status: string; advisory_tenant_keys: string[]; advisory_trusted_source_count: number }>; - parser_gaps: Array<{ reason: string; blocking: boolean }>; - }; + repo_security_contracts: Array<{ kind: string }>; + session_trust?: unknown; + authorization?: unknown; + tenant_scope?: unknown; + parser_gap_summaries: unknown[]; + do_not_include: string[]; }; - expect(securityContext.accepted_contracts).toEqual(expect.arrayContaining([ + expect(securityContext.repo_security_contracts).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "session_object_must_come_from_trusted_helper" }), expect.objectContaining({ kind: "api_route_requires_authorization" }), expect.objectContaining({ kind: "api_route_requires_tenant_scope" }) ])); - expect(securityContext.session_trust.routes).toEqual([expect.objectContaining({ - proof_status: "advisory_only", - advisory_trusted_source_count: 1 - })]); - expect(securityContext.authorization.routes).toEqual([expect.objectContaining({ - proof_status: "advisory_only", - advisory_guard_ids: ["authorization_require_role"], - advisory_role_count: 1 - })]); - expect(securityContext.tenant_scope.routes).toEqual([expect.objectContaining({ - proof_status: "advisory_only", - advisory_tenant_keys: ["tenantId"], - advisory_trusted_source_count: 1 - })]); - expect(securityContext.tenant_scope.parser_gaps).toEqual([ - { reason: "unsupported_tenant_dynamic_property", blocking: true } - ]); + expect(securityContext.session_trust).toBeUndefined(); + expect(securityContext.authorization).toBeUndefined(); + expect(securityContext.tenant_scope).toBeUndefined(); + expect(securityContext.parser_gap_summaries).toEqual([]); + expect(securityContext.do_not_include).toContain("secret values"); expect(JSON.stringify(securityContext)).not.toContain("session.user.tenantId"); - expect(JSON.stringify(securityContext)).not.toContain("cookie"); + expect(JSON.stringify(securityContext)).not.toContain("session=secret"); expect(JSON.stringify(securityContext)).not.toContain("request.json()"); }); @@ -2001,27 +2023,27 @@ describe("read-only MCP handlers", () => { const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ repo_id: "repo_abc" } as never) as { - sensitive_response: { - proof_status: string; - routes: Array<{ - route_id: string; - proof_status: string; - leak_reasons: string[]; - missing_proof_codes: string[]; - }>; - }; + current_proof_status: Array<{ route_id: string; proof_status: string; enforcement_result: string }>; + changed_route_security: Array<{ + route_id: string; + file_path: string; + current_proof_status: string; + missing_proof: Array<{ code: string }>; + }>; }; - expect(securityContext.sensitive_response.proof_status).toBe("missing_proof"); - expect(securityContext.sensitive_response.routes).toEqual([{ + expect(securityContext.current_proof_status).toEqual([{ route_id: "route:apps/web/app/api/users/route.ts:GET", file_path: "apps/web/app/api/users/route.ts", proof_status: "missing_proof", - proven: false, - leak_reasons: ["sensitive_field_without_serializer"], - missing_proof_codes: ["sensitive_response_field_unfiltered"], - parser_gap_codes: [] + enforcement_result: "block" }]); + expect(securityContext.changed_route_security).toEqual([expect.objectContaining({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + file_path: "apps/web/app/api/users/route.ts", + current_proof_status: "missing_proof", + missing_proof: [expect.objectContaining({ code: "sensitive_response_field_unfiltered" })] + })]); expect(JSON.stringify(securityContext)).not.toContain("redacted@example.test"); expect(JSON.stringify(securityContext)).not.toContain("process.env"); }); @@ -3006,7 +3028,7 @@ describe("read-only MCP handlers", () => { mcp_version: "0.1.0", core_version: "0.1.0", scanner_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }, v1_scope: { diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index 0233abbb..69119d60 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -30,7 +30,7 @@ export { evaluateRoleEdge } from "./role-ontology.js"; export { scoreHelperSimilarity } from "./helper-similarity.js"; export { buildRepoTopology } from "./repo-topology.js"; export { buildReadiness } from "./readiness.js"; -export { buildSecurityBoundaryProofReadModel } from "./security-boundary-proof.js"; +export { buildSecurityBoundaryProofReadModel, buildSecurityPhase8ReadModel } from "./security-boundary-proof.js"; export type { BuildEntrypointFlowProofInput } from "./flow-proof.js"; export type { BuildChangeImpactInput, ChangeImpactRouteFlow } from "./change-impact.js"; export type { ClassifyDataOperationRiskInput } from "./data-operation-risk.js"; @@ -51,8 +51,11 @@ export type { BuildSymbolIdentityInput } from "./symbol-identity.js"; export type { RelevantTestsSelection, SelectRelevantTestsInput } from "./test-intelligence.js"; export type { BuildSecurityBoundaryProofReadModelInput, + BuildSecurityPhase8ReadModelInput, SecurityBoundaryProofReadModel, - SecurityBoundaryProofRouteSummary + SecurityBoundaryProofRouteSummary, + SecurityCapabilitySummary, + SecurityPhase8Route } from "./security-boundary-proof.js"; export interface GraphRepoMapFile { diff --git a/drift v3/packages/query/src/security-boundary-proof.ts b/drift v3/packages/query/src/security-boundary-proof.ts index a12da4a2..5cfde9fa 100644 --- a/drift v3/packages/query/src/security-boundary-proof.ts +++ b/drift v3/packages/query/src/security-boundary-proof.ts @@ -1,4 +1,4 @@ -import type { SecurityBoundaryProof } from "@drift/core"; +import type { AcceptedConvention, SecurityBoundaryProof } from "@drift/core"; export interface SecurityFindingSummaryInput { finding_id: string; @@ -78,6 +78,214 @@ export interface SecurityBoundaryProofReadModel { routes: SecurityBoundaryProofRouteSummary[]; } +export interface BuildSecurityPhase8ReadModelInput { + repo_id: string; + scan_id: string | null; + check_id: string | null; + proofs: SecurityBoundaryProof[]; + findings: SecurityFindingSummaryInput[]; + accepted_conventions: AcceptedConvention[]; + changed_files?: string[]; + known_routes?: Array<{ + route_id: string; + file_path: string; + path?: string; + method?: string; + file_role?: string; + }>; +} + +export interface SecurityCapabilitySummary { + name: string; + capability: "deterministic_check" | "heuristic_check" | "briefing_only"; + status: "complete" | "partial" | "missing" | "unsupported"; + can_block: boolean; + parser_gap_count: number; + missing_proof_count: number; + affected_files: string[]; +} + +export interface SecurityPhase8Route { + route_id: string; + path: string | null; + method: string | null; + file_path: string; + security: { + public_or_protected: "public" | "protected" | "unknown"; + auth_proven: boolean | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + middleware_proven: boolean | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + tenant_scope: ProofState; + request_validation: ProofState; + sensitive_response: ProofState; + phase6: { + ssrf: ProofState; + raw_sql: ProofState; + cors: ProofState; + csrf: ProofState; + rate_limit: ProofState; + }; + proof_status: SecurityBoundaryProof["result"]["proof_status"] | "unknown"; + enforcement_result: SecurityBoundaryProof["result"]["enforcement_result"] | "unknown"; + missing_proof_codes: string[]; + parser_gap_codes: string[]; + finding_ids: string[]; + next_command: string; + }; +} + +type ProofState = "proven" | "not_required" | "missing_proof" | "parser_gap" | "unknown"; + +const SECURITY_CAPABILITIES = [ + "control_flow_guard_dominance", + "middleware_coverage", + "request_validation_facts", + "session_trust", + "authorization", + "tenant_scope", + "sensitive_response", + "secret_exposure", + "ssrf", + "raw_sql", + "cors_policy", + "csrf", + "rate_limit" +] as const; + +const CAPABILITY_ALIASES: Record = { + response_shape_facts: "sensitive_response", + outbound_request_facts: "ssrf", + raw_sql_facts: "raw_sql", + cors_policy_facts: "cors_policy", + csrf_facts: "csrf", + rate_limit_facts: "rate_limit" +}; + +export function buildSecurityPhase8ReadModel(input: BuildSecurityPhase8ReadModelInput) { + const proofRoutes = input.proofs.map((proof) => phase8Route(input.repo_id, proof)); + const proofRouteIds = new Set(proofRoutes.map((route) => route.route_id)); + const unknownRoutes = (input.known_routes ?? []) + .filter((route) => !proofRouteIds.has(route.route_id)) + .map((route) => unknownPhase8Route(input.repo_id, route)); + const routes = [...proofRoutes, ...unknownRoutes].sort((left, right) => + left.file_path.localeCompare(right.file_path) || left.route_id.localeCompare(right.route_id) + ); + const changedFiles = new Set(input.changed_files ?? routes.map((route) => route.file_path)); + return { + response_schema: "drift.security.phase8.read-model.v1", + repo_id: input.repo_id, + scan_id: input.scan_id, + check_id: input.check_id, + security_capabilities: securityCapabilities(input.proofs, input.accepted_conventions), + routes, + repo_security_contracts: input.accepted_conventions + .filter((convention) => isSecurityConventionKind(convention.kind)) + .map(acceptedSecurityConventionSummary), + changed_route_security: routes + .filter((route) => changedFiles.has(route.file_path)) + .map((route) => { + const proof = input.proofs.find((candidate) => candidate.route.route_id === route.route_id); + return { + route_id: route.route_id, + path: route.path, + method: route.method, + file_path: route.file_path, + required_proofs: proof ? requiredProofSummaries(proof) : [], + current_proof_status: route.security.proof_status, + enforcement_result: route.security.enforcement_result, + missing_proof: (proof?.missing_proof ?? []).map((missing) => ({ + id: missing.id, + capability: normalizedCapability(missing.capability), + code: missing.code, + blocks_enforcement: missing.blocks_enforcement + })), + parser_gaps: (proof?.parser_gaps ?? []).map((gap) => ({ + parser_gap_id: gap.parser_gap_id, + capability: normalizedCapability(gap.capability), + code: gap.code, + file_path: gap.file_path, + ...(gap.start_line ? { start_line: gap.start_line } : {}), + ...(gap.end_line ? { end_line: gap.end_line } : {}), + blocks_enforcement: gap.blocks_enforcement + })), + next_command: route.security.next_command + }; + }), + required_proofs: routes.flatMap((route) => { + const proof = input.proofs.find((candidate) => candidate.route.route_id === route.route_id); + return proof ? requiredProofSummaries(proof) : []; + }), + current_proof_status: routes.map((route) => ({ + route_id: route.route_id, + file_path: route.file_path, + proof_status: route.security.proof_status, + enforcement_result: route.security.enforcement_result + })), + missing_proof_summaries: input.proofs.flatMap((proof) => proof.missing_proof.map((missing) => ({ + route_id: proof.route.route_id, + file_path: proof.route.file_path, + id: missing.id, + capability: normalizedCapability(missing.capability), + code: missing.code, + blocks_enforcement: missing.blocks_enforcement + }))), + parser_gap_summaries: input.proofs.flatMap((proof) => proof.parser_gaps.map((gap) => ({ + route_id: proof.route.route_id, + parser_gap_id: gap.parser_gap_id, + capability: normalizedCapability(gap.capability), + code: gap.code, + file_path: gap.file_path, + ...(gap.start_line ? { start_line: gap.start_line } : {}), + ...(gap.end_line ? { end_line: gap.end_line } : {}), + blocks_enforcement: gap.blocks_enforcement + }))), + do_not_include: [ + "source snippets", + "secret values", + "raw request payload examples", + "headers", + "raw SQL", + "raw URLs", + "env values", + "tokens", + "user IDs", + "tenant IDs" + ] as const + }; +} + +function unknownPhase8Route( + repoId: string, + route: { route_id: string; file_path: string; path?: string; method?: string } +): SecurityPhase8Route { + return { + route_id: route.route_id, + path: route.path ?? null, + method: route.method ?? null, + file_path: route.file_path, + security: { + public_or_protected: "unknown", + auth_proven: "unknown", + middleware_proven: "unknown", + tenant_scope: "unknown", + request_validation: "unknown", + sensitive_response: "unknown", + phase6: { + ssrf: "unknown", + raw_sql: "unknown", + cors: "unknown", + csrf: "unknown", + rate_limit: "unknown" + }, + proof_status: "unknown", + enforcement_result: "unknown", + missing_proof_codes: ["no_security_proof"], + parser_gap_codes: [], + finding_ids: [], + next_command: `drift check --repo ${repoId} --json` + } + }; +} + export function buildSecurityBoundaryProofReadModel( input: BuildSecurityBoundaryProofReadModelInput ): SecurityBoundaryProofReadModel { @@ -233,3 +441,367 @@ export function buildSecurityBoundaryProofReadModel( }) }; } + +function phase8Route(repoId: string, proof: SecurityBoundaryProof): SecurityPhase8Route { + const middleware = proof.middleware ?? { + required: false, + proven: false, + matched_middleware: [], + mismatches: [] + }; + const requestValidation = proof.request_validation ?? { + required: false, + proven: false, + input_reads: [], + validations: [], + validated_uses: [], + unvalidated_uses: [] + }; + const tenant = proof.tenant ?? { + required: false, + proven: false, + tenant_sources: [], + predicates: [], + missing: [] + }; + const responseShape = proof.response_shape ?? { + required: false, + proven: false, + sensitive_leaks: [] + }; + const ssrf = proof.ssrf ?? { + required: false, + proven: false, + outbound_requests: [], + allowlist_proofs: [], + missing_proof: [] + }; + const rawSql = proof.raw_sql ?? { + required: false, + proven: false, + raw_sql_calls: [], + parameterized_sql: [], + missing_proof: [] + }; + const cors = proof.cors ?? { + required: false, + proven: false, + policies: [], + missing_proof: [] + }; + const csrf = proof.csrf ?? { + required: false, + proven: false, + guard_calls: [], + missing_proof: [] + }; + const rateLimit = proof.rate_limit ?? { + required: false, + proven: false, + guard_calls: [], + missing_proof: [] + }; + return { + route_id: proof.route.route_id, + path: proof.route.endpoint?.path ?? null, + method: proof.route.endpoint?.method ?? null, + file_path: proof.route.file_path, + security: { + public_or_protected: proof.auth.required || proof.auth.proven ? "protected" : "unknown", + auth_proven: booleanSecurityState(proof.auth.required, proof.auth.proven, proof, "control_flow_guard_dominance"), + middleware_proven: booleanSecurityState(middleware.required, middleware.proven, proof, "middleware_coverage"), + tenant_scope: proofState(tenant.required, tenant.proven, proof, "tenant_scope"), + request_validation: proofState(requestValidation.required, requestValidation.proven, proof, "request_validation_facts"), + sensitive_response: proofState(responseShape.required, responseShape.proven, proof, "sensitive_response"), + phase6: { + ssrf: proofState(ssrf.required, ssrf.proven, proof, "ssrf"), + raw_sql: proofState(rawSql.required, rawSql.proven, proof, "raw_sql"), + cors: proofState(cors.required, cors.proven, proof, "cors_policy"), + csrf: proofState(csrf.required, csrf.proven, proof, "csrf"), + rate_limit: proofState(rateLimit.required, rateLimit.proven, proof, "rate_limit") + }, + proof_status: proof.result.proof_status, + enforcement_result: proof.result.enforcement_result, + missing_proof_codes: proof.missing_proof.map((missing) => missing.code), + parser_gap_codes: proof.parser_gaps.map((gap) => gap.code), + finding_ids: proof.result.finding_ids, + next_command: `drift repo map --repo ${repoId} --path ${proof.route.file_path} --json` + } + }; +} + +function booleanSecurityState( + required: boolean, + proven: boolean, + proof: SecurityBoundaryProof, + capability: string +): boolean | "not_required" | "missing_proof" | "parser_gap" | "unknown" { + const state = proofState(required, proven, proof, capability); + if (state === "proven") { + return true; + } + return state; +} + +function proofState( + required: boolean, + proven: boolean, + proof: SecurityBoundaryProof, + capability: string +): ProofState { + if (!required) { + return "not_required"; + } + if (proven) { + return "proven"; + } + if (proof.parser_gaps.some((gap) => normalizedCapability(gap.capability) === capability)) { + return "parser_gap"; + } + if (proof.missing_proof.some((missing) => normalizedCapability(missing.capability) === capability)) { + return "missing_proof"; + } + if (proof.result.proof_status === "parser_gap") { + return "parser_gap"; + } + if (proof.result.proof_status === "missing_proof" || proof.result.proof_status === "violated") { + return "missing_proof"; + } + return "unknown"; +} + +function securityCapabilities( + proofs: SecurityBoundaryProof[], + acceptedConventions: AcceptedConvention[] +): SecurityCapabilitySummary[] { + return SECURITY_CAPABILITIES.map((name) => { + const matchingConventions = acceptedConventions.filter((convention) => + securityConventionCapability(convention.kind) === name + ); + const matchingContracts = proofs.flatMap((proof) => + proof.contracts.filter((contract) => contract.matched && normalizedCapability(securityConventionCapability(contract.kind) ?? "") === name) + ); + const matchingProofs = proofs.filter((proof) => + proof.capability_status.some((status) => normalizedCapability(status.name) === name) || + proof.missing_proof.some((missing) => normalizedCapability(missing.capability) === name) || + proof.parser_gaps.some((gap) => normalizedCapability(gap.capability) === name) + ); + const parserGapCount = matchingProofs.reduce((count, proof) => + count + proof.parser_gaps.filter((gap) => normalizedCapability(gap.capability) === name).length, 0); + const missingProofCount = matchingProofs.reduce((count, proof) => + count + proof.missing_proof.filter((missing) => normalizedCapability(missing.capability) === name).length, 0); + const affectedFiles = [...new Set(matchingProofs.flatMap((proof) => [ + proof.route.file_path, + ...proof.parser_gaps + .filter((gap) => normalizedCapability(gap.capability) === name) + .map((gap) => gap.file_path) + ]))].sort(); + const explicitStatuses = matchingProofs.flatMap((proof) => + proof.capability_status.filter((status) => normalizedCapability(status.name) === name) + ); + const requiredByContract = matchingConventions.some((convention) => + ["warn", "block"].includes(convention.enforcement_mode) + ) || matchingContracts.some((contract) => ["warn", "block"].includes(contract.enforcement_mode)); + const capability = strongestCapability(matchingConventions, matchingContracts); + const complete = explicitStatuses.length > 0 && + explicitStatuses.every((status) => status.status === "complete") && + parserGapCount === 0 && + missingProofCount === 0; + const partial = explicitStatuses.some((status) => status.status === "partial") || + parserGapCount > 0 || + missingProofCount > 0 || + matchingProofs.length > 0; + return { + name, + capability, + status: complete + ? "complete" + : partial + ? "partial" + : requiredByContract + ? "missing" + : "unsupported", + can_block: matchingConventions.some((convention) => + convention.enforcement_capability === "deterministic_check" && + convention.enforcement_mode === "block" + ) || matchingContracts.some((contract) => + contract.capability === "deterministic_check" && + contract.enforcement_mode === "block" + ), + parser_gap_count: parserGapCount, + missing_proof_count: missingProofCount, + affected_files: affectedFiles + }; + }); +} + +function strongestCapability( + conventions: AcceptedConvention[], + contracts: SecurityBoundaryProof["contracts"] = [] +): "deterministic_check" | "heuristic_check" | "briefing_only" { + if ( + conventions.some((convention) => convention.enforcement_capability === "deterministic_check") || + contracts.some((contract) => contract.capability === "deterministic_check") + ) { + return "deterministic_check"; + } + if ( + conventions.some((convention) => convention.enforcement_capability === "heuristic_check") || + contracts.some((contract) => contract.capability === "heuristic_check") + ) { + return "heuristic_check"; + } + return "briefing_only"; +} + +function normalizedCapability(capability: string): string { + return CAPABILITY_ALIASES[capability] ?? capability; +} + +function isSecurityConventionKind(kind: string): boolean { + return securityConventionCapability(kind) !== null; +} + +function securityConventionCapability(kind: string): string | null { + switch (kind) { + case "api_route_requires_auth_helper": + return "control_flow_guard_dominance"; + case "middleware_must_cover_routes": + return "middleware_coverage"; + case "api_route_requires_request_validation": + return "request_validation_facts"; + case "session_object_must_come_from_trusted_helper": + return "session_trust"; + case "api_route_requires_authorization": + return "authorization"; + case "api_route_requires_tenant_scope": + return "tenant_scope"; + case "api_route_forbids_sensitive_response_fields": + return "sensitive_response"; + case "api_route_forbids_secret_exposure": + return "secret_exposure"; + case "api_route_forbids_untrusted_ssrf": + return "ssrf"; + case "api_route_forbids_raw_sql_without_params": + return "raw_sql"; + case "api_route_cors_must_match_policy": + return "cors_policy"; + case "api_route_requires_csrf_for_mutation": + return "csrf"; + case "api_route_requires_rate_limit": + return "rate_limit"; + default: + return null; + } +} + +function acceptedSecurityConventionSummary(convention: AcceptedConvention) { + const matcher = convention.matcher as unknown as Record; + return { + convention_id: convention.id, + kind: convention.kind, + enforcement_mode: convention.enforcement_mode, + capability: convention.enforcement_capability, + matcher_summary: matcherSummary(convention.matcher), + route_scope: { + file_roles: stringArray(matcher.file_roles ?? matcher.applies_to_file_roles), + paths: stringArray(matcher.paths ?? matcher.route_paths ?? convention.scope.path_globs), + methods: stringArray(matcher.methods) + }, + trusted_helpers: trustedHelpers(convention.requires), + requires_summary: requiredProofSummariesForKind(convention.kind), + accepted_at: convention.accepted_at, + updated_at: convention.updated_at, + expires_at: convention.expires_at + }; +} + +function matcherSummary(matcher: AcceptedConvention["matcher"]): string { + const record = matcher as unknown as Record; + const roles = stringArray(record.file_roles ?? record.applies_to_file_roles); + const methods = stringArray(record.methods); + const paths = stringArray(record.paths ?? record.route_paths ?? record.path_globs); + return [ + roles.length > 0 ? `file roles ${roles.join(",")}` : null, + methods.length > 0 ? `methods ${methods.join(",")}` : null, + paths.length > 0 ? `paths ${paths.join(",")}` : null + ].filter((value): value is string => Boolean(value)).join("; ") || "security convention matcher"; +} + +function trustedHelpers(requires: Record | undefined) { + if (!requires) { + return []; + } + const helpers = [ + ...helperStrings(requires.auth_helpers, "auth"), + ...helperStrings(requires.authorization_helpers, "authorization"), + ...helperStrings(requires.tenant_helpers, "tenant"), + ...helperStrings(requires.validators, "validator"), + ...helperObjects(requires.outbound_url_allowlist_helpers), + ...helperObjects(requires.csrf_helpers), + ...helperObjects(requires.rate_limit_helpers), + ...helperObjects(requires.response_serializers) + ]; + return helpers.sort((left, right) => left.helper_id.localeCompare(right.helper_id)); +} + +function helperStrings(value: unknown, prefix: string) { + return stringArray(value).map((symbol) => ({ helper_id: `${prefix}:${symbol}`, symbol })); +} + +function helperObjects(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((entry): entry is Record => entry !== null && typeof entry === "object") + .map((entry) => ({ + helper_id: String(entry.helper_id ?? entry.serializer_id ?? entry.symbol ?? "helper"), + symbol: String(entry.symbol ?? entry.imported_name ?? entry.local_name ?? entry.serializer_id ?? "helper"), + ...(typeof entry.module === "string" ? { module: entry.module } : {}), + ...(typeof entry.import_source === "string" ? { import: entry.import_source } : {}) + })); +} + +function requiredProofSummaries(proof: SecurityBoundaryProof): string[] { + return proof.contracts.flatMap((contract) => requiredProofSummariesForKind(contract.kind)); +} + +function requiredProofSummariesForKind(kind: string): string[] { + switch (kind) { + case "api_route_requires_auth_helper": + return ["auth helper must dominate data and response sinks"]; + case "middleware_must_cover_routes": + return ["middleware must cover matched route and method"]; + case "api_route_requires_request_validation": + return ["request input must be validated before trusted sinks"]; + case "session_object_must_come_from_trusted_helper": + return ["session object must come from trusted helper"]; + case "api_route_requires_authorization": + return ["authorization guard must dominate protected operations"]; + case "api_route_requires_tenant_scope": + return ["tenant predicate must bind trusted tenant source to data operation"]; + case "api_route_forbids_sensitive_response_fields": + return ["sensitive response fields must be filtered by accepted serializer"]; + case "api_route_forbids_secret_exposure": + return ["secret values must not reach response or log sinks"]; + case "api_route_forbids_untrusted_ssrf": + return ["outbound URL must be constant or accepted allowlisted value"]; + case "api_route_forbids_raw_sql_without_params": + return ["raw SQL must be parameterized"]; + case "api_route_cors_must_match_policy": + return ["CORS policy must match accepted origin and credential policy"]; + case "api_route_requires_csrf_for_mutation": + return ["CSRF guard must dominate mutation route sinks"]; + case "api_route_requires_rate_limit": + return ["rate-limit guard must dominate matched route sinks"]; + default: + return []; + } +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string").sort() + : []; +} diff --git a/drift v3/packages/query/test/security-boundary-proof.test.ts b/drift v3/packages/query/test/security-boundary-proof.test.ts index c05d6599..a90a24dd 100644 --- a/drift v3/packages/query/test/security-boundary-proof.test.ts +++ b/drift v3/packages/query/test/security-boundary-proof.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildSecurityBoundaryProofReadModel, fallbackFactRepoMapFiles } from "../src/index.js"; +import { buildSecurityBoundaryProofReadModel, buildSecurityPhase8ReadModel, fallbackFactRepoMapFiles } from "../src/index.js"; describe("security boundary proof read model", () => { it("renders proof, findings, and parser gaps without snippets", () => { @@ -555,4 +555,301 @@ describe("security boundary proof read model", () => { }); expect(JSON.stringify(model)).not.toContain("https://token"); }); + + it("builds Phase 8 route security from proofs only", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [securityBoundaryProofFixture({ + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + }, + auth: { required: true, proven: true, proof_kind: "handler_guard", trusted_guard_calls: [], dominated_sinks: [], undominated_sinks: [] }, + tenant: { + required: true, + proven: false, + tenant_sources: [], + predicates: [], + missing: [{ data_operation_fact_id: "fact_find_many", reason: "tenant_predicate_not_bound_to_query" }] + }, + missing_proof: [{ + id: "missing_tenant", + capability: "tenant_scope", + code: "tenant_predicate_missing", + blocks_enforcement: true, + fact_ids: ["fact_tenant"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_tenant"] + } + })], + findings: [{ finding_id: "finding_tenant", title: "Tenant missing", lifecycle: "new" }], + accepted_conventions: [] + }); + + expect(model.routes[0]).toMatchObject({ + route_id: "route_users_get", + path: "/api/users", + method: "GET", + security: { + auth_proven: true, + tenant_scope: "missing_proof", + proof_status: "missing_proof", + enforcement_result: "block" + } + }); + expect(model.security_capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "tenant_scope", + missing_proof_count: 1, + affected_files: ["app/api/users/route.ts"] + }) + ])); + }); + + it("filters Phase 8 route security to changed files", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [ + securityBoundaryProofFixture({ + route: { route_id: "route_users", file_path: "app/api/users/route.ts", file_role: "api_route" } + }), + securityBoundaryProofFixture({ + route: { route_id: "route_admin", file_path: "app/api/admin/route.ts", file_role: "api_route" } + }) + ], + findings: [], + accepted_conventions: [], + changed_files: ["app/api/users/route.ts"] + }); + + expect(model.changed_route_security.map((route) => route.file_path)).toEqual(["app/api/users/route.ts"]); + }); + + it("redacts accepted_by from security read model", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [], + findings: [], + accepted_conventions: [acceptedConventionFixture({ + accepted_by: "geoffrey@example.com", + accepted_at: "2026-05-27T00:00:00.000Z" + })] + }); + + expect(JSON.stringify(model)).not.toContain("geoffrey@example.com"); + expect(model.repo_security_contracts[0]).not.toHaveProperty("accepted_by"); + expect(model.repo_security_contracts[0]?.accepted_at).toBe("2026-05-27T00:00:00.000Z"); + }); + + it("known routes without proof are emitted as unknown", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: null, + proofs: [], + findings: [], + accepted_conventions: [], + known_routes: [{ + route_id: "route:GET:apps/web/app/api/users/route.ts", + file_path: "apps/web/app/api/users/route.ts", + method: "GET", + path: "/api/users", + file_role: "api_route" + }] + }); + + expect(model.routes).toEqual([expect.objectContaining({ + route_id: "route:GET:apps/web/app/api/users/route.ts", + path: "/api/users", + method: "GET", + security: expect.objectContaining({ + proof_status: "unknown", + missing_proof_codes: ["no_security_proof"] + }) + })]); + }); + + it("derives mixed capability route status by capability", () => { + const model = buildSecurityPhase8ReadModel({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + proofs: [securityBoundaryProofFixture({ + contracts: [{ + contract_id: "security_api_auth", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }, { + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }, { + contract_id: "security_custom", + kind: "custom_briefing", + enforcement_mode: "warn", + capability: "heuristic_check", + matched: true + }], + capability_status: [{ + name: "control_flow_guard_dominance", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }, { + name: "request_validation_facts", + status: "partial", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: ["missing_validation"] + }], + request_validation: { + required: true, + proven: false, + input_reads: [], + validations: [], + validated_uses: [], + unvalidated_uses: [] + }, + missing_proof: [{ + id: "missing_validation", + capability: "request_validation_facts", + code: "request_input_not_validated", + blocks_enforcement: true, + fact_ids: ["fact_body"], + graph_edge_ids: [] + }], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: [] + } + })], + findings: [], + accepted_conventions: [ + acceptedConventionFixture(), + acceptedConventionFixture({ + id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + enforcement_capability: "deterministic_check", + enforcement_mode: "block" + }), + acceptedConventionFixture({ + id: "candidate_only_signal", + kind: "api_route_forbids_secret_exposure", + enforcement_capability: "heuristic_check", + enforcement_mode: "warn" + }) + ] + }); + + expect(model.security_capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "control_flow_guard_dominance", + status: "complete", + capability: "deterministic_check", + can_block: true + }), + expect.objectContaining({ + name: "request_validation_facts", + status: "partial", + capability: "deterministic_check", + can_block: true + }), + expect.objectContaining({ + name: "secret_exposure", + capability: "heuristic_check", + can_block: false + }) + ])); + }); }); + +function securityBoundaryProofFixture(overrides: Record = {}) { + return { + proof_id: "proof_route_users_get", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "control_flow_guard_dominance", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "handler_guard", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + }, + ...overrides + }; +} + +function acceptedConventionFixture(overrides: Record = {}) { + return { + id: "security_api_auth", + contract_id: "contract_security", + kind: "api_route_requires_auth_helper", + statement: "API routes require accepted auth helper proof.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_auth_helper", + applies_to_file_roles: ["api_route"] + }, + requires: { + auth_helpers: ["requireUser"] + }, + severity: "error", + enforcement_mode: "block", + enforcement_capability: "deterministic_check", + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "local-user", + accepted_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z", + ...overrides + } as const; +} diff --git a/drift v3/packages/storage/src/migrations.ts b/drift v3/packages/storage/src/migrations.ts index f0941239..836ffd17 100644 --- a/drift v3/packages/storage/src/migrations.ts +++ b/drift v3/packages/storage/src/migrations.ts @@ -709,5 +709,41 @@ export const MIGRATIONS: Migration[] = [ ALTER TABLE accepted_conventions ADD COLUMN requires_json TEXT; ` + }, + { + id: "025_security_boundary_proof_runs", + sql: ` + CREATE TABLE IF NOT EXISTS security_boundary_proof_runs ( + storage_id TEXT PRIMARY KEY, + proof_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + scan_id TEXT NOT NULL, + check_id TEXT NOT NULL, + route_id TEXT NOT NULL, + file_path TEXT NOT NULL, + contract_kinds_json TEXT NOT NULL, + capability_names_json TEXT NOT NULL, + proof_status TEXT NOT NULL, + enforcement_result TEXT NOT NULL, + parser_gap_count INTEGER NOT NULL, + missing_proof_count INTEGER NOT NULL, + affected_files_json TEXT NOT NULL, + proof_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (repo_id) REFERENCES repos(id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_unique + ON security_boundary_proof_runs(check_id, proof_id); + + CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_scan + ON security_boundary_proof_runs(repo_id, scan_id); + + CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_check + ON security_boundary_proof_runs(repo_id, check_id); + + CREATE INDEX IF NOT EXISTS idx_security_boundary_proof_runs_repo_route + ON security_boundary_proof_runs(repo_id, route_id); + ` } ]; diff --git a/drift v3/packages/storage/src/sqlite-storage.ts b/drift v3/packages/storage/src/sqlite-storage.ts index de247797..7a0679e6 100644 --- a/drift v3/packages/storage/src/sqlite-storage.ts +++ b/drift v3/packages/storage/src/sqlite-storage.ts @@ -71,6 +71,25 @@ export interface DriftStorageOptions { databasePath: string; } +export interface StoredSecurityBoundaryProofRun { + storage_id: string; + proof_id: string; + repo_id: string; + scan_id: string; + check_id: string; + route_id: string; + file_path: string; + contract_kinds: string[]; + capability_names: string[]; + proof_status: "proven" | "violated" | "missing_proof" | "parser_gap" | "advisory_only"; + enforcement_result: "pass" | "brief" | "warn" | "block"; + parser_gap_count: number; + missing_proof_count: number; + affected_files: string[]; + proof: SecurityBoundaryProof; + created_at: string; +} + type DatabaseHandle = Database.Database; export class SqliteDriftStorage { @@ -608,6 +627,139 @@ export class SqliteDriftStorage { ); } + upsertSecurityBoundaryProofRuns(input: { + repo_id: string; + scan_id: string; + check_id: string; + proofs: SecurityBoundaryProof[]; + created_at: string; + }): void { + const proofs = input.proofs.map((proof) => SecurityBoundaryProofSchema.parse(proof)); + const insert = this.db.prepare(` + INSERT INTO security_boundary_proof_runs ( + storage_id, proof_id, repo_id, scan_id, check_id, route_id, file_path, + contract_kinds_json, capability_names_json, proof_status, enforcement_result, + parser_gap_count, missing_proof_count, affected_files_json, proof_json, created_at + ) + VALUES ( + @storage_id, @proof_id, @repo_id, @scan_id, @check_id, @route_id, @file_path, + @contract_kinds_json, @capability_names_json, @proof_status, @enforcement_result, + @parser_gap_count, @missing_proof_count, @affected_files_json, @proof_json, @created_at + ) + ON CONFLICT(check_id, proof_id) DO UPDATE SET + route_id = excluded.route_id, + file_path = excluded.file_path, + contract_kinds_json = excluded.contract_kinds_json, + capability_names_json = excluded.capability_names_json, + proof_status = excluded.proof_status, + enforcement_result = excluded.enforcement_result, + parser_gap_count = excluded.parser_gap_count, + missing_proof_count = excluded.missing_proof_count, + affected_files_json = excluded.affected_files_json, + proof_json = excluded.proof_json, + created_at = excluded.created_at + `); + const transaction = this.db.transaction(() => { + for (const proof of proofs) { + const affectedFiles = [...new Set([ + proof.route.file_path, + ...proof.parser_gaps.map((gap) => gap.file_path) + ])].sort(); + insert.run({ + storage_id: `${input.check_id}:${proof.proof_id}`, + proof_id: proof.proof_id, + repo_id: input.repo_id, + scan_id: input.scan_id, + check_id: input.check_id, + route_id: proof.route.route_id, + file_path: proof.route.file_path, + contract_kinds_json: stringifyJson(proof.contracts.map((contract) => contract.kind)), + capability_names_json: stringifyJson(proof.capability_status.map((status) => status.name)), + proof_status: proof.result.proof_status, + enforcement_result: proof.result.enforcement_result, + parser_gap_count: proof.parser_gaps.length, + missing_proof_count: proof.missing_proof.length, + affected_files_json: stringifyJson(affectedFiles), + proof_json: stringifyJson(proof), + created_at: input.created_at + }); + } + }); + transaction(); + } + + listSecurityBoundaryProofRuns(input: { + repo_id: string; + scan_id?: string; + check_id?: string; + file_path?: string; + route_id?: string; + contract_kind?: string; + latest_only?: boolean; + }): StoredSecurityBoundaryProofRun[] { + const clauses = ["repo_id = ?"]; + const params: unknown[] = [input.repo_id]; + if (input.scan_id) { + clauses.push("scan_id = ?"); + params.push(input.scan_id); + } + if (input.check_id) { + clauses.push("check_id = ?"); + params.push(input.check_id); + } + if (input.file_path) { + clauses.push("file_path = ?"); + params.push(input.file_path); + } + if (input.route_id) { + clauses.push("route_id = ?"); + params.push(input.route_id); + } + const rows = this.db + .prepare(` + SELECT * FROM security_boundary_proof_runs + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC, route_id, proof_id + `) + .all(...params) + .map(securityBoundaryProofRunFromRow); + const filtered = input.contract_kind + ? rows.filter((row) => row.contract_kinds.includes(input.contract_kind as string)) + : rows; + if (!input.latest_only) { + return filtered; + } + const latestCheckId = filtered[0]?.check_id; + return latestCheckId ? filtered.filter((row) => row.check_id === latestCheckId) : []; + } + + listLatestSecurityBoundaryProofRunsForRepo(input: { + repo_id: string; + file_path?: string; + check_id?: string; + }): StoredSecurityBoundaryProofRun[] { + const clauses = ["repo_id = ?"]; + const params: unknown[] = [input.repo_id]; + if (input.check_id) { + clauses.push("check_id = ?"); + params.push(input.check_id); + } + if (input.file_path) { + clauses.push("file_path = ?"); + params.push(input.file_path); + } + const rows = this.db + .prepare(` + SELECT * FROM security_boundary_proof_runs + WHERE ${clauses.join(" AND ")} + ORDER BY created_at DESC, check_id DESC, proof_id ASC + `) + .all(...params) + .map(securityBoundaryProofRunFromRow); + const latestCheckId = rows[0]?.check_id; + return latestCheckId ? rows.filter((row) => row.check_id === latestCheckId) : []; + } + upsertSymbolIdentities(identities: SymbolIdentity[]): void { const parsedIdentities = identities.map((identity) => SymbolIdentitySchema.parse(identity)); const insert = this.db.prepare(` @@ -1643,6 +1795,34 @@ function scanCapabilityReportFromRow(row: unknown): ScanCapabilityReport { }); } +function securityBoundaryProofRunFromRow(row: unknown): StoredSecurityBoundaryProofRun { + const record = row as Record; + return { + storage_id: rowValue(row, "storage_id"), + proof_id: rowValue(row, "proof_id"), + repo_id: rowValue(row, "repo_id"), + scan_id: rowValue(row, "scan_id"), + check_id: rowValue(row, "check_id"), + route_id: rowValue(row, "route_id"), + file_path: rowValue(row, "file_path"), + contract_kinds: parseJsonArray(record.contract_kinds_json).filter((value): value is string => + typeof value === "string" + ), + capability_names: parseJsonArray(record.capability_names_json).filter((value): value is string => + typeof value === "string" + ), + proof_status: rowValue(row, "proof_status"), + enforcement_result: rowValue(row, "enforcement_result"), + parser_gap_count: rowValue(row, "parser_gap_count"), + missing_proof_count: rowValue(row, "missing_proof_count"), + affected_files: parseJsonArray(record.affected_files_json).filter((value): value is string => + typeof value === "string" + ), + proof: SecurityBoundaryProofSchema.parse(JSON.parse(rowValue(row, "proof_json"))), + created_at: rowValue(row, "created_at") + }; +} + function deduplicateParserGaps(gaps: ParserGap[]): ParserGap[] { const bySemanticKey = new Map(); for (const gap of gaps) { diff --git a/drift v3/packages/storage/test/sqlite-storage.test.ts b/drift v3/packages/storage/test/sqlite-storage.test.ts index 8b7534c3..6e58357f 100644 --- a/drift v3/packages/storage/test/sqlite-storage.test.ts +++ b/drift v3/packages/storage/test/sqlite-storage.test.ts @@ -34,6 +34,49 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); +function validSecurityBoundaryProof(overrides: Record = {}) { + return { + proof_id: "proof_route_users_get", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "control_flow_guard_dominance", + status: "partial", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: ["missing_auth"] + }], + auth: { + required: true, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_auth"] + }, + ...overrides + }; +} + describe("SQLite Drift storage", () => { it("applies schema migrations into SQLite", async () => { const storage = openDriftStorage({ databasePath: await tempDatabasePath() }); @@ -64,7 +107,8 @@ describe("SQLite Drift storage", () => { "021_graph_evidence_confidence", "022_fact_imported_name", "023_security_boundary_proofs", - "024_phase7_candidate_election_metadata" + "024_phase7_candidate_election_metadata", + "025_security_boundary_proof_runs" ]); storage.close(); }); @@ -134,7 +178,8 @@ describe("SQLite Drift storage", () => { "021_graph_evidence_confidence", "022_fact_imported_name", "023_security_boundary_proofs", - "024_phase7_candidate_election_metadata" + "024_phase7_candidate_election_metadata", + "025_security_boundary_proof_runs" ]); expect(storage.getRepo("repo_abc")?.fingerprint).toBe("repo-fp"); storage.close(); @@ -621,6 +666,162 @@ describe("SQLite Drift storage", () => { storage.close(); }); + it("persists security boundary proof runs by check run without snippets", async () => { + const storage = openDriftStorage({ databasePath: await tempDatabasePath() }); + storage.migrate(); + storage.upsertRepo({ + id: "repo_security", + root_path: "/repo", + fingerprint: "repo-fp", + created_at: "2026-05-27T00:00:00.000Z", + updated_at: "2026-05-27T00:00:00.000Z" + }); + storage.upsertScanManifest({ + id: "scan_security", + repo_id: "repo_security", + branch: "main", + commit: "abc123", + dirty: false, + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + rule_engine_version: "0.1.0", + status: "completed", + file_count: 1, + fact_count: 1, + finding_count: 1, + started_at: "2026-05-27T00:00:00.000Z", + completed_at: "2026-05-27T00:00:01.000Z" + }); + storage.upsertCheckRun({ + id: "check_security", + repo_id: "repo_security", + repo_contract_id: "contract_security", + contract_fingerprint: "contract-fp", + scan_id: "scan_security", + status: "fail", + scope: "full", + engine_source: "rust", + fallback_used: false, + stale_scan: false, + capability_complete: true, + findings_count: 1, + blocking_count: 1, + started_at: "2026-05-27T00:00:01.000Z", + completed_at: "2026-05-27T00:00:02.000Z", + }); + + storage.upsertSecurityBoundaryProofRuns({ + repo_id: "repo_security", + scan_id: "scan_security", + check_id: "check_security", + created_at: "2026-05-27T00:00:02.000Z", + proofs: [validSecurityBoundaryProof({ + proof_id: "proof_route_users_get", + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + }, + missing_proof: [{ + id: "missing_auth", + capability: "control_flow_guard_dominance", + code: "auth_guard_not_dominating_sink", + blocks_enforcement: true, + fact_ids: ["fact_sink"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_auth"] + } + })] + }); + + const rows = storage.listSecurityBoundaryProofRuns({ + repo_id: "repo_security", + check_id: "check_security" + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.missing_proof_count).toBe(1); + expect(rows[0]?.affected_files).toEqual(["app/api/users/route.ts"]); + expect(JSON.stringify(rows[0])).not.toContain("select *"); + expect(JSON.stringify(rows[0])).not.toContain("secret="); + storage.close(); + }); + + it("lists latest security boundary proof runs by repo across check scan ids", async () => { + const storage = openDriftStorage({ databasePath: await tempDatabasePath() }); + storage.migrate(); + storage.upsertRepo({ + id: "repo_security", + root_path: "/repo", + fingerprint: "repo-fp", + created_at: "2026-05-27T00:00:00.000Z", + updated_at: "2026-05-27T00:00:00.000Z" + }); + storage.upsertScanManifest({ + id: "scan_indexed", + repo_id: "repo_security", + branch: "main", + commit: "abc123", + dirty: false, + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + rule_engine_version: "0.1.0", + status: "completed", + file_count: 1, + fact_count: 1, + finding_count: 0, + started_at: "2026-05-27T00:00:00.000Z", + completed_at: "2026-05-27T00:00:01.000Z" + }); + storage.upsertCheckRun({ + id: "check_security", + repo_id: "repo_security", + repo_contract_id: "contract_security", + contract_fingerprint: "contract-fp", + scan_id: "scan_indexed", + status: "pass", + scope: "full", + engine_source: "rust", + fallback_used: false, + stale_scan: false, + capability_complete: true, + findings_count: 0, + blocking_count: 0, + started_at: "2026-05-27T00:00:01.000Z", + completed_at: "2026-05-27T00:00:02.000Z" + }); + storage.upsertSecurityBoundaryProofRuns({ + repo_id: "repo_security", + scan_id: "scan_check_security", + check_id: "check_security", + created_at: "2026-05-27T00:00:02.000Z", + proofs: [validSecurityBoundaryProof({ + route: { + route_id: "route_users_get", + file_path: "app/api/users/route.ts", + file_role: "api_route", + endpoint: { path: "/api/users", method: "GET", framework: "next" } + } + })] + }); + + const latestRows = storage.listLatestSecurityBoundaryProofRunsForRepo({ + repo_id: "repo_security", + file_path: "app/api/users/route.ts" + }); + + expect(latestRows).toHaveLength(1); + expect(latestRows[0]?.check_id).toBe("check_security"); + expect(latestRows[0]?.scan_id).toBe("scan_check_security"); + storage.close(); + }); + it("lists file snapshots for a scan", async () => { const storage = openDriftStorage({ databasePath: await tempDatabasePath() }); storage.migrate(); diff --git a/drift v3/scripts/run-beta-proof.mjs b/drift v3/scripts/run-beta-proof.mjs index f6f16661..275b6802 100644 --- a/drift v3/scripts/run-beta-proof.mjs +++ b/drift v3/scripts/run-beta-proof.mjs @@ -566,7 +566,49 @@ function responseSchemasVerified(bundle) { } function stablePayloadForParity(payload) { - return stripParityVolatileFields(payload); + return stripParityVolatileFields(normalizeParityPayload(payload)); +} + +function normalizeParityPayload(payload) { + if (!payload || typeof payload !== "object") { + return payload; + } + if (payload.response_schema !== "drift.findings.list.v1") { + return payload; + } + return { + ...payload, + review_items: Array.isArray(payload.review_items) + ? payload.review_items.map((item) => ({ + ...item, + first_evidence: item.first_evidence + ? { + file_path: item.first_evidence.file_path, + start_line: item.first_evidence.start_line ?? null + } + : null + })) + : payload.review_items, + findings: Array.isArray(payload.findings) + ? payload.findings.map((finding) => ({ + finding_id: finding.finding_id ?? finding.id, + convention_id: finding.convention_id, + title: finding.title, + severity: finding.severity, + lifecycle: finding.lifecycle ?? finding.status, + diff_status: finding.diff_status, + enforcement_result: finding.enforcement_result, + file_refs: finding.file_refs ?? (Array.isArray(finding.evidence_refs) + ? finding.evidence_refs.map((ref) => ({ + file_path: ref.file_path, + ...(ref.start_line ? { start_line: ref.start_line } : {}), + ...(ref.end_line ? { end_line: ref.end_line } : {}), + redaction_state: ref.start_line || ref.end_line ? "line_only" : "metadata_only" + })) + : []) + })) + : payload.findings + }; } function stripParityVolatileFields(value) { diff --git a/drift v3/test/e2e/cli-bin.test.ts b/drift v3/test/e2e/cli-bin.test.ts index a1ca4e43..f2329b72 100644 --- a/drift v3/test/e2e/cli-bin.test.ts +++ b/drift v3/test/e2e/cli-bin.test.ts @@ -52,7 +52,7 @@ describe("built drift CLI binary", () => { expect(payload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(payload.v1_scope).toMatchObject({ diff --git a/drift v3/test/e2e/dogfood-enforcement-proof.test.ts b/drift v3/test/e2e/dogfood-enforcement-proof.test.ts index 9e94b5c0..704511b6 100644 --- a/drift v3/test/e2e/dogfood-enforcement-proof.test.ts +++ b/drift v3/test/e2e/dogfood-enforcement-proof.test.ts @@ -188,15 +188,17 @@ describe("Drift-on-Drift accepted-contract enforcement proof", () => { expect(mcpFindings.summary.filtered_count).toBe(badPayload.summary.blocking_count); expect(mcpFindings.summary.by_severity.error).toBe(badPayload.summary.blocking_count); expect(mcpFindings.findings[0]).toMatchObject({ - id: finding.id, - check_id: badPayload.check.id, - repo_contract_id: "contract_drift_package_boundary", + finding_id: finding.id, convention_id: "agent_contract_mcp_no_cli_imports", enforcement_result: "block", - evidence_refs: [expect.objectContaining({ + file_refs: [expect.objectContaining({ file_path: mcpSourcePath, - import_source: "@drift/cli" + start_line: 1, + end_line: 1, + redaction_state: "line_only" })] }); + expect(JSON.stringify(mcpFindings.findings[0])).not.toContain("@drift/cli"); + expect(mcpFindings.findings[0]).not.toHaveProperty("evidence_refs"); }, 15_000); }); diff --git a/drift v3/test/e2e/golden.test.ts b/drift v3/test/e2e/golden.test.ts index 0f93ebe3..cb9dd963 100644 --- a/drift v3/test/e2e/golden.test.ts +++ b/drift v3/test/e2e/golden.test.ts @@ -147,7 +147,7 @@ describe("golden fixture CLI lifecycle", () => { "governance_read_only": false, "next_command_count": 3, "repo_matches": true, - "schema_version": 24, + "schema_version": 25, } `); @@ -166,7 +166,7 @@ describe("golden fixture CLI lifecycle", () => { "governance_read_only": false, "next_command_count": 2, "repo_matches": true, - "schema_version": 24, + "schema_version": 25, "write_intent": true, } `); diff --git a/drift v3/test/e2e/installed-flow.test.ts b/drift v3/test/e2e/installed-flow.test.ts index 5963f467..0aab2bb4 100644 --- a/drift v3/test/e2e/installed-flow.test.ts +++ b/drift v3/test/e2e/installed-flow.test.ts @@ -153,7 +153,7 @@ describe("installed Drift package flow", () => { expect(doctorPayload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(doctorPayload.engine).toMatchObject({ @@ -212,7 +212,7 @@ describe("installed Drift package flow", () => { expect(versionPayload.runtime).toMatchObject({ cli_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(versionPayload.engine).toMatchObject({ @@ -1177,7 +1177,7 @@ describe("installed Drift package flow", () => { expect(runtimePayload.runtime).toMatchObject({ mcp_version: "0.1.0", core_version: "0.1.0", - supported_sqlite_schema_version: 24, + supported_sqlite_schema_version: 25, storage_driver: "sqlite" }); expect(runtimePayload.governance).toMatchObject({