From 4da896f52f11a3e01242461c9507a7a5cb197ca3 Mon Sep 17 00:00:00 2001 From: geoffrey fernald Date: Mon, 25 May 2026 16:36:43 -0400 Subject: [PATCH 1/2] 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/2] 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"]),