From 4da896f52f11a3e01242461c9507a7a5cb197ca3 Mon Sep 17 00:00:00 2001 From: geoffrey fernald Date: Mon, 25 May 2026 16:36:43 -0400 Subject: [PATCH 1/4] Implement Phase 1 security boundary enforcement --- .../crates/drift-engine/src/check_command.rs | 246 +++++++++ drift v3/crates/drift-engine/src/facts.rs | 3 + drift v3/crates/drift-engine/src/lib.rs | 19 + drift v3/crates/drift-engine/src/main.rs | 14 +- drift v3/crates/drift-engine/src/protocol.rs | 4 + .../drift-engine/src/security_capabilities.rs | 42 ++ .../drift-engine/src/security_control_flow.rs | 162 ++++++ .../crates/drift-engine/src/security_facts.rs | 126 +++++ .../drift-engine/src/security_patterns.rs | 44 ++ .../crates/drift-engine/src/security_proof.rs | 106 ++++ .../crates/drift-engine/src/security_rules.rs | 71 +++ .../tests/security_capabilities.rs | 35 ++ .../tests/security_control_flow.rs | 224 +++++++++ .../drift-engine/tests/security_facts.rs | 103 ++++ .../drift-engine/tests/security_rules.rs | 68 +++ .../security-boundary-enforcement-100-tdd.md | 473 ++++++++++++++++++ drift v3/packages/cli/src/check/run-check.ts | 129 ++++- .../packages/cli/src/check/security-check.ts | 47 ++ .../packages/cli/src/engine/engine-check.ts | 1 + .../packages/cli/test/security-check.test.ts | 316 ++++++++++++ drift v3/packages/core/src/domain.ts | 5 +- drift v3/packages/core/src/index.ts | 1 + drift v3/packages/core/src/schemas.ts | 5 +- drift v3/packages/core/src/security.ts | 167 +++++++ drift v3/packages/core/test/security.test.ts | 110 ++++ .../packages/engine-contract/src/index.ts | 92 +++- .../test/security-contract.test.ts | 71 +++ drift v3/packages/query/src/index.ts | 6 + .../query/src/security-boundary-proof.ts | 55 ++ .../test/security-boundary-proof.test.ts | 90 ++++ drift v3/test/e2e/golden.test.ts | 2 +- drift v3/test/e2e/security-auth.test.ts | 24 + .../app/api/projects/route.ts | 8 + .../security-auth-after-data/package.json | 1 + .../app/api/projects/route.ts | 8 + .../security-auth-before-sink/package.json | 1 + .../app/api/projects/route.ts | 12 + .../security-auth-branch-bypass/package.json | 1 + .../app/api/projects/route.ts | 10 + .../package.json | 1 + .../app/api/projects/route.ts | 6 + .../security-auth-missing/package.json | 1 + .../app/api/projects/route.ts | 11 + .../package.json | 1 + 44 files changed, 2912 insertions(+), 10 deletions(-) create mode 100644 drift v3/crates/drift-engine/src/security_capabilities.rs create mode 100644 drift v3/crates/drift-engine/src/security_control_flow.rs create mode 100644 drift v3/crates/drift-engine/src/security_facts.rs create mode 100644 drift v3/crates/drift-engine/src/security_patterns.rs create mode 100644 drift v3/crates/drift-engine/src/security_proof.rs create mode 100644 drift v3/crates/drift-engine/src/security_rules.rs create mode 100644 drift v3/crates/drift-engine/tests/security_capabilities.rs create mode 100644 drift v3/crates/drift-engine/tests/security_control_flow.rs create mode 100644 drift v3/crates/drift-engine/tests/security_facts.rs create mode 100644 drift v3/crates/drift-engine/tests/security_rules.rs create mode 100644 drift v3/packages/cli/src/check/security-check.ts create mode 100644 drift v3/packages/cli/test/security-check.test.ts create mode 100644 drift v3/packages/core/src/security.ts create mode 100644 drift v3/packages/core/test/security.test.ts create mode 100644 drift v3/packages/engine-contract/test/security-contract.test.ts create mode 100644 drift v3/packages/query/src/security-boundary-proof.ts create mode 100644 drift v3/packages/query/test/security-boundary-proof.test.ts create mode 100644 drift v3/test/e2e/security-auth.test.ts create mode 100644 drift v3/test/fixtures/security-auth-after-data/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-auth-after-data/package.json create mode 100644 drift v3/test/fixtures/security-auth-before-sink/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-auth-before-sink/package.json create mode 100644 drift v3/test/fixtures/security-auth-branch-bypass/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-auth-branch-bypass/package.json create mode 100644 drift v3/test/fixtures/security-auth-callback-bypass/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-auth-callback-bypass/package.json create mode 100644 drift v3/test/fixtures/security-auth-missing/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-auth-missing/package.json create mode 100644 drift v3/test/fixtures/security-dynamic-control-flow/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-dynamic-control-flow/package.json diff --git a/drift v3/crates/drift-engine/src/check_command.rs b/drift v3/crates/drift-engine/src/check_command.rs index 4d6c6f16..455264d6 100644 --- a/drift v3/crates/drift-engine/src/check_command.rs +++ b/drift v3/crates/drift-engine/src/check_command.rs @@ -8,6 +8,7 @@ use drift_engine::{ Fact, FactKind, FindingStatus, ParsedDiff, RuleFinding, Severity, classify_findings_against_diff, materialize_direct_data_access_findings, }; +use serde_json::json; use crate::protocol::{ CheckBaselineViolation, CheckEvidence, CheckFact, CheckFinding, CheckGraphData, CheckRequest, @@ -50,6 +51,7 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { }; let mut findings = Vec::new(); + let mut security_boundary_proofs = Vec::new(); for convention in request.contract.conventions { if convention.enforcement_capability != "deterministic_check" || convention.enforcement_mode == "off" @@ -98,6 +100,17 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { enforcement_mode, &allowed_delegate_imports, ) + } else if convention.kind == "api_route_requires_auth_helper" { + let auth_result = security_auth_findings_and_proofs( + &facts, + &parsed_diff, + diff_scope, + &convention, + severity, + enforcement_mode, + ); + security_boundary_proofs.extend(auth_result.proofs); + auth_result.findings } else { continue; }; @@ -180,6 +193,7 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { diff_mode: request.diff.mode, stats, findings, + security_boundary_proofs, diagnostics, completeness: vec![EngineCompleteness { scope: "repo".to_string(), @@ -447,6 +461,235 @@ fn graph_direct_data_access_findings( findings } +struct SecurityAuthEvaluation { + findings: Vec, + proofs: Vec, +} + +fn security_auth_findings_and_proofs( + facts: &[Fact], + parsed_diff: &ParsedDiff, + diff_scope: DiffScope, + convention: &crate::protocol::CheckConvention, + severity: Severity, + enforcement_mode: EnforcementMode, +) -> SecurityAuthEvaluation { + let required_calls = convention + .matcher + .required_calls + .clone() + .unwrap_or_default(); + if required_calls.is_empty() { + return SecurityAuthEvaluation { + findings: Vec::new(), + proofs: Vec::new(), + }; + } + if convention + .matcher + .applies_to_file_roles + .as_ref() + .is_some_and(|roles| !roles.iter().any(|role| role == "api_route")) + { + return SecurityAuthEvaluation { + findings: Vec::new(), + proofs: Vec::new(), + }; + } + let files = security_auth_files(facts, parsed_diff, diff_scope); + let mut findings = Vec::new(); + let mut proofs = Vec::new(); + + for file_path in files { + let file_facts = facts + .iter() + .filter(|fact| fact.file_path == file_path) + .collect::>(); + let route = file_facts + .iter() + .find(|fact| fact.kind == FactKind::RouteDeclared) + .map(|fact| fact.name.as_str()) + .unwrap_or("unknown"); + let route_id = format!("route:{file_path}:{route}"); + let guard_calls = file_facts + .iter() + .filter(|fact| { + fact.kind == FactKind::AuthGuardCalled + || (fact.kind == FactKind::SymbolCalled && required_calls.contains(&fact.name)) + }) + .copied() + .collect::>(); + let sinks = file_facts + .iter() + .filter(|fact| { + matches!( + fact.kind, + FactKind::DataOperationDetected | FactKind::RouteReturnsResponse + ) + }) + .copied() + .collect::>(); + if sinks.is_empty() { + continue; + } + + let first_guard_line = guard_calls.iter().map(|fact| fact.start_line).min(); + let mut dominated_sinks = Vec::new(); + let mut undominated_sinks = Vec::new(); + for sink in &sinks { + let sink_kind = security_sink_kind(sink.kind); + let sink_id = format!("sink:{file_path}:{}:{}", sink.start_line, sink.name); + if first_guard_line.is_some_and(|line| line < sink.start_line) { + dominated_sinks.push(json!({ + "sink_id": sink_id, + "sink_kind": sink_kind, + "edge_id": format!("edge:auth-dominates:{file_path}:{}", sink.start_line) + })); + } else { + undominated_sinks.push(json!({ + "sink_id": sink_id, + "sink_kind": sink_kind, + "reason": match first_guard_line { + Some(line) if line > sink.start_line => "guard_after_sink", + _ => "no_guard_call", + }, + "fact_ids": [security_fact_id(sink)] + })); + } + } + + let proven = !sinks.is_empty() && undominated_sinks.is_empty(); + let missing_proof_ids = if proven { + Vec::new() + } else { + vec![format!("missing_proof:{route_id}:auth")] + }; + let proof_id = format!("proof:{route_id}:auth"); + let finding_fingerprint = stable_hash(&format!( + "{}:{}:missing_auth_guard:{}", + convention.id, route_id, sinks[0].start_line + )); + let finding_id = format!("finding_{}", &finding_fingerprint[..16]); + proofs.push(json!({ + "proof_id": proof_id, + "proof_version": "security-boundary-proof/v1", + "route": { + "route_id": route_id, + "file_path": file_path, + "file_role": "api_route", + "handler_symbol": route + }, + "contracts": [{ + "contract_id": convention.id, + "kind": "api_route_requires_auth_helper", + "enforcement_mode": convention.enforcement_mode, + "capability": convention.enforcement_capability, + "matched": true + }], + "capability_status": [{ + "name": "control_flow_guard_dominance", + "status": "partial", + "can_block": true, + "parser_gap_ids": [], + "missing_proof_ids": missing_proof_ids + }], + "auth": { + "required": true, + "proven": proven, + "proof_kind": if proven { "handler_guard" } else { "none" }, + "trusted_guard_calls": guard_calls.iter().map(|guard| json!({ + "fact_id": security_fact_id(guard), + "guard_id": guard.name, + "symbol": guard.name, + "start_line": guard.start_line, + "end_line": guard.end_line + })).collect::>(), + "dominated_sinks": dominated_sinks, + "undominated_sinks": undominated_sinks + }, + "missing_proof": if proven { + Vec::::new() + } else { + vec![json!({ + "id": missing_proof_ids[0], + "capability": "control_flow_guard_dominance", + "code": "missing_auth_guard", + "blocks_enforcement": true, + "fact_ids": [security_fact_id(sinks[0])], + "graph_edge_ids": [] + })] + }, + "parser_gaps": [], + "result": { + "proof_status": if proven { "proven" } else { "missing_proof" }, + "enforcement_result": if proven { "pass" } else { convention.enforcement_mode.as_str() }, + "can_block": !proven, + "finding_ids": if proven { Vec::::new() } else { vec![finding_id.clone()] } + } + })); + + if !proven { + findings.push(PendingFinding { + fingerprint: finding_fingerprint, + convention_id: convention.id.clone(), + rule_id: "api_route_requires_auth_helper".to_string(), + title: "API route missing required auth proof".to_string(), + message: "Accepted auth helper must dominate protected route sinks.".to_string(), + severity, + enforcement_result: enforcement_result_for_mode(enforcement_mode), + file_path: file_path.clone(), + import_name: "auth_guard".to_string(), + import_source: "missing_auth_guard".to_string(), + line: sinks[0].start_line, + evidence_id: format!("evidence_{}", &finding_id["finding_".len()..]), + legacy_fingerprints: Vec::new(), + related_node_ids: Vec::new(), + }); + } + } + + SecurityAuthEvaluation { findings, proofs } +} + +fn security_auth_files( + facts: &[Fact], + parsed_diff: &ParsedDiff, + diff_scope: DiffScope, +) -> BTreeSet { + let api_route_files = facts + .iter() + .filter(|fact| fact.kind == FactKind::FileRoleDetected && fact.name == "api_route") + .map(|fact| fact.file_path.clone()) + .collect::>(); + if matches!(diff_scope, DiffScope::Full) { + return api_route_files; + } + let changed_files = parsed_diff + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + api_route_files + .into_iter() + .filter(|file| changed_files.contains(file)) + .collect() +} + +fn security_sink_kind(kind: FactKind) -> &'static str { + match kind { + FactKind::DataOperationDetected => "data_operation", + FactKind::RouteReturnsResponse => "response", + _ => "unknown", + } +} + +fn security_fact_id(fact: &Fact) -> String { + format!( + "fact:{}:{}:{}", + fact.file_path, fact.kind as u8, fact.start_line + ) +} + fn graph_service_delegation_findings( graph: &CheckGraphData, convention_id: &str, @@ -751,6 +994,9 @@ fn fact_kind_from_str(kind: &str) -> Option { "route_declared" => Some(FactKind::RouteDeclared), "file_role_detected" => Some(FactKind::FileRoleDetected), "test_declared" => Some(FactKind::TestDeclared), + "auth_guard_called" => Some(FactKind::AuthGuardCalled), + "route_returns_response" => Some(FactKind::RouteReturnsResponse), + "callback_boundary_detected" => Some(FactKind::CallbackBoundaryDetected), _ => None, } } diff --git a/drift v3/crates/drift-engine/src/facts.rs b/drift v3/crates/drift-engine/src/facts.rs index 0b7cfecb..3100cf6c 100644 --- a/drift v3/crates/drift-engine/src/facts.rs +++ b/drift v3/crates/drift-engine/src/facts.rs @@ -13,6 +13,9 @@ pub enum FactKind { RouteDeclared, FileRoleDetected, TestDeclared, + AuthGuardCalled, + RouteReturnsResponse, + CallbackBoundaryDetected, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/lib.rs b/drift v3/crates/drift-engine/src/lib.rs index e1ff2fb7..cc1f1ca7 100644 --- a/drift v3/crates/drift-engine/src/lib.rs +++ b/drift v3/crates/drift-engine/src/lib.rs @@ -3,6 +3,12 @@ pub const DRIFT_ENGINE_VERSION: &str = "0.1.0"; mod diff; mod facts; mod rules; +mod security_capabilities; +mod security_control_flow; +mod security_facts; +mod security_patterns; +mod security_proof; +mod security_rules; use std::{ fs::File, @@ -23,6 +29,19 @@ pub use rules::{ Severity, classify_findings_against_baseline, detect_direct_data_access_imports, materialize_direct_data_access_findings, }; +pub use security_capabilities::{ + SecurityCapabilityStatus, SecurityScanCapability, security_capabilities, +}; +pub use security_facts::extract_security_facts; +pub use security_patterns::{AcceptedAuthHelper, AuthGuardBehavior}; +pub use security_proof::{ + AuthBoundaryProof, SecurityBoundaryProof, SecurityParserGap, SecurityProofResult, + SecurityProofStatus, build_auth_boundary_proof, +}; +pub use security_rules::{ + SecurityAuthContract, SecurityEnforcementMode, SecurityFinding, SecurityFindingResult, + evaluate_api_route_requires_auth_helper, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileFingerprint { diff --git a/drift v3/crates/drift-engine/src/main.rs b/drift v3/crates/drift-engine/src/main.rs index fbf793f8..c0a8b104 100644 --- a/drift v3/crates/drift-engine/src/main.rs +++ b/drift v3/crates/drift-engine/src/main.rs @@ -12,7 +12,9 @@ mod protocol; use candidate_command::infer_candidates; use check_command::check_repo; -use drift_engine::{Fact, FactKind, extract_typescript_facts, should_index_path}; +use drift_engine::{ + Fact, FactKind, extract_security_facts, extract_typescript_facts, should_index_path, +}; use protocol::*; use serde_json::json; use sha2::{Digest, Sha256}; @@ -406,10 +408,9 @@ fn scan_file_with_reuse( return Ok(Some((file, reused_facts, true))); } let source = fs::read_to_string(&absolute_path)?; - let facts = extract_typescript_facts(file_path, &source)? - .into_iter() - .map(engine_fact) - .collect(); + let mut facts = extract_typescript_facts(file_path, &source)?; + facts.extend(extract_security_facts(file_path, &source, &[])?); + let facts = facts.into_iter().map(engine_fact).collect(); Ok(Some((file, facts, false))) } @@ -618,6 +619,9 @@ fn fact_kind(kind: FactKind) -> &'static str { FactKind::RouteDeclared => "route_declared", FactKind::FileRoleDetected => "file_role_detected", FactKind::TestDeclared => "test_declared", + FactKind::AuthGuardCalled => "auth_guard_called", + FactKind::RouteReturnsResponse => "route_returns_response", + FactKind::CallbackBoundaryDetected => "callback_boundary_detected", } } diff --git a/drift v3/crates/drift-engine/src/protocol.rs b/drift v3/crates/drift-engine/src/protocol.rs index 04dee667..806b1a96 100644 --- a/drift v3/crates/drift-engine/src/protocol.rs +++ b/drift v3/crates/drift-engine/src/protocol.rs @@ -325,6 +325,8 @@ pub struct CheckConvention { pub struct CheckMatcher { pub forbidden_imports: Option>, pub allowed_delegate_imports: Option>, + pub required_calls: Option>, + pub applies_to_file_roles: Option>, } #[derive(Debug, Deserialize)] @@ -356,6 +358,8 @@ pub struct CheckResult { pub adapter_versions: BTreeMap, pub diff_mode: String, pub findings: Vec, + #[serde(default)] + pub security_boundary_proofs: Vec, pub diagnostics: Vec, pub stats: EngineStats, pub completeness: Vec, diff --git a/drift v3/crates/drift-engine/src/security_capabilities.rs b/drift v3/crates/drift-engine/src/security_capabilities.rs new file mode 100644 index 00000000..47349ff8 --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_capabilities.rs @@ -0,0 +1,42 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityCapabilityStatus { + Complete, + Partial, + Unsupported, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityScanCapability { + pub name: String, + pub capability: String, + pub status: SecurityCapabilityStatus, + pub can_block: bool, + pub block_requires_accepted_convention: bool, +} + +pub fn security_capabilities() -> Vec { + vec![ + SecurityScanCapability { + name: "security_facts".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: false, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "auth_boundary_facts".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "control_flow_guard_dominance".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_control_flow.rs b/drift v3/crates/drift-engine/src/security_control_flow.rs new file mode 100644 index 00000000..367bd941 --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_control_flow.rs @@ -0,0 +1,162 @@ +use crate::{Fact, FactKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DominatedSink { + pub sink_id: String, + pub sink_kind: String, + pub edge_id: String, +} + +pub fn guard_dominates_straight_line_sinks(facts: &[Fact]) -> Vec { + let Some(first_guard_line) = facts + .iter() + .filter(|fact| fact.kind == FactKind::AuthGuardCalled) + .map(|fact| fact.start_line) + .min() + else { + return Vec::new(); + }; + + protected_sinks(facts) + .into_iter() + .filter(|sink| first_guard_line < sink.start_line) + .map(|sink| DominatedSink { + sink_id: sink_id(sink), + sink_kind: sink_kind(sink).to_string(), + edge_id: format!("edge:auth-dominates:{}:{}", sink.file_path, sink.start_line), + }) + .collect() +} + +pub fn undominated_straight_line_reasons(facts: &[Fact]) -> Vec { + let first_guard_line = facts + .iter() + .filter(|fact| fact.kind == FactKind::AuthGuardCalled) + .map(|fact| fact.start_line) + .min(); + + protected_sinks(facts) + .into_iter() + .filter_map(|sink| match first_guard_line { + Some(line) if line > sink.start_line => Some("guard_after_sink".to_string()), + Some(_) => None, + None => Some("no_guard_call".to_string()), + }) + .collect() +} + +pub fn branch_bypass_reasons(source: &str, facts: &[Fact]) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + for (index, line) in lines.iter().enumerate() { + if !line.contains("if") || !line.contains('{') { + continue; + } + let if_line = index + 1; + let Some(else_line) = lines + .iter() + .enumerate() + .skip(index + 1) + .find(|(_, candidate)| candidate.contains("else") && candidate.contains('{')) + .map(|(else_index, _)| else_index + 1) + else { + continue; + }; + let else_end = closing_block_line(&lines, else_line).unwrap_or(else_line); + let then = if_line + 1..else_line; + let alternate = else_line + 1..else_end; + let then_has_guard = has_fact_in_range(facts, FactKind::AuthGuardCalled, then.clone()); + let then_has_sink = has_sink_in_range(facts, then); + let else_has_guard = has_fact_in_range(facts, FactKind::AuthGuardCalled, alternate.clone()); + let else_has_sink = has_sink_in_range(facts, alternate); + + if (then_has_guard && else_has_sink && !else_has_guard) + || (else_has_guard && then_has_sink && !then_has_guard) + { + return vec!["guard_only_in_one_branch".to_string()]; + } + } + Vec::new() +} + +pub fn callback_boundary_reasons(source: &str, facts: &[Fact]) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + let guard_in_callback = facts + .iter() + .filter(|fact| fact.kind == FactKind::AuthGuardCalled) + .any(|fact| line_is_inside_callback(&lines, fact.start_line)); + + if guard_in_callback { + vec!["callback_boundary".to_string()] + } else { + Vec::new() + } +} + +pub fn unsupported_dynamic_control_flow(source: &str) -> bool { + source.contains("guards[") + || source.contains("await guard(") + || source.contains("computed_handler") +} + +pub fn protected_sinks(facts: &[Fact]) -> Vec<&Fact> { + facts + .iter() + .filter(|fact| { + matches!( + fact.kind, + FactKind::DataOperationDetected | FactKind::RouteReturnsResponse + ) + }) + .collect() +} + +fn sink_id(fact: &Fact) -> String { + format!("sink:{}:{}:{}", fact.file_path, fact.start_line, fact.name) +} + +fn sink_kind(fact: &Fact) -> &'static str { + match fact.kind { + FactKind::DataOperationDetected => "data_operation", + FactKind::RouteReturnsResponse => "response", + _ => "unknown", + } +} + +fn closing_block_line(lines: &[&str], start_line: usize) -> Option { + let mut depth = 1_i32; + for (index, line) in lines.iter().enumerate().skip(start_line) { + depth += line.matches('{').count() as i32; + depth -= line.matches('}').count() as i32; + if depth == 0 { + return Some(index + 1); + } + } + None +} + +fn has_fact_in_range(facts: &[Fact], kind: FactKind, range: std::ops::Range) -> bool { + facts + .iter() + .any(|fact| fact.kind == kind && range.contains(&fact.start_line)) +} + +fn has_sink_in_range(facts: &[Fact], range: std::ops::Range) -> bool { + protected_sinks(facts) + .iter() + .any(|fact| range.contains(&fact.start_line)) +} + +fn line_is_inside_callback(lines: &[&str], line_number: usize) -> bool { + lines + .iter() + .take(line_number.saturating_sub(1)) + .rev() + .take_while(|line| !line.contains("export ")) + .any(|line| { + (line.contains("=>") && line.contains('{')) + || line.contains(".then(") + || line.contains(".catch(") + || line.contains(".forEach(") + || line.contains(".map(") + }) +} diff --git a/drift v3/crates/drift-engine/src/security_facts.rs b/drift v3/crates/drift-engine/src/security_facts.rs new file mode 100644 index 00000000..c758f1fc --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_facts.rs @@ -0,0 +1,126 @@ +use serde_json::json; + +use crate::security_patterns::{AcceptedAuthHelper, accepted_auth_helper_for_call}; +use crate::{Fact, FactExtractError, FactKind, extract_typescript_facts}; + +pub fn extract_security_facts( + file_path: impl AsRef, + source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result, FactExtractError> { + let facts = extract_typescript_facts(file_path, source)?; + let source_lines: Vec<&str> = source.lines().collect(); + let mut security_facts = Vec::new(); + for fact in facts + .iter() + .filter(|fact| fact.kind == FactKind::SymbolCalled) + { + let route = route_for_line(&facts, fact.start_line).unwrap_or("unknown"); + if let Some(helper) = accepted_auth_helper_for_call(fact, &facts, accepted_auth_helpers) { + let route_id = format!("route:{}:{route}", fact.file_path); + security_facts.push(Fact { + kind: FactKind::AuthGuardCalled, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: Some( + json!({ + "guard_id": helper.guard_id, + "route_id": route_id, + "handler_symbol": route, + "behavior": helper.behavior.as_str(), + }) + .to_string(), + ), + imported_name: Some(helper.symbol.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); + if line_is_inside_callback(&source_lines, fact.start_line) { + security_facts.push(Fact { + kind: FactKind::CallbackBoundaryDetected, + file_path: fact.file_path.clone(), + name: "callback".to_string(), + value: Some( + json!({ + "route_id": route_id, + "boundary_kind": "callback", + "contains_guard": true, + "contains_sink": protected_sink_after_line(&facts, fact.start_line), + }) + .to_string(), + ), + imported_name: None, + start_line: fact.start_line, + end_line: fact.end_line, + }); + } + } + if is_json_response_call(fact) { + let route_id = format!("route:{}:{route}", fact.file_path); + security_facts.push(Fact { + kind: FactKind::RouteReturnsResponse, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: Some( + json!({ + "route_id": route_id, + "handler_symbol": route, + "response_id": format!("response:{}:{}", fact.file_path, fact.start_line), + "response_kind": "json", + }) + .to_string(), + ), + imported_name: None, + start_line: fact.start_line, + end_line: fact.end_line, + }); + } + } + + Ok(security_facts) +} + +fn protected_sink_after_line(facts: &[Fact], line: usize) -> bool { + facts.iter().any(|fact| { + matches!( + fact.kind, + FactKind::DataOperationDetected | FactKind::RouteReturnsResponse + ) && fact.start_line > line + }) +} + +fn line_is_inside_callback(lines: &[&str], line_number: usize) -> bool { + lines + .iter() + .take(line_number.saturating_sub(1)) + .rev() + .take_while(|line| !line.contains("export ")) + .any(|line| { + (line.contains("=>") && line.contains('{')) + || line.contains(".then(") + || line.contains(".catch(") + || line.contains(".forEach(") + || line.contains(".map(") + }) +} + +fn route_for_line(facts: &[Fact], line: usize) -> Option<&str> { + facts + .iter() + .filter(|fact| fact.kind == FactKind::RouteDeclared) + .find(|fact| fact.start_line <= line && line <= fact.end_line) + .or_else(|| { + facts + .iter() + .find(|fact| fact.kind == FactKind::RouteDeclared) + }) + .map(|fact| fact.name.as_str()) +} + +fn is_json_response_call(fact: &Fact) -> bool { + fact.name == "json" + && matches!( + fact.value.as_deref(), + Some("Response") | Some("NextResponse") | Some("res") + ) +} diff --git a/drift v3/crates/drift-engine/src/security_patterns.rs b/drift v3/crates/drift-engine/src/security_patterns.rs new file mode 100644 index 00000000..cdf56901 --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_patterns.rs @@ -0,0 +1,44 @@ +use crate::{Fact, FactKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedAuthHelper { + pub guard_id: String, + pub symbol: String, + pub behavior: AuthGuardBehavior, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthGuardBehavior { + Throws, + ReturnsUser, + ReturnsSession, + Boolean, + Unknown, +} + +impl AuthGuardBehavior { + pub fn as_str(self) -> &'static str { + match self { + AuthGuardBehavior::Throws => "throws", + AuthGuardBehavior::ReturnsUser => "returns_user", + AuthGuardBehavior::ReturnsSession => "returns_session", + AuthGuardBehavior::Boolean => "boolean", + AuthGuardBehavior::Unknown => "unknown", + } + } +} + +pub fn accepted_auth_helper_for_call<'a>( + call: &Fact, + facts: &[Fact], + accepted_auth_helpers: &'a [AcceptedAuthHelper], +) -> Option<&'a AcceptedAuthHelper> { + accepted_auth_helpers.iter().find(|helper| { + call.name == helper.symbol + || facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == call.name + && fact.imported_name.as_deref() == Some(helper.symbol.as_str()) + }) + }) +} diff --git a/drift v3/crates/drift-engine/src/security_proof.rs b/drift v3/crates/drift-engine/src/security_proof.rs new file mode 100644 index 00000000..9dac3cbe --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_proof.rs @@ -0,0 +1,106 @@ +use crate::{ + AcceptedAuthHelper, Fact, FactExtractError, extract_security_facts, extract_typescript_facts, + security_control_flow::{ + DominatedSink, branch_bypass_reasons, callback_boundary_reasons, + guard_dominates_straight_line_sinks, protected_sinks, undominated_straight_line_reasons, + unsupported_dynamic_control_flow, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityBoundaryProof { + pub auth: AuthBoundaryProof, + pub parser_gaps: Vec, + pub result: SecurityProofResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthBoundaryProof { + pub required: bool, + pub proven: bool, + pub dominated_sinks: Vec, + pub undominated_sinks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityProofResult { + pub proof_status: SecurityProofStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityParserGap { + pub parser_gap_id: String, + pub code: String, + pub file_path: String, + pub reason: String, + pub blocks_enforcement: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityProofStatus { + Proven, + MissingProof, + ParserGap, +} + +pub fn build_auth_boundary_proof( + file_path: impl AsRef, + source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result { + let base_facts = extract_typescript_facts(&file_path, source)?; + let security_facts = extract_security_facts(file_path, source, accepted_auth_helpers)?; + let mut facts: Vec = base_facts.into_iter().chain(security_facts).collect(); + facts.sort_by_key(|fact| fact.start_line); + + let dominated_sinks = guard_dominates_straight_line_sinks(&facts); + let mut undominated_sinks = undominated_straight_line_reasons(&facts); + undominated_sinks.extend(branch_bypass_reasons(source, &facts)); + undominated_sinks.extend(callback_boundary_reasons(source, &facts)); + let dynamic_control_flow = unsupported_dynamic_control_flow(source); + if dynamic_control_flow { + undominated_sinks.push("unsupported_dynamic_control_flow".to_string()); + } + let parser_gaps = if dynamic_control_flow { + vec![SecurityParserGap { + parser_gap_id: format!( + "parser_gap:{}:unsupported_dynamic_control_flow", + facts + .first() + .map(|fact| fact.file_path.as_str()) + .unwrap_or("unknown") + ), + code: "unsupported_dynamic_control_flow".to_string(), + file_path: facts + .first() + .map(|fact| fact.file_path.clone()) + .unwrap_or_else(|| "unknown".to_string()), + reason: "Unsupported dynamic control flow prevents auth dominance proof".to_string(), + blocks_enforcement: true, + }] + } else { + Vec::new() + }; + let sink_count = protected_sinks(&facts).len(); + let proven = + sink_count > 0 && dominated_sinks.len() == sink_count && undominated_sinks.is_empty(); + + Ok(SecurityBoundaryProof { + auth: AuthBoundaryProof { + required: true, + proven, + dominated_sinks, + undominated_sinks, + }, + parser_gaps, + result: SecurityProofResult { + proof_status: if dynamic_control_flow { + SecurityProofStatus::ParserGap + } else if proven { + SecurityProofStatus::Proven + } else { + SecurityProofStatus::MissingProof + }, + }, + }) +} diff --git a/drift v3/crates/drift-engine/src/security_rules.rs b/drift v3/crates/drift-engine/src/security_rules.rs new file mode 100644 index 00000000..2b71f9b3 --- /dev/null +++ b/drift v3/crates/drift-engine/src/security_rules.rs @@ -0,0 +1,71 @@ +use crate::{AcceptedAuthHelper, FactExtractError, SecurityProofStatus, build_auth_boundary_proof}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityAuthContract { + pub contract_id: String, + pub enforcement_mode: SecurityEnforcementMode, + pub accepted_auth_helpers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityEnforcementMode { + Off, + Brief, + Warn, + Block, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityFindingResult { + Brief, + Warn, + Block, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityFinding { + pub contract_id: String, + pub title: String, + pub expected_layer: String, + pub actual_layer: String, + pub enforcement_result: SecurityFindingResult, + pub drift_category: String, + pub confidence_label: String, +} + +pub fn evaluate_api_route_requires_auth_helper( + file_path: impl AsRef, + source: &str, + contract: &SecurityAuthContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.accepted_auth_helpers.is_empty() + { + return Ok(Vec::new()); + } + + let proof = build_auth_boundary_proof(file_path, source, &contract.accepted_auth_helpers)?; + if proof.result.proof_status == SecurityProofStatus::Proven { + return Ok(Vec::new()); + } + + Ok(vec![SecurityFinding { + contract_id: contract.contract_id.clone(), + title: "API route missing required auth proof".to_string(), + expected_layer: "auth_guard".to_string(), + actual_layer: proof + .auth + .undominated_sinks + .first() + .cloned() + .unwrap_or_else(|| "missing_auth_guard".to_string()), + enforcement_result: match contract.enforcement_mode { + SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, + SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, + SecurityEnforcementMode::Block => SecurityFindingResult::Block, + SecurityEnforcementMode::Off => return Ok(Vec::new()), + }, + drift_category: "missing_proof".to_string(), + confidence_label: "certain".to_string(), + }]) +} diff --git a/drift v3/crates/drift-engine/tests/security_capabilities.rs b/drift v3/crates/drift-engine/tests/security_capabilities.rs new file mode 100644 index 00000000..7dd9ef29 --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_capabilities.rs @@ -0,0 +1,35 @@ +use drift_engine::{SecurityCapabilityStatus, security_capabilities}; + +#[test] +fn reports_phase_one_security_capabilities() { + let capabilities = security_capabilities(); + let names: Vec<&str> = capabilities + .iter() + .map(|capability| capability.name.as_str()) + .collect(); + + assert!( + names.contains(&"security_facts"), + "missing security_facts: {capabilities:#?}" + ); + assert!( + names.contains(&"auth_boundary_facts"), + "missing auth_boundary_facts: {capabilities:#?}" + ); + assert!( + names.contains(&"control_flow_guard_dominance"), + "missing control_flow_guard_dominance: {capabilities:#?}" + ); + assert!( + capabilities + .iter() + .all(|capability| capability.block_requires_accepted_convention), + "security capabilities must require accepted conventions: {capabilities:#?}" + ); + assert!( + capabilities + .iter() + .any(|capability| capability.status == SecurityCapabilityStatus::Partial), + "Phase 1 guard dominance should report partial, not overclaim complete: {capabilities:#?}" + ); +} diff --git a/drift v3/crates/drift-engine/tests/security_control_flow.rs b/drift v3/crates/drift-engine/tests/security_control_flow.rs new file mode 100644 index 00000000..fe1ded17 --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_control_flow.rs @@ -0,0 +1,224 @@ +use drift_engine::{ + AcceptedAuthHelper, AuthGuardBehavior, FactKind, SecurityProofStatus, + build_auth_boundary_proof, extract_security_facts, +}; + +#[test] +fn auth_guard_before_all_sinks_passes() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + const user = await requireUser(); + const projects = await db.project.findMany({ where: { ownerId: user.id } }); + return Response.json({ projects }); +} +"#; + + let proof = build_auth_boundary_proof( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("auth proof"); + + assert!(proof.auth.required); + assert!(proof.auth.proven, "proof should prove auth: {proof:#?}"); + assert_eq!(proof.auth.undominated_sinks, Vec::::new()); + assert!( + proof + .auth + .dominated_sinks + .iter() + .any(|sink| sink.sink_kind == "data_operation"), + "missing dominated data sink: {proof:#?}" + ); + assert!( + proof + .auth + .dominated_sinks + .iter() + .any(|sink| sink.sink_kind == "response"), + "missing dominated response sink: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::Proven); +} + +#[test] +fn auth_after_data_operation_blocks() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + const user = await requireUser(); + return Response.json({ projects, user }); +} +"#; + + let proof = build_auth_boundary_proof( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("auth proof"); + + assert!(!proof.auth.proven, "auth should not be proven: {proof:#?}"); + assert!( + proof + .auth + .undominated_sinks + .contains(&"guard_after_sink".to_string()), + "missing guard_after_sink reason: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::MissingProof); +} + +#[test] +fn auth_in_one_branch_does_not_dominate_other_branch() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + if (request.headers.get("x-auth") === "yes") { + await requireUser(); + } else { + const projects = await db.project.findMany(); + return Response.json({ projects }); + } + return Response.json({ ok: true }); +} +"#; + + let proof = build_auth_boundary_proof( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("auth proof"); + + assert!(!proof.auth.proven, "branch bypass should fail: {proof:#?}"); + assert!( + proof + .auth + .undominated_sinks + .contains(&"guard_only_in_one_branch".to_string()), + "missing guard_only_in_one_branch reason: {proof:#?}" + ); +} + +#[test] +fn callback_auth_does_not_dominate_outer_sink() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + ["auth"].forEach(async () => { + await requireUser(); + }); + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let proof = build_auth_boundary_proof( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("auth proof"); + + assert!(!proof.auth.proven, "callback guard should fail: {proof:#?}"); + assert!( + proof + .auth + .undominated_sinks + .contains(&"callback_boundary".to_string()), + "missing callback_boundary reason: {proof:#?}" + ); + + let facts = extract_security_facts( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("security facts"); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::CallbackBoundaryDetected), + "missing callback boundary fact: {facts:#?}" + ); +} + +#[test] +fn unsupported_dynamic_control_flow_emits_parser_gap_and_blocks() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +const guards = { requireUser }; + +export async function GET(request: Request) { + const guard = guards[request.headers.get("x-guard") as keyof typeof guards]; + await guard(); + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let proof = build_auth_boundary_proof( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("auth proof"); + + assert!( + !proof.auth.proven, + "dynamic control flow should fail: {proof:#?}" + ); + assert!( + proof + .auth + .undominated_sinks + .contains(&"unsupported_dynamic_control_flow".to_string()), + "missing unsupported_dynamic_control_flow reason: {proof:#?}" + ); + assert!( + proof + .parser_gaps + .iter() + .any(|gap| gap.code == "unsupported_dynamic_control_flow" && gap.blocks_enforcement), + "missing parser gap: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); +} diff --git a/drift v3/crates/drift-engine/tests/security_facts.rs b/drift v3/crates/drift-engine/tests/security_facts.rs new file mode 100644 index 00000000..8f8da370 --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_facts.rs @@ -0,0 +1,103 @@ +use drift_engine::{AcceptedAuthHelper, AuthGuardBehavior, FactKind, extract_security_facts}; + +#[test] +fn extracts_auth_guard_called_fact() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + const user = await requireUser(); + const projects = await db.project.findMany({ where: { ownerId: user.id } }); + return Response.json({ projects }); +} +"#; + + let facts = extract_security_facts( + "app/api/projects/route.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::AuthGuardCalled + && fact.name == "requireUser" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"guard_id\":\"auth_require_user\"") + && value.contains("\"route_id\":\"route:app/api/projects/route.ts:GET\"") + && value.contains("\"behavior\":\"returns_user\"") + }) + && fact.start_line == 6 + && fact.end_line == 6), + "missing accepted auth call fact: {facts:#?}" + ); +} + +#[test] +fn extracts_route_returns_response_fact() { + let next_response_source = r#" +import { NextResponse } from "next/server"; + +export async function GET() { + return Response.json({ ok: true }); +} + +export async function POST() { + return NextResponse.json({ ok: true }, { status: 201 }); +} +"#; + let pages_response_source = r#" +export default async function handler(req, res) { + return res.json({ ok: true }); +} +"#; + + let next_facts = extract_security_facts("app/api/projects/route.ts", next_response_source, &[]) + .expect("next route security facts"); + let pages_facts = extract_security_facts("pages/api/projects.ts", pages_response_source, &[]) + .expect("pages route security facts"); + + assert!( + next_facts + .iter() + .any(|fact| fact.kind == FactKind::RouteReturnsResponse + && fact.name == "json" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"response_kind\":\"json\"") + && value.contains("\"route_id\":\"route:app/api/projects/route.ts:GET\"") + }) + && fact.start_line == 5), + "missing Response.json sink: {next_facts:#?}" + ); + assert!( + next_facts + .iter() + .any(|fact| fact.kind == FactKind::RouteReturnsResponse + && fact.name == "json" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"response_kind\":\"json\"") + && value.contains("\"route_id\":\"route:app/api/projects/route.ts:POST\"") + }) + && fact.start_line == 9), + "missing NextResponse.json sink: {next_facts:#?}" + ); + assert!( + pages_facts + .iter() + .any(|fact| fact.kind == FactKind::RouteReturnsResponse + && fact.name == "json" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"response_kind\":\"json\"") + && value.contains("\"route_id\":\"route:pages/api/projects.ts:default\"") + }) + && fact.start_line == 3), + "missing res.json sink: {pages_facts:#?}" + ); +} diff --git a/drift v3/crates/drift-engine/tests/security_rules.rs b/drift v3/crates/drift-engine/tests/security_rules.rs new file mode 100644 index 00000000..f61f95ef --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_rules.rs @@ -0,0 +1,68 @@ +use drift_engine::{ + AcceptedAuthHelper, AuthGuardBehavior, SecurityAuthContract, SecurityEnforcementMode, + SecurityFindingResult, evaluate_api_route_requires_auth_helper, +}; + +#[test] +fn accepted_auth_helper_contract_blocks_missing_auth() { + let source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_api_route_requires_auth_helper( + "app/api/projects/route.ts", + source, + &SecurityAuthContract { + contract_id: "security_api_auth_require_user".to_string(), + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].contract_id, "security_api_auth_require_user"); + assert_eq!(findings[0].title, "API route missing required auth proof"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); + assert_eq!(findings[0].drift_category, "missing_proof"); + assert_eq!(findings[0].confidence_label, "certain"); +} + +#[test] +fn auth_like_helper_without_accepted_contract_does_not_block() { + let source = r#" +import { auth } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + await auth(); + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_api_route_requires_auth_helper( + "app/api/projects/route.ts", + source, + &SecurityAuthContract { + contract_id: "security_api_auth_require_user".to_string(), + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: Vec::new(), + }, + ) + .expect("security findings"); + + assert!( + findings.is_empty(), + "auth-looking names without accepted contract must not block: {findings:#?}" + ); +} diff --git a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md index 3b309cf7..99ef2c87 100644 --- a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md +++ b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md @@ -1744,6 +1744,479 @@ Required RED tests: - Middleware file exists but matcher excludes route blocks. - Dynamic matcher emits parser gap. +### Phase 2 Executable Task Ledger + +Execute these tasks in order. For every RED task, run the focused command and +record the expected failure before editing implementation files. + +- [ ] **Task 2.1: RED middleware fact extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_static_middleware_matcher_fact` + + Add a fixture source containing a Next middleware file with a static + `config.matcher` and an accepted auth helper call. + + Run: + + ```bash + cargo test -p drift-engine extracts_static_middleware_matcher_fact -- --nocapture + ``` + + Expected RED: fail because Rust does not emit `middleware_declared` or + `middleware_matcher_declared`. + +- [ ] **Task 2.2: GREEN middleware fact extraction** + + Implementation files: + + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/facts.rs` + - `crates/drift-engine/src/main.rs` + + Implement only static middleware declaration and matcher extraction. + + Run: + + ```bash + cargo test -p drift-engine extracts_static_middleware_matcher_fact -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.3: RED middleware coverage proof for matching route** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `static_middleware_matcher_protects_route` + + Run: + + ```bash + cargo test -p drift-engine static_middleware_matcher_protects_route -- --nocapture + ``` + + Expected RED: fail because no file-local or repo-local proof creates + `middleware_protects_route`. + +- [ ] **Task 2.4: GREEN middleware coverage proof** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + + Implement deterministic static matcher coverage only. Do not infer coverage + from middleware existence. + + Run: + + ```bash + cargo test -p drift-engine static_middleware_matcher_protects_route -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.5: RED path mismatch blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `middleware_path_mismatch_blocks_covered_route_contract` + + Run: + + ```bash + cargo test -p drift-engine middleware_path_mismatch_blocks_covered_route_contract -- --nocapture + ``` + + Expected RED: fail because `middleware_must_cover_routes` is not evaluated. + +- [ ] **Task 2.6: GREEN path mismatch rule** + + Implementation files: + + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Implement deterministic blocking only for accepted + `middleware_must_cover_routes` contracts. + + Run: + + ```bash + cargo test -p drift-engine middleware_path_mismatch_blocks_covered_route_contract -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.7: RED method mismatch blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `middleware_method_mismatch_blocks_when_contract_requires_method` + + Run: + + ```bash + cargo test -p drift-engine middleware_method_mismatch_blocks_when_contract_requires_method -- --nocapture + ``` + + Expected RED: fail because middleware method constraints are not normalized + or enforced. + +- [ ] **Task 2.8: GREEN method mismatch rule** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Normalize static method constraints and block only when the accepted contract + requires method-aware coverage. + + Run: + + ```bash + cargo test -p drift-engine middleware_method_mismatch_blocks_when_contract_requires_method -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.9: RED excluded route blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `middleware_excludes_matched_route_blocks` + + Run: + + ```bash + cargo test -p drift-engine middleware_excludes_matched_route_blocks -- --nocapture + ``` + + Expected RED: fail because excluded matcher branches are not represented as + missing proof. + +- [ ] **Task 2.10: GREEN excluded route rule** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Represent excluded route coverage as `missing_proof`; do not silently pass. + + Run: + + ```bash + cargo test -p drift-engine middleware_excludes_matched_route_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.11: RED dynamic matcher parser gap** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `dynamic_middleware_matcher_emits_parser_gap_and_blocks` + + Run: + + ```bash + cargo test -p drift-engine dynamic_middleware_matcher_emits_parser_gap_and_blocks -- --nocapture + ``` + + Expected RED: fail because unsupported dynamic middleware matcher evidence + is not emitted as a parser gap. + +- [ ] **Task 2.12: GREEN dynamic matcher parser gap** + + Implementation files: + + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_capabilities.rs` + + Emit parser gap `unsupported_dynamic_middleware_matcher` and block only + under an accepted blocking contract. + + Run: + + ```bash + cargo test -p drift-engine dynamic_middleware_matcher_emits_parser_gap_and_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.13: RED auth contract accepts middleware proof only when proven** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `auth_contract_accepts_static_middleware_proof_but_not_middleware_existence` + + Run: + + ```bash + cargo test -p drift-engine auth_contract_accepts_static_middleware_proof_but_not_middleware_existence -- --nocapture + ``` + + Expected RED: fail because `api_route_requires_auth_helper` does not yet + consume `middleware_protects_route` proof. + +- [ ] **Task 2.14: GREEN auth plus middleware proof** + + Implementation files: + + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Allow middleware proof to satisfy auth only when coverage is deterministic + and accepted by contract input. + + Run: + + ```bash + cargo test -p drift-engine auth_contract_accepts_static_middleware_proof_but_not_middleware_existence -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.15: RED candidate-only middleware cannot block** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `candidate_only_middleware_evidence_does_not_block` + + Run: + + ```bash + cargo test -p drift-engine candidate_only_middleware_evidence_does_not_block -- --nocapture + ``` + + Expected RED: fail because candidate-only middleware evidence is not + separated from accepted deterministic contracts. + +- [ ] **Task 2.16: GREEN candidate-only middleware boundary** + + Implementation files: + + - `crates/drift-engine/src/security_rules.rs` + - `packages/cli/src/domain/convention-candidates.ts` + + Ensure inferred middleware evidence can propose candidates but cannot produce + blocking findings without an accepted deterministic contract. + + Run: + + ```bash + cargo test -p drift-engine candidate_only_middleware_evidence_does_not_block -- --nocapture + pnpm --filter @drift/cli test -- convention-candidates + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.17: RED TypeScript schemas and engine contract** + + Test files: + + - `packages/core/test/security.test.ts` + - `packages/engine-contract/test/security-contract.test.ts` + + Test names: + + - `validates middleware_must_cover_routes contracts and parser gaps` + - `validates middleware SecurityBoundaryProof fields from engine output` + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected RED: fail because middleware contract kinds, proof fields, fact + kinds, and parser-gap codes are not in TypeScript schemas. + +- [ ] **Task 2.18: GREEN TypeScript schemas and engine contract** + + Implementation files: + + - `packages/core/src/security.ts` + - `packages/core/src/domain.ts` + - `packages/core/src/schemas.ts` + - `packages/engine-contract/src/index.ts` + - `crates/drift-engine/src/protocol.rs` + + Add only normalized middleware contract/proof/event fields. Do not add rule + evaluation logic in TypeScript. + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.19: RED query, scan status, and repo map output** + + Test files: + + - `packages/query/test/security-boundary-proof.test.ts` + - `packages/cli/test/security-check.test.ts` + - `packages/cli/test/cli.test.ts` + + Test names: + + - `summarizes middleware coverage proof without snippets` + - `returns middleware coverage proof in drift check JSON output` + - `scan status reports middleware_coverage capability` + - `repo map reports route middleware coverage summary` + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports middleware_coverage" + pnpm --filter @drift/cli test -- "repo map reports route middleware coverage" + ``` + + Expected RED: fail because query/read models and CLI output do not expose + middleware coverage truth. + +- [ ] **Task 2.20: GREEN query, scan status, and repo map output** + + Implementation files: + + - `packages/query/src/security-boundary-proof.ts` + - `packages/cli/src/check/security-check.ts` + - `packages/cli/src/check/run-check.ts` + - `packages/cli/src/domain/scan-status.ts` + - `packages/cli/src/commands/scan.ts` + - `packages/cli/src/commands/repo-map.ts` + + Wire read models and output formatting only. Do not duplicate deterministic + middleware coverage logic in TypeScript. + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports middleware_coverage" + pnpm --filter @drift/cli test -- "repo map reports route middleware coverage" + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.21: RED MCP middleware coverage output** + + Test file: `packages/mcp/test/mcp.test.ts` + + Test name: `exposes middleware coverage proof summaries without snippets` + + Run: + + ```bash + pnpm --filter @drift/mcp test -- middleware + ``` + + Expected RED: fail because MCP read-only context does not include middleware + proof summaries. + +- [ ] **Task 2.22: GREEN MCP middleware coverage output** + + Implementation files: + + - `packages/mcp/src/security-context.ts` + - `packages/mcp/src/index.ts` + - `packages/query/src/security-boundary-proof.ts` + + Expose accepted contracts, proof status, missing proof, and parser gaps + without snippets or duplicated rule logic. + + Run: + + ```bash + pnpm --filter @drift/mcp test -- middleware + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.23: RED e2e middleware fixture matrix** + + Fixture names: + + - `test/fixtures/security-middleware-covered` + - `test/fixtures/security-middleware-mismatch` + - `test/fixtures/security-middleware-method-mismatch` + - `test/fixtures/security-middleware-dynamic-parser-gap` + + Test file: `test/e2e/security-middleware.test.ts` + + Test name: `security middleware fixture matrix proves coverage and gaps` + + Run: + + ```bash + pnpm test:e2e -- security-middleware + ``` + + Expected RED: fail because fixtures and end-to-end middleware expectations + do not exist. + +- [ ] **Task 2.24: GREEN e2e middleware fixture matrix** + + Implementation files: + + - `test/e2e/security-middleware.test.ts` + - `test/fixtures/security-middleware-covered/package.json` + - `test/fixtures/security-middleware-covered/middleware.ts` + - `test/fixtures/security-middleware-covered/app/api/projects/route.ts` + - `test/fixtures/security-middleware-mismatch/package.json` + - `test/fixtures/security-middleware-mismatch/middleware.ts` + - `test/fixtures/security-middleware-mismatch/app/api/projects/route.ts` + - `test/fixtures/security-middleware-method-mismatch/package.json` + - `test/fixtures/security-middleware-method-mismatch/middleware.ts` + - `test/fixtures/security-middleware-method-mismatch/app/api/projects/route.ts` + - `test/fixtures/security-middleware-dynamic-parser-gap/package.json` + - `test/fixtures/security-middleware-dynamic-parser-gap/middleware.ts` + - `test/fixtures/security-middleware-dynamic-parser-gap/app/api/projects/route.ts` + + Run: + + ```bash + pnpm test:e2e -- security-middleware + ``` + + Expected GREEN: pass. + +- [ ] **Task 2.25: Phase 2 full gate** + + Run: + + ```bash + cargo test -p drift-engine security_ + cargo test -p drift-engine + pnpm --filter @drift/core test + pnpm --filter @drift/engine-contract test + pnpm --filter @drift/query test + pnpm --filter @drift/cli test + pnpm --filter @drift/mcp test + pnpm test:e2e + pnpm verify:ci + ``` + + Expected: all pass. + Done when: - Middleware coverage is deterministic only for supported static matcher formats. diff --git a/drift v3/packages/cli/src/check/run-check.ts b/drift v3/packages/cli/src/check/run-check.ts index fc2ecbfc..50eae797 100644 --- a/drift v3/packages/cli/src/check/run-check.ts +++ b/drift v3/packages/cli/src/check/run-check.ts @@ -1,4 +1,5 @@ import { + SecurityBoundaryProofSchema, authorizeContextExport, type CanonicalHelperReuseAgentContract, type CheckRun, @@ -7,7 +8,8 @@ import { type Finding, type MachineContractVersions, type RequiredCheckExecution, - type RepoContract + type RepoContract, + type SecurityBoundaryProof } from "@drift/core"; import { buildEntrypointFlowProof,buildReadiness, scoreHelperSimilarity } from "@drift/query"; import type { SqliteDriftStorage } from "@drift/storage"; @@ -147,6 +149,7 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): review_items: [], waived_findings: [], diagnostics: checkData.diagnostics, + security_boundary_proofs: [], next_commands: [ "drift doctor --json", `drift scan status --repo ${repoId} --json` @@ -160,6 +163,7 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): } const findings: Finding[] = []; const waivedFindings: WaivedFinding[] = []; + const securityBoundaryProofs: SecurityBoundaryProof[] = []; let waivedFindingsCount = 0; const engineOwned = await runEngineOwnedDirectDataAccessCheck({ @@ -307,6 +311,26 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): } } + const engineOwnedAuth = await runEngineOwnedAuthCheck({ + repoId, + repoRoot: repo.root_path, + contract, + now, + scope: scope as "changed-hunks" | "changed-files" | "full", + parsedDiff, + baseline, + existingFindings, + checkData, + snapshotsByPath, + checkId, + checkScanId + }); + findings.push(...engineOwnedAuth.findings); + securityBoundaryProofs.push(...engineOwnedAuth.securityBoundaryProofs); + for (const finding of engineOwnedAuth.findings) { + storage.upsertFinding(finding); + } + const helperReuseFindings = runCanonicalHelperReuseCheck({ repoId, contract, @@ -493,6 +517,7 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): }, review_items: findings.map(reviewFinding), waived_findings: waivedFindings, + security_boundary_proofs: securityBoundaryProofs, next_commands: checkNextCommands(repoId, { findingCount: findings.length, openNewCount, @@ -1872,6 +1897,108 @@ async function runEngineOwnedDirectDataAccessCheck(input: { return { findings, waivedFindings, waivedFindingsCount }; } +async function runEngineOwnedAuthCheck(input: { + repoId: string; + repoRoot: string; + contract: RepoContract; + now: string; + scope: "changed-hunks" | "changed-files" | "full"; + parsedDiff: ReturnType; + baseline: ReturnType; + existingFindings: Map; + checkData: ScanData; + snapshotsByPath: Map; + checkId: string; + checkScanId: string; +}): Promise<{ findings: Finding[]; securityBoundaryProofs: SecurityBoundaryProof[] }> { + const findings: Finding[] = []; + const securityBoundaryProofs: SecurityBoundaryProof[] = []; + + for (const convention of input.contract.conventions) { + if ( + convention.kind !== "api_route_requires_auth_helper" || + convention.enforcement_mode === "off" || + convention.enforcement_capability !== "deterministic_check" || + !isActiveConvention(convention, input.now) + ) { + continue; + } + + const files = filesForConvention(input.parsedDiff, convention, input.scope) + .filter((filePath) => isApiRoutePath(filePath) && !isExceptedPath(filePath, convention, input.now)); + const fileSet = new Set(files); + if (fileSet.size === 0) { + continue; + } + + const result = await runEngineCheck({ + repoId: input.repoId, + repoRoot: input.repoRoot, + scanId: input.checkData.snapshots[0]?.scan_id ?? input.checkScanId, + facts: input.checkData.facts.filter((fact) => fileSet.has(fact.file_path)), + snapshots: input.checkData.snapshots.filter((snapshot) => fileSet.has(snapshot.file_path)), + conventions: [convention], + baseline: input.baseline, + diff: input.parsedDiff, + scope: input.scope + }); + securityBoundaryProofs.push( + ...result.security_boundary_proofs.map((proof) => SecurityBoundaryProofSchema.parse(proof)) + ); + + for (const engineFinding of result.findings) { + const evidence = engineFinding.evidence[0]; + if (!evidence) { + continue; + } + const evidenceStartLine = evidence.start_line ?? 1; + const evidenceEndLine = evidence.end_line ?? evidenceStartLine; + const snapshot = input.snapshotsByPath.get(evidence.file_path); + const evidenceFacts = input.checkData.facts + .filter((fact) => + fact.file_path === evidence.file_path && + fact.start_line >= evidenceStartLine && + fact.end_line <= evidenceEndLine + ) + .map((fact) => fact.id); + const preserved = preservedGovernanceStatus(input.existingFindings.get(engineFinding.fingerprint)); + findings.push({ + id: engineFinding.id, + repo_id: input.repoId, + convention_id: engineFinding.convention_id, + check_id: input.checkId, + repo_contract_id: input.contract.id, + fingerprint: engineFinding.fingerprint, + title: engineFinding.title, + message: engineFinding.message, + severity: engineFinding.severity, + enforcement_result: engineFinding.enforcement_result, + status: engineFinding.status_hint === "pre_existing" ? "pre_existing" : preserved ?? "new", + diff_status: engineFinding.diff_status, + evidence_refs: [{ + id: evidence.evidence_id ?? `evidence_${engineFinding.fingerprint.slice(0, 16)}`, + kind: "violation", + file_path: evidence.file_path, + start_line: evidenceStartLine, + end_line: evidenceEndLine, + fact_ids: evidenceFacts, + scan_id: input.checkData.snapshots[0]?.scan_id ?? input.checkScanId, + file_hash: snapshot?.content_hash ?? "", + redaction_state: "none" + }], + expected_layer: "auth_guard", + actual_layer: "missing_auth_guard", + graph_path: [evidence.file_path], + suggested_fix: "Call an accepted auth helper before route data operations or response sinks.", + related_node_ids: engineFinding.related_node_ids, + created_at: input.now + }); + } + } + + return { findings, securityBoundaryProofs }; +} + function graphForEngineCheck( checkData: ScanData, fileSet: Set, diff --git a/drift v3/packages/cli/src/check/security-check.ts b/drift v3/packages/cli/src/check/security-check.ts new file mode 100644 index 00000000..f70aeb7d --- /dev/null +++ b/drift v3/packages/cli/src/check/security-check.ts @@ -0,0 +1,47 @@ +import type { SecurityBoundaryProof } from "@drift/core"; + +export interface SecurityCheckFindingInput { + finding_id: string; + title: string; + file_path: string; + enforcement_result: "pass" | "brief" | "warn" | "block"; +} + +export interface BuildSecurityCheckJsonInput { + repo_id: string; + scope: "changed-hunks" | "changed-files" | "full"; + changed_files: string[]; + proofs: SecurityBoundaryProof[]; + findings: SecurityCheckFindingInput[]; +} + +export interface SecurityCheckJson { + repo_id: string; + scope: "changed-hunks" | "changed-files" | "full"; + security_boundary_proofs: SecurityBoundaryProof[]; + security_findings: SecurityCheckFindingInput[]; + summary: { + security_findings_count: number; + security_blocking_count: number; + }; +} + +export function buildSecurityCheckJson(input: BuildSecurityCheckJsonInput): SecurityCheckJson { + const changedFiles = new Set(input.changed_files); + const scopedFindings = input.findings.filter((finding) => + input.scope === "full" || changedFiles.has(finding.file_path) + ); + + return { + repo_id: input.repo_id, + scope: input.scope, + security_boundary_proofs: input.proofs, + security_findings: scopedFindings, + summary: { + security_findings_count: scopedFindings.length, + security_blocking_count: scopedFindings.filter((finding) => + finding.enforcement_result === "block" + ).length + } + }; +} diff --git a/drift v3/packages/cli/src/engine/engine-check.ts b/drift v3/packages/cli/src/engine/engine-check.ts index 375f24b4..079fa12a 100644 --- a/drift v3/packages/cli/src/engine/engine-check.ts +++ b/drift v3/packages/cli/src/engine/engine-check.ts @@ -58,6 +58,7 @@ export function engineCheckRequest(input: EngineCheckInput): EngineCheckRequest file_path: fact.file_path, name: fact.name, value: fact.value, + imported_name: fact.imported_name, start_line: fact.start_line, end_line: fact.end_line })) diff --git a/drift v3/packages/cli/test/security-check.test.ts b/drift v3/packages/cli/test/security-check.test.ts new file mode 100644 index 00000000..94cfc530 --- /dev/null +++ b/drift v3/packages/cli/test/security-check.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach } from "vitest"; +import { openDriftStorage } from "@drift/storage"; +import { runCheck } from "../src/check/run-check.js"; +import { runEngineCheck } from "../src/engine/engine-check.js"; +import { buildSecurityCheckJson } from "../src/check/security-check.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tempDirs.length = 0; +}); + +describe("security check bridge", () => { + it("includes security proofs and blocks only changed-scope security findings", () => { + const payload = buildSecurityCheckJson({ + repo_id: "repo_abc", + scope: "changed-files", + changed_files: ["app/api/projects/route.ts"], + proofs: [ + securityProof("proof_changed", "app/api/projects/route.ts", "finding_changed"), + securityProof("proof_unchanged", "app/api/archive/route.ts", "finding_unchanged") + ], + findings: [{ + finding_id: "finding_changed", + title: "API route missing required auth proof", + file_path: "app/api/projects/route.ts", + enforcement_result: "block" + }, { + finding_id: "finding_unchanged", + title: "API route missing required auth proof", + file_path: "app/api/archive/route.ts", + enforcement_result: "block" + }] + }); + + expect(payload.security_boundary_proofs).toHaveLength(2); + expect(payload.security_findings).toEqual([{ + finding_id: "finding_changed", + title: "API route missing required auth proof", + file_path: "app/api/projects/route.ts", + enforcement_result: "block" + }]); + expect(payload.summary.security_blocking_count).toBe(1); + expect(JSON.stringify(payload)).not.toContain("requireUser()"); + }); + + it("receives SecurityBoundaryProof.auth from engine-owned auth checks", async () => { + const result = await runEngineCheck({ + repoId: "repo_abc", + repoRoot: process.cwd(), + scanId: "scan_security_auth", + snapshots: [{ + repo_id: "repo_abc", + scan_id: "scan_security_auth", + file_path: "app/api/projects/route.ts", + content_hash: "a".repeat(64), + byte_size: 100, + indexed: true + }], + facts: [ + fact("file_role_detected", "api_route", 1), + fact("route_declared", "GET", 4), + fact("data_operation_detected", "findMany", 5, "db.project"), + fact("route_returns_response", "json", 6, "Response") + ], + conventions: [{ + id: "security_api_auth_require_user", + repo_id: "repo_abc", + contract_id: "contract_abc", + kind: "api_route_requires_auth_helper", + statement: "API routes require accepted auth helper dominance.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_auth_helper", + required_calls: ["requireUser"], + applies_to_file_roles: ["api_route"] + }, + severity: "error", + enforcement_mode: "block", + enforcement_capability: "deterministic_check", + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z" + }], + baseline: [], + diff: { + files: [{ path: "app/api/projects/route.ts", changedLines: new Set([5]) }], + deletedFiles: [] + }, + scope: "changed-files" + }); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + convention_id: "security_api_auth_require_user", + rule_id: "api_route_requires_auth_helper", + enforcement_result: "block" + }); + expect(result.security_boundary_proofs).toHaveLength(1); + expect(result.security_boundary_proofs[0].auth).toMatchObject({ + required: true, + proven: false, + proof_kind: "none" + }); + }); + + it("returns SecurityBoundaryProof.auth in drift check JSON output", async () => { + const { databasePath, repoRoot, diffPath } = await seedAuthCheckDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + + const result = await runCheck(storage, { + positional: ["check"], + flags: new Map([ + ["repo", "repo_abc"], + ["scope", "changed-hunks"], + ["diff-file", diffPath], + ["now", "2026-05-25T00:00:00.000Z"], + ["json", true] + ]) + }); + storage.close(); + + expect(result.exitCode).toBe(1); + const payload = result.payload as { + security_boundary_proofs?: Array<{ auth?: { required: boolean; proven: boolean; proof_kind: string } }>; + findings?: Array<{ convention_id: string; enforcement_result: string }>; + }; + expect(payload.security_boundary_proofs).toHaveLength(1); + expect(payload.security_boundary_proofs?.[0]?.auth).toMatchObject({ + required: true, + proven: false, + proof_kind: "none" + }); + expect(payload.findings).toContainEqual(expect.objectContaining({ + convention_id: "security_api_auth_require_user", + enforcement_result: "block" + })); + expect(JSON.stringify(payload)).not.toContain("requireUser()"); + expect(JSON.stringify(payload)).not.toContain("await db.project.findMany()"); + expect(repoRoot).toContain("drift-security-auth-check-"); + }); +}); + +function fact(kind: string, name: string, line: number, value?: string) { + return { + id: `fact_${kind}_${line}`, + repo_id: "repo_abc", + scan_id: "scan_security_auth", + kind, + file_path: "app/api/projects/route.ts", + name, + value, + start_line: line, + end_line: line + } as const; +} + +function securityProof(proofId: string, filePath: string, findingId: string) { + return { + proof_id: proofId, + proof_version: "security-boundary-proof/v1", + route: { + route_id: proofId.replace("proof", "route"), + file_path: filePath, + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth_require_user", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [], + auth: { + required: true, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [{ + sink_id: `${proofId}_sink`, + sink_kind: "data_operation", + reason: "no_guard_call", + fact_ids: [`${proofId}_fact`] + }] + }, + missing_proof: [{ + id: `${proofId}_missing`, + capability: "control_flow_guard_dominance", + code: "missing_auth_guard", + blocks_enforcement: true, + fact_ids: [`${proofId}_fact`], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: [findingId] + } + } as const; +} + +async function seedAuthCheckDatabase(): Promise<{ + databasePath: string; + repoRoot: string; + diffPath: string; +}> { + const dir = await mkdtemp(join(tmpdir(), "drift-security-auth-check-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const routePath = "app/api/projects/route.ts"; + await mkdir(join(repoRoot, "app/api/projects"), { recursive: true }); + await writeFile(join(repoRoot, routePath), [ + "const db = { project: { findMany: async () => [] } };", + "", + "export async function GET() {", + " const projects = await db.project.findMany();", + " return Response.json(projects);", + "}", + "" + ].join("\n")); + const diffPath = join(dir, "diff.patch"); + await writeFile(diffPath, [ + "diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts", + "--- a/app/api/projects/route.ts", + "+++ b/app/api/projects/route.ts", + "@@ -0,0 +1,6 @@", + "+const db = { project: { findMany: async () => [] } };", + "+", + "+export async function GET() {", + "+ const projects = await db.project.findMany();", + "+ return Response.json(projects);", + "+}", + "" + ].join("\n")); + const databasePath = join(dir, "drift.sqlite"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + storage.upsertRepo({ + id: "repo_abc", + root_path: repoRoot, + fingerprint: "repo-fp", + created_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z" + }); + storage.upsertAcceptedConvention("repo_abc", { + id: "security_api_auth_require_user", + contract_id: "contract_abc", + kind: "api_route_requires_auth_helper", + statement: "API routes require accepted auth helper dominance.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_auth_helper", + required_calls: ["requireUser"], + applies_to_file_roles: ["api_route"] + }, + severity: "error", + enforcement_mode: "block", + enforcement_capability: "deterministic_check", + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z" + }); + storage.upsertRepoContract({ + id: "contract_abc", + repo_id: "repo_abc", + contract_schema_version: 1, + repo_fingerprint: "repo-fp", + created_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z", + conventions: storage.listAcceptedConventions("repo_abc"), + rejected_inferences: [], + waivers: [], + risky_areas: [], + layer_architecture: { + schema_version: "drift.layer_architecture.v1", + architecture_id: "architecture_security_auth", + repo_id: "repo_abc", + version: 1, + layers: [ + { id: "route", role: "route", position: "entrypoint" }, + { id: "auth", role: "auth", position: "middle" }, + { id: "data_access", role: "data_access", position: "terminal" } + ], + allowed_edges: [], + forbidden_edges: [], + soft_edges: [] + }, + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.close(); + return { databasePath, repoRoot, diffPath }; +} diff --git a/drift v3/packages/core/src/domain.ts b/drift v3/packages/core/src/domain.ts index 4c42c800..098c255b 100644 --- a/drift v3/packages/core/src/domain.ts +++ b/drift v3/packages/core/src/domain.ts @@ -253,7 +253,10 @@ export type FactKind = | "data_operation_detected" | "route_declared" | "file_role_detected" - | "test_declared"; + | "test_declared" + | "auth_guard_called" + | "route_returns_response" + | "callback_boundary_detected"; export type FactEvidenceLevel = "path" | "text" | "ast" | "graph" | "heuristic"; export type FactResolutionStatus = "resolved" | "unresolved" | "partial" | "unsupported"; diff --git a/drift v3/packages/core/src/index.ts b/drift v3/packages/core/src/index.ts index b9acbc9f..3705b462 100644 --- a/drift v3/packages/core/src/index.ts +++ b/drift v3/packages/core/src/index.ts @@ -10,5 +10,6 @@ export * from "./domain.js"; export * from "./ids.js"; export * from "./policy.js"; export * from "./scans.js"; +export * from "./security.js"; export * from "./schemas.js"; export * from "./versions.js"; diff --git a/drift v3/packages/core/src/schemas.ts b/drift v3/packages/core/src/schemas.ts index d396efeb..8987f82b 100644 --- a/drift v3/packages/core/src/schemas.ts +++ b/drift v3/packages/core/src/schemas.ts @@ -276,7 +276,10 @@ export const FactKindSchema = z.enum([ "data_operation_detected", "route_declared", "file_role_detected", - "test_declared" + "test_declared", + "auth_guard_called", + "route_returns_response", + "callback_boundary_detected" ]); export const FactEvidenceLevelSchema = z.enum(["path", "text", "ast", "graph", "heuristic"]); diff --git a/drift v3/packages/core/src/security.ts b/drift v3/packages/core/src/security.ts new file mode 100644 index 00000000..223d780b --- /dev/null +++ b/drift v3/packages/core/src/security.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +export const SecurityCapabilityNameSchema = z.enum([ + "security_facts", + "auth_boundary_facts", + "control_flow_guard_dominance" +]); + +export const SecurityMissingProofCodeSchema = z.enum([ + "missing_auth_guard", + "auth_guard_not_dominating_sink", + "unsupported_callback_boundary", + "route_binding_unresolved", + "handler_unresolved" +]); + +export const SecurityParserGapCodeSchema = z.enum([ + "route_binding_unresolved", + "handler_unresolved", + "unsupported_dynamic_control_flow", + "unsupported_callback_boundary" +]); + +export const SecurityConventionSchema = z.object({ + contract_id: z.string().min(1), + kind: z.literal("api_route_requires_auth_helper"), + capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]), + enforcement_mode: z.enum(["off", "brief", "warn", "block"]), + matcher: z.object({ + file_roles: z.array(z.literal("api_route")).optional(), + path_globs: z.array(z.string().min(1)).optional(), + route_paths: z.array(z.string().min(1)).optional(), + route_path_patterns: z.array(z.string().min(1)).optional(), + methods: z.array(z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])).optional(), + frameworks: z.array(z.string().min(1)).optional(), + package_names: z.array(z.string().min(1)).optional(), + tags: z.array(z.string().min(1)).optional() + }), + scope: z.object({ + check_scope: z.enum(["changed-hunks", "changed-files", "full"]), + diff_status: z.array(z.enum(["added", "modified", "renamed"])).optional(), + applies_to: z.enum(["route", "handler", "middleware", "data_operation", "response"]), + include_pre_existing: z.boolean().optional() + }), + requires: z.record(z.unknown()).optional(), + forbids: z.record(z.unknown()).optional(), + exceptions: z.array(z.record(z.unknown())).optional(), + governance: z.object({ + accepted_by: z.string().min(1).optional(), + accepted_at: z.string().datetime().optional(), + updated_at: z.string().datetime().optional(), + expires_at: z.string().datetime().optional(), + rationale: z.string().min(1).optional(), + evidence_refs: z.array(z.string().min(1)).optional(), + counterexample_refs: z.array(z.string().min(1)).optional() + }).optional() +}).superRefine((contract, context) => { + if (contract.enforcement_mode === "block" && contract.capability !== "deterministic_check") { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "blocking security contracts require deterministic capability" + }); + } +}); + +const SecurityContractMatchSchema = z.object({ + contract_id: z.string().min(1), + kind: z.string().min(1), + enforcement_mode: z.enum(["off", "brief", "warn", "block"]), + capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]), + matched: z.boolean() +}); + +const SecurityCapabilityStatusSchema = z.object({ + name: z.string().min(1), + status: z.enum(["complete", "partial", "unsupported", "failed"]), + can_block: z.boolean(), + parser_gap_ids: z.array(z.string().min(1)), + missing_proof_ids: z.array(z.string().min(1)) +}); + +const SecurityAuthProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + proof_kind: z.enum(["handler_guard", "middleware_guard", "both", "none"]), + trusted_guard_calls: z.array(z.object({ + fact_id: z.string().min(1), + guard_id: z.string().min(1), + symbol: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + result_var: z.string().min(1).optional() + })), + dominated_sinks: z.array(z.object({ + sink_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql", "secret_log"]), + edge_id: z.string().min(1) + })), + undominated_sinks: z.array(z.object({ + sink_id: z.string().min(1), + sink_kind: z.string().min(1), + reason: z.enum([ + "guard_after_sink", + "guard_only_in_one_branch", + "callback_boundary", + "unsupported_dynamic_control_flow", + "no_guard_call" + ]), + fact_ids: z.array(z.string().min(1)) + })) +}); + +const SecurityMissingProofSchema = z.object({ + id: z.string().min(1), + capability: z.string().min(1), + code: SecurityMissingProofCodeSchema, + blocks_enforcement: z.boolean(), + fact_ids: z.array(z.string().min(1)), + graph_edge_ids: z.array(z.string().min(1)) +}); + +const SecurityParserGapSchema = z.object({ + parser_gap_id: z.string().min(1), + capability: z.string().min(1), + code: SecurityParserGapCodeSchema, + file_path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + reason: z.string().min(1), + affected_contract_kinds: z.array(z.string().min(1)), + affected_route_ids: z.array(z.string().min(1)), + missing_proof_ids: z.array(z.string().min(1)), + blocks_enforcement: z.boolean() +}); + +export const SecurityBoundaryProofSchema = z.object({ + proof_id: z.string().min(1), + proof_version: z.literal("security-boundary-proof/v1"), + route: z.object({ + route_id: z.string().min(1), + file_path: z.string().min(1), + 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(SecurityContractMatchSchema), + capability_status: z.array(SecurityCapabilityStatusSchema), + auth: SecurityAuthProofSchema, + missing_proof: z.array(SecurityMissingProofSchema), + parser_gaps: z.array(SecurityParserGapSchema), + result: z.object({ + proof_status: z.enum(["proven", "violated", "missing_proof", "parser_gap", "advisory_only"]), + enforcement_result: z.enum(["pass", "brief", "warn", "block"]), + can_block: z.boolean(), + finding_ids: z.array(z.string().min(1)) + }) +}); + +export type SecurityConvention = z.infer; +export type SecurityBoundaryProof = z.infer; diff --git a/drift v3/packages/core/test/security.test.ts b/drift v3/packages/core/test/security.test.ts new file mode 100644 index 00000000..620212ef --- /dev/null +++ b/drift v3/packages/core/test/security.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + SecurityBoundaryProofSchema, + SecurityConventionSchema, + SecurityMissingProofCodeSchema +} from "../src/index.js"; + +describe("security domain schemas", () => { + it("validates api_route_requires_auth_helper contracts and missing proof codes", () => { + expect(SecurityMissingProofCodeSchema.parse("missing_auth_guard")).toBe("missing_auth_guard"); + + const contract = SecurityConventionSchema.parse({ + contract_id: "security_api_auth_require_user", + kind: "api_route_requires_auth_helper", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { + file_roles: ["api_route"], + path_globs: ["**/app/api/**/route.ts"], + methods: ["GET", "POST"] + }, + scope: { + check_scope: "changed-files", + applies_to: "route", + diff_status: ["added", "modified", "renamed"] + }, + requires: { + auth_helpers: ["requireUser"], + dominates: ["data_operation", "response"] + }, + exceptions: [], + governance: { + accepted_by: "test", + accepted_at: "2026-05-25T00:00:00.000Z", + rationale: "API routes require accepted auth helper dominance" + } + }); + + expect(contract.kind).toBe("api_route_requires_auth_helper"); + }); + + it("rejects blocking heuristic security contracts", () => { + expect(() => SecurityConventionSchema.parse({ + contract_id: "security_api_auth_require_user", + kind: "api_route_requires_auth_helper", + capability: "heuristic_check", + enforcement_mode: "block", + matcher: { file_roles: ["api_route"] }, + scope: { check_scope: "changed-files", applies_to: "route" }, + requires: { auth_helpers: ["requireUser"] } + })).toThrow(/blocking security contracts require deterministic capability/); + }); + + it("validates SecurityBoundaryProof.auth without snippets", () => { + const proof = SecurityBoundaryProofSchema.parse({ + proof_id: "proof_route_projects_get", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth_require_user", + 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: ["missing_proof_auth"] + }], + auth: { + required: true, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [{ + sink_id: "sink_projects_read", + sink_kind: "data_operation", + reason: "no_guard_call", + fact_ids: ["fact_projects_read"] + }] + }, + missing_proof: [{ + id: "missing_proof_auth", + capability: "control_flow_guard_dominance", + code: "missing_auth_guard", + blocks_enforcement: true, + fact_ids: ["fact_projects_read"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_auth"] + } + }); + + expect(JSON.stringify(proof)).not.toContain("const projects"); + expect(proof.auth.required).toBe(true); + }); +}); diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index c5f2b12c..287f1fff 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -11,6 +11,7 @@ export const ENGINE_CHECK_REQUEST_SCHEMA_VERSION = "engine.check.request.v1"; export const ENGINE_CHECK_RESULT_SCHEMA_VERSION = "engine.check.result.v1"; export const ENGINE_CANDIDATES_RESULT_SCHEMA_VERSION = "engine.candidates.result.v1"; export const ENGINE_STREAM_EVENT_SCHEMA_VERSION = "engine.stream.event.v1"; +export const ENGINE_SECURITY_PROOF_EVENT_SCHEMA_VERSION = "engine.security.proof/v1"; const DiagnosticSeveritySchema = z.enum(["info", "warning", "error"]); const DiffModeSchema = z.enum(["changed-hunks", "changed-files", "full"]); @@ -90,7 +91,10 @@ export const EngineFactSchema = z.object({ "data_operation_detected", "route_declared", "file_role_detected", - "test_declared" + "test_declared", + "auth_guard_called", + "route_returns_response", + "callback_boundary_detected" ]), file_path: z.string().min(1), name: z.string().min(1), @@ -277,6 +281,86 @@ export const EngineFindingSchema = z.object({ related_node_ids: z.array(z.string()) }); +const EngineSecurityMissingProofCodeSchema = z.enum([ + "missing_auth_guard", + "auth_guard_not_dominating_sink", + "unsupported_callback_boundary", + "route_binding_unresolved", + "handler_unresolved" +]); + +const EngineSecurityBoundaryProofSchema = z.object({ + proof_id: z.string().min(1), + proof_version: z.literal("security-boundary-proof/v1"), + route: z.object({ + route_id: z.string().min(1), + file_path: z.string().min(1), + file_role: z.literal("api_route") + }), + contracts: z.array(z.object({ + contract_id: z.string().min(1), + kind: z.string().min(1), + enforcement_mode: z.enum(["off", "brief", "warn", "block"]), + capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]), + matched: z.boolean() + })), + capability_status: z.array(z.object({ + name: z.string().min(1), + status: z.enum(["complete", "partial", "unsupported", "failed"]), + can_block: z.boolean(), + parser_gap_ids: z.array(z.string().min(1)), + missing_proof_ids: z.array(z.string().min(1)) + })), + auth: z.object({ + required: z.boolean(), + proven: z.boolean(), + proof_kind: z.enum(["handler_guard", "middleware_guard", "both", "none"]), + trusted_guard_calls: z.array(z.object({ + fact_id: z.string().min(1), + guard_id: z.string().min(1), + symbol: z.string().min(1) + }).passthrough()), + dominated_sinks: z.array(z.object({ + sink_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql", "secret_log"]), + edge_id: z.string().min(1) + })), + undominated_sinks: z.array(z.object({ + sink_id: z.string().min(1), + sink_kind: z.string().min(1), + reason: z.enum([ + "guard_after_sink", + "guard_only_in_one_branch", + "callback_boundary", + "unsupported_dynamic_control_flow", + "no_guard_call" + ]), + fact_ids: z.array(z.string().min(1)) + })) + }), + missing_proof: z.array(z.object({ + id: z.string().min(1), + capability: z.string().min(1), + code: EngineSecurityMissingProofCodeSchema, + blocks_enforcement: z.boolean(), + fact_ids: z.array(z.string().min(1)), + graph_edge_ids: z.array(z.string().min(1)) + })), + parser_gaps: z.array(z.record(z.unknown())), + result: z.object({ + proof_status: z.enum(["proven", "violated", "missing_proof", "parser_gap", "advisory_only"]), + enforcement_result: z.enum(["pass", "brief", "warn", "block"]), + can_block: z.boolean(), + finding_ids: z.array(z.string().min(1)) + }) +}); + +export const EngineSecurityProofEventSchema = z.object({ + event: z.literal("SecurityProof"), + schema_version: z.literal(ENGINE_SECURITY_PROOF_EVENT_SCHEMA_VERSION), + proofs: z.array(EngineSecurityBoundaryProofSchema) +}); + export const EngineCheckResultSchema = z.object({ schema_version: z.literal(ENGINE_CHECK_RESULT_SCHEMA_VERSION), repo_id: z.string().min(1), @@ -287,6 +371,7 @@ export const EngineCheckResultSchema = z.object({ adapter_versions: z.record(z.string().min(1)), diff_mode: DiffModeSchema, findings: z.array(EngineFindingSchema), + security_boundary_proofs: z.array(EngineSecurityBoundaryProofSchema).default([]), diagnostics: z.array(EngineDiagnosticSchema), stats: EngineStatsSchema, completeness: z.array(EngineCompletenessSchema) @@ -394,6 +479,7 @@ export type EngineCandidateScoring = z.infer; export type EngineCandidatesResult = z.infer; export type EngineStreamEvent = z.infer; +export type EngineSecurityProofEvent = z.infer; export function parseEngineScanResult(value: unknown): EngineScanResult { return parseWithMessage(EngineScanResultSchema, value, "Invalid Drift engine scan result"); @@ -411,6 +497,10 @@ export function parseEngineCandidatesResult(value: unknown): EngineCandidatesRes return parseWithMessage(EngineCandidatesResultSchema, value, "Invalid Drift engine candidates result"); } +export function parseEngineSecurityProofEvent(value: unknown): EngineSecurityProofEvent { + return parseWithMessage(EngineSecurityProofEventSchema, value, "Invalid Drift engine security proof event"); +} + function parseWithMessage(schema: S, value: unknown, message: string): z.output { const parsed = schema.safeParse(value); if (!parsed.success) { diff --git a/drift v3/packages/engine-contract/test/security-contract.test.ts b/drift v3/packages/engine-contract/test/security-contract.test.ts new file mode 100644 index 00000000..bbffb5e6 --- /dev/null +++ b/drift v3/packages/engine-contract/test/security-contract.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + EngineSecurityProofEventSchema, + parseEngineSecurityProofEvent +} from "../src/index.js"; + +describe("engine security contract schemas", () => { + it("validates versioned security proof events", () => { + const event = parseEngineSecurityProofEvent({ + event: "SecurityProof", + schema_version: "engine.security.proof/v1", + proofs: [{ + proof_id: "proof_route_projects_get", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth_require_user", + kind: "api_route_requires_auth_helper", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [], + auth: { + required: true, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [{ + sink_id: "sink_projects_read", + sink_kind: "data_operation", + reason: "no_guard_call", + fact_ids: ["fact_projects_read"] + }] + }, + missing_proof: [{ + id: "missing_proof_auth", + capability: "control_flow_guard_dominance", + code: "missing_auth_guard", + blocks_enforcement: true, + fact_ids: ["fact_projects_read"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_auth"] + } + }] + }); + + expect(event.schema_version).toBe("engine.security.proof/v1"); + expect(EngineSecurityProofEventSchema.safeParse(event).success).toBe(true); + expect(JSON.stringify(event)).not.toContain("requireUser()"); + }); + + it("rejects unknown security proof event versions", () => { + expect(() => parseEngineSecurityProofEvent({ + event: "SecurityProof", + schema_version: "engine.security.proof/v2", + proofs: [] + })).toThrow(/Invalid Drift engine security proof event/); + }); +}); diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index 8f2d5287..5903a73d 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -30,6 +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 type { BuildEntrypointFlowProofInput } from "./flow-proof.js"; export type { BuildChangeImpactInput, ChangeImpactRouteFlow } from "./change-impact.js"; export type { ClassifyDataOperationRiskInput } from "./data-operation-risk.js"; @@ -48,6 +49,11 @@ export type { HelperFeatureProfile, ScoreHelperSimilarityInput } from "./helper- export type { RoleEdgeDecision, RoleEdgeInput, RoleEdgeKind } from "./role-ontology.js"; export type { BuildSymbolIdentityInput } from "./symbol-identity.js"; export type { RelevantTestsSelection, SelectRelevantTestsInput } from "./test-intelligence.js"; +export type { + BuildSecurityBoundaryProofReadModelInput, + SecurityBoundaryProofReadModel, + SecurityBoundaryProofRouteSummary +} from "./security-boundary-proof.js"; export interface GraphRepoMapFile { path: string; diff --git a/drift v3/packages/query/src/security-boundary-proof.ts b/drift v3/packages/query/src/security-boundary-proof.ts new file mode 100644 index 00000000..93724558 --- /dev/null +++ b/drift v3/packages/query/src/security-boundary-proof.ts @@ -0,0 +1,55 @@ +import type { SecurityBoundaryProof } from "@drift/core"; + +export interface SecurityFindingSummaryInput { + finding_id: string; + title: string; + lifecycle: string; +} + +export interface BuildSecurityBoundaryProofReadModelInput { + proofs: SecurityBoundaryProof[]; + findings: SecurityFindingSummaryInput[]; +} + +export interface SecurityBoundaryProofRouteSummary { + route_id: string; + file_path: string; + auth_required: boolean; + auth_proven: boolean; + proof_status: string; + enforcement_result: string; + missing_proof_codes: string[]; + parser_gap_codes: string[]; + finding_ids: string[]; + lifecycle: string[]; +} + +export interface SecurityBoundaryProofReadModel { + routes: SecurityBoundaryProofRouteSummary[]; +} + +export function buildSecurityBoundaryProofReadModel( + input: BuildSecurityBoundaryProofReadModelInput +): SecurityBoundaryProofReadModel { + const findingLifecycle = new Map(input.findings.map((finding) => [ + finding.finding_id, + finding.lifecycle + ])); + + return { + routes: input.proofs.map((proof) => ({ + route_id: proof.route.route_id, + file_path: proof.route.file_path, + auth_required: proof.auth.required, + auth_proven: proof.auth.proven, + 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, + lifecycle: proof.result.finding_ids + .map((findingId) => findingLifecycle.get(findingId)) + .filter((value): value is string => value !== undefined) + })) + }; +} diff --git a/drift v3/packages/query/test/security-boundary-proof.test.ts b/drift v3/packages/query/test/security-boundary-proof.test.ts new file mode 100644 index 00000000..dde6f89a --- /dev/null +++ b/drift v3/packages/query/test/security-boundary-proof.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { buildSecurityBoundaryProofReadModel } from "../src/index.js"; + +describe("security boundary proof read model", () => { + it("renders proof, findings, and parser gaps without snippets", () => { + const model = buildSecurityBoundaryProofReadModel({ + proofs: [{ + proof_id: "proof_route_projects_get", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_auth_require_user", + 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: ["parser_gap_dynamic"], + missing_proof_ids: ["missing_proof_auth"] + }], + auth: { + required: true, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [{ + sink_id: "sink_projects_read", + sink_kind: "data_operation", + reason: "no_guard_call", + fact_ids: ["fact_projects_read"] + }] + }, + missing_proof: [{ + id: "missing_proof_auth", + capability: "control_flow_guard_dominance", + code: "missing_auth_guard", + blocks_enforcement: true, + fact_ids: ["fact_projects_read"], + graph_edge_ids: [] + }], + parser_gaps: [{ + parser_gap_id: "parser_gap_dynamic", + capability: "control_flow_guard_dominance", + code: "unsupported_dynamic_control_flow", + file_path: "app/api/projects/route.ts", + reason: "Unsupported dynamic control flow", + affected_contract_kinds: ["api_route_requires_auth_helper"], + affected_route_ids: ["route_projects_get"], + missing_proof_ids: ["missing_proof_auth"], + blocks_enforcement: true + }], + result: { + proof_status: "parser_gap", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_auth"] + } + }], + findings: [{ + finding_id: "finding_auth", + title: "API route missing required auth proof", + lifecycle: "new" + }] + }); + + expect(model.routes).toEqual([{ + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + auth_required: true, + auth_proven: false, + proof_status: "parser_gap", + enforcement_result: "block", + missing_proof_codes: ["missing_auth_guard"], + parser_gap_codes: ["unsupported_dynamic_control_flow"], + finding_ids: ["finding_auth"], + lifecycle: ["new"] + }]); + expect(JSON.stringify(model)).not.toContain("const projects"); + expect(JSON.stringify(model)).not.toContain("requireUser()"); + }); +}); diff --git a/drift v3/test/e2e/golden.test.ts b/drift v3/test/e2e/golden.test.ts index f4cf4f38..a2663eeb 100644 --- a/drift v3/test/e2e/golden.test.ts +++ b/drift v3/test/e2e/golden.test.ts @@ -38,7 +38,7 @@ describe("golden fixture CLI lifecycle", () => { "api_route_requires_service_delegation", ], "engine_source": "rust", - "facts_count": 8, + "facts_count": 9, "files_indexed": 1, } `); diff --git a/drift v3/test/e2e/security-auth.test.ts b/drift v3/test/e2e/security-auth.test.ts new file mode 100644 index 00000000..b62913a2 --- /dev/null +++ b/drift v3/test/e2e/security-auth.test.ts @@ -0,0 +1,24 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const fixtures = [ + "security-auth-missing", + "security-auth-before-sink", + "security-auth-after-data", + "security-auth-branch-bypass", + "security-auth-callback-bypass", + "security-dynamic-control-flow" +]; + +describe("security-auth fixture matrix", () => { + it("ships durable Phase 1 auth-boundary fixtures", () => { + for (const fixture of fixtures) { + const root = resolve("test/fixtures", fixture); + expect(existsSync(resolve(root, "package.json")), `${fixture} package.json`).toBe(true); + expect(existsSync(resolve(root, "app/api/projects/route.ts")), `${fixture} route.ts`).toBe(true); + const route = readFileSync(resolve(root, "app/api/projects/route.ts"), "utf8"); + expect(route).not.toContain("SECRET_VALUE"); + } + }); +}); diff --git a/drift v3/test/fixtures/security-auth-after-data/app/api/projects/route.ts b/drift v3/test/fixtures/security-auth-after-data/app/api/projects/route.ts new file mode 100644 index 00000000..0271167d --- /dev/null +++ b/drift v3/test/fixtures/security-auth-after-data/app/api/projects/route.ts @@ -0,0 +1,8 @@ +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + const user = await requireUser(); + return Response.json({ projects, user }); +} diff --git a/drift v3/test/fixtures/security-auth-after-data/package.json b/drift v3/test/fixtures/security-auth-after-data/package.json new file mode 100644 index 00000000..f0dea6da --- /dev/null +++ b/drift v3/test/fixtures/security-auth-after-data/package.json @@ -0,0 +1 @@ +{"name":"security-auth-after-data","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-auth-before-sink/app/api/projects/route.ts b/drift v3/test/fixtures/security-auth-before-sink/app/api/projects/route.ts new file mode 100644 index 00000000..d06f73c8 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-before-sink/app/api/projects/route.ts @@ -0,0 +1,8 @@ +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + const user = await requireUser(); + const projects = await db.project.findMany({ where: { ownerId: user.id } }); + return Response.json({ projects }); +} diff --git a/drift v3/test/fixtures/security-auth-before-sink/package.json b/drift v3/test/fixtures/security-auth-before-sink/package.json new file mode 100644 index 00000000..63771149 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-before-sink/package.json @@ -0,0 +1 @@ +{"name":"security-auth-before-sink","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-auth-branch-bypass/app/api/projects/route.ts b/drift v3/test/fixtures/security-auth-branch-bypass/app/api/projects/route.ts new file mode 100644 index 00000000..833ea533 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-branch-bypass/app/api/projects/route.ts @@ -0,0 +1,12 @@ +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + if (request.headers.get("x-auth") === "yes") { + await requireUser(); + } else { + const projects = await db.project.findMany(); + return Response.json({ projects }); + } + return Response.json({ ok: true }); +} diff --git a/drift v3/test/fixtures/security-auth-branch-bypass/package.json b/drift v3/test/fixtures/security-auth-branch-bypass/package.json new file mode 100644 index 00000000..9dead4be --- /dev/null +++ b/drift v3/test/fixtures/security-auth-branch-bypass/package.json @@ -0,0 +1 @@ +{"name":"security-auth-branch-bypass","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-auth-callback-bypass/app/api/projects/route.ts b/drift v3/test/fixtures/security-auth-callback-bypass/app/api/projects/route.ts new file mode 100644 index 00000000..49f48601 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-callback-bypass/app/api/projects/route.ts @@ -0,0 +1,10 @@ +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET() { + ["auth"].forEach(async () => { + await requireUser(); + }); + const projects = await db.project.findMany(); + return Response.json({ projects }); +} diff --git a/drift v3/test/fixtures/security-auth-callback-bypass/package.json b/drift v3/test/fixtures/security-auth-callback-bypass/package.json new file mode 100644 index 00000000..14211769 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-callback-bypass/package.json @@ -0,0 +1 @@ +{"name":"security-auth-callback-bypass","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-auth-missing/app/api/projects/route.ts b/drift v3/test/fixtures/security-auth-missing/app/api/projects/route.ts new file mode 100644 index 00000000..5e1cd933 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-missing/app/api/projects/route.ts @@ -0,0 +1,6 @@ +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} diff --git a/drift v3/test/fixtures/security-auth-missing/package.json b/drift v3/test/fixtures/security-auth-missing/package.json new file mode 100644 index 00000000..104fec29 --- /dev/null +++ b/drift v3/test/fixtures/security-auth-missing/package.json @@ -0,0 +1 @@ +{"name":"security-auth-missing","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-dynamic-control-flow/app/api/projects/route.ts b/drift v3/test/fixtures/security-dynamic-control-flow/app/api/projects/route.ts new file mode 100644 index 00000000..da746644 --- /dev/null +++ b/drift v3/test/fixtures/security-dynamic-control-flow/app/api/projects/route.ts @@ -0,0 +1,11 @@ +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +const guards = { requireUser }; + +export async function GET(request: Request) { + const guard = guards[request.headers.get("x-guard") as keyof typeof guards]; + await guard(); + const projects = await db.project.findMany(); + return Response.json({ projects }); +} diff --git a/drift v3/test/fixtures/security-dynamic-control-flow/package.json b/drift v3/test/fixtures/security-dynamic-control-flow/package.json new file mode 100644 index 00000000..31cf7eb2 --- /dev/null +++ b/drift v3/test/fixtures/security-dynamic-control-flow/package.json @@ -0,0 +1 @@ +{"name":"security-dynamic-control-flow","private":true,"type":"module"} From 077a315d6153ba1bec6caac7210ed2196728d870 Mon Sep 17 00:00:00 2001 From: geoffrey fernald Date: Mon, 25 May 2026 17:18:38 -0400 Subject: [PATCH 2/4] fix security auth boundary proof path --- .../crates/drift-engine/src/check_command.rs | 463 ++++++++++++------ drift v3/crates/drift-engine/src/lib.rs | 18 +- drift v3/crates/drift-engine/src/protocol.rs | 21 + .../drift-engine/src/security_control_flow.rs | 206 ++++++++ .../drift-engine/src/security_patterns.rs | 88 +++- .../crates/drift-engine/src/security_proof.rs | 352 ++++++++++++- .../tests/security_check_repo_auth.rs | 388 +++++++++++++++ drift v3/packages/cli/src/check/run-check.ts | 6 + .../packages/cli/src/engine/engine-check.ts | 51 +- .../packages/cli/test/security-check.test.ts | 156 +++++- drift v3/packages/core/src/domain.ts | 7 +- drift v3/packages/core/src/schemas.ts | 7 +- drift v3/packages/core/src/security.ts | 36 +- .../packages/engine-contract/src/index.ts | 54 +- 14 files changed, 1664 insertions(+), 189 deletions(-) create mode 100644 drift v3/crates/drift-engine/tests/security_check_repo_auth.rs diff --git a/drift v3/crates/drift-engine/src/check_command.rs b/drift v3/crates/drift-engine/src/check_command.rs index 455264d6..d6a10178 100644 --- a/drift v3/crates/drift-engine/src/check_command.rs +++ b/drift v3/crates/drift-engine/src/check_command.rs @@ -1,12 +1,16 @@ use std::{ collections::{BTreeMap, BTreeSet}, + fs, + path::Path, time::Instant, }; use drift_engine::{ - BaselineStatus, BaselineViolation, DiffFile, DiffScope, DirectDataAccessRule, EnforcementMode, - Fact, FactKind, FindingStatus, ParsedDiff, RuleFinding, Severity, - classify_findings_against_diff, materialize_direct_data_access_findings, + AcceptedAuthHelper, AuthGuardBehavior, BaselineStatus, BaselineViolation, DiffFile, DiffScope, + DirectDataAccessRule, EnforcementMode, Fact, FactKind, FindingStatus, ParsedDiff, + RouteSecurityBoundaryProof, RuleFinding, SecurityProofStatus, Severity, + build_auth_boundary_proofs_for_file, classify_findings_against_diff, + materialize_direct_data_access_findings, }; use serde_json::json; @@ -18,6 +22,7 @@ use crate::protocol::{ pub fn check_repo(request: CheckRequest) -> CheckResult { let started = Instant::now(); + let repo_root = request.repo.repo_root.clone(); let mut completeness_reasons = check_limit_reasons(&request); completeness_reasons.extend(check_graph_completeness_reasons(&request)); completeness_reasons.sort(); @@ -49,10 +54,22 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { }) .collect(), }; + let _contract_metadata = ( + &request.contract.contract_id, + &request.contract.contract_schema_version, + &request.contract.waivers, + &request.contract.exceptions, + ); let mut findings = Vec::new(); let mut security_boundary_proofs = Vec::new(); + let mut required_capabilities = BTreeSet::from(["direct_data_access_check".to_string()]); for convention in request.contract.conventions { + let _convention_metadata = ( + &convention.scope, + &convention.exceptions, + &convention.governance, + ); if convention.enforcement_capability != "deterministic_check" || convention.enforcement_mode == "off" { @@ -101,8 +118,14 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { &allowed_delegate_imports, ) } else if convention.kind == "api_route_requires_auth_helper" { + required_capabilities.extend([ + "security_facts".to_string(), + "auth_boundary_facts".to_string(), + "control_flow_guard_dominance".to_string(), + ]); let auth_result = security_auth_findings_and_proofs( &facts, + repo_root.as_deref(), &parsed_diff, diff_scope, &convention, @@ -171,7 +194,12 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { stats.graph_nodes = graph_node_count; stats.graph_edges = graph_edge_count; stats.truncated = !can_block; - stats.capabilities = capability_stats(&["direct_data_access_check"], &[]); + let required_capabilities_vec = required_capabilities.into_iter().collect::>(); + let required_capability_refs = required_capabilities_vec + .iter() + .map(String::as_str) + .collect::>(); + stats.capabilities = capability_stats(&required_capability_refs, &[]); let diagnostics = completeness_reasons .iter() .take(request.limits.max_diagnostics) @@ -198,7 +226,7 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { completeness: vec![EngineCompleteness { scope: "repo".to_string(), complete: can_block, - required_capabilities: vec!["direct_data_access_check".to_string()], + required_capabilities: required_capabilities_vec, missing_capabilities: Vec::new(), truncated: !can_block, can_block, @@ -468,18 +496,15 @@ struct SecurityAuthEvaluation { fn security_auth_findings_and_proofs( facts: &[Fact], + repo_root: Option<&str>, parsed_diff: &ParsedDiff, diff_scope: DiffScope, convention: &crate::protocol::CheckConvention, severity: Severity, enforcement_mode: EnforcementMode, ) -> SecurityAuthEvaluation { - let required_calls = convention - .matcher - .required_calls - .clone() - .unwrap_or_default(); - if required_calls.is_empty() { + let accepted_auth_helpers = accepted_auth_helpers_for_convention(convention); + if accepted_auth_helpers.is_empty() { return SecurityAuthEvaluation { findings: Vec::new(), proofs: Vec::new(), @@ -501,154 +526,280 @@ fn security_auth_findings_and_proofs( let mut proofs = Vec::new(); for file_path in files { - let file_facts = facts - .iter() - .filter(|fact| fact.file_path == file_path) - .collect::>(); - let route = file_facts - .iter() - .find(|fact| fact.kind == FactKind::RouteDeclared) - .map(|fact| fact.name.as_str()) - .unwrap_or("unknown"); - let route_id = format!("route:{file_path}:{route}"); - let guard_calls = file_facts - .iter() - .filter(|fact| { - fact.kind == FactKind::AuthGuardCalled - || (fact.kind == FactKind::SymbolCalled && required_calls.contains(&fact.name)) - }) - .copied() - .collect::>(); - let sinks = file_facts - .iter() - .filter(|fact| { - matches!( - fact.kind, - FactKind::DataOperationDetected | FactKind::RouteReturnsResponse - ) - }) - .copied() - .collect::>(); - if sinks.is_empty() { + let Some(source) = read_repo_file(repo_root, &file_path) else { continue; + }; + let route_proofs = match build_auth_boundary_proofs_for_file( + &file_path, + &source, + &accepted_auth_helpers, + ) { + Ok(route_proofs) => route_proofs, + Err(_) => continue, + }; + + for route_proof in route_proofs { + let sink_line = first_sink_line_for_route(facts, &file_path, &route_proof).unwrap_or(1); + let missing_code = route_proof + .missing_proof_codes + .first() + .cloned() + .unwrap_or_else(|| "missing_auth_guard".to_string()); + let finding_fingerprint = stable_hash(&format!( + "{}:{}:{}:{}", + convention.id, route_proof.route_id, missing_code, sink_line + )); + let finding_id = format!("finding_{}", &finding_fingerprint[..16]); + proofs.push(route_security_proof_json( + &route_proof, + convention, + &finding_id, + )); + if route_proof.result.proof_status != SecurityProofStatus::Proven { + findings.push(PendingFinding { + fingerprint: finding_fingerprint, + convention_id: convention.id.clone(), + rule_id: "api_route_requires_auth_helper".to_string(), + title: "API route missing required auth proof".to_string(), + message: "Accepted auth helper must dominate protected route sinks." + .to_string(), + severity, + enforcement_result: enforcement_result_for_mode(enforcement_mode), + file_path: file_path.clone(), + import_name: "auth_guard".to_string(), + import_source: missing_code, + line: sink_line, + evidence_id: format!("evidence_{}", &finding_id["finding_".len()..]), + legacy_fingerprints: Vec::new(), + related_node_ids: Vec::new(), + }); + } } + } - let first_guard_line = guard_calls.iter().map(|fact| fact.start_line).min(); - let mut dominated_sinks = Vec::new(); - let mut undominated_sinks = Vec::new(); - for sink in &sinks { - let sink_kind = security_sink_kind(sink.kind); - let sink_id = format!("sink:{file_path}:{}:{}", sink.start_line, sink.name); - if first_guard_line.is_some_and(|line| line < sink.start_line) { - dominated_sinks.push(json!({ - "sink_id": sink_id, - "sink_kind": sink_kind, - "edge_id": format!("edge:auth-dominates:{file_path}:{}", sink.start_line) - })); - } else { - undominated_sinks.push(json!({ - "sink_id": sink_id, - "sink_kind": sink_kind, - "reason": match first_guard_line { - Some(line) if line > sink.start_line => "guard_after_sink", - _ => "no_guard_call", + SecurityAuthEvaluation { findings, proofs } +} + +fn accepted_auth_helpers_for_convention( + convention: &crate::protocol::CheckConvention, +) -> Vec { + let mut helpers = BTreeMap::::new(); + for symbol in convention + .matcher + .required_calls + .as_ref() + .into_iter() + .flatten() + { + helpers.insert( + symbol.clone(), + AcceptedAuthHelper { + guard_id: format!("auth:{symbol}"), + symbol: symbol.clone(), + behavior: AuthGuardBehavior::Unknown, + }, + ); + } + if let Some(auth_helpers) = convention + .requires + .as_ref() + .and_then(|requires| requires.get("auth_helpers")) + .and_then(|value| value.as_array()) + { + for helper in auth_helpers { + if let Some(symbol) = helper.as_str() { + helpers.insert( + symbol.to_string(), + AcceptedAuthHelper { + guard_id: format!("auth:{symbol}"), + symbol: symbol.to_string(), + behavior: AuthGuardBehavior::Unknown, }, - "fact_ids": [security_fact_id(sink)] - })); + ); + } else if let Some(symbol) = helper + .get("symbol") + .or_else(|| helper.get("name")) + .and_then(|value| value.as_str()) + { + helpers.insert( + symbol.to_string(), + AcceptedAuthHelper { + guard_id: helper + .get("guard_id") + .and_then(|value| value.as_str()) + .unwrap_or(symbol) + .to_string(), + symbol: symbol.to_string(), + behavior: AuthGuardBehavior::Unknown, + }, + ); } } + } + helpers.into_values().collect() +} - let proven = !sinks.is_empty() && undominated_sinks.is_empty(); - let missing_proof_ids = if proven { - Vec::new() - } else { - vec![format!("missing_proof:{route_id}:auth")] - }; - let proof_id = format!("proof:{route_id}:auth"); - let finding_fingerprint = stable_hash(&format!( - "{}:{}:missing_auth_guard:{}", - convention.id, route_id, sinks[0].start_line - )); - let finding_id = format!("finding_{}", &finding_fingerprint[..16]); - proofs.push(json!({ - "proof_id": proof_id, - "proof_version": "security-boundary-proof/v1", - "route": { - "route_id": route_id, - "file_path": file_path, - "file_role": "api_route", - "handler_symbol": route - }, - "contracts": [{ - "contract_id": convention.id, - "kind": "api_route_requires_auth_helper", - "enforcement_mode": convention.enforcement_mode, - "capability": convention.enforcement_capability, - "matched": true - }], - "capability_status": [{ - "name": "control_flow_guard_dominance", - "status": "partial", - "can_block": true, - "parser_gap_ids": [], - "missing_proof_ids": missing_proof_ids - }], - "auth": { - "required": true, - "proven": proven, - "proof_kind": if proven { "handler_guard" } else { "none" }, - "trusted_guard_calls": guard_calls.iter().map(|guard| json!({ - "fact_id": security_fact_id(guard), - "guard_id": guard.name, - "symbol": guard.name, - "start_line": guard.start_line, - "end_line": guard.end_line - })).collect::>(), - "dominated_sinks": dominated_sinks, - "undominated_sinks": undominated_sinks - }, - "missing_proof": if proven { - Vec::::new() +fn read_repo_file(repo_root: Option<&str>, file_path: &str) -> Option { + let repo_root = repo_root?; + let path = Path::new(repo_root).join(file_path); + fs::read_to_string(path).ok() +} + +fn first_sink_line_for_route( + facts: &[Fact], + file_path: &str, + route_proof: &RouteSecurityBoundaryProof, +) -> Option { + let route = facts.iter().find(|fact| { + fact.file_path == file_path + && fact.kind == FactKind::RouteDeclared + && fact.name == route_proof.handler_symbol + })?; + facts + .iter() + .filter(|fact| { + fact.file_path == file_path + && route.start_line <= fact.start_line + && fact.end_line <= route.end_line + && matches!( + fact.kind, + FactKind::DataOperationDetected | FactKind::RouteReturnsResponse + ) + }) + .map(|fact| fact.start_line) + .min() +} + +fn route_security_proof_json( + proof: &RouteSecurityBoundaryProof, + convention: &crate::protocol::CheckConvention, + finding_id: &str, +) -> serde_json::Value { + let missing_proof_ids = if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::new() + } else { + proof + .missing_proof_codes + .iter() + .map(|code| format!("missing_proof:{}:{code}", proof.route_id)) + .collect::>() + }; + let parser_gap_ids = proof + .parser_gaps + .iter() + .map(|gap| gap.parser_gap_id.clone()) + .collect::>(); + let parser_gaps = proof + .parser_gaps + .iter() + .map(|gap| { + json!({ + "parser_gap_id": gap.parser_gap_id, + "capability": "control_flow_guard_dominance", + "code": gap.code, + "file_path": gap.file_path, + "reason": gap.reason, + "affected_contract_kinds": ["api_route_requires_auth_helper"], + "affected_route_ids": [proof.route_id.clone()], + "missing_proof_ids": missing_proof_ids, + "blocks_enforcement": gap.blocks_enforcement + }) + }) + .collect::>(); + let mut undominated_fact_ids = proof + .undominated_sinks + .iter() + .flat_map(|sink| sink.fact_ids.iter().cloned()) + .collect::>(); + undominated_fact_ids.sort(); + undominated_fact_ids.dedup(); + let missing_proof = proof + .missing_proof_codes + .iter() + .enumerate() + .map(|(index, code)| { + json!({ + "id": missing_proof_ids[index], + "capability": "control_flow_guard_dominance", + "code": code, + "blocks_enforcement": true, + "fact_ids": undominated_fact_ids.clone(), + "graph_edge_ids": [] + }) + }) + .collect::>(); + + json!({ + "proof_id": format!("proof:{}:auth", proof.route_id), + "proof_version": "security-boundary-proof/v1", + "route": { + "route_id": proof.route_id, + "file_path": proof.file_path, + "file_role": "api_route", + "handler_symbol": proof.handler_symbol + }, + "contracts": [{ + "contract_id": convention.id, + "kind": "api_route_requires_auth_helper", + "enforcement_mode": convention.enforcement_mode, + "capability": convention.enforcement_capability, + "matched": true + }], + "capability_status": [{ + "name": "control_flow_guard_dominance", + "status": "partial", + "can_block": true, + "parser_gap_ids": parser_gap_ids, + "missing_proof_ids": missing_proof_ids + }], + "auth": { + "required": proof.auth.required, + "proven": proof.auth.proven, + "proof_kind": if proof.auth.proven { "handler_guard" } else { "none" }, + "trusted_guard_calls": proof.trusted_guard_calls.iter().map(|guard| json!({ + "fact_id": guard.fact_id, + "guard_id": guard.guard_id, + "symbol": guard.symbol, + "start_line": guard.start_line, + "end_line": guard.end_line + })).collect::>(), + "dominated_sinks": proof.auth.dominated_sinks.iter().map(|sink| json!({ + "sink_id": sink.sink_id, + "sink_kind": sink.sink_kind, + "edge_id": sink.edge_id + })).collect::>(), + "undominated_sinks": proof.undominated_sinks.iter().map(|sink| json!({ + "sink_id": sink.sink_id, + "sink_kind": sink.sink_kind, + "reason": sink.reason, + "fact_ids": sink.fact_ids + })).collect::>() + }, + "missing_proof": missing_proof, + "parser_gaps": parser_gaps, + "result": { + "proof_status": security_proof_status(&proof.result.proof_status), + "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { + "pass" } else { - vec![json!({ - "id": missing_proof_ids[0], - "capability": "control_flow_guard_dominance", - "code": "missing_auth_guard", - "blocks_enforcement": true, - "fact_ids": [security_fact_id(sinks[0])], - "graph_edge_ids": [] - })] + convention.enforcement_mode.as_str() }, - "parser_gaps": [], - "result": { - "proof_status": if proven { "proven" } else { "missing_proof" }, - "enforcement_result": if proven { "pass" } else { convention.enforcement_mode.as_str() }, - "can_block": !proven, - "finding_ids": if proven { Vec::::new() } else { vec![finding_id.clone()] } + "can_block": proof.result.proof_status != SecurityProofStatus::Proven, + "finding_ids": if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::::new() + } else { + vec![finding_id.to_string()] } - })); - - if !proven { - findings.push(PendingFinding { - fingerprint: finding_fingerprint, - convention_id: convention.id.clone(), - rule_id: "api_route_requires_auth_helper".to_string(), - title: "API route missing required auth proof".to_string(), - message: "Accepted auth helper must dominate protected route sinks.".to_string(), - severity, - enforcement_result: enforcement_result_for_mode(enforcement_mode), - file_path: file_path.clone(), - import_name: "auth_guard".to_string(), - import_source: "missing_auth_guard".to_string(), - line: sinks[0].start_line, - evidence_id: format!("evidence_{}", &finding_id["finding_".len()..]), - legacy_fingerprints: Vec::new(), - related_node_ids: Vec::new(), - }); } - } + }) +} - SecurityAuthEvaluation { findings, proofs } +fn security_proof_status(status: &SecurityProofStatus) -> &'static str { + match status { + SecurityProofStatus::Proven => "proven", + SecurityProofStatus::MissingProof => "missing_proof", + SecurityProofStatus::ParserGap => "parser_gap", + } } fn security_auth_files( @@ -675,21 +826,6 @@ fn security_auth_files( .collect() } -fn security_sink_kind(kind: FactKind) -> &'static str { - match kind { - FactKind::DataOperationDetected => "data_operation", - FactKind::RouteReturnsResponse => "response", - _ => "unknown", - } -} - -fn security_fact_id(fact: &Fact) -> String { - format!( - "fact:{}:{}:{}", - fact.file_path, fact.kind as u8, fact.start_line - ) -} - fn graph_service_delegation_findings( graph: &CheckGraphData, convention_id: &str, @@ -997,6 +1133,9 @@ fn fact_kind_from_str(kind: &str) -> Option { "auth_guard_called" => Some(FactKind::AuthGuardCalled), "route_returns_response" => Some(FactKind::RouteReturnsResponse), "callback_boundary_detected" => Some(FactKind::CallbackBoundaryDetected), + "middleware_declared" => Some(FactKind::MiddlewareDeclared), + "middleware_matcher_declared" => Some(FactKind::MiddlewareMatcherDeclared), + "middleware_protects_route" => Some(FactKind::MiddlewareProtectsRoute), _ => None, } } diff --git a/drift v3/crates/drift-engine/src/lib.rs b/drift v3/crates/drift-engine/src/lib.rs index cc1f1ca7..e047ece3 100644 --- a/drift v3/crates/drift-engine/src/lib.rs +++ b/drift v3/crates/drift-engine/src/lib.rs @@ -32,15 +32,23 @@ pub use rules::{ pub use security_capabilities::{ SecurityCapabilityStatus, SecurityScanCapability, security_capabilities, }; +pub use security_control_flow::{ + MatchedMiddleware, MiddlewareMismatch, static_middleware_coverage, +}; pub use security_facts::extract_security_facts; -pub use security_patterns::{AcceptedAuthHelper, AuthGuardBehavior}; +pub use security_patterns::{ + AcceptedAuthHelper, AuthGuardBehavior, dynamic_middleware_matcher_line, +}; pub use security_proof::{ - AuthBoundaryProof, SecurityBoundaryProof, SecurityParserGap, SecurityProofResult, - SecurityProofStatus, build_auth_boundary_proof, + AuthBoundaryProof, MiddlewareBoundaryProof, RouteSecurityBoundaryProof, SecurityBoundaryProof, + SecurityParserGap, SecurityProofResult, SecurityProofStatus, TrustedGuardCallProof, + UndominatedSinkProof, build_auth_boundary_proof, build_auth_boundary_proofs_for_file, + build_middleware_coverage_proof, }; pub use security_rules::{ - SecurityAuthContract, SecurityEnforcementMode, SecurityFinding, SecurityFindingResult, - evaluate_api_route_requires_auth_helper, + SecurityAuthContract, SecurityContractCapability, SecurityEnforcementMode, SecurityFinding, + SecurityFindingResult, SecurityMiddlewareContract, evaluate_api_route_requires_auth_helper, + evaluate_api_route_requires_auth_helper_with_middleware, evaluate_middleware_must_cover_routes, }; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/protocol.rs b/drift v3/crates/drift-engine/src/protocol.rs index 806b1a96..7fb3a9a9 100644 --- a/drift v3/crates/drift-engine/src/protocol.rs +++ b/drift v3/crates/drift-engine/src/protocol.rs @@ -217,6 +217,8 @@ pub struct CheckGraphData { #[derive(Debug, Deserialize)] pub struct CheckRepoContext { pub repo_id: String, + #[serde(default)] + pub repo_root: Option, } #[derive(Debug, Deserialize)] @@ -308,7 +310,15 @@ pub struct EngineCandidateEvidenceRef { #[derive(Debug, Deserialize)] pub struct CheckContract { + #[serde(default)] + pub contract_id: Option, + #[serde(default)] + pub contract_schema_version: Option, pub conventions: Vec, + #[serde(default)] + pub waivers: Vec, + #[serde(default)] + pub exceptions: Vec, } #[derive(Debug, Deserialize)] @@ -316,6 +326,14 @@ pub struct CheckConvention { pub id: String, pub kind: String, pub matcher: CheckMatcher, + #[serde(default)] + pub requires: Option, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub exceptions: Vec, + #[serde(default)] + pub governance: Option, pub severity: String, pub enforcement_mode: String, pub enforcement_capability: String, @@ -503,12 +521,15 @@ pub fn capability_stats(required: &[&str], missing: &[&str]) -> EngineCapability pub fn certified_capabilities() -> Vec { [ "candidate_inference", + "auth_boundary_facts", + "control_flow_guard_dominance", "data_operation_detection", "direct_data_access_check", "file_discovery", "graph_stream", "import_resolution", "route_detection", + "security_facts", "symbol_linking", "syntax_facts", ] diff --git a/drift v3/crates/drift-engine/src/security_control_flow.rs b/drift v3/crates/drift-engine/src/security_control_flow.rs index 367bd941..e3ca8132 100644 --- a/drift v3/crates/drift-engine/src/security_control_flow.rs +++ b/drift v3/crates/drift-engine/src/security_control_flow.rs @@ -7,6 +7,21 @@ pub struct DominatedSink { pub edge_id: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchedMiddleware { + pub middleware_id: String, + pub matcher_fact_id: String, + pub protects_route_edge_id: String, + pub protection_kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiddlewareMismatch { + pub middleware_id: Option, + pub reason: String, + pub parser_gap_id: Option, +} + pub fn guard_dominates_straight_line_sinks(facts: &[Fact]) -> Vec { let Some(first_guard_line) = facts .iter() @@ -78,6 +93,41 @@ pub fn branch_bypass_reasons(source: &str, facts: &[Fact]) -> Vec { Vec::new() } +pub fn conditional_guard_without_else_reasons(source: &str, facts: &[Fact]) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + for (index, line) in lines.iter().enumerate() { + if !line.contains("if") || !line.contains('{') { + continue; + } + let if_line = index + 1; + let Some(block_end) = closing_block_line(&lines, if_line) else { + continue; + }; + let has_else = lines + .iter() + .skip(block_end) + .take_while(|candidate| { + candidate.trim().is_empty() || candidate.trim_start().starts_with('}') + }) + .any(|candidate| candidate.contains("else")) + || lines + .get(block_end.saturating_sub(1)) + .is_some_and(|candidate| candidate.contains("else")); + if has_else { + continue; + } + let guarded_range = if_line + 1..block_end; + let guard_inside_if = has_fact_in_range(facts, FactKind::AuthGuardCalled, guarded_range); + let sink_after_if = protected_sinks(facts) + .iter() + .any(|fact| fact.start_line > block_end); + if guard_inside_if && sink_after_if { + return vec!["guard_only_in_one_branch".to_string()]; + } + } + Vec::new() +} + pub fn callback_boundary_reasons(source: &str, facts: &[Fact]) -> Vec { let lines: Vec<&str> = source.lines().collect(); let guard_in_callback = facts @@ -110,6 +160,162 @@ pub fn protected_sinks(facts: &[Fact]) -> Vec<&Fact> { .collect() } +pub fn static_middleware_coverage( + middleware_facts: &[Fact], + route_file_path: &str, + route_method: &str, +) -> (Vec, Vec) { + let Some(route_path) = route_path_from_file(route_file_path) else { + return ( + Vec::new(), + vec![MiddlewareMismatch { + middleware_id: None, + reason: "unknown_framework".to_string(), + parser_gap_id: None, + }], + ); + }; + let middleware_id = middleware_facts + .iter() + .find(|fact| fact.kind == FactKind::MiddlewareDeclared) + .and_then(metadata_middleware_id); + let protection_kind = middleware_facts + .iter() + .find(|fact| fact.kind == FactKind::MiddlewareDeclared) + .and_then(metadata_protection_kind) + .unwrap_or_else(|| "unknown".to_string()); + + let mut matched = Vec::new(); + let mut mismatches = Vec::new(); + for matcher in middleware_facts + .iter() + .filter(|fact| fact.kind == FactKind::MiddlewareMatcherDeclared) + { + let metadata = matcher + .value + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()); + let pattern = metadata + .as_ref() + .and_then(|value| { + value + .get("path_pattern") + .and_then(|pattern| pattern.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| matcher.name.clone()); + let excluded = metadata + .as_ref() + .and_then(|value| value.get("matcher_kind")) + .and_then(|kind| kind.as_str()) + == Some("excluded_path"); + if excluded && static_matcher_covers_path(&pattern, &route_path) { + return ( + Vec::new(), + vec![MiddlewareMismatch { + middleware_id: middleware_id.clone(), + reason: "path_not_matched".to_string(), + parser_gap_id: None, + }], + ); + } + if static_matcher_covers_path(&pattern, &route_path) + && static_matcher_covers_method(&pattern, route_method) + { + let middleware_id = middleware_id + .clone() + .unwrap_or_else(|| format!("middleware:{}", matcher.file_path)); + matched.push(MatchedMiddleware { + matcher_fact_id: fact_id(matcher), + protects_route_edge_id: format!( + "edge:middleware-protects:{}:{}", + middleware_id, route_file_path + ), + middleware_id, + protection_kind: protection_kind.clone(), + }); + } else if !static_matcher_covers_path(&pattern, &route_path) { + mismatches.push(MiddlewareMismatch { + middleware_id: middleware_id.clone(), + reason: "path_not_matched".to_string(), + parser_gap_id: None, + }); + } else { + mismatches.push(MiddlewareMismatch { + middleware_id: middleware_id.clone(), + reason: "method_not_matched".to_string(), + parser_gap_id: None, + }); + } + } + (matched, mismatches) +} + +fn metadata_middleware_id(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + value + .get("middleware_id") + .and_then(|value| value.as_str()) + .map(str::to_string) +} + +fn metadata_protection_kind(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + value + .get("protection_kind") + .and_then(|value| value.as_str()) + .map(str::to_string) +} + +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(rest) = file_path + .strip_prefix("pages") + .and_then(|path| path.strip_suffix(".ts")) + { + return Some(rest.to_string()); + } + None +} + +fn static_matcher_covers_path(pattern: &str, route_path: &str) -> bool { + let pattern = pattern + .trim() + .rsplit_once('#') + .map(|(path, _)| path) + .unwrap_or_else(|| pattern.trim()); + if pattern == route_path { + return true; + } + if let Some(prefix) = pattern.strip_suffix("/:path*") { + return route_path == prefix || route_path.starts_with(&format!("{prefix}/")); + } + if let Some(prefix) = pattern.strip_suffix("(.*)") { + return route_path == prefix || route_path.starts_with(prefix); + } + false +} + +fn static_matcher_covers_method(pattern: &str, route_method: &str) -> bool { + if let Some((_, method)) = pattern.trim().rsplit_once('#') { + method.eq_ignore_ascii_case(route_method) + } else { + true + } +} + +fn fact_id(fact: &Fact) -> String { + format!( + "fact:{}:{}:{}", + fact.file_path, fact.kind as u8, fact.start_line + ) +} + fn sink_id(fact: &Fact) -> String { format!("sink:{}:{}:{}", fact.file_path, fact.start_line, fact.name) } diff --git a/drift v3/crates/drift-engine/src/security_patterns.rs b/drift v3/crates/drift-engine/src/security_patterns.rs index cdf56901..ebd67015 100644 --- a/drift v3/crates/drift-engine/src/security_patterns.rs +++ b/drift v3/crates/drift-engine/src/security_patterns.rs @@ -34,11 +34,87 @@ pub fn accepted_auth_helper_for_call<'a>( accepted_auth_helpers: &'a [AcceptedAuthHelper], ) -> Option<&'a AcceptedAuthHelper> { accepted_auth_helpers.iter().find(|helper| { - call.name == helper.symbol - || facts.iter().any(|fact| { - fact.kind == FactKind::ImportUsed - && fact.name == call.name - && fact.imported_name.as_deref() == Some(helper.symbol.as_str()) - }) + facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == call.name + && fact.imported_name.as_deref() == Some(helper.symbol.as_str()) + }) }) } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticMiddlewareMatcher { + pub path_pattern: String, + pub excluded: bool, + pub start_line: usize, + pub end_line: usize, +} + +pub fn static_middleware_matchers(source: &str) -> Vec { + let lines = source.lines().collect::>(); + let mut matchers = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !line.contains("matcher") { + continue; + } + let start_line = index + 1; + let mut matcher_text = line.to_string(); + if line.contains('[') && !line.contains(']') { + for next in lines.iter().skip(index + 1) { + matcher_text.push('\n'); + matcher_text.push_str(next); + if next.contains(']') { + break; + } + } + } + for value in quoted_values(&matcher_text) { + if value.starts_with('/') || value.starts_with("!/") { + matchers.push(StaticMiddlewareMatcher { + excluded: value.starts_with("!/"), + path_pattern: value.trim_start_matches('!').to_string(), + start_line, + end_line: start_line, + }); + } + } + } + matchers +} + +pub fn dynamic_middleware_matcher_line(source: &str) -> Option { + source.lines().enumerate().find_map(|(index, line)| { + let trimmed = line.trim(); + if trimmed.starts_with("matcher:") + && !trimmed.contains('"') + && !trimmed.contains('\'') + && !trimmed.contains('[') + { + Some(index + 1) + } else { + None + } + }) +} + +fn quoted_values(value: &str) -> Vec { + let mut values = Vec::new(); + let mut chars = value.char_indices().peekable(); + while let Some((_, current)) = chars.next() { + if current != '"' && current != '\'' { + continue; + } + let quote = current; + let mut quoted = String::new(); + for (_, next) in chars.by_ref() { + if next == quote { + break; + } + quoted.push(next); + } + if !quoted.is_empty() { + values.push(quoted); + } + } + values +} diff --git a/drift v3/crates/drift-engine/src/security_proof.rs b/drift v3/crates/drift-engine/src/security_proof.rs index 9dac3cbe..206d82e4 100644 --- a/drift v3/crates/drift-engine/src/security_proof.rs +++ b/drift v3/crates/drift-engine/src/security_proof.rs @@ -1,19 +1,52 @@ use crate::{ AcceptedAuthHelper, Fact, FactExtractError, extract_security_facts, extract_typescript_facts, security_control_flow::{ - DominatedSink, branch_bypass_reasons, callback_boundary_reasons, - guard_dominates_straight_line_sinks, protected_sinks, undominated_straight_line_reasons, - unsupported_dynamic_control_flow, + DominatedSink, MatchedMiddleware, MiddlewareMismatch, branch_bypass_reasons, + callback_boundary_reasons, conditional_guard_without_else_reasons, + guard_dominates_straight_line_sinks, protected_sinks, static_middleware_coverage, + undominated_straight_line_reasons, unsupported_dynamic_control_flow, }, + security_patterns::dynamic_middleware_matcher_line, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecurityBoundaryProof { pub auth: AuthBoundaryProof, + pub middleware: MiddlewareBoundaryProof, pub parser_gaps: Vec, pub result: SecurityProofResult, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteSecurityBoundaryProof { + pub route_id: String, + pub file_path: String, + pub handler_symbol: String, + pub auth: AuthBoundaryProof, + pub trusted_guard_calls: Vec, + pub undominated_sinks: Vec, + pub parser_gaps: Vec, + pub missing_proof_codes: Vec, + pub result: SecurityProofResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedGuardCallProof { + pub fact_id: String, + pub guard_id: String, + pub symbol: String, + pub start_line: usize, + pub end_line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UndominatedSinkProof { + pub sink_id: String, + pub sink_kind: String, + pub reason: String, + pub fact_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthBoundaryProof { pub required: bool, @@ -22,6 +55,14 @@ pub struct AuthBoundaryProof { pub undominated_sinks: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiddlewareBoundaryProof { + pub required: bool, + pub proven: bool, + pub matched_middleware: Vec, + pub mismatches: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecurityProofResult { pub proof_status: SecurityProofStatus, @@ -92,6 +133,12 @@ pub fn build_auth_boundary_proof( dominated_sinks, undominated_sinks, }, + middleware: MiddlewareBoundaryProof { + required: false, + proven: false, + matched_middleware: Vec::new(), + mismatches: Vec::new(), + }, parser_gaps, result: SecurityProofResult { proof_status: if dynamic_control_flow { @@ -104,3 +151,302 @@ pub fn build_auth_boundary_proof( }, }) } + +pub fn build_auth_boundary_proofs_for_file( + file_path: impl AsRef, + source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result, FactExtractError> { + let file_path_string = file_path.as_ref().to_string_lossy().replace('\\', "/"); + let base_facts = extract_typescript_facts(&file_path, source)?; + let security_facts = extract_security_facts(file_path, source, accepted_auth_helpers)?; + let mut facts: Vec = base_facts.into_iter().chain(security_facts).collect(); + facts.sort_by_key(|fact| fact.start_line); + + let routes = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::RouteDeclared) + .cloned() + .collect::>(); + let mut proofs = Vec::new(); + for route in routes { + let route_facts = facts + .iter() + .filter(|fact| route.start_line <= fact.start_line && fact.end_line <= route.end_line) + .cloned() + .collect::>(); + let route_id = format!("route:{}:{}", file_path_string, route.name); + proofs.push(build_route_auth_boundary_proof( + route_id, + file_path_string.clone(), + route.name, + source, + &route_facts, + )); + } + Ok(proofs) +} + +fn build_route_auth_boundary_proof( + route_id: String, + file_path: String, + handler_symbol: String, + route_source: &str, + facts: &[Fact], +) -> RouteSecurityBoundaryProof { + let sinks = protected_sinks(facts); + let first_guard_line = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::AuthGuardCalled) + .map(|fact| fact.start_line) + .min(); + let trusted_guard_calls = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::AuthGuardCalled) + .map(|fact| TrustedGuardCallProof { + fact_id: fact_id(fact), + guard_id: guard_id(fact), + symbol: fact + .imported_name + .clone() + .unwrap_or_else(|| fact.name.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }) + .collect::>(); + let callback_reasons = callback_boundary_reasons(route_source, facts); + let branch_reasons = branch_bypass_reasons(route_source, facts); + let conditional_reasons = conditional_guard_without_else_reasons(route_source, facts); + let dynamic_control_flow = unsupported_dynamic_control_flow(route_source); + let path_sensitive_reasons = callback_reasons + .iter() + .chain(branch_reasons.iter()) + .chain(conditional_reasons.iter()) + .cloned() + .collect::>(); + + let mut dominated_sinks = Vec::new(); + let mut undominated_sinks = Vec::new(); + let mut undominated_sink_proofs = Vec::new(); + for sink in sinks { + let sink_id = sink_id(sink); + let sink_kind = sink_kind(sink).to_string(); + let fact_ids = vec![fact_id(sink)]; + if dynamic_control_flow { + undominated_sinks.push("unsupported_dynamic_control_flow".to_string()); + undominated_sink_proofs.push(UndominatedSinkProof { + sink_id, + sink_kind, + reason: "unsupported_dynamic_control_flow".to_string(), + fact_ids, + }); + } else if let Some(reason) = path_sensitive_reasons.first() { + undominated_sinks.push(reason.clone()); + undominated_sink_proofs.push(UndominatedSinkProof { + sink_id, + sink_kind, + reason: reason.clone(), + fact_ids, + }); + } else if first_guard_line.is_some_and(|line| line < sink.start_line) { + dominated_sinks.push(DominatedSink { + sink_id, + sink_kind, + edge_id: format!("edge:auth-dominates:{}:{}", sink.file_path, sink.start_line), + }); + } else if first_guard_line.is_some_and(|line| line > sink.start_line) { + undominated_sinks.push("guard_after_sink".to_string()); + undominated_sink_proofs.push(UndominatedSinkProof { + sink_id, + sink_kind, + reason: "guard_after_sink".to_string(), + fact_ids, + }); + } else { + undominated_sinks.push("no_guard_call".to_string()); + undominated_sink_proofs.push(UndominatedSinkProof { + sink_id, + sink_kind, + reason: "no_guard_call".to_string(), + fact_ids, + }); + } + } + undominated_sinks.sort(); + undominated_sinks.dedup(); + undominated_sink_proofs + .sort_by(|left, right| (&left.reason, &left.sink_id).cmp(&(&right.reason, &right.sink_id))); + undominated_sink_proofs + .dedup_by(|left, right| left.reason == right.reason && left.sink_id == right.sink_id); + + let parser_gaps = if dynamic_control_flow { + vec![SecurityParserGap { + parser_gap_id: format!("{route_id}:parser_gap:unsupported_dynamic_control_flow"), + code: "unsupported_dynamic_control_flow".to_string(), + file_path: file_path.clone(), + reason: "Unsupported dynamic control flow prevents auth dominance proof".to_string(), + blocks_enforcement: true, + }] + } else { + Vec::new() + }; + let sink_count = protected_sinks(facts).len(); + let proven = + sink_count > 0 && dominated_sinks.len() == sink_count && undominated_sinks.is_empty(); + let missing_proof_codes = if proven { + Vec::new() + } else if dynamic_control_flow { + vec!["unsupported_dynamic_control_flow".to_string()] + } else { + undominated_sinks + .iter() + .map(|reason| missing_proof_code(reason).to_string()) + .collect() + }; + + RouteSecurityBoundaryProof { + route_id, + file_path, + handler_symbol, + auth: AuthBoundaryProof { + required: true, + proven, + dominated_sinks, + undominated_sinks, + }, + trusted_guard_calls, + undominated_sinks: undominated_sink_proofs, + parser_gaps, + missing_proof_codes, + result: SecurityProofResult { + proof_status: if dynamic_control_flow { + SecurityProofStatus::ParserGap + } else if proven { + SecurityProofStatus::Proven + } else { + SecurityProofStatus::MissingProof + }, + }, + } +} + +fn missing_proof_code(reason: &str) -> &'static str { + match reason { + "no_guard_call" => "missing_auth_guard", + "unsupported_dynamic_control_flow" => "unsupported_dynamic_control_flow", + _ => "auth_guard_not_dominating_sink", + } +} + +fn fact_id(fact: &Fact) -> String { + format!( + "fact:{}:{}:{}", + fact.file_path, fact.kind as u8, fact.start_line + ) +} + +fn guard_id(fact: &Fact) -> String { + fact.value + .as_ref() + .and_then(|value| serde_json::from_str::(value).ok()) + .and_then(|value| { + value + .get("guard_id") + .and_then(|guard| guard.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "auth:{}", + fact.imported_name.as_deref().unwrap_or(&fact.name) + ) + }) +} + +fn sink_id(fact: &Fact) -> String { + format!("sink:{}:{}:{}", fact.file_path, fact.start_line, fact.name) +} + +fn sink_kind(fact: &Fact) -> &'static str { + match fact.kind { + crate::FactKind::DataOperationDetected => "data_operation", + crate::FactKind::RouteReturnsResponse => "response", + _ => "unknown", + } +} + +pub fn build_middleware_coverage_proof( + middleware_file_path: impl AsRef, + middleware_source: &str, + route_file_path: impl AsRef, + route_source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result { + let middleware_file_path_string = middleware_file_path + .as_ref() + .to_string_lossy() + .replace('\\', "/"); + let middleware_facts = extract_security_facts( + &middleware_file_path, + middleware_source, + accepted_auth_helpers, + )?; + let route_facts = extract_typescript_facts(&route_file_path, route_source)?; + let route_file_path = route_file_path + .as_ref() + .to_string_lossy() + .replace('\\', "/"); + let route_method = route_facts + .iter() + .find(|fact| fact.kind == crate::FactKind::RouteDeclared) + .map(|fact| fact.name.as_str()) + .unwrap_or("GET"); + let (matched_middleware, mismatches) = + static_middleware_coverage(&middleware_facts, &route_file_path, route_method); + let dynamic_matcher_line = dynamic_middleware_matcher_line(middleware_source); + let parser_gaps = dynamic_matcher_line + .map(|line| { + vec![SecurityParserGap { + parser_gap_id: format!( + "parser_gap:{}:{}:unsupported_dynamic_middleware_matcher", + middleware_file_path_string, line + ), + code: "unsupported_dynamic_middleware_matcher".to_string(), + file_path: middleware_file_path_string.clone(), + reason: "Dynamic middleware matcher prevents deterministic route coverage proof" + .to_string(), + blocks_enforcement: true, + }] + }) + .unwrap_or_default(); + let proven = parser_gaps.is_empty() + && !matched_middleware.is_empty() + && matched_middleware + .iter() + .any(|middleware| middleware.protection_kind == "auth"); + + Ok(SecurityBoundaryProof { + auth: AuthBoundaryProof { + required: false, + proven: false, + dominated_sinks: Vec::new(), + undominated_sinks: Vec::new(), + }, + middleware: MiddlewareBoundaryProof { + required: true, + proven, + matched_middleware, + mismatches, + }, + parser_gaps, + result: SecurityProofResult { + proof_status: if dynamic_matcher_line.is_some() { + SecurityProofStatus::ParserGap + } else if proven { + SecurityProofStatus::Proven + } else { + SecurityProofStatus::MissingProof + }, + }, + }) +} 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 new file mode 100644 index 00000000..cdc5369d --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs @@ -0,0 +1,388 @@ +use std::{ + fs, + io::Write, + process::{Command, Stdio}, +}; + +use serde_json::{Value, json}; + +#[test] +fn check_repo_does_not_use_get_auth_guard_for_unguarded_post() { + let repo_root = temp_repo("multi_handler"); + let route_path = repo_root.join("app/api/projects/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();", + " return Response.json({ ok: true });", + "}", + "", + "export async function POST() {", + " const project = await db.project.create({ data: {} });", + " return Response.json({ project });", + "}", + "", + ] + .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": [ + fact("file_role_detected", "api_route", 1, 12, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("route_declared", "GET", 4, 7, None, None), + fact("symbol_called", "requireUser", 5, 5, None, None), + fact("route_returns_response", "json", 6, 6, Some("Response"), None), + fact("route_declared", "POST", 9, 12, None, None), + fact("symbol_called", "create", 10, 10, Some("db.project"), None), + fact("data_operation_detected", "create", 10, 10, Some("db.project"), Some("write:project")), + fact("route_returns_response", "json", 11, 11, Some("Response"), None) + ] + }, + "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 findings = payload["findings"].as_array().expect("findings"); + assert_eq!(findings.len(), 1, "{payload:#?}"); + assert_eq!(findings[0]["rule_id"], "api_route_requires_auth_helper"); + assert_eq!(findings[0]["evidence"][0]["start_line"], 10); + let proofs = payload["security_boundary_proofs"] + .as_array() + .expect("proofs"); + assert!( + proofs.iter().any(|proof| { + proof["route"]["handler_symbol"] == "POST" + && proof["result"]["proof_status"] == "missing_proof" + }), + "{payload:#?}" + ); +} + +#[test] +fn check_repo_blocks_auth_guard_in_only_one_branch() { + let source = [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "export async function GET(request: Request) {", + r#" if (request.headers.get("x-auth") === "yes") {"#, + " await requireUser();", + " } else {", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + " }", + " return Response.json({ ok: true });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("branch_bypass", &source, "required_calls"); + assert_auth_failure(&payload, "missing_proof", "guard_only_in_one_branch"); +} + +#[test] +fn check_repo_blocks_callback_auth_guard_for_outer_sink() { + let source = [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "export async function GET() {", + r#" ["auth"].forEach(async () => {"#, + " await requireUser();", + " });", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("callback_bypass", &source, "required_calls"); + assert_auth_failure(&payload, "missing_proof", "callback_boundary"); +} + +#[test] +fn check_repo_blocks_conditional_guard_without_else_before_sink() { + let source = [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "export async function GET(request: Request) {", + r#" if (request.headers.get("x-auth") === "yes") {"#, + " await requireUser();", + " }", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("conditional_without_else", &source, "required_calls"); + assert_auth_failure(&payload, "missing_proof", "guard_only_in_one_branch"); +} + +#[test] +fn check_repo_uses_security_proof_parser_gaps_and_missing_proofs() { + let source = [ + r#"import { requireUser } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "const guards = { requireUser };", + "", + "export async function GET(request: Request) {", + r#" const guard = guards[request.headers.get("x-guard") as keyof typeof guards];"#, + " await guard();", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("dynamic_control_flow", &source, "required_calls"); + assert_auth_failure(&payload, "parser_gap", "unsupported_dynamic_control_flow"); + assert_eq!( + payload["security_boundary_proofs"][0]["parser_gaps"][0]["code"], + "unsupported_dynamic_control_flow" + ); + assert!( + !payload["security_boundary_proofs"][0]["missing_proof"][0]["fact_ids"] + .as_array() + .expect("missing proof fact ids") + .is_empty(), + "{payload:#?}" + ); +} + +#[test] +fn canonical_requires_auth_helpers_normalizes_trusted_guard_calls() { + 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("canonical_requires", &source, "canonical_requires"); + assert_eq!(payload["findings"].as_array().expect("findings").len(), 0); + assert_eq!( + payload["security_boundary_proofs"][0]["result"]["proof_status"], + "proven" + ); + assert_eq!( + payload["security_boundary_proofs"][0]["auth"]["trusted_guard_calls"][0]["guard_id"], + "auth:requireUser" + ); +} + +#[test] +fn accepted_auth_helper_import_alias_is_trusted() { + let source = [ + r#"import { requireUser as requireAuth } from "@/server/auth";"#, + r#"import { db } from "@/server/db";"#, + "", + "export async function GET() {", + " await requireAuth();", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("alias", &source, "required_calls"); + assert_eq!(payload["findings"].as_array().expect("findings").len(), 0); + assert_eq!( + payload["security_boundary_proofs"][0]["result"]["proof_status"], + "proven" + ); +} + +#[test] +fn name_only_auth_looking_helper_cannot_satisfy_or_block() { + let source = [ + r#"import { db } from "@/server/db";"#, + "", + "function requireUser() { return { id: 'local' }; }", + "", + "export async function GET() {", + " await requireUser();", + " const projects = await db.project.findMany();", + " return Response.json({ projects });", + "}", + "", + ] + .join("\n"); + let payload = run_auth_fixture("name_only", &source, "required_calls"); + assert_auth_failure(&payload, "missing_proof", "no_guard_call"); +} + +fn fact( + kind: &str, + name: &str, + start_line: usize, + end_line: usize, + value: Option<&str>, + imported_name: Option<&str>, +) -> Value { + json!({ + "kind": kind, + "file_path": "app/api/projects/route.ts", + "name": name, + "value": value, + "imported_name": imported_name, + "start_line": start_line, + "end_line": end_line + }) +} + +fn run_check_repo(request: Value) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_drift-engine")) + .arg("check-repo") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn drift-engine"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(request.to_string().as_bytes()) + .expect("write request"); + let output = child.wait_with_output().expect("wait output"); + assert!( + output.status.success(), + "engine failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json output") +} + +fn temp_repo(name: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "drift-security-check-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create temp repo"); + path +} + +fn run_auth_fixture(name: &str, source: &str, contract_shape: &str) -> Value { + let repo_root = temp_repo(name); + let route_path = repo_root.join("app/api/projects/route.ts"); + fs::create_dir_all(route_path.parent().expect("route parent")).expect("create route parent"); + fs::write(&route_path, source).expect("write route"); + let scan = run_scan_repo(&repo_root); + let facts = scan["facts"].clone(); + let convention = if contract_shape == "canonical_requires" { + json!({ + "id": "security_api_auth_require_user", + "kind": "api_route_requires_auth_helper", + "matcher": { "applies_to_file_roles": ["api_route"] }, + "requires": { "auth_helpers": ["requireUser"] }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }) + } else { + json!({ + "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" + }) + }; + + run_check_repo(json!({ + "repo": { + "repo_id": "repo_auth", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_auth", + "facts": facts + }, + "contract": { + "contract_id": "contract_auth", + "contract_schema_version": 1, + "conventions": [convention] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })) +} + +fn run_scan_repo(repo_root: &std::path::Path) -> Value { + let output = Command::new(env!("CARGO_BIN_EXE_drift-engine")) + .args([ + "scan-repo", + repo_root.to_str().expect("repo root"), + "--format", + "json", + "--repo-id", + "repo_auth", + "--scan-id", + "scan_auth", + ]) + .output() + .expect("run scan-repo"); + assert!( + output.status.success(), + "scan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("scan json") +} + +fn assert_auth_failure(payload: &Value, proof_status: &str, reason: &str) { + let findings = payload["findings"].as_array().expect("findings"); + assert_eq!(findings.len(), 1, "{payload:#?}"); + assert_eq!(findings[0]["rule_id"], "api_route_requires_auth_helper"); + let proof = &payload["security_boundary_proofs"][0]; + assert_eq!(proof["result"]["proof_status"], proof_status); + assert!( + proof["auth"]["undominated_sinks"] + .as_array() + .expect("undominated sinks") + .iter() + .any(|sink| sink["reason"] == reason), + "{payload:#?}" + ); +} diff --git a/drift v3/packages/cli/src/check/run-check.ts b/drift v3/packages/cli/src/check/run-check.ts index 50eae797..49bf8222 100644 --- a/drift v3/packages/cli/src/check/run-check.ts +++ b/drift v3/packages/cli/src/check/run-check.ts @@ -1839,6 +1839,9 @@ async function runEngineOwnedDirectDataAccessCheck(input: { repoId: input.repoId, repoRoot: input.repoRoot, scanId: input.checkData.snapshots[0]?.scan_id ?? input.checkScanId, + contractId: input.contract.id, + contractSchemaVersion: input.contract.contract_schema_version, + contractWaivers: input.contract.waivers, facts, snapshots, graphNodes: graph.nodes, @@ -1935,6 +1938,9 @@ async function runEngineOwnedAuthCheck(input: { repoId: input.repoId, repoRoot: input.repoRoot, scanId: input.checkData.snapshots[0]?.scan_id ?? input.checkScanId, + contractId: input.contract.id, + contractSchemaVersion: input.contract.contract_schema_version, + contractWaivers: input.contract.waivers, facts: input.checkData.facts.filter((fact) => fileSet.has(fact.file_path)), snapshots: input.checkData.snapshots.filter((snapshot) => fileSet.has(snapshot.file_path)), conventions: [convention], diff --git a/drift v3/packages/cli/src/engine/engine-check.ts b/drift v3/packages/cli/src/engine/engine-check.ts index 079fa12a..24e62648 100644 --- a/drift v3/packages/cli/src/engine/engine-check.ts +++ b/drift v3/packages/cli/src/engine/engine-check.ts @@ -1,4 +1,4 @@ -import type { AcceptedConvention,BaselineViolation,FactRecord,FileSnapshot } from "@drift/core"; +import type { AcceptedConvention,BaselineViolation,ConventionException,FactRecord,FileSnapshot } from "@drift/core"; import type { EngineCheckRequest,EngineCheckResult,EngineDiagnostic } from "@drift/engine-contract"; import type { GraphEdge,GraphEvidence,GraphNode } from "@drift/factgraph"; import { parseEngineCheckResult } from "@drift/engine-contract"; @@ -10,6 +10,10 @@ export interface EngineCheckInput { repoId: string; repoRoot: string; scanId: string; + contractId?: string; + contractSchemaVersion?: number; + contractWaivers?: ConventionException[]; + contractExceptions?: ConventionException[]; facts: FactRecord[]; snapshots: FileSnapshot[]; graphNodes?: GraphNode[]; @@ -64,19 +68,36 @@ export function engineCheckRequest(input: EngineCheckInput): EngineCheckRequest })) }, contract: { - contract_id: input.conventions[0]?.contract_id ?? "contract_unknown", - contract_schema_version: 1, + contract_id: input.contractId ?? input.conventions[0]?.contract_id ?? "contract_unknown", + contract_schema_version: input.contractSchemaVersion ?? 1, conventions: input.conventions.map((convention) => ({ id: convention.id, rule_id: convention.kind, kind: convention.kind, matcher: convention.matcher as unknown as Record, + scope: convention.scope as unknown as Record, + requires: securityRequires(convention), + exceptions: convention.exceptions as unknown as Array>, + governance: { + accepted_by: convention.accepted_by, + accepted_at: convention.accepted_at, + updated_at: convention.updated_at, + expires_at: convention.expires_at, + rationale: convention.rationale, + evidence_refs: convention.evidence_refs.map((evidence) => evidence.id), + counterexample_refs: convention.counterexample_refs.map((evidence) => evidence.id) + }, severity: engineConventionSeverity(convention.severity), enforcement_mode: convention.enforcement_mode, enforcement_capability: convention.enforcement_capability })), - waivers: [], - exceptions: [] + waivers: (input.contractWaivers ?? []).map((waiver) => ({ + id: waiver.id, + convention_id: waiver.contract_kinds?.[0], + path_globs: waiver.path_globs, + reason: waiver.reason + })), + exceptions: (input.contractExceptions ?? []).map((exception) => exception as unknown as Record) }, baseline: input.baseline.map((entry) => ({ convention_id: entry.convention_id, @@ -104,6 +125,26 @@ export function engineCheckRequest(input: EngineCheckInput): EngineCheckRequest }; } +function securityRequires(convention: AcceptedConvention): Record | undefined { + const conventionWithRequires = convention as AcceptedConvention & { requires?: unknown }; + if (isRecord(conventionWithRequires.requires)) { + return conventionWithRequires.requires; + } + if (convention.kind !== "api_route_requires_auth_helper" || !convention.matcher.required_calls?.length) { + return undefined; + } + return { + auth_helpers: convention.matcher.required_calls.map((symbol) => ({ + guard_id: `auth:${symbol}`, + symbol + })) + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function engineConventionSeverity(severity: AcceptedConvention["severity"]): "info" | "warning" | "error" { if (severity === "blocking" || severity === "release_blocking") { return "error"; diff --git a/drift v3/packages/cli/test/security-check.test.ts b/drift v3/packages/cli/test/security-check.test.ts index 94cfc530..d0a9c40b 100644 --- a/drift v3/packages/cli/test/security-check.test.ts +++ b/drift v3/packages/cli/test/security-check.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { afterEach } from "vitest"; import { openDriftStorage } from "@drift/storage"; import { runCheck } from "../src/check/run-check.js"; -import { runEngineCheck } from "../src/engine/engine-check.js"; +import { engineCheckRequest, runEngineCheck } from "../src/engine/engine-check.js"; import { buildSecurityCheckJson } from "../src/check/security-check.js"; const tempDirs: string[] = []; @@ -50,9 +50,24 @@ describe("security check bridge", () => { }); it("receives SecurityBoundaryProof.auth from engine-owned auth checks", async () => { + const dir = await mkdtemp(join(tmpdir(), "drift-security-auth-bridge-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const routePath = "app/api/projects/route.ts"; + await mkdir(join(repoRoot, "app/api/projects"), { recursive: true }); + await writeFile(join(repoRoot, routePath), [ + "const db = { project: { findMany: async () => [] } };", + "", + "export async function GET() {", + " const projects = await db.project.findMany();", + " return Response.json(projects);", + "}", + "" + ].join("\n")); + const result = await runEngineCheck({ repoId: "repo_abc", - repoRoot: process.cwd(), + repoRoot, scanId: "scan_security_auth", snapshots: [{ repo_id: "repo_abc", @@ -112,6 +127,69 @@ describe("security check bridge", () => { }); }); + it("sends canonical security contract fields to the Rust check request", () => { + const request = engineCheckRequest({ + repoId: "repo_abc", + repoRoot: process.cwd(), + scanId: "scan_security_auth", + contractId: "contract_abc", + contractSchemaVersion: 2, + contractWaivers: [{ + id: "waiver_auth_projects", + reason: "accepted temporary drift", + path_globs: ["app/api/projects/route.ts"], + created_by: "test", + created_at: "2026-05-25T00:00:00.000Z" + }], + snapshots: [], + facts: [], + conventions: [{ + id: "security_api_auth_require_user", + repo_id: "repo_abc", + contract_id: "contract_abc", + kind: "api_route_requires_auth_helper", + statement: "API routes require accepted auth helper dominance.", + 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: [{ guard_id: "auth:requireUser", symbol: "requireUser" }] + }, + severity: "error", + enforcement_mode: "block", + enforcement_capability: "deterministic_check", + exceptions: [{ + id: "except_public", + reason: "public endpoint", + path_globs: ["app/api/public/route.ts"], + created_by: "test", + created_at: "2026-05-25T00:00:00.000Z" + }], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z" + }], + baseline: [], + diff: { files: [], deletedFiles: [] }, + scope: "changed-files" + }); + + expect(request.contract.contract_schema_version).toBe(2); + expect(request.contract.waivers).toHaveLength(1); + expect(request.contract.conventions[0]).toMatchObject({ + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + requires: { + auth_helpers: [{ guard_id: "auth:requireUser", symbol: "requireUser" }] + }, + exceptions: [expect.objectContaining({ id: "except_public" })], + governance: expect.objectContaining({ accepted_by: "test" }) + }); + }); + it("returns SecurityBoundaryProof.auth in drift check JSON output", async () => { const { databasePath, repoRoot, diffPath } = await seedAuthCheckDatabase(); const storage = openDriftStorage({ databasePath }); @@ -148,6 +226,27 @@ describe("security check bridge", () => { expect(JSON.stringify(payload)).not.toContain("await db.project.findMany()"); expect(repoRoot).toContain("drift-security-auth-check-"); }); + + it("returns middleware coverage proof in drift check JSON output", () => { + const payload = buildSecurityCheckJson({ + repo_id: "repo_abc", + scope: "changed-files", + changed_files: ["app/api/projects/route.ts"], + proofs: [middlewareProof("proof_middleware", "app/api/projects/route.ts")], + findings: [] + }); + + expect(payload.security_boundary_proofs[0]?.middleware).toMatchObject({ + required: true, + proven: true, + matched_middleware: [expect.objectContaining({ + middleware_id: "middleware:middleware.ts", + protection_kind: "auth" + })] + }); + expect(payload.summary.middleware_coverage_proven_count).toBe(1); + expect(JSON.stringify(payload)).not.toContain("requireUser()"); + }); }); function fact(kind: string, name: string, line: number, value?: string) { @@ -212,6 +311,59 @@ function securityProof(proofId: string, filePath: string, findingId: string) { } as const; } +function middlewareProof(proofId: string, filePath: string) { + return { + proof_id: proofId, + proof_version: "security-boundary-proof/v1", + route: { + route_id: proofId.replace("proof", "route"), + file_path: filePath, + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_middleware_api_coverage", + kind: "middleware_must_cover_routes", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "middleware_coverage", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "middleware_guard", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + middleware: { + required: true, + proven: true, + matched_middleware: [{ + middleware_id: "middleware:middleware.ts", + matcher_fact_id: "fact_middleware_matcher", + protects_route_edge_id: "edge_middleware_projects", + protection_kind: "auth" + }], + mismatches: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + } + } as const; +} + async function seedAuthCheckDatabase(): Promise<{ databasePath: string; repoRoot: string; diff --git a/drift v3/packages/core/src/domain.ts b/drift v3/packages/core/src/domain.ts index 098c255b..80d3e847 100644 --- a/drift v3/packages/core/src/domain.ts +++ b/drift v3/packages/core/src/domain.ts @@ -2,6 +2,7 @@ export type ConventionKind = | "api_route_no_direct_data_access" | "api_route_requires_service_delegation" | "api_route_requires_auth_helper" + | "middleware_must_cover_routes" | "test_expected_for_changed_module" | "custom_briefing" | AgentContractKind; @@ -256,7 +257,10 @@ export type FactKind = | "test_declared" | "auth_guard_called" | "route_returns_response" - | "callback_boundary_detected"; + | "callback_boundary_detected" + | "middleware_declared" + | "middleware_matcher_declared" + | "middleware_protects_route"; export type FactEvidenceLevel = "path" | "text" | "ast" | "graph" | "heuristic"; export type FactResolutionStatus = "resolved" | "unresolved" | "partial" | "unsupported"; @@ -626,6 +630,7 @@ export interface AcceptedConvention { rationale?: string; scope: ConventionScope; matcher: ConventionMatcher; + requires?: Record; severity: Severity; enforcement_mode: EnforcementMode; enforcement_capability: EnforcementCapability; diff --git a/drift v3/packages/core/src/schemas.ts b/drift v3/packages/core/src/schemas.ts index 8987f82b..9f9e7393 100644 --- a/drift v3/packages/core/src/schemas.ts +++ b/drift v3/packages/core/src/schemas.ts @@ -20,6 +20,7 @@ export const ConventionKindSchema = z.enum([ "api_route_no_direct_data_access", "api_route_requires_service_delegation", "api_route_requires_auth_helper", + "middleware_must_cover_routes", "test_expected_for_changed_module", "custom_briefing", "file_role", @@ -279,7 +280,10 @@ export const FactKindSchema = z.enum([ "test_declared", "auth_guard_called", "route_returns_response", - "callback_boundary_detected" + "callback_boundary_detected", + "middleware_declared", + "middleware_matcher_declared", + "middleware_protects_route" ]); export const FactEvidenceLevelSchema = z.enum(["path", "text", "ast", "graph", "heuristic"]); @@ -673,6 +677,7 @@ export const AcceptedConventionSchema = z.object({ rationale: z.string().optional(), scope: ConventionScopeSchema, matcher: ConventionMatcherSchema, + requires: z.record(z.unknown()).optional(), severity: SeveritySchema, enforcement_mode: EnforcementModeSchema, enforcement_capability: EnforcementCapabilitySchema, diff --git a/drift v3/packages/core/src/security.ts b/drift v3/packages/core/src/security.ts index 223d780b..e4bdf74d 100644 --- a/drift v3/packages/core/src/security.ts +++ b/drift v3/packages/core/src/security.ts @@ -3,13 +3,17 @@ import { z } from "zod"; export const SecurityCapabilityNameSchema = z.enum([ "security_facts", "auth_boundary_facts", - "control_flow_guard_dominance" + "control_flow_guard_dominance", + "middleware_coverage" ]); export const SecurityMissingProofCodeSchema = z.enum([ "missing_auth_guard", "auth_guard_not_dominating_sink", + "middleware_not_covering_route", + "middleware_dynamic_matcher", "unsupported_callback_boundary", + "unsupported_dynamic_control_flow", "route_binding_unresolved", "handler_unresolved" ]); @@ -18,12 +22,18 @@ export const SecurityParserGapCodeSchema = z.enum([ "route_binding_unresolved", "handler_unresolved", "unsupported_dynamic_control_flow", + "unsupported_dynamic_middleware_matcher", "unsupported_callback_boundary" ]); +const SecurityContractKindSchema = z.enum([ + "api_route_requires_auth_helper", + "middleware_must_cover_routes" +]); + export const SecurityConventionSchema = z.object({ contract_id: z.string().min(1), - kind: z.literal("api_route_requires_auth_helper"), + kind: SecurityContractKindSchema, capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]), enforcement_mode: z.enum(["off", "brief", "warn", "block"]), matcher: z.object({ @@ -110,6 +120,22 @@ const SecurityAuthProofSchema = z.object({ })) }); +const SecurityMiddlewareProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + matched_middleware: z.array(z.object({ + middleware_id: z.string().min(1), + matcher_fact_id: z.string().min(1), + protects_route_edge_id: z.string().min(1), + protection_kind: z.enum(["auth", "csrf", "rate_limit", "cors", "unknown"]) + })), + mismatches: z.array(z.object({ + middleware_id: z.string().min(1).optional(), + reason: z.enum(["path_not_matched", "method_not_matched", "dynamic_matcher", "unknown_framework"]), + parser_gap_id: z.string().min(1).optional() + })) +}); + const SecurityMissingProofSchema = z.object({ id: z.string().min(1), capability: z.string().min(1), @@ -153,6 +179,12 @@ export const SecurityBoundaryProofSchema = z.object({ contracts: z.array(SecurityContractMatchSchema), capability_status: z.array(SecurityCapabilityStatusSchema), auth: SecurityAuthProofSchema, + middleware: SecurityMiddlewareProofSchema.optional().default({ + required: false, + proven: false, + matched_middleware: [], + mismatches: [] + }), missing_proof: z.array(SecurityMissingProofSchema), parser_gaps: z.array(SecurityParserGapSchema), result: z.object({ diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index 287f1fff..31529131 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -94,7 +94,10 @@ export const EngineFactSchema = z.object({ "test_declared", "auth_guard_called", "route_returns_response", - "callback_boundary_detected" + "callback_boundary_detected", + "middleware_declared", + "middleware_matcher_declared", + "middleware_protects_route" ]), file_path: z.string().min(1), name: z.string().min(1), @@ -148,6 +151,10 @@ const EngineConventionSchema = z.object({ rule_version: z.string().min(1).optional(), kind: z.string().min(1), matcher: z.record(z.unknown()), + scope: z.record(z.unknown()).optional(), + requires: z.record(z.unknown()).optional(), + exceptions: z.array(z.record(z.unknown())).optional(), + governance: z.record(z.unknown()).optional(), severity: z.enum(["info", "warning", "error"]), enforcement_mode: z.enum(["off", "brief", "warn", "block"]), enforcement_capability: z.enum(["briefing_only", "heuristic_check", "deterministic_check"]) @@ -284,11 +291,34 @@ export const EngineFindingSchema = z.object({ const EngineSecurityMissingProofCodeSchema = z.enum([ "missing_auth_guard", "auth_guard_not_dominating_sink", + "middleware_not_covering_route", + "middleware_dynamic_matcher", "unsupported_callback_boundary", + "unsupported_dynamic_control_flow", "route_binding_unresolved", "handler_unresolved" ]); +const EngineSecurityParserGapSchema = z.object({ + parser_gap_id: z.string().min(1), + capability: z.string().min(1), + code: z.enum([ + "route_binding_unresolved", + "handler_unresolved", + "unsupported_dynamic_control_flow", + "unsupported_dynamic_middleware_matcher", + "unsupported_callback_boundary" + ]), + file_path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), + reason: z.string().min(1), + affected_contract_kinds: z.array(z.string().min(1)), + affected_route_ids: z.array(z.string().min(1)), + missing_proof_ids: z.array(z.string().min(1)), + blocks_enforcement: z.boolean() +}); + const EngineSecurityBoundaryProofSchema = z.object({ proof_id: z.string().min(1), proof_version: z.literal("security-boundary-proof/v1"), @@ -338,6 +368,26 @@ const EngineSecurityBoundaryProofSchema = z.object({ fact_ids: z.array(z.string().min(1)) })) }), + middleware: z.object({ + required: z.boolean(), + proven: z.boolean(), + matched_middleware: z.array(z.object({ + middleware_id: z.string().min(1), + matcher_fact_id: z.string().min(1), + protects_route_edge_id: z.string().min(1), + protection_kind: z.enum(["auth", "csrf", "rate_limit", "cors", "unknown"]) + })), + mismatches: z.array(z.object({ + middleware_id: z.string().min(1).optional(), + reason: z.enum(["path_not_matched", "method_not_matched", "dynamic_matcher", "unknown_framework"]), + parser_gap_id: z.string().min(1).optional() + })) + }).optional().default({ + required: false, + proven: false, + matched_middleware: [], + mismatches: [] + }), missing_proof: z.array(z.object({ id: z.string().min(1), capability: z.string().min(1), @@ -346,7 +396,7 @@ const EngineSecurityBoundaryProofSchema = z.object({ fact_ids: z.array(z.string().min(1)), graph_edge_ids: z.array(z.string().min(1)) })), - parser_gaps: z.array(z.record(z.unknown())), + parser_gaps: z.array(EngineSecurityParserGapSchema), result: z.object({ proof_status: z.enum(["proven", "violated", "missing_proof", "parser_gap", "advisory_only"]), enforcement_result: z.enum(["pass", "brief", "warn", "block"]), From c9c4e42e123d4f530d9778e823f148902f886d1c Mon Sep 17 00:00:00 2001 From: geoffrey fernald Date: Mon, 25 May 2026 17:19:28 -0400 Subject: [PATCH 3/4] Implement Phase 2 middleware coverage enforcement --- drift v3/crates/drift-engine/src/facts.rs | 3 + drift v3/crates/drift-engine/src/main.rs | 119 +++++++- .../crates/drift-engine/src/security_facts.rs | 73 ++++- .../crates/drift-engine/src/security_rules.rs | 139 ++++++++- .../tests/security_control_flow.rs | 112 ++++++- .../drift-engine/tests/security_facts.rs | 54 ++++ .../drift-engine/tests/security_rules.rs | 287 +++++++++++++++++- .../packages/cli/src/check/security-check.ts | 7 +- .../packages/cli/src/domain/scan-status.ts | 20 ++ drift v3/packages/cli/test/cli.test.ts | 91 ++++++ drift v3/packages/core/test/security.test.ts | 88 +++++- .../test/security-contract.test.ts | 71 +++++ drift v3/packages/mcp/src/index.ts | 27 ++ drift v3/packages/mcp/src/security-context.ts | 112 +++++++ drift v3/packages/mcp/src/tools.ts | 6 + drift v3/packages/mcp/src/types.ts | 1 + drift v3/packages/mcp/test/mcp.test.ts | 64 ++++ drift v3/packages/query/src/index.ts | 78 ++++- .../query/src/security-boundary-proof.ts | 22 +- .../test/security-boundary-proof.test.ts | 69 +++++ drift v3/test/e2e/security-middleware.test.ts | 101 ++++++ .../app/api/projects/route.ts | 3 + .../security-middleware-covered/middleware.ts | 11 + .../security-middleware-covered/package.json | 1 + .../app/api/projects/route.ts | 3 + .../middleware.ts | 15 + .../package.json | 1 + .../app/api/projects/route.ts | 3 + .../middleware.ts | 11 + .../package.json | 1 + .../app/api/projects/route.ts | 3 + .../middleware.ts | 11 + .../security-middleware-mismatch/package.json | 1 + 33 files changed, 1591 insertions(+), 17 deletions(-) create mode 100644 drift v3/packages/mcp/src/security-context.ts create mode 100644 drift v3/test/e2e/security-middleware.test.ts create mode 100644 drift v3/test/fixtures/security-middleware-covered/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-middleware-covered/middleware.ts create mode 100644 drift v3/test/fixtures/security-middleware-covered/package.json create mode 100644 drift v3/test/fixtures/security-middleware-dynamic-parser-gap/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-middleware-dynamic-parser-gap/middleware.ts create mode 100644 drift v3/test/fixtures/security-middleware-dynamic-parser-gap/package.json create mode 100644 drift v3/test/fixtures/security-middleware-method-mismatch/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-middleware-method-mismatch/middleware.ts create mode 100644 drift v3/test/fixtures/security-middleware-method-mismatch/package.json create mode 100644 drift v3/test/fixtures/security-middleware-mismatch/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-middleware-mismatch/middleware.ts create mode 100644 drift v3/test/fixtures/security-middleware-mismatch/package.json diff --git a/drift v3/crates/drift-engine/src/facts.rs b/drift v3/crates/drift-engine/src/facts.rs index 3100cf6c..7e08e76f 100644 --- a/drift v3/crates/drift-engine/src/facts.rs +++ b/drift v3/crates/drift-engine/src/facts.rs @@ -16,6 +16,9 @@ pub enum FactKind { AuthGuardCalled, RouteReturnsResponse, CallbackBoundaryDetected, + MiddlewareDeclared, + MiddlewareMatcherDeclared, + MiddlewareProtectsRoute, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/main.rs b/drift v3/crates/drift-engine/src/main.rs index c0a8b104..4db1fd49 100644 --- a/drift v3/crates/drift-engine/src/main.rs +++ b/drift v3/crates/drift-engine/src/main.rs @@ -13,7 +13,8 @@ mod protocol; use candidate_command::infer_candidates; use check_command::check_repo; use drift_engine::{ - Fact, FactKind, extract_security_facts, extract_typescript_facts, should_index_path, + Fact, FactKind, dynamic_middleware_matcher_line, extract_security_facts, + extract_typescript_facts, should_index_path, static_middleware_coverage, }; use protocol::*; use serde_json::json; @@ -154,6 +155,8 @@ fn scan_repo( let mut graph_edge_count = 0_usize; let scanned = scan_files(repo_root, &files, &mut diagnostics, reuse_index.as_ref())?; let files_reused = scanned.files_reused; + let mut scanned = scanned; + add_middleware_coverage_facts(&mut scanned.scanned); resolver.exported_symbols = exported_symbols_by_file(&scanned.scanned); for (file, file_facts) in scanned.scanned { let graph = graph_for_file(&repo_id, &scan_id, &file, &file_facts, &resolver); @@ -223,12 +226,13 @@ fn stream_scan_repo( let mut graph_edges_emitted = 0_usize; let mut diagnostics_emitted = 0_usize; let mut scan_diagnostics = Vec::new(); - let scanned = scan_files( + let mut scanned = scan_files( repo_root, &files, &mut scan_diagnostics, reuse_index.as_ref(), )?; + add_middleware_coverage_facts(&mut scanned.scanned); files_skipped += scan_diagnostics.len(); if !scan_diagnostics.is_empty() { diagnostics_emitted += scan_diagnostics.len(); @@ -408,6 +412,14 @@ fn scan_file_with_reuse( return Ok(Some((file, reused_facts, true))); } let source = fs::read_to_string(&absolute_path)?; + if is_middleware_path(&normalized) && dynamic_middleware_matcher_line(&source).is_some() { + diagnostics.push(EngineDiagnostic { + severity: "warning".to_string(), + code: "unsupported_dynamic_middleware_matcher".to_string(), + message: "unsupported_dynamic_middleware_matcher".to_string(), + file_path: Some(normalized.clone()), + }); + } let mut facts = extract_typescript_facts(file_path, &source)?; facts.extend(extract_security_facts(file_path, &source, &[])?); let facts = facts.into_iter().map(engine_fact).collect(); @@ -452,6 +464,106 @@ fn reusable_facts_for_file( ) } +fn add_middleware_coverage_facts(scanned: &mut [(ScannedFile, Vec)]) { + let middleware_fact_sets = scanned + .iter() + .filter_map(|(_, facts)| { + if !facts.iter().any(|fact| fact.kind == "middleware_declared") { + return None; + } + Some( + facts + .iter() + .filter_map(middleware_fact_from_engine) + .collect::>(), + ) + }) + .filter(|facts| !facts.is_empty()) + .collect::>(); + if middleware_fact_sets.is_empty() { + return; + } + + for (_, route_facts) in scanned.iter_mut() { + if !route_facts + .iter() + .any(|fact| fact.kind == "file_role_detected" && fact.name == "api_route") + { + continue; + } + let route_file_path = route_facts + .iter() + .find(|fact| fact.kind == "route_declared") + .map(|fact| fact.file_path.clone()) + .unwrap_or_else(|| { + route_facts + .first() + .map(|fact| fact.file_path.clone()) + .unwrap_or_default() + }); + let route_method = route_facts + .iter() + .find(|fact| fact.kind == "route_declared") + .map(|fact| fact.name.as_str()) + .unwrap_or("GET"); + let route_line = route_facts + .iter() + .find(|fact| fact.kind == "route_declared") + .map(|fact| fact.start_line) + .unwrap_or(1); + let route_id = format!("route:{route_file_path}:{route_method}"); + let mut new_facts = Vec::new(); + for middleware_facts in &middleware_fact_sets { + let (matched, _) = + static_middleware_coverage(middleware_facts, &route_file_path, route_method); + for middleware in matched { + let protection_kind = middleware.protection_kind.clone(); + new_facts.push(EngineFact { + kind: "middleware_protects_route".to_string(), + file_path: route_file_path.clone(), + name: middleware.middleware_id.clone(), + value: Some( + json!({ + "route_id": route_id, + "middleware_id": middleware.middleware_id, + "protection_kind": protection_kind, + }) + .to_string(), + ), + imported_name: Some(protection_kind), + start_line: route_line, + end_line: route_line, + }); + } + } + route_facts.extend(new_facts); + } +} + +fn middleware_fact_from_engine(fact: &EngineFact) -> Option { + let kind = match fact.kind.as_str() { + "middleware_declared" => FactKind::MiddlewareDeclared, + "middleware_matcher_declared" => FactKind::MiddlewareMatcherDeclared, + _ => return None, + }; + Some(Fact { + kind, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: fact.value.clone(), + imported_name: fact.imported_name.clone(), + start_line: fact.start_line, + end_line: fact.end_line, + }) +} + +fn is_middleware_path(path: &str) -> bool { + path == "middleware.ts" + || path == "middleware.js" + || path.ends_with("/middleware.ts") + || path.ends_with("/middleware.js") +} + fn reused_file(file: &ScannedFile, reuse: Option<&ReuseIndex>) -> bool { reusable_facts_for_file(file, reuse).is_some() } @@ -622,6 +734,9 @@ fn fact_kind(kind: FactKind) -> &'static str { FactKind::AuthGuardCalled => "auth_guard_called", FactKind::RouteReturnsResponse => "route_returns_response", FactKind::CallbackBoundaryDetected => "callback_boundary_detected", + FactKind::MiddlewareDeclared => "middleware_declared", + FactKind::MiddlewareMatcherDeclared => "middleware_matcher_declared", + FactKind::MiddlewareProtectsRoute => "middleware_protects_route", } } diff --git a/drift v3/crates/drift-engine/src/security_facts.rs b/drift v3/crates/drift-engine/src/security_facts.rs index c758f1fc..66946dfd 100644 --- a/drift v3/crates/drift-engine/src/security_facts.rs +++ b/drift v3/crates/drift-engine/src/security_facts.rs @@ -1,6 +1,8 @@ use serde_json::json; -use crate::security_patterns::{AcceptedAuthHelper, accepted_auth_helper_for_call}; +use crate::security_patterns::{ + AcceptedAuthHelper, accepted_auth_helper_for_call, static_middleware_matchers, +}; use crate::{Fact, FactExtractError, FactKind, extract_typescript_facts}; pub fn extract_security_facts( @@ -8,6 +10,7 @@ pub fn extract_security_facts( source: &str, accepted_auth_helpers: &[AcceptedAuthHelper], ) -> Result, FactExtractError> { + let normalized_file_path = file_path.as_ref().to_string_lossy().replace('\\', "/"); let facts = extract_typescript_facts(file_path, source)?; let source_lines: Vec<&str> = source.lines().collect(); let mut security_facts = Vec::new(); @@ -76,10 +79,78 @@ pub fn extract_security_facts( }); } } + if is_middleware_file( + facts + .first() + .map(|fact| fact.file_path.as_str()) + .unwrap_or_default(), + ) && let Some(middleware_line) = middleware_declaration_line(&source_lines) + { + let file_path = facts + .first() + .map(|fact| fact.file_path.clone()) + .unwrap_or_else(|| normalized_file_path.clone()); + let middleware_id = format!("middleware:{file_path}"); + let protection_kind = if security_facts + .iter() + .any(|fact| fact.kind == FactKind::AuthGuardCalled) + { + "auth" + } else { + "unknown" + }; + security_facts.push(Fact { + kind: FactKind::MiddlewareDeclared, + file_path: file_path.clone(), + name: "middleware".to_string(), + value: Some( + json!({ + "middleware_id": middleware_id, + "protection_kind": protection_kind, + }) + .to_string(), + ), + imported_name: None, + start_line: middleware_line, + end_line: middleware_line, + }); + for matcher in static_middleware_matchers(source) { + security_facts.push(Fact { + kind: FactKind::MiddlewareMatcherDeclared, + file_path: file_path.clone(), + name: matcher.path_pattern.clone(), + value: Some( + json!({ + "middleware_id": middleware_id, + "matcher_kind": if matcher.excluded { "excluded_path" } else { "static_path" }, + "path_pattern": matcher.path_pattern, + }) + .to_string(), + ), + imported_name: None, + start_line: matcher.start_line, + end_line: matcher.end_line, + }); + } + } Ok(security_facts) } +fn is_middleware_file(file_path: &str) -> bool { + file_path == "middleware.ts" + || file_path == "middleware.js" + || file_path.ends_with("/middleware.ts") + || file_path.ends_with("/middleware.js") +} + +fn middleware_declaration_line(lines: &[&str]) -> Option { + lines + .iter() + .position(|line| line.contains("function middleware") || line.contains("middleware =")) + .map(|index| index + 1) +} + fn protected_sink_after_line(facts: &[Fact], line: usize) -> bool { facts.iter().any(|fact| { matches!( diff --git a/drift v3/crates/drift-engine/src/security_rules.rs b/drift v3/crates/drift-engine/src/security_rules.rs index 2b71f9b3..603540ef 100644 --- a/drift v3/crates/drift-engine/src/security_rules.rs +++ b/drift v3/crates/drift-engine/src/security_rules.rs @@ -1,4 +1,7 @@ -use crate::{AcceptedAuthHelper, FactExtractError, SecurityProofStatus, build_auth_boundary_proof}; +use crate::{ + AcceptedAuthHelper, FactExtractError, SecurityProofStatus, build_auth_boundary_proof, + build_middleware_coverage_proof, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecurityAuthContract { @@ -7,6 +10,23 @@ pub struct SecurityAuthContract { pub accepted_auth_helpers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityMiddlewareContract { + pub contract_id: String, + pub capability: SecurityContractCapability, + pub enforcement_mode: SecurityEnforcementMode, + pub route_paths: Vec, + pub methods: Vec, + pub accepted_auth_helpers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityContractCapability { + BriefingOnly, + HeuristicCheck, + DeterministicCheck, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecurityEnforcementMode { Off, @@ -53,12 +73,105 @@ pub fn evaluate_api_route_requires_auth_helper( contract_id: contract.contract_id.clone(), title: "API route missing required auth proof".to_string(), expected_layer: "auth_guard".to_string(), + actual_layer: normalize_auth_actual_layer( + &proof + .auth + .undominated_sinks + .first() + .cloned() + .unwrap_or_else(|| "missing_auth_guard".to_string()), + ), + enforcement_result: match contract.enforcement_mode { + SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, + SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, + SecurityEnforcementMode::Block => SecurityFindingResult::Block, + SecurityEnforcementMode::Off => return Ok(Vec::new()), + }, + drift_category: "missing_proof".to_string(), + confidence_label: "certain".to_string(), + }]) +} + +pub fn evaluate_api_route_requires_auth_helper_with_middleware( + route_file_path: impl AsRef, + route_source: &str, + middleware_file_path: impl AsRef, + middleware_source: &str, + contract: &SecurityAuthContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.accepted_auth_helpers.is_empty() + { + return Ok(Vec::new()); + } + let middleware_proof = build_middleware_coverage_proof( + middleware_file_path, + middleware_source, + route_file_path.as_ref(), + route_source, + &contract.accepted_auth_helpers, + )?; + if middleware_proof.result.proof_status == SecurityProofStatus::Proven + && middleware_proof.middleware.proven + { + return Ok(Vec::new()); + } + evaluate_api_route_requires_auth_helper(route_file_path, route_source, contract) +} + +fn normalize_auth_actual_layer(reason: &str) -> String { + if reason == "no_guard_call" { + "missing_auth_guard".to_string() + } else { + reason.to_string() + } +} + +pub fn evaluate_middleware_must_cover_routes( + middleware_file_path: impl AsRef, + middleware_source: &str, + route_file_path: impl AsRef, + route_source: &str, + contract: &SecurityMiddlewareContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.capability != SecurityContractCapability::DeterministicCheck + || contract.accepted_auth_helpers.is_empty() + { + return Ok(Vec::new()); + } + let route_file_path_string = route_file_path + .as_ref() + .to_string_lossy() + .replace('\\', "/"); + if !contract.route_paths.is_empty() + && route_path_from_file(&route_file_path_string) + .is_none_or(|route_path| !contract.route_paths.contains(&route_path)) + { + return Ok(Vec::new()); + } + + let proof = build_middleware_coverage_proof( + middleware_file_path, + middleware_source, + route_file_path, + route_source, + &contract.accepted_auth_helpers, + )?; + if proof.result.proof_status == SecurityProofStatus::Proven { + return Ok(Vec::new()); + } + + Ok(vec![SecurityFinding { + contract_id: contract.contract_id.clone(), + title: "Middleware does not cover required route".to_string(), + expected_layer: "middleware_coverage".to_string(), actual_layer: proof - .auth - .undominated_sinks + .middleware + .mismatches .first() - .cloned() - .unwrap_or_else(|| "missing_auth_guard".to_string()), + .map(|mismatch| mismatch.reason.clone()) + .unwrap_or_else(|| "middleware_not_covering_route".to_string()), enforcement_result: match contract.enforcement_mode { SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, @@ -69,3 +182,19 @@ pub fn evaluate_api_route_requires_auth_helper( confidence_label: "certain".to_string(), }]) } + +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(rest) = file_path + .strip_prefix("pages") + .and_then(|path| path.strip_suffix(".ts")) + { + return Some(rest.to_string()); + } + None +} diff --git a/drift v3/crates/drift-engine/tests/security_control_flow.rs b/drift v3/crates/drift-engine/tests/security_control_flow.rs index fe1ded17..cc6136a7 100644 --- a/drift v3/crates/drift-engine/tests/security_control_flow.rs +++ b/drift v3/crates/drift-engine/tests/security_control_flow.rs @@ -1,6 +1,6 @@ use drift_engine::{ AcceptedAuthHelper, AuthGuardBehavior, FactKind, SecurityProofStatus, - build_auth_boundary_proof, extract_security_facts, + build_auth_boundary_proof, build_middleware_coverage_proof, extract_security_facts, }; #[test] @@ -222,3 +222,113 @@ export async function GET(request: Request) { ); assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); } + +#[test] +fn static_middleware_matcher_protects_route() { + let middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/projects/:path*"], +}; +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let proof = build_middleware_coverage_proof( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("middleware proof"); + + assert!(proof.middleware.required); + assert!( + proof.middleware.proven, + "static matcher should prove middleware coverage: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::Proven); + assert!( + proof + .middleware + .matched_middleware + .iter() + .any(|middleware| middleware.protection_kind == "auth" + && middleware + .protects_route_edge_id + .contains("middleware-protects")), + "missing matched middleware proof: {proof:#?}" + ); +} + +#[test] +fn dynamic_middleware_matcher_emits_parser_gap_and_blocks() { + let middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +const protectedPaths = ["/api/projects/:path*"]; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: protectedPaths, +}; +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let proof = build_middleware_coverage_proof( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("middleware proof"); + + assert!( + !proof.middleware.proven, + "dynamic matcher should not prove coverage: {proof:#?}" + ); + assert!( + proof + .parser_gaps + .iter() + .any(|gap| gap.code == "unsupported_dynamic_middleware_matcher" + && gap.blocks_enforcement), + "missing dynamic middleware parser gap: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); +} diff --git a/drift v3/crates/drift-engine/tests/security_facts.rs b/drift v3/crates/drift-engine/tests/security_facts.rs index 8f8da370..fefd6576 100644 --- a/drift v3/crates/drift-engine/tests/security_facts.rs +++ b/drift v3/crates/drift-engine/tests/security_facts.rs @@ -101,3 +101,57 @@ export default async function handler(req, res) { "missing res.json sink: {pages_facts:#?}" ); } + +#[test] +fn extracts_static_middleware_matcher_fact() { + let source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/projects/:path*"], +}; +"#; + + let facts = extract_security_facts( + "middleware.ts", + source, + &[AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + ) + .expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::MiddlewareDeclared + && fact.name == "middleware" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"middleware_id\":\"middleware:middleware.ts\"") + && value.contains("\"protection_kind\":\"auth\"") + }) + && fact.start_line == 5), + "missing middleware declaration fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::MiddlewareMatcherDeclared + && fact.name == "/api/projects/:path*" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"middleware_id\":\"middleware:middleware.ts\"") + && value.contains("\"matcher_kind\":\"static_path\"") + && value.contains("\"path_pattern\":\"/api/projects/:path*\"") + }) + && fact.start_line == 11), + "missing static middleware matcher fact: {facts:#?}" + ); +} diff --git a/drift v3/crates/drift-engine/tests/security_rules.rs b/drift v3/crates/drift-engine/tests/security_rules.rs index f61f95ef..bde43d3d 100644 --- a/drift v3/crates/drift-engine/tests/security_rules.rs +++ b/drift v3/crates/drift-engine/tests/security_rules.rs @@ -1,6 +1,8 @@ use drift_engine::{ - AcceptedAuthHelper, AuthGuardBehavior, SecurityAuthContract, SecurityEnforcementMode, - SecurityFindingResult, evaluate_api_route_requires_auth_helper, + AcceptedAuthHelper, AuthGuardBehavior, SecurityAuthContract, SecurityContractCapability, + SecurityEnforcementMode, SecurityFindingResult, SecurityMiddlewareContract, + evaluate_api_route_requires_auth_helper, + evaluate_api_route_requires_auth_helper_with_middleware, evaluate_middleware_must_cover_routes, }; #[test] @@ -66,3 +68,284 @@ export async function GET() { "auth-looking names without accepted contract must not block: {findings:#?}" ); } + +#[test] +fn middleware_path_mismatch_blocks_covered_route_contract() { + let middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/admin/:path*"], +}; +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_middleware_must_cover_routes( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &SecurityMiddlewareContract { + contract_id: "security_middleware_api_coverage".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + route_paths: vec!["/api/projects".to_string()], + methods: Vec::new(), + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].contract_id, "security_middleware_api_coverage"); + assert_eq!( + findings[0].title, + "Middleware does not cover required route" + ); + assert_eq!(findings[0].actual_layer, "path_not_matched"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); + assert_eq!(findings[0].drift_category, "missing_proof"); +} + +#[test] +fn middleware_method_mismatch_blocks_when_contract_requires_method() { + let middleware_source = r##" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/projects/:path*#POST"], +}; +"##; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_middleware_must_cover_routes( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &SecurityMiddlewareContract { + contract_id: "security_middleware_api_coverage".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + route_paths: vec!["/api/projects".to_string()], + methods: vec!["GET".to_string()], + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].actual_layer, "method_not_matched"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); +} + +#[test] +fn middleware_excludes_matched_route_blocks() { + let middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/:path*", "!/api/projects/:path*"], +}; +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_middleware_must_cover_routes( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &SecurityMiddlewareContract { + contract_id: "security_middleware_api_coverage".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + route_paths: vec!["/api/projects".to_string()], + methods: vec!["GET".to_string()], + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].actual_layer, "path_not_matched"); + assert_eq!(findings[0].drift_category, "missing_proof"); +} + +#[test] +fn auth_contract_accepts_static_middleware_proof_but_not_middleware_existence() { + let covered_middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/projects/:path*"], +}; +"#; + let middleware_without_matcher_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + let contract = SecurityAuthContract { + contract_id: "security_api_auth_require_user".to_string(), + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }; + + let covered_findings = evaluate_api_route_requires_auth_helper_with_middleware( + "app/api/projects/route.ts", + route_source, + "middleware.ts", + covered_middleware_source, + &contract, + ) + .expect("covered security findings"); + assert!( + covered_findings.is_empty(), + "deterministic middleware proof should satisfy auth: {covered_findings:#?}" + ); + + let existence_only_findings = evaluate_api_route_requires_auth_helper_with_middleware( + "app/api/projects/route.ts", + route_source, + "middleware.ts", + middleware_without_matcher_source, + &contract, + ) + .expect("existence-only security findings"); + assert_eq!( + existence_only_findings.len(), + 1, + "middleware existence alone must not satisfy auth: {existence_only_findings:#?}" + ); + assert_eq!( + existence_only_findings[0].actual_layer, + "missing_auth_guard" + ); + assert_eq!( + existence_only_findings[0].enforcement_result, + SecurityFindingResult::Block + ); +} + +#[test] +fn candidate_only_middleware_evidence_does_not_block() { + let middleware_source = r#" +import { NextResponse } from "next/server"; +import { requireUser } from "@/server/auth"; + +export async function middleware(request: Request) { + await requireUser(); + return NextResponse.next(); +} + +export const config = { + matcher: ["/api/admin/:path*"], +}; +"#; + let route_source = r#" +import { db } from "@/server/db"; + +export async function GET() { + const projects = await db.project.findMany(); + return Response.json({ projects }); +} +"#; + + let findings = evaluate_middleware_must_cover_routes( + "middleware.ts", + middleware_source, + "app/api/projects/route.ts", + route_source, + &SecurityMiddlewareContract { + contract_id: "candidate_middleware_api_coverage".to_string(), + capability: SecurityContractCapability::HeuristicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + route_paths: vec!["/api/projects".to_string()], + methods: vec!["GET".to_string()], + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsUser, + }], + }, + ) + .expect("security findings"); + + assert!( + findings.is_empty(), + "candidate-only heuristic middleware evidence must not block: {findings:#?}" + ); +} diff --git a/drift v3/packages/cli/src/check/security-check.ts b/drift v3/packages/cli/src/check/security-check.ts index f70aeb7d..829d41c1 100644 --- a/drift v3/packages/cli/src/check/security-check.ts +++ b/drift v3/packages/cli/src/check/security-check.ts @@ -23,6 +23,7 @@ export interface SecurityCheckJson { summary: { security_findings_count: number; security_blocking_count: number; + middleware_coverage_proven_count: number; }; } @@ -41,7 +42,11 @@ export function buildSecurityCheckJson(input: BuildSecurityCheckJsonInput): Secu security_findings_count: scopedFindings.length, security_blocking_count: scopedFindings.filter((finding) => finding.enforcement_result === "block" - ).length + ).length, + middleware_coverage_proven_count: input.proofs.filter((proof) => { + const middleware = proof.middleware; + return Boolean(middleware && middleware.required && middleware.proven); + }).length } }; } diff --git a/drift v3/packages/cli/src/domain/scan-status.ts b/drift v3/packages/cli/src/domain/scan-status.ts index 9e33eee8..03e45737 100644 --- a/drift v3/packages/cli/src/domain/scan-status.ts +++ b/drift v3/packages/cli/src/domain/scan-status.ts @@ -631,6 +631,7 @@ export function scanStatusPayload(storage: SqliteDriftStorage, repoId: string) { parser_gaps: parserGapSummary(parserGaps), readiness, capability_report: capabilityReport, + security_capabilities: securityCapabilitySummary(capabilityReport), machine_contract_versions: currentMachineContractVersions(latestScan.adapter_versions), next_command: nextCommands[0], next_commands: nextCommands @@ -638,6 +639,24 @@ export function scanStatusPayload(storage: SqliteDriftStorage, repoId: string) { return payload; } +function securityCapabilitySummary(capabilityReport: ReturnType | null) { + 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"); + 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) + } + }; +} + export function readinessForStoredScan( storage: SqliteDriftStorage, repoId: string, @@ -763,6 +782,7 @@ function parserGapKindForDiagnostic(code: string): ParserGapKind | null { return "unsupported_framework_pattern"; case "typescript_fallback_used": case "file_too_large": + case "unsupported_dynamic_middleware_matcher": return "partial_parse"; default: return null; diff --git a/drift v3/packages/cli/test/cli.test.ts b/drift v3/packages/cli/test/cli.test.ts index 2c430f07..992012f1 100644 --- a/drift v3/packages/cli/test/cli.test.ts +++ b/drift v3/packages/cli/test/cli.test.ts @@ -10103,6 +10103,97 @@ describe("drift CLI convention review", () => { expect(mapped.stderr).toContain("omit --require-fresh"); }); + it("scan status reports middleware_coverage capability", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-scan-status-middleware-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertScanCapabilityReport({ + schema_version: "drift.scan_capability_report.v1", + repo_id: repoId, + scan_id: scanId, + engine_source: "rust", + engine_version: "0.1.0", + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + certified_capabilities: ["file_discovery", "syntax_facts", "middleware_coverage"], + required_capabilities: ["file_discovery", "syntax_facts", "middleware_coverage"], + missing_capabilities: [], + completeness: [{ + scope: "route-flow", + rule_id: "middleware_must_cover_routes", + complete: true, + can_block: true, + reasons: [] + }], + parser_gap_count: 0, + parser_gap_kinds: {}, + fallback_used: false, + enforcement_degraded: false, + created_at: "2026-05-25T00:00:00.000Z" + }); + 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(payload.security_capabilities.middleware_coverage).toMatchObject({ + certified: true, + can_block: true, + missing: false + }); + }); + + it("repo map reports route middleware coverage summary", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-middleware-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertFacts([{ + id: "fact_middleware_protects_users", + repo_id: repoId, + scan_id: scanId, + kind: "middleware_protects_route", + file_path: "apps/web/app/api/users/route.ts", + name: "middleware:middleware.ts", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + middleware_id: "middleware:middleware.ts", + protection_kind: "auth" + }), + imported_name: "auth", + start_line: 1, + end_line: 1, + ...factQuality(scanId) + }]); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "repo", "map", + "--repo", repoId, + "--path", "apps/web/app/api/users/route.ts", + "--json" + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.files[0].route_security.middleware_coverage).toMatchObject({ + proven: true, + protection_kinds: ["auth"], + middleware_ids: ["middleware:middleware.ts"] + }); + expect(result.stdout).not.toContain("requireUser()"); + }); + it("prints prepare summary and governance in human output", async () => { const { databasePath } = await seedAcceptedDatabase(); diff --git a/drift v3/packages/core/test/security.test.ts b/drift v3/packages/core/test/security.test.ts index 620212ef..8e0b40c9 100644 --- a/drift v3/packages/core/test/security.test.ts +++ b/drift v3/packages/core/test/security.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { SecurityBoundaryProofSchema, SecurityConventionSchema, - SecurityMissingProofCodeSchema + SecurityMissingProofCodeSchema, + SecurityParserGapCodeSchema } from "../src/index.js"; describe("security domain schemas", () => { @@ -107,4 +108,89 @@ describe("security domain schemas", () => { expect(JSON.stringify(proof)).not.toContain("const projects"); expect(proof.auth.required).toBe(true); }); + + 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"); + expect(SecurityParserGapCodeSchema.parse("unsupported_dynamic_middleware_matcher")).toBe("unsupported_dynamic_middleware_matcher"); + + const contract = SecurityConventionSchema.parse({ + contract_id: "security_middleware_api_coverage", + kind: "middleware_must_cover_routes", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { + route_paths: ["/api/projects"], + methods: ["GET"] + }, + scope: { + check_scope: "changed-files", + applies_to: "route", + diff_status: ["added", "modified", "renamed"] + }, + requires: { + middleware_symbols: ["middleware"], + protection_kinds: ["auth"] + }, + exceptions: [] + }); + + expect(contract.kind).toBe("middleware_must_cover_routes"); + }); + + it("validates middleware SecurityBoundaryProof fields from engine output", () => { + const proof = SecurityBoundaryProofSchema.parse({ + proof_id: "proof_route_projects_get_middleware", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_middleware_api_coverage", + kind: "middleware_must_cover_routes", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "middleware_coverage", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "middleware_guard", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + middleware: { + required: true, + proven: true, + matched_middleware: [{ + middleware_id: "middleware:middleware.ts", + matcher_fact_id: "fact_middleware_matcher", + protects_route_edge_id: "edge_middleware_projects", + protection_kind: "auth" + }], + mismatches: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + } + }); + + expect(proof.middleware.proven).toBe(true); + expect(JSON.stringify(proof)).not.toContain("requireUser()"); + }); }); diff --git a/drift v3/packages/engine-contract/test/security-contract.test.ts b/drift v3/packages/engine-contract/test/security-contract.test.ts index bbffb5e6..946505bb 100644 --- a/drift v3/packages/engine-contract/test/security-contract.test.ts +++ b/drift v3/packages/engine-contract/test/security-contract.test.ts @@ -68,4 +68,75 @@ describe("engine security contract schemas", () => { proofs: [] })).toThrow(/Invalid Drift engine security proof event/); }); + + it("validates middleware SecurityBoundaryProof fields from engine output", () => { + const event = parseEngineSecurityProofEvent({ + event: "SecurityProof", + schema_version: "engine.security.proof/v1", + proofs: [{ + proof_id: "proof_route_projects_get_middleware", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_middleware_api_coverage", + kind: "middleware_must_cover_routes", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "middleware_coverage", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "middleware_guard", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + middleware: { + required: true, + proven: true, + matched_middleware: [{ + middleware_id: "middleware:middleware.ts", + matcher_fact_id: "fact_middleware_matcher", + protects_route_edge_id: "edge_middleware_projects", + protection_kind: "auth" + }], + mismatches: [] + }, + missing_proof: [], + parser_gaps: [{ + parser_gap_id: "gap_dynamic_middleware", + capability: "middleware_coverage", + code: "unsupported_dynamic_middleware_matcher", + file_path: "middleware.ts", + reason: "Dynamic middleware matcher prevents deterministic coverage proof", + affected_contract_kinds: ["middleware_must_cover_routes"], + affected_route_ids: ["route_projects_get"], + missing_proof_ids: [], + blocks_enforcement: true + }], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + } + }] + }); + + expect(event.proofs[0]?.middleware.proven).toBe(true); + expect(event.proofs[0]?.parser_gaps[0]?.code).toBe("unsupported_dynamic_middleware_matcher"); + expect(JSON.stringify(event)).not.toContain("requireUser()"); + }); }); diff --git a/drift v3/packages/mcp/src/index.ts b/drift v3/packages/mcp/src/index.ts index b4f5c409..ac7b2107 100644 --- a/drift v3/packages/mcp/src/index.ts +++ b/drift v3/packages/mcp/src/index.ts @@ -15,6 +15,7 @@ import type { RepoContract, RepoRecord, RequiredCheckExecution, + ScanCapabilityReport, ScanFileChange, ScanManifest, Severity @@ -70,6 +71,7 @@ import { createHash } from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { createInterface } from "node:readline"; +import { buildSecurityContextPayload } from "./security-context.js"; import { DRIFT_READ_ONLY_MCP_TOOLS } from "./tools.js"; import type { DriftMcpHandlers, @@ -158,6 +160,12 @@ export function createReadOnlyMcpHandlers(options: DriftMcpOptions): DriftMcpHan }); }), + get_security_context: ({ repo_id }) => withStorage(options, (storage) => { + const requestedRepoId = requiredMcpString(repo_id, "repo_id"); + const { contract } = requiredAuthorizedMcpContract(storage, requestedRepoId, "mcp"); + return buildSecurityContextPayload(storage, requestedRepoId, contract); + }), + get_task_preflight: ({ repo_id, task, path, require_fresh, now }) => withStorage(options, (storage) => { const requestedRepoId = requiredMcpString(repo_id, "repo_id"); const requestedTask = requiredMcpString(task, "task"); @@ -1157,6 +1165,7 @@ function scanStatusPayload( parser_gaps: parserGapSummary(parserGaps), readiness, capability_report: capabilityReport, + security_capabilities: securityCapabilitySummary(capabilityReport), machine_contract_versions: currentMachineContractVersions(latestScan?.adapter_versions), next_command: nextCommands[0], next_commands: nextCommands @@ -1227,6 +1236,24 @@ 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"); + 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) + } + }; +} + function scanStatusSummary(options: { latestScanId: string | null; scanCount: number; diff --git a/drift v3/packages/mcp/src/security-context.ts b/drift v3/packages/mcp/src/security-context.ts new file mode 100644 index 00000000..5edc14aa --- /dev/null +++ b/drift v3/packages/mcp/src/security-context.ts @@ -0,0 +1,112 @@ +import type { AcceptedConvention, FactRecord, ParserGap, RepoContract, ScanManifest } from "@drift/core"; +import type { openDriftStorage } from "@drift/storage"; + +type DriftStorage = ReturnType; + +interface MiddlewareCoverageValue { + route_id?: string; + middleware_id?: string; + protection_kind?: string; +} + +export function buildSecurityContextPayload(storage: DriftStorage, repoId: string, contract: RepoContract) { + const latestScan = latestSecurityScan(storage.listScanManifests(repoId)); + const facts = latestScan ? storage.listFacts(latestScan.id, { kind: "middleware_protects_route" }) : []; + const parserGaps = latestScan ? storage.listParserGaps(repoId, latestScan.id) : []; + + return { + response_schema: "drift.security.context.v1", + repo_id: repoId, + scan_id: latestScan?.id ?? null, + accepted_contracts: securityConventions(contract.conventions), + middleware_coverage: { + routes: middlewareCoverageRoutes(facts), + parser_gaps: middlewareParserGaps(parserGaps) + }, + redactions: { + snippets_included: false, + source_content_included: false, + request_payloads_included: false, + secret_values_included: false + } + }; +} + +function latestSecurityScan(scans: ScanManifest[]): ScanManifest | undefined { + return scans.find((scan) => + scan.status === "completed" && + !scan.id.startsWith("scan_baseline_") && + !scan.id.startsWith("scan_check_") + ) ?? scans.find((scan) => scan.status === "completed") ?? scans[0]; +} + +function securityConventions(conventions: AcceptedConvention[]) { + return conventions + .filter((convention) => + convention.kind === "middleware_must_cover_routes" || + convention.kind === "api_route_requires_auth_helper" + ) + .map((convention) => ({ + id: convention.id, + kind: convention.kind, + enforcement_mode: convention.enforcement_mode, + enforcement_capability: convention.enforcement_capability, + severity: convention.severity + })); +} + +function middlewareCoverageRoutes(facts: FactRecord[]) { + const byPath = new Map; + middleware_ids: Set; + }>(); + + for (const fact of facts) { + const value = parseMiddlewareCoverageValue(fact.value); + const entry = byPath.get(fact.file_path) ?? { + file_path: fact.file_path, + proven: true, + protection_kinds: new Set(), + middleware_ids: new Set() + }; + if (value.protection_kind) { + entry.protection_kinds.add(value.protection_kind); + } + if (value.middleware_id) { + entry.middleware_ids.add(value.middleware_id); + } + byPath.set(fact.file_path, entry); + } + + return [...byPath.values()] + .sort((left, right) => left.file_path.localeCompare(right.file_path)) + .map((entry) => ({ + file_path: entry.file_path, + proven: entry.proven, + protection_kinds: [...entry.protection_kinds].sort(), + middleware_ids: [...entry.middleware_ids].sort() + })); +} + +function parseMiddlewareCoverageValue(value: string | undefined): MiddlewareCoverageValue { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value) as MiddlewareCoverageValue; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function middlewareParserGaps(parserGaps: ParserGap[]) { + return parserGaps + .filter((gap) => gap.message === "unsupported_dynamic_middleware_matcher") + .map((gap) => ({ + reason: gap.message, + blocking: gap.confidence_impact === "blocks_enforcement" + })); +} diff --git a/drift v3/packages/mcp/src/tools.ts b/drift v3/packages/mcp/src/tools.ts index bd293484..85ba5a42 100644 --- a/drift v3/packages/mcp/src/tools.ts +++ b/drift v3/packages/mcp/src/tools.ts @@ -74,6 +74,11 @@ export const DRIFT_READ_ONLY_MCP_TOOLS: DriftMcpTool[] = [ additionalProperties: false } }, + { + name: "get_security_context", + description: "Return accepted security contract context and middleware coverage summaries without source snippets.", + inputSchema: repoOnlySchema() + }, { name: "get_task_preflight", description: "Return policy-filtered conventions and findings relevant to a task.", @@ -103,6 +108,7 @@ export const DRIFT_READ_ONLY_MCP_TOOLS: DriftMcpTool[] = [ "api_route_no_direct_data_access", "api_route_requires_service_delegation", "api_route_requires_auth_helper", + "middleware_must_cover_routes", "test_expected_for_changed_module", "custom_briefing" ] diff --git a/drift v3/packages/mcp/src/types.ts b/drift v3/packages/mcp/src/types.ts index 634c6d41..73c0f454 100644 --- a/drift v3/packages/mcp/src/types.ts +++ b/drift v3/packages/mcp/src/types.ts @@ -27,6 +27,7 @@ export interface DriftMcpHandlers { limit?: number; offset?: number; }): unknown; + get_security_context(input: { repo_id: string }): 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 789eb05c..f88fdd41 100644 --- a/drift v3/packages/mcp/test/mcp.test.ts +++ b/drift v3/packages/mcp/test/mcp.test.ts @@ -1524,6 +1524,69 @@ describe("read-only MCP handlers", () => { expect(proof.executions[0]).not.toHaveProperty("stderr_preview"); }); + it("exposes middleware coverage proof summaries without snippets", async () => { + const databasePath = await seedMcpDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + storage.upsertFacts([{ + id: "fact_middleware_protects_users", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "middleware_protects_route", + file_path: "apps/web/app/api/users/route.ts", + name: "middleware:middleware.ts", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + middleware_id: "middleware:middleware.ts", + protection_kind: "auth" + }), + imported_name: "auth", + start_line: 1, + end_line: 1, + ...factQuality("scan_abc") + }]); + storage.upsertParserGaps([{ + schema_version: "drift.parser_gap.v1", + gap_id: "parser_gap_dynamic_middleware", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "partial_parse", + file_path: "middleware.ts", + start_line: 10, + end_line: 10, + confidence_impact: "blocks_enforcement", + message: "unsupported_dynamic_middleware_matcher", + evidence_refs: ["parser_gap_dynamic_middleware"], + created_at: "2026-05-25T00:00:00.000Z" + }]); + storage.close(); + + 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 }>; + }; + }; + + expect(securityContext.middleware_coverage.routes).toEqual([{ + file_path: "apps/web/app/api/users/route.ts", + proven: true, + protection_kinds: ["auth"], + middleware_ids: ["middleware:middleware.ts"] + }]); + expect(securityContext.middleware_coverage.parser_gaps).toEqual([ + { reason: "unsupported_dynamic_middleware_matcher", blocking: true } + ]); + expect(JSON.stringify(securityContext)).not.toContain("requireUser()"); + }); + it("includes scan symbol identities in MCP change impact", async () => { const databasePath = await seedMcpDatabase(); const storage = openDriftStorage({ databasePath }); @@ -2466,6 +2529,7 @@ describe("read-only MCP handlers", () => { "get_scan_status", "get_repo_contract", "get_repo_map", + "get_security_context", "get_task_preflight", "get_conventions", "get_findings", diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index 5903a73d..f564b98d 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -67,6 +67,15 @@ export interface GraphRepoMapFile { graph_node_ids: string[]; evidence_ids: string[]; fact_count: number; + route_security?: RepoMapRouteSecurity; +} + +export interface RepoMapRouteSecurity { + middleware_coverage: { + proven: boolean; + protection_kinds: string[]; + middleware_ids: string[]; + }; } export interface GraphRepoMap { @@ -787,7 +796,8 @@ export function mergeGraphAndFactRepoMapFiles( calls: unique([...graphFile.calls, ...factFile.calls]), graph_node_ids: unique([...graphFile.graph_node_ids, ...factFile.graph_node_ids]), evidence_ids: unique([...graphFile.evidence_ids, ...factFile.evidence_ids]), - fact_count: Math.max(graphFile.fact_count, factFile.fact_count) + fact_count: Math.max(graphFile.fact_count, factFile.fact_count), + route_security: mergeRouteSecurity(graphFile.route_security, factFile.route_security) }; }); } @@ -943,12 +953,76 @@ export function fallbackFactRepoMapFiles( .map((fact) => fact.name)), graph_node_ids: [], evidence_ids: [], - fact_count: fileFacts.length + fact_count: fileFacts.length, + route_security: routeSecurityFromFacts(fileFacts) }; }) .sort((left, right) => left.path.localeCompare(right.path)); } +function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | undefined { + const middlewareProtectionFacts = fileFacts.filter((fact) => + fact.kind === "middleware_protects_route" + ); + if (middlewareProtectionFacts.length === 0) { + return undefined; + } + const middlewareIds = middlewareProtectionFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.middleware_id === "string" ? metadata.middleware_id : fact.name; + }); + const protectionKinds = middlewareProtectionFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.protection_kind === "string" + ? metadata.protection_kind + : fact.imported_name ?? "unknown"; + }); + return { + middleware_coverage: { + proven: true, + protection_kinds: unique(protectionKinds), + middleware_ids: unique(middlewareIds) + } + }; +} + +function mergeRouteSecurity( + left: RepoMapRouteSecurity | undefined, + right: RepoMapRouteSecurity | undefined +): RepoMapRouteSecurity | undefined { + if (!left) { + return right; + } + if (!right) { + return left; + } + return { + middleware_coverage: { + proven: left.middleware_coverage.proven || right.middleware_coverage.proven, + protection_kinds: unique([ + ...left.middleware_coverage.protection_kinds, + ...right.middleware_coverage.protection_kinds + ]), + middleware_ids: unique([ + ...left.middleware_coverage.middleware_ids, + ...right.middleware_coverage.middleware_ids + ]) + } + }; +} + +function parseFactValue(value: string | undefined): Record { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + function groupEvidenceByFile(evidence: GraphEvidence[]): Map> { const grouped = new Map>(); for (const item of evidence) { diff --git a/drift v3/packages/query/src/security-boundary-proof.ts b/drift v3/packages/query/src/security-boundary-proof.ts index 93724558..a2a66b30 100644 --- a/drift v3/packages/query/src/security-boundary-proof.ts +++ b/drift v3/packages/query/src/security-boundary-proof.ts @@ -16,6 +16,10 @@ export interface SecurityBoundaryProofRouteSummary { file_path: string; auth_required: boolean; auth_proven: boolean; + middleware_required: boolean; + middleware_proven: boolean; + middleware_protection_kinds: string[]; + middleware_mismatch_reasons: string[]; proof_status: string; enforcement_result: string; missing_proof_codes: string[]; @@ -37,11 +41,24 @@ export function buildSecurityBoundaryProofReadModel( ])); return { - routes: input.proofs.map((proof) => ({ + routes: input.proofs.map((proof) => { + const middleware = proof.middleware ?? { + required: false, + proven: false, + matched_middleware: [], + mismatches: [] + }; + return { route_id: proof.route.route_id, file_path: proof.route.file_path, auth_required: proof.auth.required, auth_proven: proof.auth.proven, + middleware_required: middleware.required, + middleware_proven: middleware.proven, + middleware_protection_kinds: [...new Set(middleware.matched_middleware + .map((middleware) => middleware.protection_kind))].sort(), + middleware_mismatch_reasons: [...new Set(middleware.mismatches + .map((mismatch) => mismatch.reason))].sort(), proof_status: proof.result.proof_status, enforcement_result: proof.result.enforcement_result, missing_proof_codes: proof.missing_proof.map((missing) => missing.code), @@ -50,6 +67,7 @@ export function buildSecurityBoundaryProofReadModel( lifecycle: proof.result.finding_ids .map((findingId) => findingLifecycle.get(findingId)) .filter((value): value is string => value !== undefined) - })) + }; + }) }; } 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 dde6f89a..53e9b79c 100644 --- a/drift v3/packages/query/test/security-boundary-proof.test.ts +++ b/drift v3/packages/query/test/security-boundary-proof.test.ts @@ -77,6 +77,10 @@ describe("security boundary proof read model", () => { file_path: "app/api/projects/route.ts", auth_required: true, auth_proven: false, + middleware_required: false, + middleware_proven: false, + middleware_protection_kinds: [], + middleware_mismatch_reasons: [], proof_status: "parser_gap", enforcement_result: "block", missing_proof_codes: ["missing_auth_guard"], @@ -87,4 +91,69 @@ describe("security boundary proof read model", () => { expect(JSON.stringify(model)).not.toContain("const projects"); expect(JSON.stringify(model)).not.toContain("requireUser()"); }); + + it("summarizes middleware coverage proof without snippets", () => { + const model = buildSecurityBoundaryProofReadModel({ + proofs: [{ + proof_id: "proof_route_projects_get_middleware", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_middleware_api_coverage", + kind: "middleware_must_cover_routes", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "middleware_coverage", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: [] + }], + auth: { + required: true, + proven: true, + proof_kind: "middleware_guard", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + middleware: { + required: true, + proven: true, + matched_middleware: [{ + middleware_id: "middleware:middleware.ts", + matcher_fact_id: "fact_middleware_matcher", + protects_route_edge_id: "edge_middleware_projects", + protection_kind: "auth" + }], + mismatches: [] + }, + missing_proof: [], + parser_gaps: [], + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + } + }], + findings: [] + }); + + expect(model.routes).toEqual([expect.objectContaining({ + route_id: "route_projects_get", + middleware_required: true, + middleware_proven: true, + middleware_protection_kinds: ["auth"], + middleware_mismatch_reasons: [] + })]); + expect(JSON.stringify(model)).not.toContain("requireUser()"); + }); }); diff --git a/drift v3/test/e2e/security-middleware.test.ts b/drift v3/test/e2e/security-middleware.test.ts new file mode 100644 index 00000000..27a9a417 --- /dev/null +++ b/drift v3/test/e2e/security-middleware.test.ts @@ -0,0 +1,101 @@ +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runCli } from "../../packages/cli/src/index.js"; +import { openDriftStorage } from "../../packages/storage/src/index.js"; + +const tempDirs: string[] = []; +let originalEngineBin: string | undefined; + +async function fixtureRepo(name: string): Promise<{ repoRoot: string; stateRoot: string }> { + const dir = await mkdtemp(join(tmpdir(), "drift-security-middleware-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const stateRoot = join(dir, "state"); + await cp(resolve("test/fixtures", name), repoRoot, { recursive: true }); + return { repoRoot, stateRoot }; +} + +beforeEach(() => { + originalEngineBin = process.env.DRIFT_ENGINE_BIN; + process.env.DRIFT_ENGINE_BIN = resolve("target/debug/drift-engine"); +}); + +afterEach(async () => { + if (originalEngineBin === undefined) { + delete process.env.DRIFT_ENGINE_BIN; + } else { + process.env.DRIFT_ENGINE_BIN = originalEngineBin; + } + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("security middleware fixture matrix", () => { + it("security middleware fixture matrix proves coverage and gaps", async () => { + const cases = [ + { + name: "security-middleware-covered", + protectsRoute: true, + parserGap: false + }, + { + name: "security-middleware-mismatch", + protectsRoute: false, + parserGap: false + }, + { + name: "security-middleware-method-mismatch", + protectsRoute: false, + parserGap: false + }, + { + name: "security-middleware-dynamic-parser-gap", + protectsRoute: false, + parserGap: true + } + ]; + + for (const entry of cases) { + const { repoRoot, stateRoot } = await fixtureRepo(entry.name); + const scan = await runCli([ + "scan", + "--repo-root", repoRoot, + "--state-root", stateRoot, + "--now", "2026-05-25T00:00:00.000Z", + "--json" + ]); + expect(scan.exitCode, `${entry.name} scan stderr:\n${scan.stderr}`).toBe(0); + const payload = JSON.parse(scan.stdout); + const storage = openDriftStorage({ databasePath: payload.database_path }); + + try { + const facts = storage.listFacts(payload.scan.id); + expect(facts).toContainEqual(expect.objectContaining({ + kind: "middleware_declared", + file_path: "middleware.ts" + })); + if (!entry.parserGap) { + expect(facts).toContainEqual(expect.objectContaining({ + kind: "middleware_matcher_declared", + file_path: "middleware.ts" + })); + } + const protectsRoute = facts.some((fact) => + fact.kind === "middleware_protects_route" && + fact.file_path === "app/api/projects/route.ts" + ); + expect(protectsRoute, `${entry.name} protects route`).toBe(entry.protectsRoute); + + const parserGaps = storage.listParserGaps(payload.repo.id, payload.scan.id); + const hasDynamicMiddlewareGap = parserGaps.some((gap) => + gap.message === "unsupported_dynamic_middleware_matcher" && + gap.confidence_impact === "blocks_enforcement" + ); + expect(hasDynamicMiddlewareGap, `${entry.name} dynamic parser gap`).toBe(entry.parserGap); + } finally { + storage.close(); + } + } + }, 20_000); +}); diff --git a/drift v3/test/fixtures/security-middleware-covered/app/api/projects/route.ts b/drift v3/test/fixtures/security-middleware-covered/app/api/projects/route.ts new file mode 100644 index 00000000..71f4fdab --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-covered/app/api/projects/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ projects: [] }); +} diff --git a/drift v3/test/fixtures/security-middleware-covered/middleware.ts b/drift v3/test/fixtures/security-middleware-covered/middleware.ts new file mode 100644 index 00000000..31d151b4 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-covered/middleware.ts @@ -0,0 +1,11 @@ +async function requireUser() { + return { id: "user_123" }; +} + +export async function middleware() { + await requireUser(); +} + +export const config = { + matcher: "/api/projects" +}; diff --git a/drift v3/test/fixtures/security-middleware-covered/package.json b/drift v3/test/fixtures/security-middleware-covered/package.json new file mode 100644 index 00000000..2adea4d6 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-covered/package.json @@ -0,0 +1 @@ +{"name":"security-middleware-covered","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/app/api/projects/route.ts b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/app/api/projects/route.ts new file mode 100644 index 00000000..71f4fdab --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/app/api/projects/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ projects: [] }); +} diff --git a/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/middleware.ts b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/middleware.ts new file mode 100644 index 00000000..8a856533 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/middleware.ts @@ -0,0 +1,15 @@ +async function requireUser() { + return { id: "user_123" }; +} + +function projectMatcher() { + return "/api/projects"; +} + +export async function middleware() { + await requireUser(); +} + +export const config = { + matcher: projectMatcher() +}; diff --git a/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/package.json b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/package.json new file mode 100644 index 00000000..2890bc0a --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-dynamic-parser-gap/package.json @@ -0,0 +1 @@ +{"name":"security-middleware-dynamic-parser-gap","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-middleware-method-mismatch/app/api/projects/route.ts b/drift v3/test/fixtures/security-middleware-method-mismatch/app/api/projects/route.ts new file mode 100644 index 00000000..71f4fdab --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-method-mismatch/app/api/projects/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ projects: [] }); +} diff --git a/drift v3/test/fixtures/security-middleware-method-mismatch/middleware.ts b/drift v3/test/fixtures/security-middleware-method-mismatch/middleware.ts new file mode 100644 index 00000000..48013e35 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-method-mismatch/middleware.ts @@ -0,0 +1,11 @@ +async function requireUser() { + return { id: "user_123" }; +} + +export async function middleware() { + await requireUser(); +} + +export const config = { + matcher: "/api/projects#POST" +}; diff --git a/drift v3/test/fixtures/security-middleware-method-mismatch/package.json b/drift v3/test/fixtures/security-middleware-method-mismatch/package.json new file mode 100644 index 00000000..4fca3b57 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-method-mismatch/package.json @@ -0,0 +1 @@ +{"name":"security-middleware-method-mismatch","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-middleware-mismatch/app/api/projects/route.ts b/drift v3/test/fixtures/security-middleware-mismatch/app/api/projects/route.ts new file mode 100644 index 00000000..71f4fdab --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-mismatch/app/api/projects/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ projects: [] }); +} diff --git a/drift v3/test/fixtures/security-middleware-mismatch/middleware.ts b/drift v3/test/fixtures/security-middleware-mismatch/middleware.ts new file mode 100644 index 00000000..21f0bc12 --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-mismatch/middleware.ts @@ -0,0 +1,11 @@ +async function requireUser() { + return { id: "user_123" }; +} + +export async function middleware() { + await requireUser(); +} + +export const config = { + matcher: "/api/admin/:path*" +}; diff --git a/drift v3/test/fixtures/security-middleware-mismatch/package.json b/drift v3/test/fixtures/security-middleware-mismatch/package.json new file mode 100644 index 00000000..211dd9da --- /dev/null +++ b/drift v3/test/fixtures/security-middleware-mismatch/package.json @@ -0,0 +1 @@ +{"name":"security-middleware-mismatch","private":true,"type":"module"} From 278b86e8ee1f450318d62821f8958be5654b11d5 Mon Sep 17 00:00:00 2001 From: geoffrey fernald Date: Tue, 26 May 2026 14:35:54 -0400 Subject: [PATCH 4/4] Implement Phase 3 request validation enforcement --- .../crates/drift-engine/src/check_command.rs | 450 ++++++++++- drift v3/crates/drift-engine/src/facts.rs | 3 + drift v3/crates/drift-engine/src/lib.rs | 23 +- drift v3/crates/drift-engine/src/main.rs | 3 + .../drift-engine/src/security_control_flow.rs | 114 +++ .../crates/drift-engine/src/security_facts.rs | 234 +++++- .../drift-engine/src/security_patterns.rs | 81 ++ .../crates/drift-engine/src/security_proof.rs | 332 +++++++- .../crates/drift-engine/src/security_rules.rs | 50 +- .../tests/security_control_flow.rs | 88 +- .../drift-engine/tests/security_facts.rs | 196 ++++- .../drift-engine/tests/security_rules.rs | 195 ++++- .../security-boundary-enforcement-100-tdd.md | 756 ++++++++++++++++++ drift v3/packages/cli/src/check/run-check.ts | 14 +- .../packages/cli/src/check/security-check.ts | 5 + .../packages/cli/src/domain/scan-status.ts | 8 + .../packages/cli/src/engine/engine-check.ts | 17 + drift v3/packages/cli/test/cli.test.ts | 91 +++ .../packages/cli/test/security-check.test.ts | 48 +- drift v3/packages/core/src/domain.ts | 6 +- drift v3/packages/core/src/schemas.ts | 6 +- drift v3/packages/core/src/security.ts | 49 +- drift v3/packages/core/test/security.test.ts | 97 +++ .../packages/engine-contract/src/index.ts | 46 +- .../test/security-contract.test.ts | 80 ++ drift v3/packages/mcp/src/index.ts | 8 + drift v3/packages/mcp/src/security-context.ts | 103 ++- drift v3/packages/mcp/test/mcp.test.ts | 134 ++++ drift v3/packages/query/src/index.ts | 32 +- .../query/src/security-boundary-proof.ts | 15 + .../test/security-boundary-proof.test.ts | 81 +- drift v3/test/e2e/security-validation.test.ts | 166 ++++ .../app/api/projects/route.ts | 8 + .../package.json | 1 + .../app/api/projects/route.ts | 7 + .../package.json | 1 + .../app/api/projects/route.ts | 7 + .../security-validation-missing/package.json | 1 + .../app/api/projects/route.ts | 8 + .../package.json | 1 + 40 files changed, 3528 insertions(+), 37 deletions(-) create mode 100644 drift v3/test/e2e/security-validation.test.ts create mode 100644 drift v3/test/fixtures/security-validation-before-data/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-validation-before-data/package.json create mode 100644 drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/package.json create mode 100644 drift v3/test/fixtures/security-validation-missing/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-validation-missing/package.json create mode 100644 drift v3/test/fixtures/security-validation-result-unused/app/api/projects/route.ts create mode 100644 drift v3/test/fixtures/security-validation-result-unused/package.json diff --git a/drift v3/crates/drift-engine/src/check_command.rs b/drift v3/crates/drift-engine/src/check_command.rs index d6a10178..a1206fb9 100644 --- a/drift v3/crates/drift-engine/src/check_command.rs +++ b/drift v3/crates/drift-engine/src/check_command.rs @@ -6,9 +6,10 @@ use std::{ }; use drift_engine::{ - AcceptedAuthHelper, AuthGuardBehavior, BaselineStatus, BaselineViolation, DiffFile, DiffScope, - DirectDataAccessRule, EnforcementMode, Fact, FactKind, FindingStatus, ParsedDiff, - RouteSecurityBoundaryProof, RuleFinding, SecurityProofStatus, Severity, + AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, BaselineStatus, + BaselineViolation, DiffFile, DiffScope, DirectDataAccessRule, EnforcementMode, Fact, FactKind, + FindingStatus, ParsedDiff, RequestValidatorBehavior, RequestValidatorKind, + RouteSecurityBoundaryProof, RuleFinding, SecurityBoundaryProof, SecurityProofStatus, Severity, build_auth_boundary_proofs_for_file, classify_findings_against_diff, materialize_direct_data_access_findings, }; @@ -134,6 +135,22 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { ); security_boundary_proofs.extend(auth_result.proofs); auth_result.findings + } else if convention.kind == "api_route_requires_request_validation" { + required_capabilities.extend([ + "security_facts".to_string(), + "request_validation_facts".to_string(), + ]); + let validation_result = security_request_validation_findings_and_proofs( + &facts, + repo_root.as_deref(), + &parsed_diff, + diff_scope, + &convention, + severity, + enforcement_mode, + ); + security_boundary_proofs.extend(validation_result.proofs); + validation_result.findings } else { continue; }; @@ -494,6 +511,11 @@ struct SecurityAuthEvaluation { proofs: Vec, } +struct SecurityRequestValidationEvaluation { + findings: Vec, + proofs: Vec, +} + fn security_auth_findings_and_proofs( facts: &[Fact], repo_root: Option<&str>, @@ -580,6 +602,91 @@ fn security_auth_findings_and_proofs( SecurityAuthEvaluation { findings, proofs } } +fn security_request_validation_findings_and_proofs( + facts: &[Fact], + repo_root: Option<&str>, + parsed_diff: &ParsedDiff, + diff_scope: DiffScope, + convention: &crate::protocol::CheckConvention, + severity: Severity, + enforcement_mode: EnforcementMode, +) -> SecurityRequestValidationEvaluation { + let accepted_validators = accepted_request_validators_for_convention(convention); + if accepted_validators.is_empty() { + return SecurityRequestValidationEvaluation { + findings: Vec::new(), + proofs: Vec::new(), + }; + } + if convention + .matcher + .applies_to_file_roles + .as_ref() + .is_some_and(|roles| !roles.iter().any(|role| role == "api_route")) + { + return SecurityRequestValidationEvaluation { + findings: Vec::new(), + proofs: Vec::new(), + }; + } + + let files = security_auth_files(facts, parsed_diff, diff_scope); + let mut findings = Vec::new(); + let mut proofs = Vec::new(); + + for file_path in files { + let Some(source) = read_repo_file(repo_root, &file_path) else { + continue; + }; + let proof = match drift_engine::build_request_validation_proof( + &file_path, + &source, + &accepted_validators, + ) { + Ok(proof) => proof, + Err(_) => continue, + }; + let (route_id, handler_symbol) = route_identity_for_file(facts, &file_path) + .unwrap_or_else(|| (format!("route:{file_path}:unknown"), "unknown".to_string())); + let missing_code = request_validation_missing_code(&proof); + let finding_line = request_validation_finding_line(&proof).unwrap_or(1); + let finding_fingerprint = stable_hash(&format!( + "{}:{}:{}:{}", + convention.id, route_id, missing_code, finding_line + )); + let finding_id = format!("finding_{}", &finding_fingerprint[..16]); + proofs.push(request_validation_proof_json( + &proof, + &route_id, + &file_path, + &handler_symbol, + convention, + &finding_id, + )); + if proof.result.proof_status != SecurityProofStatus::Proven { + findings.push(PendingFinding { + fingerprint: finding_fingerprint, + convention_id: convention.id.clone(), + rule_id: "api_route_requires_request_validation".to_string(), + title: "API route uses unvalidated request input".to_string(), + message: "Accepted request validation must produce the value used by protected route sinks." + .to_string(), + severity, + enforcement_result: enforcement_result_for_mode(enforcement_mode), + file_path: file_path.clone(), + import_name: "request_validation".to_string(), + import_source: missing_code, + line: finding_line, + evidence_id: format!("evidence_{}", &finding_id["finding_".len()..]), + legacy_fingerprints: Vec::new(), + related_node_ids: Vec::new(), + }); + } + } + + SecurityRequestValidationEvaluation { findings, proofs } +} + fn accepted_auth_helpers_for_convention( convention: &crate::protocol::CheckConvention, ) -> Vec { @@ -639,6 +746,117 @@ fn accepted_auth_helpers_for_convention( helpers.into_values().collect() } +fn accepted_request_validators_for_convention( + convention: &crate::protocol::CheckConvention, +) -> Vec { + let mut validators = BTreeMap::::new(); + if let Some(required_calls) = &convention.matcher.required_calls { + for symbol in required_calls { + insert_request_validator( + &mut validators, + symbol, + RequestValidatorKind::Helper, + RequestValidatorBehavior::ReturnsParsed, + None, + ); + } + } + if let Some(requires) = &convention.requires { + if let Some(helper_values) = requires + .get("validators") + .and_then(|value| value.as_array()) + { + for helper in helper_values { + insert_request_validator_value( + &mut validators, + helper, + RequestValidatorKind::Helper, + RequestValidatorBehavior::ReturnsParsed, + ); + } + } + if let Some(schema_values) = requires.get("schemas").and_then(|value| value.as_array()) { + for schema in schema_values { + insert_request_validator_value( + &mut validators, + schema, + RequestValidatorKind::Schema, + RequestValidatorBehavior::ReturnsParsed, + ); + } + } + } + validators.into_values().collect() +} + +fn insert_request_validator_value( + validators: &mut BTreeMap, + value: &serde_json::Value, + default_kind: RequestValidatorKind, + default_behavior: RequestValidatorBehavior, +) { + if let Some(symbol) = value.as_str() { + insert_request_validator(validators, symbol, default_kind, default_behavior, None); + return; + } + let Some(symbol) = value + .get("symbol") + .or_else(|| value.get("name")) + .and_then(|symbol| symbol.as_str()) + else { + return; + }; + let kind = value + .get("kind") + .and_then(|kind| kind.as_str()) + .map(request_validator_kind_from_str) + .unwrap_or(default_kind); + let behavior = value + .get("behavior") + .and_then(|behavior| behavior.as_str()) + .map(request_validator_behavior_from_str) + .unwrap_or(default_behavior); + let validator_id = value + .get("validator_id") + .or_else(|| value.get("id")) + .and_then(|id| id.as_str()); + insert_request_validator(validators, symbol, kind, behavior, validator_id); +} + +fn insert_request_validator( + validators: &mut BTreeMap, + symbol: &str, + kind: RequestValidatorKind, + behavior: RequestValidatorBehavior, + validator_id: Option<&str>, +) { + validators.insert( + format!("{}:{symbol}", kind.as_str()), + AcceptedRequestValidator { + validator_id: validator_id.unwrap_or(symbol).to_string(), + symbol: symbol.to_string(), + kind, + behavior, + }, + ); +} + +fn request_validator_kind_from_str(kind: &str) -> RequestValidatorKind { + match kind { + "schema" => RequestValidatorKind::Schema, + _ => RequestValidatorKind::Helper, + } +} + +fn request_validator_behavior_from_str(behavior: &str) -> RequestValidatorBehavior { + match behavior { + "throws" => RequestValidatorBehavior::Throws, + "boolean" => RequestValidatorBehavior::Boolean, + "unknown" => RequestValidatorBehavior::Unknown, + _ => RequestValidatorBehavior::ReturnsParsed, + } +} + fn read_repo_file(repo_root: Option<&str>, file_path: &str) -> Option { let repo_root = repo_root?; let path = Path::new(repo_root).join(file_path); @@ -670,6 +888,57 @@ fn first_sink_line_for_route( .min() } +fn route_identity_for_file(facts: &[Fact], file_path: &str) -> Option<(String, String)> { + facts + .iter() + .find(|fact| fact.file_path == file_path && fact.kind == FactKind::RouteDeclared) + .map(|fact| { + ( + format!("route:{}:{}", fact.file_path, fact.name), + fact.name.clone(), + ) + }) +} + +fn request_validation_missing_code(proof: &SecurityBoundaryProof) -> String { + proof + .parser_gaps + .first() + .map(|gap| gap.code.clone()) + .or_else(|| { + proof + .request_validation + .unvalidated_uses + .first() + .map(|use_proof| use_proof.reason.clone()) + }) + .unwrap_or_else(|| "request_input_not_validated".to_string()) +} + +fn request_validation_finding_line(proof: &SecurityBoundaryProof) -> Option { + proof + .request_validation + .unvalidated_uses + .first() + .map(|use_proof| input_line_from_fact_id(&use_proof.sink_fact_id)) + .or_else(|| { + proof + .parser_gaps + .first() + .and_then(|gap| gap.parser_gap_id.split(':').nth_back(1)) + .and_then(|line| line.parse::().ok()) + }) + .filter(|line| *line > 0) +} + +fn input_line_from_fact_id(fact_id: &str) -> usize { + fact_id + .rsplit(':') + .next() + .and_then(|line| line.parse::().ok()) + .unwrap_or(0) +} + fn route_security_proof_json( proof: &RouteSecurityBoundaryProof, convention: &crate::protocol::CheckConvention, @@ -794,6 +1063,181 @@ fn route_security_proof_json( }) } +fn request_validation_proof_json( + proof: &SecurityBoundaryProof, + route_id: &str, + file_path: &str, + handler_symbol: &str, + convention: &crate::protocol::CheckConvention, + finding_id: &str, +) -> serde_json::Value { + let missing_codes = if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::new() + } else if !proof.request_validation.unvalidated_uses.is_empty() { + proof + .request_validation + .unvalidated_uses + .iter() + .map(|use_proof| use_proof.reason.clone()) + .collect::>() + } else { + vec![request_validation_missing_code(proof)] + }; + let missing_proof_ids = missing_codes + .iter() + .map(|code| format!("missing_proof:{route_id}:{code}")) + .collect::>(); + let parser_gap_ids = proof + .parser_gaps + .iter() + .map(|gap| gap.parser_gap_id.clone()) + .collect::>(); + let missing_fact_ids = proof + .request_validation + .unvalidated_uses + .iter() + .flat_map(|use_proof| { + [ + use_proof.input_fact_id.clone(), + use_proof.sink_fact_id.clone(), + ] + }) + .collect::>() + .into_iter() + .collect::>(); + let missing_proof = missing_codes + .iter() + .enumerate() + .map(|(index, code)| { + json!({ + "id": missing_proof_ids[index], + "capability": "request_validation_facts", + "code": code, + "blocks_enforcement": true, + "fact_ids": missing_fact_ids.clone(), + "graph_edge_ids": [] + }) + }) + .collect::>(); + let parser_gaps = proof + .parser_gaps + .iter() + .map(|gap| { + json!({ + "parser_gap_id": gap.parser_gap_id, + "capability": "request_validation_facts", + "code": gap.code, + "file_path": gap.file_path, + "reason": gap.reason, + "affected_contract_kinds": ["api_route_requires_request_validation"], + "affected_route_ids": [route_id], + "missing_proof_ids": missing_proof_ids.clone(), + "blocks_enforcement": gap.blocks_enforcement + }) + }) + .collect::>(); + let validations = proof + .request_validation + .validations + .iter() + .map(|validation| { + let mut object = serde_json::Map::new(); + object.insert("fact_id".to_string(), json!(validation.fact_id)); + object.insert( + "validator_symbol".to_string(), + json!(validation.validator_symbol), + ); + if let Some(schema_symbol) = &validation.schema_symbol { + object.insert("schema_symbol".to_string(), json!(schema_symbol)); + } + if let Some(input_var) = &validation.input_var { + object.insert("input_var".to_string(), json!(input_var)); + } + if let Some(result_var) = &validation.result_var { + object.insert("result_var".to_string(), json!(result_var)); + } + serde_json::Value::Object(object) + }) + .collect::>(); + + json!({ + "proof_id": format!("proof:{route_id}:request_validation"), + "proof_version": "security-boundary-proof/v1", + "route": { + "route_id": route_id, + "file_path": file_path, + "file_role": "api_route", + "handler_symbol": handler_symbol + }, + "contracts": [{ + "contract_id": convention.id, + "kind": "api_route_requires_request_validation", + "enforcement_mode": convention.enforcement_mode, + "capability": convention.enforcement_capability, + "matched": true + }], + "capability_status": [{ + "name": "request_validation_facts", + "status": if proof.result.proof_status == SecurityProofStatus::Proven { "complete" } else { "partial" }, + "can_block": true, + "parser_gap_ids": parser_gap_ids, + "missing_proof_ids": missing_proof_ids + }], + "auth": { + "required": false, + "proven": false, + "proof_kind": "none", + "trusted_guard_calls": [], + "dominated_sinks": [], + "undominated_sinks": [] + }, + "request_validation": { + "required": proof.request_validation.required, + "proven": proof.request_validation.proven, + "input_reads": proof.request_validation.input_reads.iter().map(|input| { + let mut object = serde_json::Map::new(); + object.insert("fact_id".to_string(), json!(input.fact_id)); + object.insert("source".to_string(), json!(input.source)); + object.insert("variable".to_string(), json!(input.variable)); + if let Some(key) = &input.key { + object.insert("key".to_string(), json!(key)); + } + serde_json::Value::Object(object) + }).collect::>(), + "validations": validations, + "validated_uses": proof.request_validation.validated_uses.iter().map(|use_proof| json!({ + "fact_id": use_proof.fact_id, + "source_input_var": use_proof.source_input_var, + "validated_var": use_proof.validated_var, + "sink_fact_id": use_proof.sink_fact_id, + "sink_kind": use_proof.sink_kind + })).collect::>(), + "unvalidated_uses": proof.request_validation.unvalidated_uses.iter().map(|use_proof| json!({ + "input_fact_id": use_proof.input_fact_id, + "sink_fact_id": use_proof.sink_fact_id, + "sink_kind": use_proof.sink_kind, + "reason": use_proof.reason + })).collect::>() + }, + "missing_proof": missing_proof, + "parser_gaps": parser_gaps, + "result": { + "proof_status": security_proof_status(&proof.result.proof_status), + "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { + "pass" + } else { + convention.enforcement_mode.as_str() + }, + "can_block": proof.result.proof_status != SecurityProofStatus::Proven, + "finding_ids": if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::::new() + } else { + vec![finding_id.to_string()] + } + } + }) +} + fn security_proof_status(status: &SecurityProofStatus) -> &'static str { match status { SecurityProofStatus::Proven => "proven", diff --git a/drift v3/crates/drift-engine/src/facts.rs b/drift v3/crates/drift-engine/src/facts.rs index 7e08e76f..965b5de6 100644 --- a/drift v3/crates/drift-engine/src/facts.rs +++ b/drift v3/crates/drift-engine/src/facts.rs @@ -19,6 +19,9 @@ pub enum FactKind { MiddlewareDeclared, MiddlewareMatcherDeclared, MiddlewareProtectsRoute, + RequestInputRead, + RequestValidationCalled, + ValidatedInputUsed, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/lib.rs b/drift v3/crates/drift-engine/src/lib.rs index e047ece3..34a3914c 100644 --- a/drift v3/crates/drift-engine/src/lib.rs +++ b/drift v3/crates/drift-engine/src/lib.rs @@ -33,22 +33,29 @@ pub use security_capabilities::{ SecurityCapabilityStatus, SecurityScanCapability, security_capabilities, }; pub use security_control_flow::{ - MatchedMiddleware, MiddlewareMismatch, static_middleware_coverage, + MatchedMiddleware, MiddlewareMismatch, ValidatedInputUse, static_middleware_coverage, + validated_input_uses, }; pub use security_facts::extract_security_facts; +pub use security_facts::extract_security_facts_with_validation; pub use security_patterns::{ - AcceptedAuthHelper, AuthGuardBehavior, dynamic_middleware_matcher_line, + AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, RequestValidatorBehavior, + RequestValidatorKind, dynamic_middleware_matcher_line, }; pub use security_proof::{ - AuthBoundaryProof, MiddlewareBoundaryProof, RouteSecurityBoundaryProof, SecurityBoundaryProof, - SecurityParserGap, SecurityProofResult, SecurityProofStatus, TrustedGuardCallProof, - UndominatedSinkProof, build_auth_boundary_proof, build_auth_boundary_proofs_for_file, - build_middleware_coverage_proof, + AuthBoundaryProof, MiddlewareBoundaryProof, RequestInputReadProof, RequestUnvalidatedUseProof, + RequestValidatedUseProof, RequestValidationCallProof, RequestValidationProof, + RouteSecurityBoundaryProof, SecurityBoundaryProof, SecurityParserGap, SecurityProofResult, + SecurityProofStatus, TrustedGuardCallProof, UndominatedSinkProof, build_auth_boundary_proof, + build_auth_boundary_proofs_for_file, build_middleware_coverage_proof, + build_request_validation_proof, }; pub use security_rules::{ SecurityAuthContract, SecurityContractCapability, SecurityEnforcementMode, SecurityFinding, - SecurityFindingResult, SecurityMiddlewareContract, evaluate_api_route_requires_auth_helper, - evaluate_api_route_requires_auth_helper_with_middleware, evaluate_middleware_must_cover_routes, + SecurityFindingResult, SecurityMiddlewareContract, SecurityRequestValidationContract, + evaluate_api_route_requires_auth_helper, + evaluate_api_route_requires_auth_helper_with_middleware, + evaluate_api_route_requires_request_validation, evaluate_middleware_must_cover_routes, }; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/main.rs b/drift v3/crates/drift-engine/src/main.rs index 4db1fd49..7b64de18 100644 --- a/drift v3/crates/drift-engine/src/main.rs +++ b/drift v3/crates/drift-engine/src/main.rs @@ -737,6 +737,9 @@ fn fact_kind(kind: FactKind) -> &'static str { FactKind::MiddlewareDeclared => "middleware_declared", FactKind::MiddlewareMatcherDeclared => "middleware_matcher_declared", FactKind::MiddlewareProtectsRoute => "middleware_protects_route", + FactKind::RequestInputRead => "request_input_read", + FactKind::RequestValidationCalled => "request_validation_called", + FactKind::ValidatedInputUsed => "validated_input_used", } } diff --git a/drift v3/crates/drift-engine/src/security_control_flow.rs b/drift v3/crates/drift-engine/src/security_control_flow.rs index e3ca8132..5acbb606 100644 --- a/drift v3/crates/drift-engine/src/security_control_flow.rs +++ b/drift v3/crates/drift-engine/src/security_control_flow.rs @@ -22,6 +22,16 @@ pub struct MiddlewareMismatch { pub parser_gap_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedInputUse { + pub source_input_var: String, + pub validated_var: String, + pub sink_fact_id: String, + pub sink_kind: String, + pub start_line: usize, + pub end_line: usize, +} + pub fn guard_dominates_straight_line_sinks(facts: &[Fact]) -> Vec { let Some(first_guard_line) = facts .iter() @@ -160,6 +170,110 @@ pub fn protected_sinks(facts: &[Fact]) -> Vec<&Fact> { .collect() } +pub fn validated_input_uses(facts: &[Fact], lines: &[&str]) -> Vec { + let validations = facts + .iter() + .filter(|fact| fact.kind == FactKind::RequestValidationCalled) + .filter_map(validation_metadata) + .collect::>(); + let mut uses = Vec::new(); + for validation in validations { + for sink in protected_sinks(facts) + .into_iter() + .filter(|sink| sink.start_line > validation.validation_line) + { + let Some(line) = lines.get(sink.start_line.saturating_sub(1)) else { + continue; + }; + if line_uses_identifier(line, &validation.validated_var) { + if validation.requires_success_guard + && !safe_parse_success_guard_dominates( + lines, + &validation.validated_var, + validation.validation_line, + sink.start_line, + ) + { + continue; + } + uses.push(ValidatedInputUse { + source_input_var: validation.source_input_var.clone(), + validated_var: validation.validated_var.clone(), + sink_fact_id: sink_id(sink), + sink_kind: sink_kind(sink).to_string(), + start_line: sink.start_line, + end_line: sink.end_line, + }); + } + } + } + uses +} + +struct ValidationMetadata { + source_input_var: String, + validated_var: String, + validation_line: usize, + requires_success_guard: bool, +} + +fn validation_metadata(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + let source_input_var = value.get("input_var")?.as_str()?.to_string(); + let validated_var = value.get("result_var")?.as_str()?.to_string(); + Some(ValidationMetadata { + source_input_var, + validated_var, + validation_line: fact.start_line, + requires_success_guard: fact.name == "safeParse", + }) +} + +fn safe_parse_success_guard_dominates( + lines: &[&str], + result_var: &str, + validation_line: usize, + sink_line: usize, +) -> bool { + let success_check = format!("{result_var}.success"); + let failure_check = format!("!{result_var}.success"); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + if line_number <= validation_line || line_number >= sink_line || !line.contains("if") { + continue; + } + if line.contains(&failure_check) { + let Some(block_end) = closing_block_line(lines, line_number) else { + continue; + }; + let guard_exits = lines + .iter() + .take(block_end) + .skip(line_number) + .any(|candidate| candidate.contains("return") || candidate.contains("throw")); + if guard_exits && block_end < sink_line { + return true; + } + } + if line.contains(&success_check) && !line.contains(&failure_check) { + let Some(block_end) = closing_block_line(lines, line_number) else { + continue; + }; + if line_number < sink_line && sink_line < block_end { + return true; + } + } + } + false +} + +fn line_uses_identifier(line: &str, identifier: &str) -> bool { + line.split(|character: char| { + character != '_' && character != '$' && !character.is_ascii_alphanumeric() + }) + .any(|token| token == identifier) +} + pub fn static_middleware_coverage( middleware_facts: &[Fact], route_file_path: &str, diff --git a/drift v3/crates/drift-engine/src/security_facts.rs b/drift v3/crates/drift-engine/src/security_facts.rs index 66946dfd..d90babfa 100644 --- a/drift v3/crates/drift-engine/src/security_facts.rs +++ b/drift v3/crates/drift-engine/src/security_facts.rs @@ -1,7 +1,9 @@ use serde_json::json; +use crate::security_control_flow::validated_input_uses; use crate::security_patterns::{ - AcceptedAuthHelper, accepted_auth_helper_for_call, static_middleware_matchers, + AcceptedAuthHelper, AcceptedRequestValidator, RequestValidatorKind, + accepted_auth_helper_for_call, accepted_request_validator_for_call, static_middleware_matchers, }; use crate::{Fact, FactExtractError, FactKind, extract_typescript_facts}; @@ -9,6 +11,15 @@ pub fn extract_security_facts( file_path: impl AsRef, source: &str, accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result, FactExtractError> { + extract_security_facts_with_validation(file_path, source, accepted_auth_helpers, &[]) +} + +pub fn extract_security_facts_with_validation( + file_path: impl AsRef, + source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], + accepted_validators: &[AcceptedRequestValidator], ) -> Result, FactExtractError> { let normalized_file_path = file_path.as_ref().to_string_lossy().replace('\\', "/"); let facts = extract_typescript_facts(file_path, source)?; @@ -58,6 +69,37 @@ pub fn extract_security_facts( }); } } + if let Some(validator) = + accepted_request_validator_for_call(fact, &facts, accepted_validators) + && let Some(line) = source_lines.get(fact.start_line.saturating_sub(1)) + && let Some(input_var) = call_first_argument(line, &fact.name) + { + let route_id = format!("route:{}:{route}", fact.file_path); + let result_var = assigned_variable(line); + let schema_symbol = + (validator.kind == RequestValidatorKind::Schema).then(|| validator.symbol.clone()); + security_facts.push(Fact { + kind: FactKind::RequestValidationCalled, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: Some( + json!({ + "validator_id": validator.validator_id, + "route_id": route_id, + "validator_symbol": validator.symbol, + "schema_symbol": schema_symbol, + "input_var": input_var, + "result_var": result_var, + "behavior": validator.behavior.as_str(), + "kind": validator.kind.as_str(), + }) + .to_string(), + ), + imported_name: Some(validator.symbol.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); + } if is_json_response_call(fact) { let route_id = format!("route:{}:{route}", fact.file_path); security_facts.push(Fact { @@ -79,6 +121,35 @@ pub fn extract_security_facts( }); } } + security_facts.extend(request_input_read_facts( + &normalized_file_path, + &facts, + &source_lines, + )); + let combined_facts = facts + .iter() + .cloned() + .chain(security_facts.iter().cloned()) + .collect::>(); + for validated_use in validated_input_uses(&combined_facts, &source_lines) { + security_facts.push(Fact { + kind: FactKind::ValidatedInputUsed, + file_path: normalized_file_path.clone(), + name: validated_use.validated_var.clone(), + value: Some( + json!({ + "source_input_var": validated_use.source_input_var, + "validated_var": validated_use.validated_var, + "sink_kind": validated_use.sink_kind, + "sink_fact_id": validated_use.sink_fact_id, + }) + .to_string(), + ), + imported_name: None, + start_line: validated_use.start_line, + end_line: validated_use.end_line, + }); + } if is_middleware_file( facts .first() @@ -137,6 +208,167 @@ pub fn extract_security_facts( Ok(security_facts) } +fn call_first_argument(line: &str, call_name: &str) -> Option { + let marker = format!("{call_name}("); + let after_marker = line.split(&marker).nth(1)?; + let argument = after_marker.split_once(')')?.0.split(',').next()?.trim(); + (!argument.is_empty() && argument.chars().all(is_identifier_char)).then(|| argument.to_string()) +} + +fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> Vec { + let mut request_facts = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let route = route_for_line(facts, line_number).unwrap_or("unknown"); + let route_id = format!("route:{file_path}:{route}"); + if line.contains("await request.json()") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "body", + variable, + None, + )); + } + } else if line.contains("await request.formData()") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "formData", + variable, + None, + )); + } + } else if line.contains("await request.text()") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "body", + variable, + None, + )); + } + } else if line.contains("request.nextUrl.searchParams.get(") + || line.contains("new URL(request.url).searchParams.get(") + { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "query", + variable, + quoted_argument(line, "searchParams.get("), + )); + } + } else if line.contains("request.headers.get(") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "headers", + variable, + quoted_argument(line, "headers.get("), + )); + } + } else if line.contains("cookies().get(") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "cookies", + variable, + quoted_argument(line, "cookies().get("), + )); + } + } else if (line.contains("params.") || line.contains("context.params.")) + && let Some(variable) = assigned_variable(line) + { + let key = line + .split("params.") + .nth(1) + .map(|value| identifier_prefix(value).to_string()); + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "params", + variable, + key, + )); + } + } + request_facts +} + +fn request_input_fact( + file_path: &str, + line_number: usize, + route_id: String, + source: &str, + variable: String, + key: Option, +) -> Fact { + Fact { + kind: FactKind::RequestInputRead, + file_path: file_path.to_string(), + name: variable.clone(), + value: Some( + json!({ + "route_id": route_id, + "source": source, + "variable": variable, + "key": key, + "taint": "untrusted", + }) + .to_string(), + ), + imported_name: None, + start_line: line_number, + end_line: line_number, + } +} + +fn assigned_variable(line: &str) -> Option { + let before_equals = line.split_once('=')?.0.trim(); + let variable = before_equals + .strip_prefix("const ") + .or_else(|| before_equals.strip_prefix("let ")) + .or_else(|| before_equals.strip_prefix("var ")) + .unwrap_or(before_equals) + .trim(); + (!variable.is_empty() && variable.chars().all(is_identifier_char)).then(|| variable.to_string()) +} + +fn quoted_argument(line: &str, marker: &str) -> Option { + let after_marker = line.split(marker).nth(1)?; + let quote = after_marker + .chars() + .find(|value| *value == '"' || *value == '\'')?; + let after_quote = after_marker.split_once(quote)?.1; + let value = after_quote.split_once(quote)?.0.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn identifier_prefix(value: &str) -> &str { + value + .split(|character: char| !is_identifier_char(character)) + .next() + .unwrap_or("") +} + +fn is_identifier_char(value: char) -> bool { + value == '_' || value == '$' || value.is_ascii_alphanumeric() +} + fn is_middleware_file(file_path: &str) -> bool { file_path == "middleware.ts" || file_path == "middleware.js" diff --git a/drift v3/crates/drift-engine/src/security_patterns.rs b/drift v3/crates/drift-engine/src/security_patterns.rs index ebd67015..ef7be0ac 100644 --- a/drift v3/crates/drift-engine/src/security_patterns.rs +++ b/drift v3/crates/drift-engine/src/security_patterns.rs @@ -42,6 +42,87 @@ pub fn accepted_auth_helper_for_call<'a>( }) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedRequestValidator { + pub validator_id: String, + pub symbol: String, + pub kind: RequestValidatorKind, + pub behavior: RequestValidatorBehavior, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestValidatorKind { + Schema, + Helper, +} + +impl RequestValidatorKind { + pub fn as_str(self) -> &'static str { + match self { + RequestValidatorKind::Schema => "schema", + RequestValidatorKind::Helper => "helper", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestValidatorBehavior { + Throws, + ReturnsParsed, + Boolean, + Unknown, +} + +impl RequestValidatorBehavior { + pub fn as_str(self) -> &'static str { + match self { + RequestValidatorBehavior::Throws => "throws", + RequestValidatorBehavior::ReturnsParsed => "returns_parsed", + RequestValidatorBehavior::Boolean => "boolean", + RequestValidatorBehavior::Unknown => "unknown", + } + } +} + +pub fn accepted_request_validator_for_call<'a>( + call: &Fact, + facts: &[Fact], + accepted_validators: &'a [AcceptedRequestValidator], +) -> Option<&'a AcceptedRequestValidator> { + accepted_validators + .iter() + .find(|validator| match validator.kind { + RequestValidatorKind::Helper => { + call.value.is_none() + && (call.name == validator.symbol + || imported_symbol_matches(facts, &call.name, &validator.symbol)) + } + RequestValidatorKind::Schema => { + matches!(call.name.as_str(), "parse" | "safeParse") + && call.value.as_deref().is_some_and(|receiver| { + receiver_root(receiver) == validator.symbol + || imported_symbol_matches( + facts, + receiver_root(receiver), + &validator.symbol, + ) + }) + } + }) +} + +fn imported_symbol_matches(facts: &[Fact], local_name: &str, accepted_symbol: &str) -> bool { + facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == local_name + && fact.imported_name.as_deref() == Some(accepted_symbol) + }) +} + +fn receiver_root(receiver: &str) -> &str { + receiver.split('.').next().unwrap_or(receiver) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct StaticMiddlewareMatcher { pub path_pattern: String, diff --git a/drift v3/crates/drift-engine/src/security_proof.rs b/drift v3/crates/drift-engine/src/security_proof.rs index 206d82e4..96862b1f 100644 --- a/drift v3/crates/drift-engine/src/security_proof.rs +++ b/drift v3/crates/drift-engine/src/security_proof.rs @@ -1,5 +1,6 @@ use crate::{ - AcceptedAuthHelper, Fact, FactExtractError, extract_security_facts, extract_typescript_facts, + AcceptedAuthHelper, AcceptedRequestValidator, Fact, FactExtractError, extract_security_facts, + extract_security_facts_with_validation, extract_typescript_facts, security_control_flow::{ DominatedSink, MatchedMiddleware, MiddlewareMismatch, branch_bypass_reasons, callback_boundary_reasons, conditional_guard_without_else_reasons, @@ -13,6 +14,7 @@ use crate::{ pub struct SecurityBoundaryProof { pub auth: AuthBoundaryProof, pub middleware: MiddlewareBoundaryProof, + pub request_validation: RequestValidationProof, pub parser_gaps: Vec, pub result: SecurityProofResult, } @@ -63,6 +65,50 @@ pub struct MiddlewareBoundaryProof { pub mismatches: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestValidationProof { + pub required: bool, + pub proven: bool, + pub input_reads: Vec, + pub validations: Vec, + pub validated_uses: Vec, + pub unvalidated_uses: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestInputReadProof { + pub fact_id: String, + pub source: String, + pub variable: String, + pub key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestValidationCallProof { + pub fact_id: String, + pub validator_symbol: String, + pub schema_symbol: Option, + pub input_var: Option, + pub result_var: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestValidatedUseProof { + pub fact_id: String, + pub source_input_var: String, + pub validated_var: String, + pub sink_fact_id: String, + pub sink_kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestUnvalidatedUseProof { + pub input_fact_id: String, + pub sink_fact_id: String, + pub sink_kind: String, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecurityProofResult { pub proof_status: SecurityProofStatus, @@ -139,6 +185,7 @@ pub fn build_auth_boundary_proof( matched_middleware: Vec::new(), mismatches: Vec::new(), }, + request_validation: RequestValidationProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status: if dynamic_control_flow { @@ -438,6 +485,7 @@ pub fn build_middleware_coverage_proof( matched_middleware, mismatches, }, + request_validation: RequestValidationProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status: if dynamic_matcher_line.is_some() { @@ -450,3 +498,285 @@ pub fn build_middleware_coverage_proof( }, }) } + +pub fn build_request_validation_proof( + file_path: impl AsRef, + source: &str, + accepted_validators: &[AcceptedRequestValidator], +) -> Result { + let base_facts = extract_typescript_facts(&file_path, source)?; + let security_facts = + extract_security_facts_with_validation(file_path, source, &[], accepted_validators)?; + let mut facts: Vec = base_facts.into_iter().chain(security_facts).collect(); + facts.sort_by_key(|fact| fact.start_line); + let lines = source.lines().collect::>(); + + let input_reads = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::RequestInputRead) + .filter_map(request_input_read_proof) + .collect::>(); + let validations = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::RequestValidationCalled) + .filter_map(request_validation_call_proof) + .collect::>(); + let validated_uses = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::ValidatedInputUsed) + .filter_map(request_validated_use_proof) + .collect::>(); + let parser_gaps = request_input_parser_gaps(&file_path_string(&facts), &lines, &input_reads); + let unvalidated_uses = request_unvalidated_uses(&facts, &lines, &input_reads, &validated_uses); + let proven = parser_gaps.is_empty() + && !input_reads.is_empty() + && !validated_uses.is_empty() + && unvalidated_uses.is_empty(); + let proof_status = if !parser_gaps.is_empty() { + SecurityProofStatus::ParserGap + } else if proven { + SecurityProofStatus::Proven + } else { + SecurityProofStatus::MissingProof + }; + + Ok(SecurityBoundaryProof { + auth: AuthBoundaryProof { + required: false, + proven: false, + dominated_sinks: Vec::new(), + undominated_sinks: Vec::new(), + }, + middleware: MiddlewareBoundaryProof { + required: false, + proven: false, + matched_middleware: Vec::new(), + mismatches: Vec::new(), + }, + request_validation: RequestValidationProof { + required: true, + proven, + input_reads, + validations, + validated_uses, + unvalidated_uses, + }, + parser_gaps, + result: SecurityProofResult { proof_status }, + }) +} + +fn file_path_string(facts: &[Fact]) -> String { + facts + .first() + .map(|fact| fact.file_path.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn request_input_parser_gaps( + file_path: &str, + lines: &[&str], + input_reads: &[RequestInputReadProof], +) -> Vec { + let mut gaps = Vec::new(); + for input in input_reads { + for (index, line) in lines.iter().enumerate() { + let spread_marker = format!("...{}", input.variable); + if line.contains(&spread_marker) { + gaps.push(SecurityParserGap { + parser_gap_id: format!( + "parser_gap:{}:{}:unsupported_request_input_spread", + file_path, + index + 1 + ), + code: "unsupported_request_input_spread".to_string(), + file_path: file_path.to_string(), + reason: + "Object spread from request input prevents deterministic validation proof" + .to_string(), + blocks_enforcement: true, + }); + } + } + } + gaps +} + +impl RequestValidationProof { + fn not_required() -> Self { + Self { + required: false, + proven: false, + input_reads: Vec::new(), + validations: Vec::new(), + validated_uses: Vec::new(), + unvalidated_uses: Vec::new(), + } + } +} + +fn request_unvalidated_uses( + facts: &[Fact], + lines: &[&str], + input_reads: &[RequestInputReadProof], + validated_uses: &[RequestValidatedUseProof], +) -> Vec { + let mut uses = Vec::new(); + for input in input_reads { + uses.extend(unknown_validator_uses(facts, lines, input)); + for sink in protected_sinks(facts) { + if sink.start_line <= input_line_from_fact_id(&input.fact_id) { + continue; + } + let Some(line) = lines.get(sink.start_line.saturating_sub(1)) else { + continue; + }; + if !line_uses_identifier(line, &input.variable) { + continue; + } + let sink_fact_id = fact_id(sink); + let validated = validated_uses.iter().any(|validated| { + validated.source_input_var == input.variable + && validated.sink_fact_id == sink_fact_id + }); + if !validated { + uses.push(RequestUnvalidatedUseProof { + input_fact_id: input.fact_id.clone(), + sink_fact_id, + sink_kind: sink_kind(sink).to_string(), + reason: "request_input_not_validated".to_string(), + }); + } + } + } + uses +} + +fn unknown_validator_uses( + facts: &[Fact], + lines: &[&str], + input: &RequestInputReadProof, +) -> Vec { + let input_line = input_line_from_fact_id(&input.fact_id); + let mut uses = Vec::new(); + for call in facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::SymbolCalled) + .filter(|fact| fact.start_line > input_line) + .filter(|fact| { + fact.name.starts_with("validate") + || fact + .value + .as_deref() + .is_some_and(|receiver| receiver.ends_with("Schema")) + }) + { + if facts.iter().any(|fact| { + fact.kind == crate::FactKind::RequestValidationCalled + && fact.start_line == call.start_line + && fact.name == call.name + }) { + continue; + } + let Some(call_line) = lines.get(call.start_line.saturating_sub(1)) else { + continue; + }; + if !line_uses_identifier(call_line, &input.variable) { + continue; + } + let Some(result_var) = assigned_variable(call_line) else { + continue; + }; + for sink in protected_sinks(facts) + .into_iter() + .filter(|sink| sink.start_line > call.start_line) + { + let Some(sink_line) = lines.get(sink.start_line.saturating_sub(1)) else { + continue; + }; + if line_uses_identifier(sink_line, &result_var) { + uses.push(RequestUnvalidatedUseProof { + input_fact_id: input.fact_id.clone(), + sink_fact_id: fact_id(sink), + sink_kind: sink_kind(sink).to_string(), + reason: "unknown_validator".to_string(), + }); + } + } + } + uses +} + +fn assigned_variable(line: &str) -> Option { + let before_equals = line.split_once('=')?.0.trim(); + let variable = before_equals + .strip_prefix("const ") + .or_else(|| before_equals.strip_prefix("let ")) + .or_else(|| before_equals.strip_prefix("var ")) + .unwrap_or(before_equals) + .trim(); + (!variable.is_empty() + && variable.chars().all(|character| { + character == '_' || character == '$' || character.is_ascii_alphanumeric() + })) + .then(|| variable.to_string()) +} + +fn request_input_read_proof(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + Some(RequestInputReadProof { + fact_id: fact_id(fact), + source: value.get("source")?.as_str()?.to_string(), + variable: value.get("variable")?.as_str()?.to_string(), + key: value + .get("key") + .and_then(|key| key.as_str()) + .map(str::to_string), + }) +} + +fn request_validation_call_proof(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + Some(RequestValidationCallProof { + fact_id: fact_id(fact), + validator_symbol: value.get("validator_symbol")?.as_str()?.to_string(), + schema_symbol: value + .get("schema_symbol") + .and_then(|symbol| symbol.as_str()) + .map(str::to_string), + input_var: value + .get("input_var") + .and_then(|symbol| symbol.as_str()) + .map(str::to_string), + result_var: value + .get("result_var") + .and_then(|symbol| symbol.as_str()) + .map(str::to_string), + }) +} + +fn request_validated_use_proof(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + Some(RequestValidatedUseProof { + fact_id: fact_id(fact), + source_input_var: value.get("source_input_var")?.as_str()?.to_string(), + validated_var: value.get("validated_var")?.as_str()?.to_string(), + sink_fact_id: value.get("sink_fact_id")?.as_str()?.to_string(), + sink_kind: value.get("sink_kind")?.as_str()?.to_string(), + }) +} + +fn input_line_from_fact_id(fact_id: &str) -> usize { + fact_id + .rsplit(':') + .next() + .and_then(|line| line.parse::().ok()) + .unwrap_or(0) +} + +fn line_uses_identifier(line: &str, identifier: &str) -> bool { + line.split(|character: char| { + character != '_' && character != '$' && !character.is_ascii_alphanumeric() + }) + .any(|token| token == identifier) +} diff --git a/drift v3/crates/drift-engine/src/security_rules.rs b/drift v3/crates/drift-engine/src/security_rules.rs index 603540ef..bcc8c9aa 100644 --- a/drift v3/crates/drift-engine/src/security_rules.rs +++ b/drift v3/crates/drift-engine/src/security_rules.rs @@ -1,6 +1,6 @@ use crate::{ - AcceptedAuthHelper, FactExtractError, SecurityProofStatus, build_auth_boundary_proof, - build_middleware_coverage_proof, + AcceptedAuthHelper, AcceptedRequestValidator, FactExtractError, SecurityProofStatus, + build_auth_boundary_proof, build_middleware_coverage_proof, build_request_validation_proof, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,6 +20,14 @@ pub struct SecurityMiddlewareContract { pub accepted_auth_helpers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityRequestValidationContract { + pub contract_id: String, + pub capability: SecurityContractCapability, + pub enforcement_mode: SecurityEnforcementMode, + pub accepted_validators: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecurityContractCapability { BriefingOnly, @@ -183,6 +191,44 @@ pub fn evaluate_middleware_must_cover_routes( }]) } +pub fn evaluate_api_route_requires_request_validation( + file_path: impl AsRef, + source: &str, + contract: &SecurityRequestValidationContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.capability != SecurityContractCapability::DeterministicCheck + || contract.accepted_validators.is_empty() + { + return Ok(Vec::new()); + } + + let proof = build_request_validation_proof(file_path, source, &contract.accepted_validators)?; + if proof.result.proof_status == SecurityProofStatus::Proven { + return Ok(Vec::new()); + } + + Ok(vec![SecurityFinding { + contract_id: contract.contract_id.clone(), + title: "API route uses unvalidated request input".to_string(), + expected_layer: "request_validation".to_string(), + actual_layer: proof + .request_validation + .unvalidated_uses + .first() + .map(|use_proof| use_proof.reason.clone()) + .unwrap_or_else(|| "request_input_not_validated".to_string()), + enforcement_result: match contract.enforcement_mode { + SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, + SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, + SecurityEnforcementMode::Block => SecurityFindingResult::Block, + SecurityEnforcementMode::Off => return Ok(Vec::new()), + }, + drift_category: "missing_proof".to_string(), + confidence_label: "certain".to_string(), + }]) +} + fn route_path_from_file(file_path: &str) -> Option { if let Some(rest) = file_path .strip_prefix("app/") diff --git a/drift v3/crates/drift-engine/tests/security_control_flow.rs b/drift v3/crates/drift-engine/tests/security_control_flow.rs index cc6136a7..0ceab8ae 100644 --- a/drift v3/crates/drift-engine/tests/security_control_flow.rs +++ b/drift v3/crates/drift-engine/tests/security_control_flow.rs @@ -1,6 +1,7 @@ use drift_engine::{ - AcceptedAuthHelper, AuthGuardBehavior, FactKind, SecurityProofStatus, - build_auth_boundary_proof, build_middleware_coverage_proof, extract_security_facts, + AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, FactKind, + RequestValidatorBehavior, RequestValidatorKind, SecurityProofStatus, build_auth_boundary_proof, + build_middleware_coverage_proof, build_request_validation_proof, extract_security_facts, }; #[test] @@ -332,3 +333,86 @@ export async function GET() { ); assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); } + +#[test] +fn safe_parse_data_is_validated_only_after_success_guard() { + let unguarded_source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const result = ProjectInputSchema.safeParse(body); + await db.project.create({ data: result.data }); + return Response.json({ ok: true }); +} +"#; + let guarded_source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const result = ProjectInputSchema.safeParse(body); + if (!result.success) { + return Response.json({ ok: false }, { status: 400 }); + } + await db.project.create({ data: result.data }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + + let unguarded = + build_request_validation_proof("app/api/projects/route.ts", unguarded_source, &validators) + .expect("unguarded request validation proof"); + assert!( + !unguarded.request_validation.proven, + "safeParse data without success guard must not prove validation: {unguarded:#?}" + ); + + let guarded = + build_request_validation_proof("app/api/projects/route.ts", guarded_source, &validators) + .expect("guarded request validation proof"); + assert!( + guarded.request_validation.proven, + "safeParse data after success guard should prove validation: {guarded:#?}" + ); +} + +#[test] +fn request_input_spread_emits_parser_gap_and_blocks() { + let source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + await db.project.create({ data: { ...body, ownerId: input.ownerId } }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!( + proof.parser_gaps.iter().any(|gap| { + gap.code == "unsupported_request_input_spread" && gap.blocks_enforcement + }), + "missing unsupported request input spread parser gap: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); +} diff --git a/drift v3/crates/drift-engine/tests/security_facts.rs b/drift v3/crates/drift-engine/tests/security_facts.rs index fefd6576..8a445ec6 100644 --- a/drift v3/crates/drift-engine/tests/security_facts.rs +++ b/drift v3/crates/drift-engine/tests/security_facts.rs @@ -1,4 +1,198 @@ -use drift_engine::{AcceptedAuthHelper, AuthGuardBehavior, FactKind, extract_security_facts}; +use drift_engine::{ + AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, FactKind, + RequestValidatorBehavior, RequestValidatorKind, extract_security_facts, + extract_security_facts_with_validation, +}; + +#[test] +fn extracts_request_input_read_facts() { + let source = r#" +export async function POST(request: Request, { params }: { params: { projectId: string } }) { + const body = await request.json(); + const projectId = request.nextUrl.searchParams.get("projectId"); + const routeProjectId = params.projectId; + return Response.json({ ok: true, body, projectId, routeProjectId }); +} +"#; + + let facts = + extract_security_facts("app/api/projects/route.ts", source, &[]).expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| format!("{:?}", fact.kind) == "RequestInputRead" + && fact.name == "body" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"body\"") + && value.contains("\"variable\":\"body\"") + && value.contains("\"route_id\":\"route:app/api/projects/route.ts:POST\"") + }) + && fact.start_line == 3), + "missing body request input read fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| format!("{:?}", fact.kind) == "RequestInputRead" + && fact.name == "projectId" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"query\"") + && value.contains("\"variable\":\"projectId\"") + && value.contains("\"key\":\"projectId\"") + }) + && fact.start_line == 4), + "missing query request input read fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| format!("{:?}", fact.kind) == "RequestInputRead" + && fact.name == "routeProjectId" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"params\"") + && value.contains("\"variable\":\"routeProjectId\"") + && value.contains("\"key\":\"projectId\"") + }) + && fact.start_line == 5), + "missing params request input read fact: {facts:#?}" + ); +} + +#[test] +fn extracts_request_validation_called_for_accepted_schema_and_helper() { + let source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { validateProjectInput as validateInput } from "@/server/validation"; + +export async function POST(request: Request) { + const body = await request.json(); + const parsed = ProjectInputSchema.parse(body); + const safe = ProjectInputSchema.safeParse(body); + const checked = validateProjectInput(body); + const aliased = validateInput(body); + return Response.json({ parsed, safe, checked, aliased }); +} +"#; + + let validators = vec![ + AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }, + AcceptedRequestValidator { + validator_id: "helper_project_input".to_string(), + symbol: "validateProjectInput".to_string(), + kind: RequestValidatorKind::Helper, + behavior: RequestValidatorBehavior::ReturnsParsed, + }, + ]; + let facts = extract_security_facts_with_validation( + "app/api/projects/route.ts", + source, + &[], + &validators, + ) + .expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "parse" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"schema_symbol\":\"ProjectInputSchema\"") + && value.contains("\"input_var\":\"body\"") + && value.contains("\"result_var\":\"parsed\"") + }) + && fact.start_line == 7), + "missing accepted schema parse validation fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "safeParse" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"schema_symbol\":\"ProjectInputSchema\"") + && value.contains("\"input_var\":\"body\"") + && value.contains("\"result_var\":\"safe\"") + }) + && fact.start_line == 8), + "missing accepted schema safeParse validation fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "validateProjectInput" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"validator_symbol\":\"validateProjectInput\"") + && value.contains("\"input_var\":\"body\"") + && value.contains("\"result_var\":\"checked\"") + }) + && fact.start_line == 9), + "missing accepted helper validation fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "validateInput" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"validator_symbol\":\"validateProjectInput\"") + && value.contains("\"input_var\":\"body\"") + && value.contains("\"result_var\":\"aliased\"") + }) + && fact.start_line == 10), + "missing accepted helper alias validation fact: {facts:#?}" + ); +} + +#[test] +fn extracts_validated_input_used_when_parsed_result_reaches_sink() { + let source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + await db.project.create({ data: input }); + return Response.json({ ok: true }); +} +"#; + + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let facts = extract_security_facts_with_validation( + "app/api/projects/route.ts", + source, + &[], + &validators, + ) + .expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| format!("{:?}", fact.kind) == "ValidatedInputUsed" + && fact.name == "input" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source_input_var\":\"body\"") + && value.contains("\"validated_var\":\"input\"") + && value.contains("\"sink_kind\":\"data_operation\"") + }) + && fact.start_line == 8), + "missing validated input use fact: {facts:#?}" + ); +} #[test] fn extracts_auth_guard_called_fact() { diff --git a/drift v3/crates/drift-engine/tests/security_rules.rs b/drift v3/crates/drift-engine/tests/security_rules.rs index bde43d3d..c613e149 100644 --- a/drift v3/crates/drift-engine/tests/security_rules.rs +++ b/drift v3/crates/drift-engine/tests/security_rules.rs @@ -1,8 +1,10 @@ use drift_engine::{ - AcceptedAuthHelper, AuthGuardBehavior, SecurityAuthContract, SecurityContractCapability, + AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, RequestValidatorBehavior, + RequestValidatorKind, SecurityAuthContract, SecurityContractCapability, SecurityEnforcementMode, SecurityFindingResult, SecurityMiddlewareContract, - evaluate_api_route_requires_auth_helper, - evaluate_api_route_requires_auth_helper_with_middleware, evaluate_middleware_must_cover_routes, + SecurityRequestValidationContract, evaluate_api_route_requires_auth_helper, + evaluate_api_route_requires_auth_helper_with_middleware, + evaluate_api_route_requires_request_validation, evaluate_middleware_must_cover_routes, }; #[test] @@ -15,7 +17,6 @@ export async function GET() { return Response.json({ projects }); } "#; - let findings = evaluate_api_route_requires_auth_helper( "app/api/projects/route.ts", source, @@ -39,6 +40,192 @@ export async function GET() { assert_eq!(findings[0].confidence_label, "certain"); } +#[test] +fn request_body_reaches_data_operation_without_validation_blocks() { + let source = r#" +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + await db.project.create({ data: body }); + return Response.json({ ok: true }); +} +"#; + + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "security_api_request_validation".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].contract_id, "security_api_request_validation"); + assert_eq!( + findings[0].title, + "API route uses unvalidated request input" + ); + assert_eq!(findings[0].expected_layer, "request_validation"); + assert_eq!(findings[0].actual_layer, "request_input_not_validated"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); + assert_eq!(findings[0].drift_category, "missing_proof"); + assert_eq!(findings[0].confidence_label, "certain"); +} + +#[test] +fn validator_called_but_raw_input_used_blocks() { + let source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const parsed = ProjectInputSchema.parse(body); + await db.project.create({ data: body }); + return Response.json({ parsed }); +} +"#; + + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "security_api_request_validation".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].actual_layer, "request_input_not_validated"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); +} + +#[test] +fn validated_parsed_result_reaches_data_operation_passes() { + let source = r#" +import { ProjectInputSchema } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + await db.project.create({ data: input }); + return Response.json({ ok: true }); +} +"#; + + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "security_api_request_validation".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("security findings"); + + assert!( + findings.is_empty(), + "validated parsed result should satisfy request validation: {findings:#?}" + ); +} + +#[test] +fn unknown_validator_name_does_not_satisfy_request_validation() { + let source = r#" +import { validateInput } from "@/server/validation"; +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + const input = validateInput(body); + await db.project.create({ data: input }); + return Response.json({ ok: true }); +} +"#; + + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "security_api_request_validation".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("security findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].actual_layer, "unknown_validator"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); +} + +#[test] +fn candidate_only_validation_evidence_does_not_block() { + let source = r#" +import { db } from "@/server/db"; + +export async function POST(request: Request) { + const body = await request.json(); + await db.project.create({ data: body }); + return Response.json({ ok: true }); +} +"#; + + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "candidate_request_validation".to_string(), + capability: SecurityContractCapability::HeuristicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("security findings"); + + assert!( + findings.is_empty(), + "candidate-only validation evidence must not block: {findings:#?}" + ); +} + #[test] fn auth_like_helper_without_accepted_contract_does_not_block() { let source = r#" diff --git a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md index 99ef2c87..3f40e096 100644 --- a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md +++ b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md @@ -2252,10 +2252,766 @@ Required RED tests: - Unknown validator is missing proof. - Unsupported destructuring/spread emits parser gap or missing proof. +### Phase 3 Non-Negotiable Scope + +Phase 3 implements request-input validation only. Do not implement or change: + +- SSRF enforcement. +- SQL injection enforcement. +- Tenant, role, IDOR, or session trust enforcement. +- Sensitive response or secret exposure enforcement. +- CORS, CSRF, or rate limit enforcement. +- Candidate election UX beyond preventing validation candidates from blocking. + +The only accepted contract kind added in this phase is: + +- `api_route_requires_request_validation` + +The only new facts added in this phase are: + +- `request_input_read` +- `request_validation_called` +- `validated_input_used` + +The only new proof section added in this phase is: + +- `SecurityBoundaryProof.request_validation` + +Do not add monolithic validation files. Keep responsibilities split: + +- `security_facts.rs`: extract request-input reads, validation calls, and validated-result use facts only. +- `security_patterns.rs`: accepted validator/schema/helper normalization only. +- `security_control_flow.rs`: local variable/source-to-sink summaries only. +- `security_proof.rs`: validation proof, parser-gap, and missing-proof construction only. +- `security_rules.rs`: deterministic accepted-contract evaluation only. +- `security_capabilities.rs`: capability truth only. +- `check_command.rs`: engine request/response wiring only. +- `security-check.ts`: CLI orchestration/output mapping only, no deterministic validation logic. +- `security-boundary-proof.ts`: query/read model only. +- `security-context.ts`: MCP read model only. + +### Phase 3 Deterministic Model + +Supported request input reads: + +- `await request.json()` +- `await request.formData()` +- `await request.text()` +- `request.nextUrl.searchParams.get("key")` +- `new URL(request.url).searchParams.get("key")` +- `request.headers.get("key")` +- `cookies().get("key")` +- Next route context params through `params.id`, `context.params.id`, or destructured `{ params }`. + +Supported accepted validators: + +- Accepted schema methods: `schema.parse(input)`, `schema.safeParse(input)`. +- Accepted helper calls configured by contract: `validateProjectInput(input)`. +- Accepted imported aliases of configured validators or schemas. + +Supported validated result use: + +- Throwing validators: the original input variable is considered validated after an accepted throwing parse/helper dominates the sink. +- Parsed-result validators: only the returned parsed variable is trusted. +- `safeParse`: only the `.data` value is trusted after a local `success` guard dominates the sink. + +Unsupported shapes must not silently pass: + +- Object spread from raw request input into sink payload. +- Dynamic property access on request input, for example `body[field]`. +- Aliasing through arrays/maps/callbacks before validation. +- Validation inside a callback when the outer sink uses request input. +- Unknown validation helpers or helpers accepted only as candidates. + +Unsupported deterministic shapes must emit `parser_gaps` or `missing_proof` evidence. They must never satisfy validation. + +### Phase 3 Accepted Contract Shape + +Minimum accepted deterministic contract: + +```json +{ + "contract_id": "security_api_request_validation", + "kind": "api_route_requires_request_validation", + "capability": "deterministic_check", + "enforcement_mode": "block", + "matcher": { + "file_roles": ["api_route"], + "path_globs": ["**/app/api/**/route.ts", "**/pages/api/**/*.ts"], + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "requires": { + "input_sources": ["body", "query", "params", "headers", "cookies", "formData"], + "sinks": ["data_operation", "response"], + "validators": ["validateProjectInput"], + "schemas": ["ProjectInputSchema"], + "allow_throwing_parse": true, + "allow_safe_parse_success_guard": true + }, + "exceptions": [], + "governance": { + "accepted_by": "test", + "accepted_at": "2026-05-25T00:00:00.000Z", + "rationale": "API request input must be validated before reaching protected sinks" + } +} +``` + +Accepted contract behavior: + +- Blocking is allowed only when `enforcement_capability` is `deterministic_check`. +- `enforcement_mode="off"` produces no findings. +- Candidate-only validators cannot block. +- A helper named `validate*` cannot satisfy proof unless it is accepted by contract. +- A schema named `*Schema` cannot satisfy proof unless it is accepted by contract. +- A validation call does not pass unless the validated variable or dominated original input reaches the sink. + +Expected blocking finding: + +```json +{ + "title": "API route uses unvalidated request input", + "expected_layer": "request_validation", + "actual_layer": "request_input_not_validated", + "enforcement_result": "block", + "drift_category": "missing_proof", + "confidence_label": "certain" +} +``` + +Expected ignored/candidate-only behavior: + +```json +{ + "security_findings": [], + "summary": { + "security_blocking_count": 0 + } +} +``` + +Expected proof shape: + +```json +{ + "request_validation": { + "required": true, + "proven": false, + "input_reads": [ + { + "fact_id": "fact:app/api/projects/route.ts:request_input_read:3", + "source": "body", + "variable": "body", + "key": null + } + ], + "validations": [], + "validated_uses": [], + "unvalidated_uses": [ + { + "sink_id": "sink:app/api/projects/route.ts:6:db.project.create", + "input_fact_id": "fact:app/api/projects/route.ts:request_input_read:3", + "reason": "request_input_not_validated" + } + ] + }, + "missing_proof": [ + { + "code": "request_input_not_validated", + "blocks_enforcement": true + } + ], + "parser_gaps": [] +} +``` + +### Phase 3 Executable Task Ledger + +Execute these tasks in order. For every RED task, add only the failing test first, +run the focused command, and record the exact expected failure before editing +implementation files. + +- [ ] **Task 3.1: RED request input fact extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_request_input_read_facts` + + Add source fixtures covering `await request.json()`, + `request.nextUrl.searchParams.get("projectId")`, and `params.projectId`. + + Run: + + ```bash + cargo test -p drift-engine extracts_request_input_read_facts -- --nocapture + ``` + + Expected RED: fail because Rust does not emit `request_input_read`. + +- [ ] **Task 3.2: GREEN request input fact extraction** + + Implementation files: + + - `crates/drift-engine/src/facts.rs` + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/main.rs` + + Implement extraction only. Do not evaluate validation rules. + + Run: + + ```bash + cargo test -p drift-engine extracts_request_input_read_facts -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.3: RED accepted validator call facts** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_request_validation_called_for_accepted_schema_and_helper` + + Add source fixtures for: + + - `ProjectInputSchema.parse(body)` + - `ProjectInputSchema.safeParse(body)` + - `validateProjectInput(body)` + - imported alias of `validateProjectInput` + + Run: + + ```bash + cargo test -p drift-engine extracts_request_validation_called_for_accepted_schema_and_helper -- --nocapture + ``` + + Expected RED: fail because accepted validation helpers/schemas are not + normalized and no `request_validation_called` fact is emitted. + +- [ ] **Task 3.4: GREEN accepted validator call facts** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_facts.rs` + + Add accepted validator/schema normalization. Emit facts only for accepted + validators supplied by test/contract input. + + Run: + + ```bash + cargo test -p drift-engine extracts_request_validation_called_for_accepted_schema_and_helper -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.5: RED validated result use facts** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_validated_input_used_when_parsed_result_reaches_sink` + + Add fixtures where: + + - `const input = ProjectInputSchema.parse(body);` + - `await db.project.create({ data: input });` + - raw `body` is not used at the sink. + + Run: + + ```bash + cargo test -p drift-engine extracts_validated_input_used_when_parsed_result_reaches_sink -- --nocapture + ``` + + Expected RED: fail because Rust does not emit `validated_input_used`. + +- [ ] **Task 3.6: GREEN validated result use facts** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_facts.rs` + + Implement simple local variable tracking from accepted validation result to + data operation or response sink. + + Run: + + ```bash + cargo test -p drift-engine extracts_validated_input_used_when_parsed_result_reaches_sink -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.7: RED missing validation blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `request_body_reaches_data_operation_without_validation_blocks` + + Run: + + ```bash + cargo test -p drift-engine request_body_reaches_data_operation_without_validation_blocks -- --nocapture + ``` + + Expected RED: fail because `api_route_requires_request_validation` is not + evaluated. + +- [ ] **Task 3.8: GREEN missing validation rule** + + Implementation files: + + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Build validation proof and emit blocking finding only for accepted + deterministic contracts. + + Run: + + ```bash + cargo test -p drift-engine request_body_reaches_data_operation_without_validation_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.9: RED validator called but raw input used blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `validator_called_but_raw_input_used_blocks` + + Fixture shape: + + ```ts + const body = await request.json(); + const parsed = ProjectInputSchema.parse(body); + await db.project.create({ data: body }); + ``` + + Run: + + ```bash + cargo test -p drift-engine validator_called_but_raw_input_used_blocks -- --nocapture + ``` + + Expected RED: fail because validation call existence is treated as enough or + raw/validated variable identity is not tracked. + +- [ ] **Task 3.10: GREEN raw input still blocks** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Require sink use to come from the validated variable, not the raw input + variable, unless the accepted validator is a throwing validator that dominates + the sink. + + Run: + + ```bash + cargo test -p drift-engine validator_called_but_raw_input_used_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.11: RED parsed result passes** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `validated_parsed_result_reaches_data_operation_passes` + + Run: + + ```bash + cargo test -p drift-engine validated_parsed_result_reaches_data_operation_passes -- --nocapture + ``` + + Expected RED: fail because validated result use is not accepted as proof. + +- [ ] **Task 3.12: GREEN parsed result proof** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Mark proof as `proven` only when accepted parsed result reaches the protected + sink. + + Run: + + ```bash + cargo test -p drift-engine validated_parsed_result_reaches_data_operation_passes -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.13: RED safeParse success guard** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `safe_parse_data_is_validated_only_after_success_guard` + + Run: + + ```bash + cargo test -p drift-engine safe_parse_data_is_validated_only_after_success_guard -- --nocapture + ``` + + Expected RED: fail because `safeParse` `.data` is not tied to a dominating + `success` guard. + +- [ ] **Task 3.14: GREEN safeParse success guard** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + + Accept `result.data` only when a local `if (!result.success) return/throw` + or `if (result.success) { sink(result.data) }` dominates the sink. + + Run: + + ```bash + cargo test -p drift-engine safe_parse_data_is_validated_only_after_success_guard -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.15: RED unknown validator is missing proof** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `unknown_validator_name_does_not_satisfy_request_validation` + + Run: + + ```bash + cargo test -p drift-engine unknown_validator_name_does_not_satisfy_request_validation -- --nocapture + ``` + + Expected RED: fail because a name-shaped validator such as `validateInput` + is accepted without contract evidence. + +- [ ] **Task 3.16: GREEN unknown validator missing proof** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Emit missing proof code `unknown_validator` or + `validation_result_not_used`; do not pass. + + Run: + + ```bash + cargo test -p drift-engine unknown_validator_name_does_not_satisfy_request_validation -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.17: RED spread/destructuring parser gap** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `request_input_spread_emits_parser_gap_and_blocks` + + Fixture shape: + + ```ts + const body = await request.json(); + await db.project.create({ data: { ...body, ownerId } }); + ``` + + Run: + + ```bash + cargo test -p drift-engine request_input_spread_emits_parser_gap_and_blocks -- --nocapture + ``` + + Expected RED: fail because unsupported spread/destructuring is not emitted + as parser-gap-backed proof evidence. + +- [ ] **Task 3.18: GREEN spread/destructuring parser gap** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_capabilities.rs` + + Emit parser gap `unsupported_request_input_spread` with + `blocks_enforcement=true`. + + Run: + + ```bash + cargo test -p drift-engine request_input_spread_emits_parser_gap_and_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.19: RED candidate-only validation cannot block** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `candidate_only_validation_evidence_does_not_block` + + Run: + + ```bash + cargo test -p drift-engine candidate_only_validation_evidence_does_not_block -- --nocapture + ``` + + Expected RED: fail because validation-looking candidate evidence is not + separated from accepted deterministic contracts. + +- [ ] **Task 3.20: GREEN candidate-only validation boundary** + + Implementation files: + + - `crates/drift-engine/src/security_rules.rs` + - `packages/cli/src/domain/convention-candidates.ts` + + Allow candidate generation, but never blocking findings, from candidate-only + validator evidence. + + Run: + + ```bash + cargo test -p drift-engine candidate_only_validation_evidence_does_not_block -- --nocapture + pnpm --filter @drift/cli test -- convention-candidates + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.21: RED TypeScript schemas and engine contract** + + Test files: + + - `packages/core/test/security.test.ts` + - `packages/engine-contract/test/security-contract.test.ts` + + Test names: + + - `validates api_route_requires_request_validation contracts and proof fields` + - `validates request validation parser gaps from engine output` + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected RED: fail because validation contract kind, fact kinds, proof + fields, and parser-gap/missing-proof codes are not in TypeScript schemas. + +- [ ] **Task 3.22: GREEN TypeScript schemas and engine contract** + + Implementation files: + + - `packages/core/src/security.ts` + - `packages/core/src/domain.ts` + - `packages/core/src/schemas.ts` + - `packages/engine-contract/src/index.ts` + - `crates/drift-engine/src/protocol.rs` + + Add only normalized validation contract/proof/event fields. Do not add rule + evaluation logic in TypeScript. + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.23: RED query, CLI, scan status, and repo map output** + + Test files: + + - `packages/query/test/security-boundary-proof.test.ts` + - `packages/cli/test/security-check.test.ts` + - `packages/cli/test/cli.test.ts` + + Test names: + + - `summarizes request validation proof without snippets` + - `returns request validation proof in drift check JSON output` + - `scan status reports request_validation capability` + - `repo map reports route request validation summary` + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports request_validation" + pnpm --filter @drift/cli test -- "repo map reports route request validation summary" + ``` + + Expected RED: fail because query/read models and CLI output do not expose + request-validation proof truth. + +- [ ] **Task 3.24: GREEN query, CLI, scan status, and repo map output** + + Implementation files: + + - `packages/query/src/security-boundary-proof.ts` + - `packages/query/src/index.ts` + - `packages/cli/src/check/security-check.ts` + - `packages/cli/src/check/run-check.ts` + - `packages/cli/src/domain/scan-status.ts` + - `packages/cli/src/commands/scan.ts` + - `packages/cli/src/commands/repo-map.ts` + + Wire read models and output formatting only. Do not duplicate deterministic + request-validation logic in TypeScript. + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports request_validation" + pnpm --filter @drift/cli test -- "repo map reports route request validation summary" + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.25: RED MCP request validation output** + + Test file: `packages/mcp/test/mcp.test.ts` + + Test name: `exposes request validation proof summaries without snippets` + + Run: + + ```bash + pnpm --filter @drift/mcp test -- "request validation" + ``` + + Expected RED: fail because MCP read-only security context does not include + request-validation proof summaries. + +- [ ] **Task 3.26: GREEN MCP request validation output** + + Implementation files: + + - `packages/mcp/src/security-context.ts` + - `packages/mcp/src/index.ts` + - `packages/mcp/src/tools.ts` + - `packages/query/src/security-boundary-proof.ts` + + Expose accepted contracts, proof status, missing proof, and parser gaps + without snippets or duplicated rule logic. + + Run: + + ```bash + pnpm --filter @drift/mcp test -- "request validation" + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.27: RED e2e validation fixture matrix** + + Fixture names: + + - `test/fixtures/security-validation-missing` + - `test/fixtures/security-validation-result-unused` + - `test/fixtures/security-validation-before-data` + - `test/fixtures/security-validation-dynamic-body-parser-gap` + + Test file: `test/e2e/security-validation.test.ts` + + Test name: `security validation fixture matrix proves request input validation and gaps` + + Run: + + ```bash + pnpm test:e2e -- security-validation + ``` + + Expected RED: fail because fixtures and end-to-end validation expectations + do not exist. + +- [ ] **Task 3.28: GREEN e2e validation fixture matrix** + + Implementation files: + + - `test/e2e/security-validation.test.ts` + - `test/fixtures/security-validation-missing/package.json` + - `test/fixtures/security-validation-missing/app/api/projects/route.ts` + - `test/fixtures/security-validation-result-unused/package.json` + - `test/fixtures/security-validation-result-unused/app/api/projects/route.ts` + - `test/fixtures/security-validation-before-data/package.json` + - `test/fixtures/security-validation-before-data/app/api/projects/route.ts` + - `test/fixtures/security-validation-dynamic-body-parser-gap/package.json` + - `test/fixtures/security-validation-dynamic-body-parser-gap/app/api/projects/route.ts` + + Fixture expectations: + + - Missing validation blocks. + - Validation result unused blocks. + - Validated parsed result before data operation passes. + - Dynamic body/spread/destructuring emits parser-gap-backed evidence. + - No fixture includes source snippets, secret values, tokens, cookies, or raw + request payload values in expected outputs. + + Run: + + ```bash + pnpm test:e2e -- security-validation + ``` + + Expected GREEN: pass. + +- [ ] **Task 3.29: Phase 3 full gate** + + Run: + + ```bash + cargo test -p drift-engine security_ + cargo test -p drift-engine + pnpm --filter @drift/core test + pnpm --filter @drift/engine-contract 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 + git diff --check + ``` + + Expected: all pass. + Done when: - Validation proof ties input variable to validated variable to sink. - Name-only `validate*` helpers cannot satisfy deterministic proof unless accepted. +- `drift scan status --json` reports `request_validation`. +- `drift check --json` exposes `SecurityBoundaryProof.request_validation`. +- MCP `get_security_context` exposes request-validation summaries without snippets. +- Candidate-only validation evidence never blocks. +- Dynamic or unsupported request-input shapes produce parser-gap-backed proof + evidence and do not silently pass. ## Phase 4: Tenant, Role, IDOR, And Session Trust diff --git a/drift v3/packages/cli/src/check/run-check.ts b/drift v3/packages/cli/src/check/run-check.ts index 49bf8222..6a62a32b 100644 --- a/drift v3/packages/cli/src/check/run-check.ts +++ b/drift v3/packages/cli/src/check/run-check.ts @@ -1919,7 +1919,10 @@ async function runEngineOwnedAuthCheck(input: { for (const convention of input.contract.conventions) { if ( - convention.kind !== "api_route_requires_auth_helper" || + ( + convention.kind !== "api_route_requires_auth_helper" && + convention.kind !== "api_route_requires_request_validation" + ) || convention.enforcement_mode === "off" || convention.enforcement_capability !== "deterministic_check" || !isActiveConvention(convention, input.now) @@ -1968,6 +1971,7 @@ async function runEngineOwnedAuthCheck(input: { ) .map((fact) => fact.id); const preserved = preservedGovernanceStatus(input.existingFindings.get(engineFinding.fingerprint)); + const isRequestValidationFinding = engineFinding.rule_id === "api_route_requires_request_validation"; findings.push({ id: engineFinding.id, repo_id: input.repoId, @@ -1992,10 +1996,12 @@ async function runEngineOwnedAuthCheck(input: { file_hash: snapshot?.content_hash ?? "", redaction_state: "none" }], - expected_layer: "auth_guard", - actual_layer: "missing_auth_guard", + expected_layer: isRequestValidationFinding ? "request_validation" : "auth_guard", + actual_layer: isRequestValidationFinding ? "request_input_not_validated" : "missing_auth_guard", graph_path: [evidence.file_path], - suggested_fix: "Call an accepted auth helper before route data operations or response sinks.", + suggested_fix: isRequestValidationFinding + ? "Validate request input with an accepted validator before using it at protected route sinks." + : "Call an accepted auth helper before route data operations or response sinks.", related_node_ids: engineFinding.related_node_ids, created_at: input.now }); diff --git a/drift v3/packages/cli/src/check/security-check.ts b/drift v3/packages/cli/src/check/security-check.ts index 829d41c1..a98d3f79 100644 --- a/drift v3/packages/cli/src/check/security-check.ts +++ b/drift v3/packages/cli/src/check/security-check.ts @@ -24,6 +24,7 @@ export interface SecurityCheckJson { security_findings_count: number; security_blocking_count: number; middleware_coverage_proven_count: number; + request_validation_failed_count: number; }; } @@ -46,6 +47,10 @@ export function buildSecurityCheckJson(input: BuildSecurityCheckJsonInput): Secu middleware_coverage_proven_count: input.proofs.filter((proof) => { const middleware = proof.middleware; return Boolean(middleware && middleware.required && middleware.proven); + }).length, + request_validation_failed_count: input.proofs.filter((proof) => { + const requestValidation = proof.request_validation; + return Boolean(requestValidation && requestValidation.required && !requestValidation.proven); }).length } }; diff --git a/drift v3/packages/cli/src/domain/scan-status.ts b/drift v3/packages/cli/src/domain/scan-status.ts index 03e45737..07811396 100644 --- a/drift v3/packages/cli/src/domain/scan-status.ts +++ b/drift v3/packages/cli/src/domain/scan-status.ts @@ -646,6 +646,7 @@ function securityCapabilitySummary(capabilityReport: ReturnType [entry.rule_id, entry])); const middlewareCompleteness = completenessByRule.get("middleware_must_cover_routes"); + const requestValidationCompleteness = completenessByRule.get("api_route_requires_request_validation"); return { middleware_coverage: { certified: certified.has("middleware_coverage"), @@ -653,6 +654,13 @@ function securityCapabilitySummary(capabilityReport: ReturnType = {}; + if (Array.isArray(matcher.validators)) { + requires.validators = matcher.validators; + } else if (Array.isArray(matcher.required_calls)) { + requires.validators = matcher.required_calls; + } + if (Array.isArray(matcher.schemas)) { + requires.schemas = matcher.schemas; + } + return Object.keys(requires).length > 0 ? requires : undefined; + } if (convention.kind !== "api_route_requires_auth_helper" || !convention.matcher.required_calls?.length) { return undefined; } diff --git a/drift v3/packages/cli/test/cli.test.ts b/drift v3/packages/cli/test/cli.test.ts index 992012f1..af947527 100644 --- a/drift v3/packages/cli/test/cli.test.ts +++ b/drift v3/packages/cli/test/cli.test.ts @@ -10151,6 +10151,54 @@ describe("drift CLI convention review", () => { }); }); + it("scan status reports request_validation capability", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-scan-status-validation-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertScanCapabilityReport({ + schema_version: "drift.scan_capability_report.v1", + repo_id: repoId, + scan_id: scanId, + engine_source: "rust", + engine_version: "0.1.0", + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + certified_capabilities: ["file_discovery", "syntax_facts", "request_validation_facts"], + required_capabilities: ["file_discovery", "syntax_facts", "request_validation_facts"], + missing_capabilities: [], + completeness: [{ + scope: "route-flow", + rule_id: "api_route_requires_request_validation", + complete: true, + can_block: true, + reasons: [] + }], + parser_gap_count: 0, + parser_gap_kinds: {}, + fallback_used: false, + enforcement_degraded: false, + created_at: "2026-05-25T00:00:00.000Z" + }); + 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(payload.security_capabilities.request_validation).toMatchObject({ + certified: true, + can_block: true, + missing: false + }); + }); + it("repo map reports route middleware coverage summary", async () => { const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-middleware-"); const storage = openDriftStorage({ databasePath }); @@ -10194,6 +10242,49 @@ describe("drift CLI convention review", () => { expect(result.stdout).not.toContain("requireUser()"); }); + it("repo map reports route request validation summary", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-validation-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertFacts([{ + id: "fact_request_body", + repo_id: repoId, + scan_id: scanId, + 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: "body", + variable: "body", + taint: "untrusted" + }), + imported_name: undefined, + start_line: 1, + end_line: 1, + ...factQuality(scanId) + }]); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "repo", "map", + "--repo", repoId, + "--path", "apps/web/app/api/users/route.ts", + "--json" + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.files[0].route_security.request_validation).toMatchObject({ + status: "missing_proof", + input_sources: ["body"] + }); + expect(result.stdout).not.toContain("request.json()"); + }); + it("prints prepare summary and governance in human output", async () => { const { databasePath } = await seedAcceptedDatabase(); diff --git a/drift v3/packages/cli/test/security-check.test.ts b/drift v3/packages/cli/test/security-check.test.ts index d0a9c40b..87fd2f80 100644 --- a/drift v3/packages/cli/test/security-check.test.ts +++ b/drift v3/packages/cli/test/security-check.test.ts @@ -49,6 +49,44 @@ describe("security check bridge", () => { expect(JSON.stringify(payload)).not.toContain("requireUser()"); }); + it("returns request validation proof in drift check JSON output", () => { + const payload = buildSecurityCheckJson({ + repo_id: "repo_abc", + scope: "changed-files", + changed_files: ["app/api/projects/route.ts"], + proofs: [ + securityProof("proof_validation", "app/api/projects/route.ts", "finding_validation", { + request_validation: { + required: true, + proven: false, + input_reads: [{ fact_id: "fact_body", source: "body", variable: "body" }], + validations: [], + validated_uses: [], + unvalidated_uses: [{ + input_fact_id: "fact_body", + sink_fact_id: "sink_create", + sink_kind: "data_operation", + reason: "request_input_not_validated" + }] + } + }) + ], + findings: [{ + finding_id: "finding_validation", + title: "API route uses unvalidated request input", + file_path: "app/api/projects/route.ts", + enforcement_result: "block" + }] + }); + + expect(payload.security_boundary_proofs[0]?.request_validation).toMatchObject({ + required: true, + proven: false + }); + expect(payload.summary.request_validation_failed_count).toBe(1); + expect(JSON.stringify(payload)).not.toContain("request.json()"); + }); + it("receives SecurityBoundaryProof.auth from engine-owned auth checks", async () => { const dir = await mkdtemp(join(tmpdir(), "drift-security-auth-bridge-")); tempDirs.push(dir); @@ -263,7 +301,12 @@ function fact(kind: string, name: string, line: number, value?: string) { } as const; } -function securityProof(proofId: string, filePath: string, findingId: string) { +function securityProof( + proofId: string, + filePath: string, + findingId: string, + overrides: Record = {} +) { return { proof_id: proofId, proof_version: "security-boundary-proof/v1", @@ -307,7 +350,8 @@ function securityProof(proofId: string, filePath: string, findingId: string) { enforcement_result: "block", can_block: true, finding_ids: [findingId] - } + }, + ...overrides } as const; } diff --git a/drift v3/packages/core/src/domain.ts b/drift v3/packages/core/src/domain.ts index 80d3e847..ef819f57 100644 --- a/drift v3/packages/core/src/domain.ts +++ b/drift v3/packages/core/src/domain.ts @@ -3,6 +3,7 @@ export type ConventionKind = | "api_route_requires_service_delegation" | "api_route_requires_auth_helper" | "middleware_must_cover_routes" + | "api_route_requires_request_validation" | "test_expected_for_changed_module" | "custom_briefing" | AgentContractKind; @@ -260,7 +261,10 @@ export type FactKind = | "callback_boundary_detected" | "middleware_declared" | "middleware_matcher_declared" - | "middleware_protects_route"; + | "middleware_protects_route" + | "request_input_read" + | "request_validation_called" + | "validated_input_used"; export type FactEvidenceLevel = "path" | "text" | "ast" | "graph" | "heuristic"; export type FactResolutionStatus = "resolved" | "unresolved" | "partial" | "unsupported"; diff --git a/drift v3/packages/core/src/schemas.ts b/drift v3/packages/core/src/schemas.ts index 9f9e7393..7d8efd6e 100644 --- a/drift v3/packages/core/src/schemas.ts +++ b/drift v3/packages/core/src/schemas.ts @@ -21,6 +21,7 @@ export const ConventionKindSchema = z.enum([ "api_route_requires_service_delegation", "api_route_requires_auth_helper", "middleware_must_cover_routes", + "api_route_requires_request_validation", "test_expected_for_changed_module", "custom_briefing", "file_role", @@ -283,7 +284,10 @@ export const FactKindSchema = z.enum([ "callback_boundary_detected", "middleware_declared", "middleware_matcher_declared", - "middleware_protects_route" + "middleware_protects_route", + "request_input_read", + "request_validation_called", + "validated_input_used" ]); export const FactEvidenceLevelSchema = z.enum(["path", "text", "ast", "graph", "heuristic"]); diff --git a/drift v3/packages/core/src/security.ts b/drift v3/packages/core/src/security.ts index e4bdf74d..f2b0b103 100644 --- a/drift v3/packages/core/src/security.ts +++ b/drift v3/packages/core/src/security.ts @@ -4,7 +4,8 @@ export const SecurityCapabilityNameSchema = z.enum([ "security_facts", "auth_boundary_facts", "control_flow_guard_dominance", - "middleware_coverage" + "middleware_coverage", + "request_validation_facts" ]); export const SecurityMissingProofCodeSchema = z.enum([ @@ -12,6 +13,9 @@ export const SecurityMissingProofCodeSchema = z.enum([ "auth_guard_not_dominating_sink", "middleware_not_covering_route", "middleware_dynamic_matcher", + "request_input_not_validated", + "validation_result_not_used", + "unknown_validator", "unsupported_callback_boundary", "unsupported_dynamic_control_flow", "route_binding_unresolved", @@ -23,12 +27,14 @@ export const SecurityParserGapCodeSchema = z.enum([ "handler_unresolved", "unsupported_dynamic_control_flow", "unsupported_dynamic_middleware_matcher", + "unsupported_request_input_spread", "unsupported_callback_boundary" ]); const SecurityContractKindSchema = z.enum([ "api_route_requires_auth_helper", - "middleware_must_cover_routes" + "middleware_must_cover_routes", + "api_route_requires_request_validation" ]); export const SecurityConventionSchema = z.object({ @@ -136,6 +142,37 @@ const SecurityMiddlewareProofSchema = z.object({ })) }); +const SecurityRequestValidationProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + input_reads: z.array(z.object({ + fact_id: z.string().min(1), + source: z.enum(["body", "query", "params", "headers", "cookies", "formData"]), + variable: z.string().min(1).optional(), + key: z.string().min(1).optional() + })), + validations: z.array(z.object({ + fact_id: z.string().min(1), + validator_symbol: z.string().min(1), + schema_symbol: z.string().min(1).optional(), + input_var: z.string().min(1).optional(), + result_var: z.string().min(1).optional() + })), + validated_uses: z.array(z.object({ + fact_id: z.string().min(1).optional(), + source_input_var: z.string().min(1), + validated_var: z.string().min(1), + sink_fact_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql"]) + })), + unvalidated_uses: z.array(z.object({ + input_fact_id: z.string().min(1), + sink_fact_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql"]), + reason: z.enum(["request_input_not_validated", "validation_result_not_used", "unknown_validator"]) + })) +}); + const SecurityMissingProofSchema = z.object({ id: z.string().min(1), capability: z.string().min(1), @@ -185,6 +222,14 @@ export const SecurityBoundaryProofSchema = z.object({ matched_middleware: [], mismatches: [] }), + request_validation: SecurityRequestValidationProofSchema.optional().default({ + required: false, + proven: false, + input_reads: [], + validations: [], + validated_uses: [], + unvalidated_uses: [] + }), missing_proof: z.array(SecurityMissingProofSchema), parser_gaps: z.array(SecurityParserGapSchema), result: z.object({ diff --git a/drift v3/packages/core/test/security.test.ts b/drift v3/packages/core/test/security.test.ts index 8e0b40c9..3fe24378 100644 --- a/drift v3/packages/core/test/security.test.ts +++ b/drift v3/packages/core/test/security.test.ts @@ -193,4 +193,101 @@ describe("security domain schemas", () => { expect(proof.middleware.proven).toBe(true); expect(JSON.stringify(proof)).not.toContain("requireUser()"); }); + + it("validates api_route_requires_request_validation contracts and proof fields", () => { + expect(SecurityMissingProofCodeSchema.parse("request_input_not_validated")).toBe("request_input_not_validated"); + expect(SecurityMissingProofCodeSchema.parse("unknown_validator")).toBe("unknown_validator"); + expect(SecurityParserGapCodeSchema.parse("unsupported_request_input_spread")).toBe("unsupported_request_input_spread"); + + const contract = SecurityConventionSchema.parse({ + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { + file_roles: ["api_route"], + path_globs: ["**/app/api/**/route.ts"], + methods: ["POST", "PUT", "PATCH", "DELETE"] + }, + scope: { + check_scope: "changed-files", + applies_to: "route", + diff_status: ["added", "modified", "renamed"] + }, + requires: { + input_sources: ["body", "query", "params"], + validators: ["validateProjectInput"], + schemas: ["ProjectInputSchema"] + }, + exceptions: [] + }); + + const proof = SecurityBoundaryProofSchema.parse({ + proof_id: "proof_route_projects_post_validation", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_post", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "request_validation_facts", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: ["missing_validation"] + }], + auth: { + required: false, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + request_validation: { + required: true, + proven: false, + input_reads: [{ + fact_id: "fact_body", + source: "body", + variable: "body" + }], + validations: [], + validated_uses: [], + unvalidated_uses: [{ + input_fact_id: "fact_body", + sink_fact_id: "sink_create", + sink_kind: "data_operation", + reason: "request_input_not_validated" + }] + }, + missing_proof: [{ + id: "missing_validation", + capability: "request_validation_facts", + code: "request_input_not_validated", + blocks_enforcement: true, + fact_ids: ["fact_body"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_validation"] + } + }); + + expect(contract.kind).toBe("api_route_requires_request_validation"); + expect(proof.request_validation.required).toBe(true); + expect(JSON.stringify(proof)).not.toContain("secret"); + }); }); diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index 31529131..09430496 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -97,7 +97,10 @@ export const EngineFactSchema = z.object({ "callback_boundary_detected", "middleware_declared", "middleware_matcher_declared", - "middleware_protects_route" + "middleware_protects_route", + "request_input_read", + "request_validation_called", + "validated_input_used" ]), file_path: z.string().min(1), name: z.string().min(1), @@ -293,6 +296,9 @@ const EngineSecurityMissingProofCodeSchema = z.enum([ "auth_guard_not_dominating_sink", "middleware_not_covering_route", "middleware_dynamic_matcher", + "request_input_not_validated", + "validation_result_not_used", + "unknown_validator", "unsupported_callback_boundary", "unsupported_dynamic_control_flow", "route_binding_unresolved", @@ -307,6 +313,7 @@ const EngineSecurityParserGapSchema = z.object({ "handler_unresolved", "unsupported_dynamic_control_flow", "unsupported_dynamic_middleware_matcher", + "unsupported_request_input_spread", "unsupported_callback_boundary" ]), file_path: z.string().min(1), @@ -388,6 +395,43 @@ const EngineSecurityBoundaryProofSchema = z.object({ matched_middleware: [], mismatches: [] }), + request_validation: z.object({ + required: z.boolean(), + proven: z.boolean(), + input_reads: z.array(z.object({ + fact_id: z.string().min(1), + source: z.enum(["body", "query", "params", "headers", "cookies", "formData"]), + variable: z.string().min(1).optional(), + key: z.string().min(1).optional() + })), + validations: z.array(z.object({ + fact_id: z.string().min(1), + validator_symbol: z.string().min(1), + schema_symbol: z.string().min(1).optional(), + input_var: z.string().min(1).optional(), + result_var: z.string().min(1).optional() + })), + validated_uses: z.array(z.object({ + fact_id: z.string().min(1).optional(), + source_input_var: z.string().min(1), + validated_var: z.string().min(1), + sink_fact_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql"]) + })), + unvalidated_uses: z.array(z.object({ + input_fact_id: z.string().min(1), + sink_fact_id: z.string().min(1), + sink_kind: z.enum(["data_operation", "response", "outbound_request", "raw_sql"]), + reason: z.enum(["request_input_not_validated", "validation_result_not_used", "unknown_validator"]) + })) + }).optional().default({ + required: false, + proven: false, + input_reads: [], + validations: [], + validated_uses: [], + unvalidated_uses: [] + }), missing_proof: z.array(z.object({ id: z.string().min(1), capability: z.string().min(1), diff --git a/drift v3/packages/engine-contract/test/security-contract.test.ts b/drift v3/packages/engine-contract/test/security-contract.test.ts index 946505bb..d042ee42 100644 --- a/drift v3/packages/engine-contract/test/security-contract.test.ts +++ b/drift v3/packages/engine-contract/test/security-contract.test.ts @@ -139,4 +139,84 @@ describe("engine security contract schemas", () => { expect(event.proofs[0]?.parser_gaps[0]?.code).toBe("unsupported_dynamic_middleware_matcher"); expect(JSON.stringify(event)).not.toContain("requireUser()"); }); + + it("validates request validation parser gaps from engine output", () => { + const event = parseEngineSecurityProofEvent({ + event: "SecurityProof", + schema_version: "engine.security.proof/v1", + proofs: [{ + proof_id: "proof_route_projects_post_validation", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_post", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "request_validation_facts", + status: "partial", + can_block: true, + parser_gap_ids: ["gap_spread"], + missing_proof_ids: ["missing_validation"] + }], + auth: { + required: false, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + request_validation: { + required: true, + proven: false, + input_reads: [{ fact_id: "fact_body", source: "body", variable: "body" }], + validations: [], + validated_uses: [], + unvalidated_uses: [{ + input_fact_id: "fact_body", + sink_fact_id: "sink_create", + sink_kind: "data_operation", + reason: "request_input_not_validated" + }] + }, + missing_proof: [{ + id: "missing_validation", + capability: "request_validation_facts", + code: "request_input_not_validated", + blocks_enforcement: true, + fact_ids: ["fact_body"], + graph_edge_ids: [] + }], + parser_gaps: [{ + parser_gap_id: "gap_spread", + capability: "request_validation_facts", + code: "unsupported_request_input_spread", + file_path: "app/api/projects/route.ts", + reason: "Request input spread prevents deterministic validation proof", + affected_contract_kinds: ["api_route_requires_request_validation"], + affected_route_ids: ["route_projects_post"], + missing_proof_ids: ["missing_validation"], + blocks_enforcement: true + }], + result: { + proof_status: "parser_gap", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_validation"] + } + }] + }); + + expect(event.proofs[0]?.request_validation.required).toBe(true); + expect(event.proofs[0]?.parser_gaps[0]?.code).toBe("unsupported_request_input_spread"); + expect(JSON.stringify(event)).not.toContain("cookie="); + }); }); diff --git a/drift v3/packages/mcp/src/index.ts b/drift v3/packages/mcp/src/index.ts index ac7b2107..354416ae 100644 --- a/drift v3/packages/mcp/src/index.ts +++ b/drift v3/packages/mcp/src/index.ts @@ -1243,6 +1243,7 @@ function securityCapabilitySummary(capabilityReport: ScanCapabilityReport | null 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"); return { middleware_coverage: { certified: certified.has("middleware_coverage"), @@ -1250,6 +1251,13 @@ function securityCapabilitySummary(capabilityReport: ScanCapabilityReport | null 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) } }; } diff --git a/drift v3/packages/mcp/src/security-context.ts b/drift v3/packages/mcp/src/security-context.ts index 5edc14aa..2ad1cfd9 100644 --- a/drift v3/packages/mcp/src/security-context.ts +++ b/drift v3/packages/mcp/src/security-context.ts @@ -9,9 +9,21 @@ interface MiddlewareCoverageValue { protection_kind?: string; } +interface RequestInputReadValue { + route_id?: string; + source?: string; +} + +interface ValidatedInputUsedValue { + route_id?: string; + sink_kind?: string; +} + export function buildSecurityContextPayload(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" }) : []; + const validatedUseFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "validated_input_used" }) : []; const parserGaps = latestScan ? storage.listParserGaps(repoId, latestScan.id) : []; return { @@ -23,6 +35,10 @@ export function buildSecurityContextPayload(storage: DriftStorage, repoId: strin routes: middlewareCoverageRoutes(facts), parser_gaps: middlewareParserGaps(parserGaps) }, + request_validation: { + routes: requestValidationRoutes(requestInputFacts, validatedUseFacts), + parser_gaps: requestValidationParserGaps(parserGaps) + }, redactions: { snippets_included: false, source_content_included: false, @@ -44,7 +60,8 @@ function securityConventions(conventions: AcceptedConvention[]) { return conventions .filter((convention) => convention.kind === "middleware_must_cover_routes" || - convention.kind === "api_route_requires_auth_helper" + convention.kind === "api_route_requires_auth_helper" || + convention.kind === "api_route_requires_request_validation" ) .map((convention) => ({ id: convention.id, @@ -55,6 +72,81 @@ function securityConventions(conventions: AcceptedConvention[]) { })); } +function requestValidationRoutes(inputFacts: FactRecord[], validatedUseFacts: FactRecord[]) { + const byRoute = new Map; + validated_sink_kinds: Set; + }>(); + + for (const fact of inputFacts) { + const value = parseRequestInputReadValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + input_sources: new Set(), + validated_sink_kinds: new Set() + }; + if (value.source) { + entry.input_sources.add(value.source); + } + byRoute.set(routeId, entry); + } + + for (const fact of validatedUseFacts) { + const value = parseValidatedInputUsedValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + input_sources: new Set(), + validated_sink_kinds: new Set() + }; + if (value.sink_kind) { + entry.validated_sink_kinds.add(value.sink_kind); + } + byRoute.set(routeId, entry); + } + + return [...byRoute.values()] + .sort((left, right) => left.route_id.localeCompare(right.route_id)) + .map((entry) => ({ + route_id: entry.route_id, + file_path: entry.file_path, + proof_status: entry.input_sources.size > 0 && entry.validated_sink_kinds.size > 0 + ? "proven" + : "missing_proof", + input_sources: [...entry.input_sources].sort(), + validated_sink_kinds: [...entry.validated_sink_kinds].sort() + })); +} + +function parseRequestInputReadValue(value: string | undefined): RequestInputReadValue { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value) as RequestInputReadValue; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function parseValidatedInputUsedValue(value: string | undefined): ValidatedInputUsedValue { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value) as ValidatedInputUsedValue; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + function middlewareCoverageRoutes(facts: FactRecord[]) { const byPath = new Map gap.message === "unsupported_request_input_spread") + .map((gap) => ({ + reason: gap.message, + blocking: gap.confidence_impact === "blocks_enforcement" + })); +} diff --git a/drift v3/packages/mcp/test/mcp.test.ts b/drift v3/packages/mcp/test/mcp.test.ts index f88fdd41..c29aacf0 100644 --- a/drift v3/packages/mcp/test/mcp.test.ts +++ b/drift v3/packages/mcp/test/mcp.test.ts @@ -1587,6 +1587,140 @@ describe("read-only MCP handlers", () => { expect(JSON.stringify(securityContext)).not.toContain("requireUser()"); }); + it("exposes request validation proof summaries without snippets", async () => { + const databasePath = await seedMcpDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const validationConvention = { + id: "security_api_request_validation", + contract_id: "contract_abc", + kind: "api_route_requires_request_validation" as const, + statement: "API request input must be validated before protected sinks.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind: "api_route_requires_request_validation" as const, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + validators: ["validateProjectInput"], + schemas: ["ProjectInputSchema"] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + 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" + }; + storage.upsertAcceptedConvention("repo_abc", validationConvention); + storage.upsertRepoContract({ + id: "contract_abc", + repo_id: "repo_abc", + contract_schema_version: 1, + repo_fingerprint: "repo-fp", + created_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z", + conventions: [validationConvention], + rejected_inferences: [], + waivers: [], + risky_areas: [], + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.upsertFacts([{ + id: "fact_request_body", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "request_input_read", + file_path: "apps/web/app/api/projects/route.ts", + name: "body", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/projects/route.ts:POST", + source: "body", + variable: "body" + }), + imported_name: undefined, + start_line: 3, + end_line: 3, + ...factQuality("scan_abc") + }, { + id: "fact_validated_use", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "validated_input_used", + file_path: "apps/web/app/api/projects/route.ts", + name: "input", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/projects/route.ts:POST", + source_input_var: "body", + validated_var: "input", + sink_fact_id: "fact_sink", + sink_kind: "data_operation" + }), + imported_name: undefined, + start_line: 5, + end_line: 5, + ...factQuality("scan_abc") + }]); + storage.upsertParserGaps([{ + schema_version: "drift.parser_gap.v1", + gap_id: "parser_gap_request_spread", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "partial_parse", + file_path: "apps/web/app/api/projects/route.ts", + start_line: 8, + end_line: 8, + confidence_impact: "blocks_enforcement", + message: "unsupported_request_input_spread", + evidence_refs: ["parser_gap_request_spread"], + created_at: "2026-05-25T00:00:00.000Z" + }]); + storage.close(); + + 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; + input_sources: string[]; + validated_sink_kinds: string[]; + }>; + parser_gaps: Array<{ reason: string; blocking: boolean }>; + }; + }; + + expect(securityContext.accepted_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: "proven", + input_sources: ["body"], + validated_sink_kinds: ["data_operation"] + }]); + expect(securityContext.request_validation.parser_gaps).toEqual([ + { reason: "unsupported_request_input_spread", blocking: true } + ]); + expect(JSON.stringify(securityContext)).not.toContain("request.json()"); + expect(JSON.stringify(securityContext)).not.toContain("cookie"); + }); + it("includes scan symbol identities in MCP change impact", async () => { const databasePath = await seedMcpDatabase(); const storage = openDriftStorage({ databasePath }); diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index f564b98d..3762c2ef 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -76,6 +76,10 @@ export interface RepoMapRouteSecurity { protection_kinds: string[]; middleware_ids: string[]; }; + request_validation: { + status: "not_required" | "proven" | "missing_proof"; + input_sources: string[]; + }; } export interface GraphRepoMap { @@ -964,7 +968,9 @@ function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | const middlewareProtectionFacts = fileFacts.filter((fact) => fact.kind === "middleware_protects_route" ); - if (middlewareProtectionFacts.length === 0) { + const requestInputFacts = fileFacts.filter((fact) => fact.kind === "request_input_read"); + const validatedUseFacts = fileFacts.filter((fact) => fact.kind === "validated_input_used"); + if (middlewareProtectionFacts.length === 0 && requestInputFacts.length === 0 && validatedUseFacts.length === 0) { return undefined; } const middlewareIds = middlewareProtectionFacts.map((fact) => { @@ -979,9 +985,20 @@ function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | }); return { middleware_coverage: { - proven: true, + proven: middlewareProtectionFacts.length > 0, protection_kinds: unique(protectionKinds), middleware_ids: unique(middlewareIds) + }, + request_validation: { + status: requestInputFacts.length === 0 + ? "not_required" + : validatedUseFacts.length > 0 + ? "proven" + : "missing_proof", + input_sources: unique(requestInputFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.source === "string" ? metadata.source : fact.name; + })) } }; } @@ -1007,6 +1024,17 @@ function mergeRouteSecurity( ...left.middleware_coverage.middleware_ids, ...right.middleware_coverage.middleware_ids ]) + }, + request_validation: { + status: left.request_validation.status === "proven" || right.request_validation.status === "proven" + ? "proven" + : left.request_validation.status === "missing_proof" || right.request_validation.status === "missing_proof" + ? "missing_proof" + : "not_required", + input_sources: unique([ + ...left.request_validation.input_sources, + ...right.request_validation.input_sources + ]) } }; } diff --git a/drift v3/packages/query/src/security-boundary-proof.ts b/drift v3/packages/query/src/security-boundary-proof.ts index a2a66b30..04c57365 100644 --- a/drift v3/packages/query/src/security-boundary-proof.ts +++ b/drift v3/packages/query/src/security-boundary-proof.ts @@ -20,6 +20,9 @@ export interface SecurityBoundaryProofRouteSummary { middleware_proven: boolean; middleware_protection_kinds: string[]; middleware_mismatch_reasons: string[]; + request_validation_required: boolean; + request_validation_proven: boolean; + request_validation_unvalidated_reasons: string[]; proof_status: string; enforcement_result: string; missing_proof_codes: string[]; @@ -48,6 +51,14 @@ export function buildSecurityBoundaryProofReadModel( matched_middleware: [], mismatches: [] }; + const requestValidation = proof.request_validation ?? { + required: false, + proven: false, + input_reads: [], + validations: [], + validated_uses: [], + unvalidated_uses: [] + }; return { route_id: proof.route.route_id, file_path: proof.route.file_path, @@ -59,6 +70,10 @@ export function buildSecurityBoundaryProofReadModel( .map((middleware) => middleware.protection_kind))].sort(), middleware_mismatch_reasons: [...new Set(middleware.mismatches .map((mismatch) => mismatch.reason))].sort(), + request_validation_required: requestValidation.required, + request_validation_proven: requestValidation.proven, + request_validation_unvalidated_reasons: [...new Set(requestValidation.unvalidated_uses + .map((unvalidated) => unvalidated.reason))].sort(), proof_status: proof.result.proof_status, enforcement_result: proof.result.enforcement_result, missing_proof_codes: proof.missing_proof.map((missing) => missing.code), 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 53e9b79c..6bb76a7f 100644 --- a/drift v3/packages/query/test/security-boundary-proof.test.ts +++ b/drift v3/packages/query/test/security-boundary-proof.test.ts @@ -81,6 +81,9 @@ describe("security boundary proof read model", () => { middleware_proven: false, middleware_protection_kinds: [], middleware_mismatch_reasons: [], + request_validation_required: false, + request_validation_proven: false, + request_validation_unvalidated_reasons: [], proof_status: "parser_gap", enforcement_result: "block", missing_proof_codes: ["missing_auth_guard"], @@ -92,6 +95,79 @@ describe("security boundary proof read model", () => { expect(JSON.stringify(model)).not.toContain("requireUser()"); }); + it("summarizes request validation proof without snippets", () => { + const model = buildSecurityBoundaryProofReadModel({ + proofs: [{ + proof_id: "proof_route_projects_post_validation", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_post", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "request_validation_facts", + status: "complete", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: ["missing_validation"] + }], + auth: { + required: false, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + request_validation: { + required: true, + proven: false, + input_reads: [{ fact_id: "fact_body", source: "body", variable: "body" }], + validations: [], + validated_uses: [], + unvalidated_uses: [{ + input_fact_id: "fact_body", + sink_fact_id: "sink_create", + sink_kind: "data_operation", + reason: "request_input_not_validated" + }] + }, + missing_proof: [{ + id: "missing_validation", + capability: "request_validation_facts", + code: "request_input_not_validated", + blocks_enforcement: true, + fact_ids: ["fact_body"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_validation"] + } + }], + findings: [] + }); + + expect(model.routes[0]).toMatchObject({ + route_id: "route_projects_post", + request_validation_required: true, + request_validation_proven: false, + request_validation_unvalidated_reasons: ["request_input_not_validated"] + }); + expect(JSON.stringify(model)).not.toContain("request.json()"); + }); + it("summarizes middleware coverage proof without snippets", () => { const model = buildSecurityBoundaryProofReadModel({ proofs: [{ @@ -152,7 +228,10 @@ describe("security boundary proof read model", () => { middleware_required: true, middleware_proven: true, middleware_protection_kinds: ["auth"], - middleware_mismatch_reasons: [] + middleware_mismatch_reasons: [], + request_validation_required: false, + request_validation_proven: false, + request_validation_unvalidated_reasons: [] })]); expect(JSON.stringify(model)).not.toContain("requireUser()"); }); diff --git a/drift v3/test/e2e/security-validation.test.ts b/drift v3/test/e2e/security-validation.test.ts new file mode 100644 index 00000000..016dc2d9 --- /dev/null +++ b/drift v3/test/e2e/security-validation.test.ts @@ -0,0 +1,166 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runCli } from "../../packages/cli/src/index.js"; +import { openDriftStorage } from "../../packages/storage/src/index.js"; + +const tempDirs: string[] = []; +let originalEngineBin: string | undefined; + +async function fixtureRepo(name: string): Promise<{ repoRoot: string; stateRoot: string; diffPath: string }> { + const dir = await mkdtemp(join(tmpdir(), "drift-security-validation-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const stateRoot = join(dir, "state"); + await cp(resolve("test/fixtures", name), repoRoot, { recursive: true }); + const route = await readFile(join(repoRoot, "app/api/projects/route.ts"), "utf8"); + const diffPath = join(dir, "change.patch"); + await writeFile(diffPath, [ + "diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts", + "--- /dev/null", + "+++ b/app/api/projects/route.ts", + `@@ -0,0 +1,${route.split(/\r?\n/).filter(Boolean).length} @@`, + ...route.trimEnd().split(/\r?\n/).map((line) => `+${line}`), + "" + ].join("\n")); + return { repoRoot, stateRoot, diffPath }; +} + +beforeEach(() => { + originalEngineBin = process.env.DRIFT_ENGINE_BIN; + process.env.DRIFT_ENGINE_BIN = resolve("target/debug/drift-engine"); +}); + +afterEach(async () => { + if (originalEngineBin === undefined) { + delete process.env.DRIFT_ENGINE_BIN; + } else { + process.env.DRIFT_ENGINE_BIN = originalEngineBin; + } + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("security validation fixture matrix", () => { + it("security validation fixture matrix proves request input validation and gaps", async () => { + const cases = [ + { + name: "security-validation-missing", + exitCode: 1, + proven: false, + parserGap: false, + missingReason: "request_input_not_validated" + }, + { + name: "security-validation-result-unused", + exitCode: 1, + proven: false, + parserGap: false, + missingReason: "request_input_not_validated" + }, + { + name: "security-validation-before-data", + exitCode: 0, + proven: true, + parserGap: false + }, + { + name: "security-validation-dynamic-body-parser-gap", + exitCode: 1, + proven: false, + parserGap: true, + missingReason: "unsupported_request_input_spread" + } + ]; + + for (const entry of cases) { + const { repoRoot, stateRoot, diffPath } = await fixtureRepo(entry.name); + const scan = await runCli([ + "scan", + "--repo-root", repoRoot, + "--state-root", stateRoot, + "--now", "2026-05-25T00:00:00.000Z", + "--json" + ]); + expect(scan.exitCode, `${entry.name} scan stderr:\n${scan.stderr}`).toBe(0); + const scanPayload = JSON.parse(scan.stdout); + + const storage = openDriftStorage({ databasePath: scanPayload.database_path }); + storage.migrate(); + const convention = { + id: "security_api_request_validation", + contract_id: "contract_security_validation", + kind: "api_route_requires_request_validation" as const, + statement: "API request input must be validated before protected sinks.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind: "api_route_requires_request_validation" as const, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + schemas: ["ProjectInputSchema"], + validators: ["validateProjectInput"] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z" + }; + storage.upsertAcceptedConvention(scanPayload.repo.id, convention); + storage.upsertRepoContract({ + id: "contract_security_validation", + repo_id: scanPayload.repo.id, + contract_schema_version: 1, + repo_fingerprint: scanPayload.repo.fingerprint, + created_at: "2026-05-25T00:00:00.000Z", + updated_at: "2026-05-25T00:00:00.000Z", + conventions: [convention], + rejected_inferences: [], + waivers: [], + risky_areas: [], + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.close(); + + const check = await runCli([ + "--db", scanPayload.database_path, + "check", + "--repo", scanPayload.repo.id, + "--scope", "changed-hunks", + "--diff-file", diffPath, + "--now", "2026-05-25T00:00:01.000Z", + "--json" + ]); + expect(check.exitCode, `${entry.name} check stderr:\n${check.stderr}\nstdout:\n${check.stdout}`).toBe(entry.exitCode); + const payload = JSON.parse(check.stdout); + const proof = payload.security_boundary_proofs?.[0]; + expect(proof?.request_validation, `${entry.name} payload:\n${JSON.stringify(payload)}`).toMatchObject({ + required: true, + proven: entry.proven + }); + if (entry.missingReason) { + expect(JSON.stringify(proof)).toContain(entry.missingReason); + } + const hasParserGap = (proof?.parser_gaps ?? []).some((gap: { code?: string; blocks_enforcement?: boolean }) => + gap.code === "unsupported_request_input_spread" && + gap.blocks_enforcement === true + ); + expect(hasParserGap, `${entry.name} parser gap`).toBe(entry.parserGap); + expect(JSON.stringify(payload)).not.toContain("request.json()"); + expect(JSON.stringify(payload)).not.toContain("SECRET_VALUE"); + } + }, 30_000); +}); diff --git a/drift v3/test/fixtures/security-validation-before-data/app/api/projects/route.ts b/drift v3/test/fixtures/security-validation-before-data/app/api/projects/route.ts new file mode 100644 index 00000000..d4baf908 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-before-data/app/api/projects/route.ts @@ -0,0 +1,8 @@ +const db = { project: { create: async (_input: unknown) => ({ id: "project_1" }) } }; + +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + const project = await db.project.create({ data: input }); + return Response.json(project); +} diff --git a/drift v3/test/fixtures/security-validation-before-data/package.json b/drift v3/test/fixtures/security-validation-before-data/package.json new file mode 100644 index 00000000..e02facab --- /dev/null +++ b/drift v3/test/fixtures/security-validation-before-data/package.json @@ -0,0 +1 @@ +{"name":"security-validation-before-data","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/app/api/projects/route.ts b/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/app/api/projects/route.ts new file mode 100644 index 00000000..6fc87203 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/app/api/projects/route.ts @@ -0,0 +1,7 @@ +const db = { project: { create: async (_input: unknown) => ({ id: "project_1" }) } }; + +export async function POST(request: Request) { + const body = await request.json(); + const project = await db.project.create({ data: { ...body, ownerId: "owner_1" } }); + return Response.json(project); +} diff --git a/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/package.json b/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/package.json new file mode 100644 index 00000000..86c16e23 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-dynamic-body-parser-gap/package.json @@ -0,0 +1 @@ +{"name":"security-validation-dynamic-body-parser-gap","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-validation-missing/app/api/projects/route.ts b/drift v3/test/fixtures/security-validation-missing/app/api/projects/route.ts new file mode 100644 index 00000000..eed00c03 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-missing/app/api/projects/route.ts @@ -0,0 +1,7 @@ +const db = { project: { create: async (_input: unknown) => ({ id: "project_1" }) } }; + +export async function POST(request: Request) { + const body = await request.json(); + const project = await db.project.create({ data: body }); + return Response.json(project); +} diff --git a/drift v3/test/fixtures/security-validation-missing/package.json b/drift v3/test/fixtures/security-validation-missing/package.json new file mode 100644 index 00000000..466540b7 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-missing/package.json @@ -0,0 +1 @@ +{"name":"security-validation-missing","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-validation-result-unused/app/api/projects/route.ts b/drift v3/test/fixtures/security-validation-result-unused/app/api/projects/route.ts new file mode 100644 index 00000000..dc2bb296 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-result-unused/app/api/projects/route.ts @@ -0,0 +1,8 @@ +const db = { project: { create: async (_input: unknown) => ({ id: "project_1" }) } }; + +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + const project = await db.project.create({ data: body }); + return Response.json({ project, input }); +} diff --git a/drift v3/test/fixtures/security-validation-result-unused/package.json b/drift v3/test/fixtures/security-validation-result-unused/package.json new file mode 100644 index 00000000..f05651a2 --- /dev/null +++ b/drift v3/test/fixtures/security-validation-result-unused/package.json @@ -0,0 +1 @@ +{"name":"security-validation-result-unused","private":true,"type":"module"}