diff --git a/drift v3/crates/drift-engine/src/candidate_command.rs b/drift v3/crates/drift-engine/src/candidate_command.rs index 52ecdd28..d5232ddc 100644 --- a/drift v3/crates/drift-engine/src/candidate_command.rs +++ b/drift v3/crates/drift-engine/src/candidate_command.rs @@ -286,6 +286,14 @@ fn is_data_access_source(source: &str) -> bool { || lower.contains("data-access") } +fn is_next_app_tree_path(file_path: &str) -> bool { + file_path.split('/').any(|part| part == "app") +} + +fn is_data_access_module_path(file_path: &str) -> bool { + !is_next_app_tree_path(file_path) && is_data_access_source(file_path) +} + fn security_candidates( request: &CandidateRequest, api_route_files: &BTreeSet<&str>, @@ -385,7 +393,8 @@ fn security_candidates( let matcher = json!({ "kind": "api_route_requires_request_validation", "applies_to_file_roles": ["api_route"], - "methods": ["POST", "PUT", "PATCH", "DELETE"] + "methods": ["POST", "PUT", "PATCH", "DELETE"], + "required_calls": [symbol] }); let requires = json!({ "input_sources": ["body", "query", "params"], @@ -876,7 +885,8 @@ fn push_request_validation_candidates(input: RequestValidationCandidateInput<'_> let matcher = json!({ "kind": "api_route_requires_request_validation", "applies_to_file_roles": ["api_route"], - "methods": ["POST", "PUT", "PATCH", "DELETE"] + "methods": ["POST", "PUT", "PATCH", "DELETE"], + "required_calls": [symbol] }); let requires = json!({ "input_sources": ["body", "query", "params"], @@ -988,27 +998,37 @@ fn grouped_route_facts<'a>( fn is_auth_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); + if is_lifecycle_event_like_symbol(&lower) { + return false; + } !is_serializer_candidate_symbol(symbol) - && (lower.contains("auth") + && ((lower.contains("auth") + && (lower.starts_with("require") + || lower.starts_with("with") + || lower.starts_with("get") + || lower.contains("authenticate") + || lower.contains("authguard"))) || lower.contains("session") || lower.contains("login") || matches!( lower.as_str(), - "requireuser" | "getuser" | "getcurrentuser" | "currentuser" + "requireuser" | "getuser" | "getcurrentuser" | "currentuser" | "withworkspace" )) } fn is_validation_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); - lower.contains("validate") - || lower.contains("validator") - || lower == "parsebody" - || lower == "parseinput" - || lower == "safeparse" + if lower.starts_with("revalidate") || lower.contains("permission") || lower.contains("role") { + return false; + } + lower.starts_with("validate") || lower.contains("validator") || lower == "safeparse" } fn is_authorization_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); + if is_lifecycle_event_like_symbol(&lower) { + return false; + } lower.contains("authorize") || lower.contains("permission") || lower.contains("requirepermission") @@ -1018,7 +1038,17 @@ fn is_authorization_candidate_symbol(symbol: &str) -> bool { fn is_tenant_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); - lower.contains("tenant") || lower.contains("scopeproject") || lower.contains("scopeorg") + if lower.starts_with("throwif") { + return false; + } + (lower.contains("tenant") + && (lower.contains("scope") + || lower.contains("guard") + || lower.contains("filter") + || lower.contains("where") + || lower.starts_with("require"))) + || lower.contains("scopeproject") + || lower.contains("scopeorg") } fn is_serializer_candidate_symbol(symbol: &str) -> bool { @@ -1035,12 +1065,25 @@ fn is_csrf_candidate_symbol(symbol: &str) -> bool { fn is_rate_limit_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("exceeded") { + return false; + } lower.contains("ratelimit") || lower.contains("rate_limit") || lower.contains("throttle") || lower.contains("limiter") } +fn is_lifecycle_event_like_symbol(lower: &str) -> bool { + lower.ends_with("authorized") + || lower.ends_with("deauthorized") + || lower.ends_with("completed") + || lower.ends_with("created") + || lower.ends_with("updated") + || lower.ends_with("deleted") + || lower.ends_with("failed") +} + fn is_ssrf_candidate_symbol(symbol: &str) -> bool { let lower = symbol.to_ascii_lowercase(); (lower.contains("allow") && lower.contains("url")) @@ -1136,10 +1179,14 @@ fn data_access_files<'a>( request: &'a CandidateRequest, service_files: &BTreeSet<&str>, ) -> BTreeSet<&'a str> { - let mut files = role_files(request, "data_access_module"); + let mut files = role_files(request, "data_access_module") + .into_iter() + .filter(|file_path| is_data_access_module_path(file_path)) + .collect::>(); for fact in &request.scan.facts { if fact.kind == "import_used" && !service_files.contains(fact.file_path.as_str()) + && !is_next_app_tree_path(&fact.file_path) && fact.value.as_deref().is_some_and(is_data_access_source) { files.insert(fact.file_path.as_str()); @@ -1198,6 +1245,7 @@ fn graph_data_access_imports(request: &CandidateRequest) -> Vec>(); let data_modules = graph_role_files(request, "data_access_module") .into_iter() + .filter(|file_path| is_data_access_module_path(file_path)) .filter_map(|file_path| module_by_file.get(file_path.as_str()).copied()) .collect::>(); let import_owner_module = request 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 dc22fc2d..9dbec631 100644 --- a/drift v3/crates/drift-engine/src/security_control_flow.rs +++ b/drift v3/crates/drift-engine/src/security_control_flow.rs @@ -722,16 +722,27 @@ fn has_sink_in_range(facts: &[Fact], range: std::ops::Range) -> bool { } fn line_is_inside_callback(lines: &[&str], line_number: usize) -> bool { + let target_index = line_number.saturating_sub(1); lines .iter() - .take(line_number.saturating_sub(1)) - .rev() - .take_while(|line| !line.contains("export ")) - .any(|line| { + .enumerate() + .take(target_index) + .filter(|(_, line)| { (line.contains("=>") && line.contains('{')) || line.contains(".then(") || line.contains(".catch(") || line.contains(".forEach(") || line.contains(".map(") }) + .any(|(callback_index, _)| open_brace_depth_until(lines, callback_index, target_index) > 0) +} + +fn open_brace_depth_until(lines: &[&str], start_index: usize, end_index: usize) -> i32 { + lines + .iter() + .take(end_index) + .skip(start_index) + .fold(0_i32, |depth, line| { + depth + line.matches('{').count() as i32 - line.matches('}').count() as i32 + }) } diff --git a/drift v3/crates/drift-engine/src/security_facts.rs b/drift v3/crates/drift-engine/src/security_facts.rs index c884df29..c997cc0e 100644 --- a/drift v3/crates/drift-engine/src/security_facts.rs +++ b/drift v3/crates/drift-engine/src/security_facts.rs @@ -75,6 +75,8 @@ fn extract_security_facts_with_policy_and_phase5( let normalized_file_path = file_path.as_ref().to_string_lossy().replace('\\', "/"); let facts = extract_typescript_facts(file_path, source)?; let source_lines: Vec<&str> = source.lines().collect(); + let request_input_facts = + request_input_read_facts(&normalized_file_path, &facts, &source_lines); let mut security_facts = Vec::new(); for fact in facts .iter() @@ -189,33 +191,35 @@ fn extract_security_facts_with_policy_and_phase5( if let Some(validator) = accepted_request_validator_for_call(fact, &facts, accepted_validators) && let Some(line) = source_lines.get(fact.start_line.saturating_sub(1)) - && let Some(input_var) = call_first_argument(line, &fact.name) { let route_id = format!("route:{}:{route}", fact.file_path); - let result_var = assigned_variable(line); let schema_symbol = (validator.kind == RequestValidatorKind::Schema).then(|| validator.symbol.clone()); - security_facts.push(Fact { - kind: FactKind::RequestValidationCalled, - file_path: fact.file_path.clone(), - name: fact.name.clone(), - value: Some( - json!({ - "validator_id": validator.validator_id, - "route_id": route_id, - "validator_symbol": validator.symbol, - "schema_symbol": schema_symbol, - "input_var": input_var, - "result_var": result_var, - "behavior": validator.behavior.as_str(), - "kind": validator.kind.as_str(), - }) - .to_string(), - ), - imported_name: Some(validator.symbol.clone()), - start_line: fact.start_line, - end_line: fact.end_line, - }); + for (input_var, result_var) in + validation_input_bindings(&source_lines, fact.start_line, line, &fact.name) + { + security_facts.push(Fact { + kind: FactKind::RequestValidationCalled, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: Some( + json!({ + "validator_id": validator.validator_id, + "route_id": route_id, + "validator_symbol": validator.symbol, + "schema_symbol": schema_symbol, + "input_var": input_var, + "result_var": result_var, + "behavior": validator.behavior.as_str(), + "kind": validator.kind.as_str(), + }) + .to_string(), + ), + imported_name: Some(validator.symbol.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); + } } if let Some(phase5) = accepted_phase5 && let Some(serializer) = @@ -255,20 +259,23 @@ fn extract_security_facts_with_policy_and_phase5( } let route_id = format!("route:{}:{route}", fact.file_path); let arguments = call_arguments(line, &fact.name); + let call_text = call_text_from_line(&source_lines, fact.start_line); let subject_var = arguments.first().cloned(); let resource_var = arguments.get(1).and_then(|argument| { (!is_quoted_literal(argument)).then(|| argument.trim().to_string()) }); - let roles = if helper.kind == crate::security_patterns::AuthorizationHelperKind::Role { - arguments - .iter() - .skip(1) - .filter_map(|argument| unquoted_literal(argument)) - .collect::>() - } else { - Vec::new() - }; - let permissions = + let mut roles = + if helper.kind == crate::security_patterns::AuthorizationHelperKind::Role { + arguments + .iter() + .skip(1) + .filter_map(|argument| unquoted_literal(argument)) + .collect::>() + } else { + Vec::new() + }; + roles.extend(string_array_property(&call_text, "requiredRoles")); + let mut permissions = if helper.kind == crate::security_patterns::AuthorizationHelperKind::Policy { arguments .iter() @@ -278,6 +285,7 @@ fn extract_security_facts_with_policy_and_phase5( } else { Vec::new() }; + permissions.extend(string_array_property(&call_text, "requiredPermissions")); security_facts.push(Fact { kind: FactKind::AuthorizationGuardCalled, file_path: fact.file_path.clone(), @@ -328,7 +336,7 @@ fn extract_security_facts_with_policy_and_phase5( .copied() .unwrap_or_default(); let url_var = call_first_argument(line, &fact.name); - let url_source = outbound_url_source(line, url_var.as_deref(), &security_facts); + let url_source = outbound_url_source(line, url_var.as_deref(), &request_input_facts); security_facts.push(Fact { kind: FactKind::OutboundRequestCalled, file_path: fact.file_path.clone(), @@ -348,11 +356,7 @@ fn extract_security_facts_with_policy_and_phase5( }); } } - security_facts.extend(request_input_read_facts( - &normalized_file_path, - &facts, - &source_lines, - )); + security_facts.extend(request_input_facts); security_facts.extend(raw_sql_facts(&normalized_file_path, &facts, &source_lines)); security_facts.extend(cors_policy_facts( &normalized_file_path, @@ -986,6 +990,32 @@ fn call_first_argument(line: &str, call_name: &str) -> Option { (!argument.is_empty() && argument.chars().all(is_identifier_char)).then(|| argument.to_string()) } +fn validation_input_variable(line: &str, call_name: &str) -> Option { + call_first_argument(line, call_name).or_else(|| { + line.contains("parseRequestBody(") + .then(|| assigned_variable(line)) + .flatten() + }) +} + +fn validation_input_bindings( + lines: &[&str], + line_number: usize, + line: &str, + call_name: &str, +) -> Vec<(String, Option)> { + if let Some(input_var) = validation_input_variable(line, call_name) { + return vec![(input_var, assigned_variable(line))]; + } + if !line.contains("parseRequestBody(") { + return Vec::new(); + } + destructured_assignment_variables(lines, line_number) + .into_iter() + .map(|variable| (variable.clone(), Some(variable))) + .collect() +} + fn call_arguments(line: &str, call_name: &str) -> Vec { let marker = format!("{call_name}("); let Some(after_marker) = line.split(&marker).nth(1) else { @@ -1002,6 +1032,44 @@ fn call_arguments(line: &str, call_name: &str) -> Vec { .collect() } +fn call_text_from_line(lines: &[&str], line_number: usize) -> String { + let mut text = String::new(); + let mut depth = 0i32; + for line in lines.iter().skip(line_number.saturating_sub(1)).take(40) { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(line); + for character in line.chars() { + if character == '(' { + depth += 1; + } else if character == ')' { + depth -= 1; + } + } + if !text.is_empty() && depth <= 0 && line.contains(')') { + break; + } + } + text +} + +fn string_array_property(text: &str, property: &str) -> Vec { + let Some(after_property) = text.split(property).nth(1) else { + return Vec::new(); + }; + let Some(after_open) = after_property.split_once('[').map(|(_, after)| after) else { + return Vec::new(); + }; + let Some(array_text) = after_open.split_once(']').map(|(array, _)| array) else { + return Vec::new(); + }; + array_text + .split(',') + .filter_map(unquoted_literal) + .collect::>() +} + fn is_quoted_literal(argument: &str) -> bool { let trimmed = argument.trim(); (trimmed.starts_with('"') && trimmed.ends_with('"')) @@ -1073,6 +1141,28 @@ fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> None, )); } + } else if line.contains("parseRequestBody(") { + if let Some(variable) = assigned_variable(line) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id, + "body", + variable, + None, + )); + } else { + for variable in destructured_assignment_variables(lines, line_number) { + request_facts.push(request_input_fact( + file_path, + line_number, + route_id.clone(), + "body", + variable, + None, + )); + } + } } else if line.contains("request.nextUrl.searchParams.get(") || line.contains("new URL(request.url).searchParams.get(") { @@ -1522,6 +1612,62 @@ fn assigned_variable(line: &str) -> Option { (!variable.is_empty() && variable.chars().all(is_identifier_char)).then(|| variable.to_string()) } +fn destructured_assignment_variables(lines: &[&str], line_number: usize) -> Vec { + let Some(end_line) = lines.get(line_number.saturating_sub(1)) else { + return Vec::new(); + }; + if !end_line.contains("} =") { + return Vec::new(); + } + let mut start_index = None; + for index in (0..line_number.saturating_sub(1)).rev() { + let trimmed = lines[index].trim(); + if trimmed.starts_with("const {") + || trimmed.starts_with("let {") + || trimmed.starts_with("var {") + { + start_index = Some(index); + break; + } + if trimmed.contains(';') || trimmed.ends_with('}') { + break; + } + } + let Some(start_index) = start_index else { + return Vec::new(); + }; + lines[start_index + 1..line_number.saturating_sub(1)] + .iter() + .filter_map(|line| destructured_binding_variable(line)) + .collect() +} + +fn destructured_binding_variable(line: &str) -> Option { + let without_comment = line.split("//").next().unwrap_or_default(); + let trimmed = without_comment + .trim() + .trim_end_matches(',') + .trim() + .trim_start_matches("...") + .trim(); + if trimmed.is_empty() || trimmed.contains('{') || trimmed.contains('}') { + return None; + } + let variable = trimmed + .split_once(':') + .map(|(_, alias)| alias.trim()) + .unwrap_or(trimmed) + .split_once('=') + .map(|(name, _)| name.trim()) + .unwrap_or_else(|| { + trimmed + .split_once(':') + .map(|(_, alias)| alias.trim()) + .unwrap_or(trimmed) + }); + (!variable.is_empty() && variable.chars().all(is_identifier_char)).then(|| variable.to_string()) +} + fn quoted_argument(line: &str, marker: &str) -> Option { let after_marker = line.split(marker).nth(1)?; let quote = after_marker @@ -1611,18 +1757,29 @@ fn protected_sink_after_line(facts: &[Fact], line: usize) -> bool { } fn line_is_inside_callback(lines: &[&str], line_number: usize) -> bool { + let target_index = line_number.saturating_sub(1); lines .iter() - .take(line_number.saturating_sub(1)) - .rev() - .take_while(|line| !line.contains("export ")) - .any(|line| { + .enumerate() + .take(target_index) + .filter(|(_, line)| { (line.contains("=>") && line.contains('{')) || line.contains(".then(") || line.contains(".catch(") || line.contains(".forEach(") || line.contains(".map(") }) + .any(|(callback_index, _)| open_brace_depth_until(lines, callback_index, target_index) > 0) +} + +fn open_brace_depth_until(lines: &[&str], start_index: usize, end_index: usize) -> i32 { + lines + .iter() + .take(end_index) + .skip(start_index) + .fold(0_i32, |depth, line| { + depth + line.matches('{').count() as i32 - line.matches('}').count() as i32 + }) } fn route_for_line(facts: &[Fact], line: usize) -> Option<&str> { @@ -1674,10 +1831,14 @@ fn outbound_request_api(fact: &Fact) -> &'static str { fn outbound_url_source(line: &str, url_var: Option<&str>, security_facts: &[Fact]) -> &'static str { if first_call_argument_text(line).is_some_and(|argument| { - argument.starts_with('"') || argument.starts_with('\'') || argument.starts_with('`') + (argument.starts_with('"') || argument.starts_with('\'')) + || (argument.starts_with('`') && !argument.contains("${")) }) { return "constant"; } + if contains_request_input(line) { + return "request_input"; + } if let Some(url_var) = url_var && security_facts .iter() @@ -1685,9 +1846,31 @@ fn outbound_url_source(line: &str, url_var: Option<&str>, security_facts: &[Fact { return "request_input"; } + if security_facts.iter().any(|fact| { + fact.kind == FactKind::RequestInputRead + && !fact.name.is_empty() + && line + .split(|character: char| !is_identifier_char(character)) + .any(|part| part == fact.name) + }) { + return "request_input"; + } "unknown" } +fn contains_request_input(text: &str) -> bool { + text.contains("request.nextUrl.searchParams.get(") + || text.contains("new URL(request.url).searchParams.get(") + || text.contains("request.headers.get(") + || text.contains("cookies().get(") + || text.contains("request.cookies.get(") + || text.contains("params.") + || text.contains("context.params.") + || text.contains("request.json()") + || text.contains("request.formData()") + || text.contains("request.text()") +} + fn first_call_argument_text(line: &str) -> Option<&str> { let after_open = line.split_once('(')?.1; let argument = after_open.split_once(')')?.0.split(',').next()?.trim(); diff --git a/drift v3/crates/drift-engine/src/security_patterns.rs b/drift v3/crates/drift-engine/src/security_patterns.rs index e6680bbe..c3298ce1 100644 --- a/drift v3/crates/drift-engine/src/security_patterns.rs +++ b/drift v3/crates/drift-engine/src/security_patterns.rs @@ -148,7 +148,7 @@ pub fn accepted_request_validator_for_call<'a>( || imported_symbol_matches(facts, &call.name, &validator.symbol)) } RequestValidatorKind::Schema => { - matches!(call.name.as_str(), "parse" | "safeParse") + matches!(call.name.as_str(), "parse" | "parseAsync" | "safeParse") && call.value.as_deref().is_some_and(|receiver| { schema_receiver_matches(facts, receiver, &validator.symbol) }) diff --git a/drift v3/crates/drift-engine/tests/candidate_inference.rs b/drift v3/crates/drift-engine/tests/candidate_inference.rs index 42a55f93..1f6b9e99 100644 --- a/drift v3/crates/drift-engine/tests/candidate_inference.rs +++ b/drift v3/crates/drift-engine/tests/candidate_inference.rs @@ -151,6 +151,333 @@ fn infer_candidates_uses_resolved_import_targets_for_data_access_modules() { assert_eq!(direct["evidence_refs"][0]["symbol"], "client"); } +#[test] +fn infer_candidates_learns_workspace_wrappers_as_auth_patterns() { + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { + "graph_nodes": [], + "graph_edges": [], + "graph_evidence": [] + }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { + "file_path": "app/api/oauth/apps/route.ts", + "content_hash": "a".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/webhooks/route.ts", + "content_hash": "b".repeat(64), + "byte_size": 120, + "indexed": true + } + ], + "facts": [ + { + "kind": "file_role_detected", + "file_path": "app/api/oauth/apps/route.ts", + "name": "api_route", + "start_line": 1, + "end_line": 8 + }, + { + "kind": "import_used", + "file_path": "app/api/oauth/apps/route.ts", + "name": "withWorkspace", + "value": "@/lib/auth", + "imported_name": "withWorkspace", + "start_line": 1, + "end_line": 1 + }, + { + "kind": "symbol_called", + "file_path": "app/api/oauth/apps/route.ts", + "name": "withWorkspace", + "start_line": 3, + "end_line": 8 + }, + { + "kind": "file_role_detected", + "file_path": "app/api/webhooks/route.ts", + "name": "api_route", + "start_line": 1, + "end_line": 8 + }, + { + "kind": "import_used", + "file_path": "app/api/webhooks/route.ts", + "name": "withWorkspace", + "value": "@/lib/auth", + "imported_name": "withWorkspace", + "start_line": 1, + "end_line": 1 + }, + { + "kind": "symbol_called", + "file_path": "app/api/webhooks/route.ts", + "name": "withWorkspace", + "start_line": 3, + "end_line": 8 + } + ] + } + }); + let payload = run_infer_candidates(request); + let candidates = payload["candidates"].as_array().expect("candidates"); + + assert!( + candidates.iter().any(|candidate| { + candidate["kind"] == "api_route_requires_auth_helper" + && candidate["matcher"]["required_calls"] + .as_array() + .is_some_and(|calls| calls.iter().any(|call| call == "withWorkspace")) + && candidate["scoring"]["supporting_examples_count"] == 2 + }), + "{payload:#?}" + ); +} + +#[test] +fn infer_candidates_does_not_treat_body_parser_as_validation_pattern() { + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { + "graph_nodes": [], + "graph_edges": [], + "graph_evidence": [] + }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { + "file_path": "app/api/oauth/apps/route.ts", + "content_hash": "a".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/tokens/route.ts", + "content_hash": "b".repeat(64), + "byte_size": 120, + "indexed": true + } + ], + "facts": [ + { + "kind": "file_role_detected", + "file_path": "app/api/oauth/apps/route.ts", + "name": "api_route", + "start_line": 1, + "end_line": 8 + }, + { + "kind": "import_used", + "file_path": "app/api/oauth/apps/route.ts", + "name": "parseRequestBody", + "value": "@/lib/api/utils", + "imported_name": "parseRequestBody", + "start_line": 1, + "end_line": 1 + }, + { + "kind": "symbol_called", + "file_path": "app/api/oauth/apps/route.ts", + "name": "parseRequestBody", + "start_line": 4, + "end_line": 4 + }, + { + "kind": "file_role_detected", + "file_path": "app/api/tokens/route.ts", + "name": "api_route", + "start_line": 1, + "end_line": 8 + }, + { + "kind": "import_used", + "file_path": "app/api/tokens/route.ts", + "name": "parseRequestBody", + "value": "@/lib/api/utils", + "imported_name": "parseRequestBody", + "start_line": 1, + "end_line": 1 + }, + { + "kind": "symbol_called", + "file_path": "app/api/tokens/route.ts", + "name": "parseRequestBody", + "start_line": 4, + "end_line": 4 + } + ] + } + }); + let payload = run_infer_candidates(request); + let candidates = payload["candidates"].as_array().expect("candidates"); + + assert!( + !candidates.iter().any(|candidate| { + candidate["kind"] == "api_route_requires_request_validation" + && candidate["requires"]["validators"][0]["symbol"] == "parseRequestBody" + }), + "{payload:#?}" + ); +} + +#[test] +fn infer_candidates_keeps_distinct_validation_patterns_separate() { + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { + "graph_nodes": [], + "graph_edges": [], + "graph_evidence": [] + }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { + "file_path": "app/api/apps/route.ts", + "content_hash": "a".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/tokens/route.ts", + "content_hash": "b".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/webhooks/route.ts", + "content_hash": "c".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/webhooks/[id]/route.ts", + "content_hash": "d".repeat(64), + "byte_size": 120, + "indexed": true + } + ], + "facts": [ + { "kind": "file_role_detected", "file_path": "app/api/apps/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/apps/route.ts", "name": "parseRequestBody", "start_line": 4, "end_line": 4 }, + { "kind": "file_role_detected", "file_path": "app/api/tokens/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/tokens/route.ts", "name": "parseRequestBody", "start_line": 4, "end_line": 4 }, + { "kind": "file_role_detected", "file_path": "app/api/webhooks/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/webhooks/route.ts", "name": "validateWebhook", "start_line": 4, "end_line": 4 }, + { "kind": "file_role_detected", "file_path": "app/api/webhooks/[id]/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/webhooks/[id]/route.ts", "name": "validateWebhook", "start_line": 4, "end_line": 4 } + ] + } + }); + let payload = run_infer_candidates(request); + let validation_candidates = payload["candidates"] + .as_array() + .expect("candidates") + .iter() + .filter(|candidate| candidate["kind"] == "api_route_requires_request_validation") + .collect::>(); + let validators = validation_candidates + .iter() + .filter_map(|candidate| candidate["requires"]["validators"][0]["symbol"].as_str()) + .collect::>(); + let candidate_ids = validation_candidates + .iter() + .filter_map(|candidate| candidate["candidate_id"].as_str()) + .collect::>(); + + assert!(validators.contains(&"validateWebhook"), "{payload:#?}"); + assert!(!validators.contains(&"parseRequestBody"), "{payload:#?}"); + assert_eq!( + candidate_ids.len(), + validation_candidates.len(), + "{payload:#?}" + ); +} + +#[test] +fn infer_candidates_filters_security_helper_noise_from_body_error_event_and_precondition_symbols() { + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { + "graph_nodes": [], + "graph_edges": [], + "graph_evidence": [] + }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { + "file_path": "app/api/oauth/apps/route.ts", + "content_hash": "a".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "app/api/oauth/tokens/route.ts", + "content_hash": "b".repeat(64), + "byte_size": 120, + "indexed": true + } + ], + "facts": [ + { "kind": "file_role_detected", "file_path": "app/api/oauth/apps/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "parseRequestBody", "start_line": 4, "end_line": 4 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "exceededLimitError", "start_line": 5, "end_line": 5 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "accountApplicationDeauthorized", "start_line": 6, "end_line": 6 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "throwIfNoPartnerIdOrTenantId", "start_line": 7, "end_line": 7 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "revalidatePath", "start_line": 8, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/apps/route.ts", "name": "validateScopesForRole", "start_line": 9, "end_line": 9 }, + { "kind": "file_role_detected", "file_path": "app/api/oauth/tokens/route.ts", "name": "api_route", "start_line": 1, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "parseRequestBody", "start_line": 4, "end_line": 4 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "exceededLimitError", "start_line": 5, "end_line": 5 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "accountApplicationDeauthorized", "start_line": 6, "end_line": 6 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "throwIfNoPartnerIdOrTenantId", "start_line": 7, "end_line": 7 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "revalidatePath", "start_line": 8, "end_line": 8 }, + { "kind": "symbol_called", "file_path": "app/api/oauth/tokens/route.ts", "name": "validateScopesForRole", "start_line": 9, "end_line": 9 } + ] + } + }); + let payload = run_infer_candidates(request); + let candidates = payload["candidates"].as_array().expect("candidates"); + let blocked_symbols = [ + ("api_route_requires_request_validation", "parseRequestBody"), + ("api_route_requires_rate_limit", "exceededLimitError"), + ( + "api_route_requires_auth_helper", + "accountApplicationDeauthorized", + ), + ( + "api_route_requires_tenant_scope", + "throwIfNoPartnerIdOrTenantId", + ), + ("api_route_requires_request_validation", "revalidatePath"), + ( + "api_route_requires_request_validation", + "validateScopesForRole", + ), + ]; + + for (kind, symbol) in blocked_symbols { + assert!( + !candidates.iter().any(|candidate| { + candidate["kind"] == kind + && candidate["matcher"]["required_calls"] + .as_array() + .is_some_and(|calls| calls.iter().any(|call| call == symbol)) + }), + "unexpected {kind} candidate for {symbol}: {payload:#?}" + ); + } +} + #[test] fn infer_candidates_uses_graph_evidence_without_raw_import_facts() { let request = json!({ @@ -240,6 +567,91 @@ fn infer_candidates_uses_graph_evidence_without_raw_import_facts() { ); } +#[test] +fn infer_candidates_does_not_make_api_route_helpers_direct_data_access_forbidden_imports() { + let request = json!({ + "repo": { "repo_id": "repo_abc" }, + "graph": { + "graph_nodes": [ + graph_node("file:apps/web/app/(ee)/api/users/route.ts", "file", "apps/web/app/(ee)/api/users/route.ts", json!({ "path": "apps/web/app/(ee)/api/users/route.ts" })), + graph_node("file:apps/web/app/(ee)/api/users/loaders.ts", "file", "apps/web/app/(ee)/api/users/loaders.ts", json!({ "path": "apps/web/app/(ee)/api/users/loaders.ts" })), + graph_node("file_role:api_route", "file_role", "api_route", json!({ "role": "api_route" })), + graph_node("file_role:data_access_module", "file_role", "data_access_module", json!({ "role": "data_access_module" })), + graph_node("module:apps/web/app/(ee)/api/users/route.ts", "module", "apps/web/app/(ee)/api/users/route.ts", json!({ "file_path": "apps/web/app/(ee)/api/users/route.ts" })), + graph_node("module:apps/web/app/(ee)/api/users/loaders.ts", "module", "apps/web/app/(ee)/api/users/loaders.ts", json!({ "file_path": "apps/web/app/(ee)/api/users/loaders.ts" })), + { + "id": "import_decl:apps/web/app/(ee)/api/users/route.ts:aaaaaaaaaaaa:./loaders:loadUsers:1-1", + "kind": "import_decl", + "label": "loadUsers from ./loaders", + "stable": false, + "evidence_ids": ["evidence_route_helper_import"], + "metadata": { + "file_path": "apps/web/app/(ee)/api/users/route.ts", + "source": "./loaders", + "local_name": "loadUsers", + "imported_name": "loadUsers", + "resolved_file_path": "apps/web/app/(ee)/api/users/loaders.ts", + "resolved_module_id": "module:apps/web/app/(ee)/api/users/loaders.ts" + } + } + ], + "graph_edges": [ + graph_edge("FILE_HAS_ROLE", "file:apps/web/app/(ee)/api/users/route.ts", "file_role:api_route"), + graph_edge("FILE_HAS_ROLE", "file:apps/web/app/(ee)/api/users/loaders.ts", "file_role:data_access_module"), + graph_edge("FILE_DEFINES_MODULE", "file:apps/web/app/(ee)/api/users/route.ts", "module:apps/web/app/(ee)/api/users/route.ts"), + graph_edge("FILE_DEFINES_MODULE", "file:apps/web/app/(ee)/api/users/loaders.ts", "module:apps/web/app/(ee)/api/users/loaders.ts"), + graph_edge_with_evidence("IMPORT_DECL_REFERENCES_MODULE", "import_decl:apps/web/app/(ee)/api/users/route.ts:aaaaaaaaaaaa:./loaders:loadUsers:1-1", "module:apps/web/app/(ee)/api/users/route.ts", "evidence_route_helper_import"), + graph_edge_with_evidence("IMPORT_RESOLVES_TO_MODULE", "import_decl:apps/web/app/(ee)/api/users/route.ts:aaaaaaaaaaaa:./loaders:loadUsers:1-1", "module:apps/web/app/(ee)/api/users/loaders.ts", "evidence_route_helper_import") + ], + "graph_evidence": [{ + "id": "evidence_route_helper_import", + "repo_id": "repo_abc", + "scan_id": "scan_abc", + "artifact_id": "file_version:apps/web/app/(ee)/api/users/route.ts:aaaaaaaaaaaa", + "file_path": "apps/web/app/(ee)/api/users/route.ts", + "file_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "start_line": 1, + "end_line": 1, + "adapter_id": "typescript", + "adapter_version": "0.1.0", + "fact_ids": ["fact_route_helper_import"], + "redaction_state": "none" + }] + }, + "scan": { + "scan_id": "scan_abc", + "file_snapshots": [ + { + "file_path": "apps/web/app/(ee)/api/users/route.ts", + "content_hash": "a".repeat(64), + "byte_size": 120, + "indexed": true + }, + { + "file_path": "apps/web/app/(ee)/api/users/loaders.ts", + "content_hash": "b".repeat(64), + "byte_size": 80, + "indexed": true + } + ], + "facts": [ + { "kind": "file_role_detected", "file_path": "apps/web/app/(ee)/api/users/loaders.ts", "name": "data_access_module", "start_line": 1, "end_line": 20 }, + { "kind": "import_used", "file_path": "apps/web/app/(ee)/api/users/loaders.ts", "name": "prisma", "value": "@dub/prisma", "start_line": 1, "end_line": 1 } + ] + } + }); + let payload = run_infer_candidates(request); + + assert!( + !payload["candidates"] + .as_array() + .expect("candidates") + .iter() + .any(|candidate| candidate["kind"] == "api_route_no_direct_data_access"), + "{payload:#?}" + ); +} + #[test] fn infer_candidates_ignores_repo_fixture_routes_when_repo_root_is_not_the_fixture() { let request = json!({ diff --git a/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs b/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs index a0fd379d..d240b3fc 100644 --- a/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs +++ b/drift v3/crates/drift-engine/tests/security_check_repo_auth.rs @@ -206,6 +206,79 @@ fn canonical_requires_auth_helpers_normalizes_trusted_guard_calls() { ); } +#[test] +fn wrapped_sibling_routes_treat_wrapper_as_route_level_auth_guard() { + let source = [ + r#"import { withWorkspace } from "@/lib/auth";"#, + r#"import { prisma } from "@dub/prisma";"#, + "", + "export const GET = withWorkspace(", + " async ({ workspace }) => {", + " const apps = await prisma.oAuthApp.findMany({ where: { projectId: workspace.id } });", + " return Response.json({ apps });", + " },", + ");", + "", + "export const POST = withWorkspace(", + " async ({ req, workspace }) => {", + " const existing = await prisma.integration.findUnique({ where: { slug: \"demo\" } });", + " return Response.json({ existing, workspace });", + " },", + ");", + "", + ] + .join("\n"); + let repo_root = temp_repo("wrapped_sibling_routes"); + 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 payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_auth", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_auth", + "facts": scan["facts"].clone() + }, + "contract": { + "contract_id": "contract_auth", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_api_auth_with_workspace", + "kind": "api_route_requires_auth_helper", + "matcher": { + "required_calls": ["withWorkspace"], + "applies_to_file_roles": ["api_route"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + + assert_eq!( + payload["findings"].as_array().expect("findings").len(), + 0, + "{payload:#?}" + ); + let proofs = payload["security_boundary_proofs"] + .as_array() + .expect("proofs"); + assert_eq!(proofs.len(), 2, "{payload:#?}"); + assert!( + proofs + .iter() + .all(|proof| proof["result"]["proof_status"] == "proven"), + "{payload:#?}" + ); +} + #[test] fn security_phase8_proof_includes_route_path_and_method() { let source = [ diff --git a/drift v3/crates/drift-engine/tests/security_facts.rs b/drift v3/crates/drift-engine/tests/security_facts.rs index 49db322b..826c0556 100644 --- a/drift v3/crates/drift-engine/tests/security_facts.rs +++ b/drift v3/crates/drift-engine/tests/security_facts.rs @@ -102,6 +102,97 @@ export async function POST(request: Request, { params }: { params: { projectId: ); } +#[test] +fn classifies_outbound_request_url_sources_without_leaking_raw_urls() { + let source = r#" +export async function POST(request: Request) { + const body = await request.json(); + const target = request.nextUrl.searchParams.get("target"); + await fetch("https://api.example.test/static"); + await fetch(target); + await fetch(request.nextUrl.searchParams.get("next")); + await fetch(`${body.callbackUrl}/hook`); +} +"#; + + let facts = + extract_security_facts("app/api/proxy/route.ts", source, &[]).expect("security facts"); + let outbound = facts + .iter() + .filter(|fact| fact.kind == FactKind::OutboundRequestCalled) + .collect::>(); + + assert!( + outbound.iter().any(|fact| { + fact.start_line == 5 + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"url_source\":\"constant\"") + && !value.contains("https://api.example.test") + }) + }), + "missing constant outbound URL classification: {facts:#?}" + ); + assert!( + outbound.iter().any(|fact| { + fact.start_line == 6 + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"url_source\":\"request_input\"") + && value.contains("\"url_var\":\"target\"") + }) + }), + "missing request-input variable outbound URL classification: {facts:#?}" + ); + assert!( + outbound.iter().any(|fact| { + fact.start_line == 7 + && fact + .value + .as_deref() + .is_some_and(|value| value.contains("\"url_source\":\"request_input\"")) + }), + "missing inline request-input outbound URL classification: {facts:#?}" + ); + assert!( + outbound.iter().any(|fact| { + fact.start_line == 8 + && fact + .value + .as_deref() + .is_some_and(|value| value.contains("\"url_source\":\"request_input\"")) + }), + "missing template request-input outbound URL classification: {facts:#?}" + ); +} + +#[test] +fn extracts_parse_request_body_as_request_input_read() { + let source = r#" +import { parseRequestBody } from "@/lib/api/utils"; + +export const POST = withWorkspace(async ({ req }) => { + const body = await parseRequestBody(req); + return Response.json({ body }); +}); +"#; + + let facts = + extract_security_facts("app/api/oauth/apps/route.ts", source, &[]).expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestInputRead + && fact.name == "body" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"body\"") + && value.contains("\"variable\":\"body\"") + && value.contains("\"route_id\":\"route:app/api/oauth/apps/route.ts:POST\"") + }) + && fact.start_line == 5), + "missing parseRequestBody request input fact: {facts:#?}" + ); +} + #[test] fn security_phase5_secret_read_ignores_unknown_public_config_unless_explicitly_accepted() { let source = r#" @@ -328,6 +419,127 @@ export async function POST(request: Request) { ); } +#[test] +fn extracts_schema_parse_async_for_parse_request_body() { + let source = r#" +import { parseRequestBody } from "@/lib/api/utils"; +import { createOAuthAppSchema } from "@/lib/zod/schemas/oauth"; + +export const POST = withWorkspace(async ({ req }) => { + const input = await createOAuthAppSchema.parseAsync(await parseRequestBody(req)); + return Response.json({ input }); +}); +"#; + + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_create_oauth_app".to_string(), + symbol: "createOAuthAppSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let facts = extract_security_facts_with_validation( + "app/api/oauth/apps/route.ts", + source, + &[], + &validators, + ) + .expect("security facts"); + + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestInputRead + && fact.name == "input" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"body\"") + && value.contains("\"variable\":\"input\"") + })), + "missing inline parseRequestBody input fact: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "parseAsync" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"schema_symbol\":\"createOAuthAppSchema\"") + && value.contains("\"input_var\":\"input\"") + && value.contains("\"result_var\":\"input\"") + }) + && fact.start_line == 6), + "missing parseAsync request validation fact: {facts:#?}" + ); +} + +#[test] +fn extracts_multiline_destructured_schema_parse_async_for_parse_request_body() { + let source = r#" +import { parseRequestBody } from "@/lib/api/utils"; +import { createOAuthAppSchema } from "@/lib/zod/schemas/oauth"; + +export const POST = withWorkspace(async ({ req }) => { + const { + name, + slug, + } = await createOAuthAppSchema.parseAsync(await parseRequestBody(req)); + await prisma.integration.create({ data: { name, slug } }); + return Response.json({ name, slug }); +}); +"#; + + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_create_oauth_app".to_string(), + symbol: "createOAuthAppSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let facts = extract_security_facts_with_validation( + "app/api/oauth/apps/route.ts", + source, + &[], + &validators, + ) + .expect("security facts"); + + for field in ["name", "slug"] { + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestInputRead + && fact.name == field + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"body\"") + && value.contains(&format!("\"variable\":\"{field}\"")) + })), + "missing destructured parseRequestBody input fact for {field}: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::RequestValidationCalled + && fact.name == "parseAsync" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"schema_symbol\":\"createOAuthAppSchema\"") + && value.contains(&format!("\"input_var\":\"{field}\"")) + && value.contains(&format!("\"result_var\":\"{field}\"")) + }) + && fact.start_line == 9), + "missing destructured parseAsync request validation fact for {field}: {facts:#?}" + ); + assert!( + facts + .iter() + .any(|fact| fact.kind == FactKind::ValidatedInputUsed + && fact.name == field + && fact.value.as_deref().is_some_and(|value| { + value.contains(&format!("\"source_input_var\":\"{field}\"")) + && value.contains(&format!("\"validated_var\":\"{field}\"")) + })), + "missing destructured validated use fact for {field}: {facts:#?}" + ); + } +} + #[test] fn extracts_request_validation_called_for_namespace_imported_schema() { let source = r#" @@ -779,6 +991,53 @@ export async function GET(request: Request, { params }: { params: { projectId: s ); } +#[test] +fn extracts_authorization_from_wrapper_required_permissions_and_roles() { + let source = r#" +import { withWorkspace } from "@/lib/auth"; +import { db } from "@/server/db"; + +export const POST = withWorkspace( + async ({ session }) => { + await db.project.create({ data: { name: "x" } }); + return Response.json({}); + }, + { + requiredPermissions: ["oauth_apps.write", "project:create"], + requiredRoles: ["owner"] + } +); +"#; + let mut policy = accepted_phase4_policy(); + policy + .authorization_helpers + .push(AcceptedAuthorizationHelper { + guard_id: "authorization_with_workspace".to_string(), + symbol: "withWorkspace".to_string(), + import_source: Some("@/lib/auth".to_string()), + kind: AuthorizationHelperKind::Policy, + behavior: AuthorizationHelperBehavior::Throws, + }); + + let facts = + extract_security_facts_with_policy("app/api/oauth/apps/route.ts", source, &policy, &[]) + .expect("security facts"); + + assert!( + facts.iter().any(|fact| { + fact.kind == FactKind::AuthorizationGuardCalled + && fact.name == "withWorkspace" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"guard_id\":\"authorization_with_workspace\"") + && value + .contains("\"permissions\":[\"oauth_apps.write\",\"project:create\"]") + && value.contains("\"roles\":[\"owner\"]") + }) + }), + "missing wrapper authorization guard fact: {facts:#?}" + ); +} + #[test] fn extracts_route_returns_response_fact() { let next_response_source = r#" diff --git a/drift v3/docs/architecture/security-boundary-p1-p8-implemented-architecture.md b/drift v3/docs/architecture/security-boundary-p1-p8-implemented-architecture.md new file mode 100644 index 00000000..400801a2 --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-p1-p8-implemented-architecture.md @@ -0,0 +1,142 @@ +# Security Boundary P1-P8 Implemented Architecture + +Current branch: `codex/security-phase8-production`. + +This describes what is implemented in code now. It does not describe planned behavior. + +## What This Adds + +Drift can now check security boundaries for Next-style API routes using Rust-owned proof runs. TypeScript stores those proofs, validates their shape, and renders them through CLI, query, and MCP surfaces. + +The rule is simple: + +```text +Rust proof can block. +Accepted contracts decide what Rust should prove. +Candidate evidence can suggest, but cannot block. +TypeScript can display proof, but must not invent proof. +``` + +## Implemented Phase Map + +| Phase | Implemented security question | Accepted contract kind | Main proof fields | +| --- | --- | --- | --- | +| P1 | Does the route have an accepted auth guard before protected sinks? | `api_route_requires_auth_helper` | `auth`, `missing_proof`, `parser_gaps` | +| P2 | Does middleware cover the route/method? | `middleware_must_cover_routes` | `middleware`, `missing_proof`, `parser_gaps` | +| P3 | Is request input validated before it reaches sinks? | `api_route_requires_request_validation` | `request_validation` | +| P4 | Are session, authorization, and tenant boundaries proven? | `session_object_must_come_from_trusted_helper`, `api_route_requires_authorization`, `api_route_requires_tenant_scope` | `session_trust`, `authorization`, `tenant` | +| P5 | Are sensitive responses and secret exposure blocked? | `api_route_forbids_sensitive_response_fields`, `api_route_forbids_secret_exposure` | `response_shape`, `sinks.secrets` | +| P6 | Are SSRF, raw SQL, CORS, CSRF, and rate-limit boundaries enforced? | `api_route_forbids_untrusted_ssrf`, `api_route_forbids_raw_sql_without_params`, `api_route_cors_must_match_policy`, `api_route_requires_csrf_for_mutation`, `api_route_requires_rate_limit` | `ssrf`, `raw_sql`, `cors`, `csrf`, `rate_limit` | +| P7 | Are new security conventions proposed safely? | Candidate rows, not accepted contracts | `convention_candidates`, `reason_not_blocking` | +| P8 | Can CLI and MCP explain proof-backed security state? | Read model over stored proof runs | `security_capabilities`, `routes[].security`, `drift.security.context.v2` | + +## Data Flow + +```mermaid +flowchart LR + A["Repo source files"] --> B["Rust scan/check engine"] + B --> C["Facts and graph"] + B --> D["SecurityBoundaryProof"] + D --> E["SQLite proof tables"] + E --> F["Query read model"] + F --> G["CLI JSON and human output"] + F --> H["MCP read-only tools"] + C --> I["Candidate inference"] + I --> J["Human accepts/rejects candidate"] + J --> K["Accepted repo contract"] + K --> B +``` + +## Runtime Authority + +Rust owns: + +- Security fact extraction. +- Route/method/path binding. +- Guard dominance and sink reachability. +- Missing proof and parser gap decisions. +- Blocking findings for deterministic accepted contracts. +- Proof payloads for P1-P6. + +TypeScript owns: + +- Contract schema validation. +- SQLite persistence. +- Query read models. +- CLI and MCP formatting. +- Candidate election lifecycle. +- Release and beta proof checks. + +TypeScript must not: + +- Treat raw facts as deterministic security proof. +- Promote candidate evidence into blocking proof. +- Synthesize a passed proof when Rust did not emit one. + +## Proof Shape + +The shared proof object is `SecurityBoundaryProof` with: + +- `proof_id`, `proof_version` +- `route`: route id, file path, role, endpoint path/method +- `contracts`: matched accepted contracts +- `capability_status`: complete/partial/unsupported/failed +- proof sections: `auth`, `middleware`, `request_validation`, `session_trust`, `authorization`, `tenant`, `response_shape`, `sinks`, `ssrf`, `raw_sql`, `cors`, `csrf`, `rate_limit` +- `missing_proof`: proof failures with fact ids +- `parser_gaps`: unsupported shapes that block deterministic proof +- `evidence_refs`: sanitized line-level references only +- `result`: `proven`, `missing_proof`, `parser_gap`, `violated`, or `advisory_only` + +## Storage + +The important security tables are: + +| Table | Purpose | +| --- | --- | +| `security_boundary_proofs` | Scan-scoped proof snapshots kept for compatibility. | +| `security_boundary_proof_runs` | Check-run-scoped proof truth used by Phase 8 surfaces. | +| `scan_capability_reports` | Scanner/graph capability diagnostics. Not proof truth. | +| `convention_candidates` | Candidate conventions. Advisory until accepted. | +| `accepted_conventions` | Human-approved contracts that Rust can enforce. | +| `findings` | Stored check findings and lifecycle state. | + +Migration `025_security_boundary_proof_runs` adds proof-run storage keyed by `(check_id, proof_id)` and indexed by repo/scan, repo/check, and repo/route. + +## Main Read Models + +| Surface | Source of security truth | +| --- | --- | +| `drift check --json` | Fresh Rust check result plus `security_boundary_proofs`. | +| Human `drift check` | Same proof payload, rendered as route/file/reason/evidence/capability/next command blocks. | +| `drift scan status --json` | Latest proof-run summaries plus diagnostic capability report. | +| `drift repo map --json` | Route map plus proof-backed `routes[].security`. | +| MCP `get_security_context` | `drift.security.context.v2` from the shared Phase 8 query read model. | +| MCP `get_repo_map` | Same route security model as CLI repo map. | + +## Output Boundary + +Phase 8 security outputs are designed to expose metadata, not source: + +- Allowed: route id, route file path, method/path, line numbers, fact ids, capability names, missing proof codes, parser gap codes, finding ids. +- Not allowed: source snippets, secret values, raw request payloads, headers, raw SQL strings, raw URLs, environment values, tokens, user ids, tenant ids, full source. + +## Current Important Files + +| Area | Files | +| --- | --- | +| Rust check routing | `crates/drift-engine/src/check_command.rs` | +| Phase 6 Rust proof | `crates/drift-engine/src/security_phase6.rs` | +| Capability registry | `crates/drift-engine/src/security_capabilities.rs` | +| Shared schemas | `packages/core/src/security.ts`, `packages/engine-contract/src/index.ts` | +| Storage | `packages/storage/src/migrations.ts`, `packages/storage/src/sqlite-storage.ts` | +| Query read model | `packages/query/src/security-boundary-proof.ts` | +| CLI check/status/map | `packages/cli/src/check/run-check.ts`, `packages/cli/src/domain/scan-status.ts`, `packages/cli/src/domain/repo-map.ts`, `packages/cli/src/formatters/checks.ts` | +| MCP | `packages/mcp/src/security-context.ts`, `packages/mcp/src/index.ts` | +| Release proof | `scripts/run-beta-proof.mjs`, `scripts/generate-release-proof.mjs` | + +## Current Risk Notes + +- `security_boundary_proofs` remains for old scan-scoped rows. New Phase 8 truth should prefer `security_boundary_proof_runs`. +- Legacy MCP security context v1 still exists in code, but v2 is the proof-backed agent surface. +- Capability reports are useful diagnostics, but they are not proof that a route is safe. +- Candidate-sensitive fields are blocked from backing imported blocking sensitive-response contracts. diff --git a/drift v3/docs/architecture/security-boundary-p1-p8-operations-and-review.md b/drift v3/docs/architecture/security-boundary-p1-p8-operations-and-review.md new file mode 100644 index 00000000..17bdb809 --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-p1-p8-operations-and-review.md @@ -0,0 +1,124 @@ +# Security Boundary P1-P8 Operations And Review Notes + +Use this when reviewing, releasing, or explaining the security-boundary system. + +## Commands That Matter + +| Command | What it proves | +| --- | --- | +| `drift scan --json` | Rust can index the repo, emit facts/graph, store candidates, and write capability diagnostics. | +| `drift check --json` | Rust can enforce accepted deterministic contracts and return proof payloads. | +| `drift check` | Human output can explain security blocks without leaking source. | +| `drift scan status --json` | Stored repo state can report Phase 8 `security_capabilities[]`. | +| `drift repo map --json` | Route map can show proof-backed `routes[].security`. | +| `drift candidates --json` | Candidate UX works without making candidates proof truth. | +| MCP `get_security_context` | Agents receive `drift.security.context.v2` from proof/query read models. | +| MCP `get_repo_map` | MCP route security matches CLI repo map semantics. | + +## Final Gates Used On This Branch + +```bash +pnpm verify:ci +git diff --check +``` + +`pnpm verify:ci` runs build, typecheck, Rust tests, package tests, e2e tests, format check, clippy, boundary checks, release matrix validation, product claim validation, beta proof, and diff whitespace check. + +## Review Checklist + +1. Check that every blocking security finding came from a `SecurityBoundaryProof`. +2. Check that the matched contract is accepted and `deterministic_check`. +3. Check that candidates have `reason_not_blocking` and do not fail checks. +4. Check that `security_boundary_proof_runs` has the latest check proof rows. +5. Check that `scan status`, `repo map`, and MCP use the shared query read model. +6. Check that output includes line-level metadata only, not source values. +7. Check old-row compatibility by loading `security_boundary_proofs` when no proof runs exist. +8. Check release proof parity between CLI and MCP. + +## Security Output Rules + +Allowed in CLI/MCP output: + +- File path. +- Route path and method. +- Line numbers. +- Fact ids. +- Finding ids. +- Missing proof codes. +- Parser gap codes. +- Capability names. +- Enforcement result. + +Not allowed in CLI/MCP proof context: + +- Source snippets. +- Full source. +- Raw SQL strings. +- Raw URLs. +- Request payloads. +- Headers. +- Cookies. +- Environment values. +- Tokens. +- User ids. +- Tenant ids. +- Secret values. + +## Backward Compatibility Rules + +- `security_boundary_proofs` is still readable for older scan-scoped rows. +- `security_boundary_proof_runs` is preferred for Phase 8 because it is check-run-scoped. +- Old proof rows may have optional endpoint path/method fields; new Rust output should populate them for supported routes. +- Capability reports remain available for diagnostics, but Phase 8 route security should not treat them as proof. + +## Known Review Pressure Points + +| Area | Why to re-check | +| --- | --- | +| MCP security context | v2 must stay proof-backed and must not regain raw-fact proof sections. | +| CLI/MCP parity | The beta proof normalizes sanitized MCP findings before comparing with CLI. Keep that intentional. | +| Phase 5 contracts | Blocking sensitive response contracts must not be backed by candidate sensitive fields. | +| Parser gaps | A parser gap can block deterministic proof; it should not be converted into a pass. | +| Capability status | `can_block` should mean deterministic accepted block capability, not general support. | +| Route normalization | Next route groups like `(admin)` should not change the public route path. | + +## Minimal Architecture Audit Command Set + +```bash +git status --short --branch +git diff --stat origin/main...HEAD +git diff --check +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +pnpm verify:ci +``` + +## Useful Source Anchors + +| Question | Source | +| --- | --- | +| What security contract kinds exist? | `packages/core/src/security.ts` | +| What proof shape is accepted? | `packages/core/src/security.ts` | +| What Rust conventions route into proof builders? | `crates/drift-engine/src/check_command.rs` | +| How are P6 proofs built? | `crates/drift-engine/src/security_phase6.rs` | +| Where are proof runs stored? | `packages/storage/src/migrations.ts`, `packages/storage/src/sqlite-storage.ts` | +| What does CLI/MCP consume? | `packages/query/src/security-boundary-proof.ts` | +| What does MCP expose to agents? | `packages/mcp/src/security-context.ts` | +| What proves release parity? | `scripts/run-beta-proof.mjs` | + +## Current Merge Readiness + +The Phase 8 branch is `codex/security-phase8-production`. + +Manual PR URL: + +`https://github.com/dadbodgeoff/drift/compare/main...codex/security-phase8-production?expand=1` diff --git a/drift v3/docs/architecture/security-boundary-p1-p8-simple-visual.md b/drift v3/docs/architecture/security-boundary-p1-p8-simple-visual.md new file mode 100644 index 00000000..b1e2c894 --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-p1-p8-simple-visual.md @@ -0,0 +1,84 @@ +# Security Boundary P1-P8 Simple Visual + +This is the simple version. + +```text +You have an API route. + +Drift asks: + +1. Who can call it? +2. Did middleware really cover it? +3. Was request input checked before use? +4. Is tenant/user access proven? +5. Could sensitive data leak out? +6. Could it call unsafe URLs, SQL, CORS, CSRF, or rate-limit paths? + +Rust answers with proof. +CLI and MCP show the proof. +Only accepted rules can block. +``` + +## Picture + +```mermaid +flowchart TD + A["API route file"] --> B["Rust reads the code"] + B --> C["Rust makes facts"] + C --> D["Rust checks accepted security rules"] + D --> E{"Can Rust prove it?"} + E -->|Yes| F["Proof: proven"] + E -->|No, code is unsafe| G["Proof: missing_proof or violated"] + E -->|No, code shape is too dynamic| H["Proof: parser_gap"] + F --> I["CLI/MCP say route is proven"] + G --> J["CLI/MCP show what proof is missing"] + H --> K["CLI/MCP show what code shape blocked proof"] + J --> L["Check can block if rule is accepted and deterministic"] + K --> L +``` + +## Tiny Legend + +| Word | Plain meaning | +| --- | --- | +| Contract | A security rule a human accepted. | +| Candidate | A possible rule Drift noticed. It is not active yet. | +| Proof | Rust's route-level answer. | +| Missing proof | The rule applies, but Drift could not find the required safe guard. | +| Parser gap | The code is too dynamic for deterministic proof. | +| Capability | The type of thing Drift knows how to prove. | +| Read model | A clean summary made from stored proof. | + +## What The User Sees + +```text +drift check --json + -> full proof payload + +drift check + -> simple BLOCK/WARN blocks + +drift scan status --json + -> what security capabilities are complete, partial, missing, unsupported + +drift repo map --json + -> each route gets a security summary + +MCP get_security_context + -> agent-safe proof summary, no source snippets +``` + +## What Is Not Happening + +```text +Raw scan fact -> block +Candidate -> block +TypeScript guess -> block +MCP raw facts -> proof +``` + +Those paths are intentionally not trusted. + +## The Whole Addition In One Sentence + +Drift now turns accepted security rules into Rust-generated route proofs, stores those proofs, and gives humans and agents a safe explanation of which API routes are proven, missing proof, or blocked by parser gaps. diff --git a/drift v3/docs/architecture/security-boundary-p1-p8-system-hierarchy.md b/drift v3/docs/architecture/security-boundary-p1-p8-system-hierarchy.md new file mode 100644 index 00000000..5b1b78b3 --- /dev/null +++ b/drift v3/docs/architecture/security-boundary-p1-p8-system-hierarchy.md @@ -0,0 +1,152 @@ +# Security Boundary P1-P8 System Hierarchy + +This is the implemented stack from lowest-level proof authority to user-facing output. + +```text +Drift Security Boundary System +| +|-- 1. Rust proof engine +| | +| |-- File and route facts +| | |-- route_declared +| | |-- file_role_detected +| | |-- import_used +| | |-- data_operation_detected +| | |-- route_returns_response +| | |-- request_input_read +| | |-- request_validation_called +| | |-- validated_input_used +| | |-- session_read +| | |-- authorization_guard_called +| | |-- tenant_source +| | |-- tenant_guard_called +| | |-- outbound_request_called +| | |-- raw_sql_called +| | |-- cors_policy_declared +| | |-- csrf_guard_called +| | |-- rate_limit_guard_called +| | |-- response_emits_field +| | |-- sensitive_field_declared +| | |-- serializer_called +| | `-- secret_read +| | +| |-- Proof families +| | |-- P1 auth dominance +| | |-- P2 middleware route coverage +| | |-- P3 request validation before sink +| | |-- P4 session trust, authorization, tenant scope +| | |-- P5 sensitive response and secret exposure +| | `-- P6 SSRF, raw SQL, CORS, CSRF, rate limit +| | +| |-- Proof result states +| | |-- proven +| | |-- missing_proof +| | |-- parser_gap +| | |-- violated +| | `-- advisory_only +| | +| `-- Blocking rule +| `-- Only deterministic accepted contracts can block. +| +|-- 2. Contract and candidate layer +| | +| |-- Accepted contracts +| | |-- Human-approved +| | |-- Stored in repo contract state +| | |-- Passed into Rust check +| | `-- Can block only when deterministic +| | +| `-- Candidates +| |-- Proposed from scan/candidate inference +| |-- Stored as convention_candidates +| |-- Carry reason_not_blocking +| |-- Require accept/reject/edit +| `-- Cannot block before acceptance +| +|-- 3. Storage layer +| | +| |-- security_boundary_proof_runs +| | |-- check_id +| | |-- proof_id +| | |-- repo_id +| | |-- scan_id +| | |-- route_id +| | |-- proof_status +| | |-- enforcement_result +| | |-- parser_gap_count +| | |-- missing_proof_count +| | `-- proof_json +| | +| |-- security_boundary_proofs +| | `-- older scan-scoped proof rows +| | +| |-- findings +| | `-- persisted violation lifecycle +| | +| `-- scan_capability_reports +| `-- diagnostics, not proof truth +| +|-- 4. Query/read-model layer +| | +| |-- buildSecurityPhase8ReadModel +| | |-- routes[].security +| | |-- repo_security_contracts +| | |-- changed_route_security +| | |-- required_proofs +| | |-- current_proof_status +| | |-- missing_proof_summaries +| | |-- parser_gap_summaries +| | `-- security_capabilities[] +| | +| `-- Safety rule +| `-- Read models summarize proof; they do not create proof. +| +|-- 5. CLI surfaces +| | +| |-- drift check --json +| | `-- full check result plus security_boundary_proofs +| | +| |-- drift check +| | `-- human proof blocks +| | +| |-- drift scan status --json +| | `-- security_capabilities[] from proof runs +| | +| |-- drift repo map --json +| | `-- proof-backed routes[].security +| | +| `-- drift candidates +| `-- alias over convention candidate review +| +`-- 6. MCP surfaces + | + |-- get_security_context + | `-- drift.security.context.v2 + | + |-- get_repo_map + | `-- CLI parity for proof-backed routes + | + |-- get_scan_status + | `-- scan status with Phase 8 capability summaries + | + `-- get_findings + `-- sanitized finding DTOs only +``` + +## Layer Ownership + +| Layer | Can decide block/pass? | Can expose to agent? | +| --- | --- | --- | +| Rust proof engine | Yes | Through sanitized proof payload only | +| Accepted contracts | Yes, by telling Rust what to prove | Yes, without personal actor fields | +| Candidates | No | Yes, as candidate/election data | +| Storage | No | Yes, after schema validation | +| Query read model | No | Yes, proof summaries only | +| CLI/MCP | No | Yes, sanitized summaries only | + +## One-Line Mental Model + +```text +Accepted contract + Rust proof = enforceable security truth. +Everything else is context. +``` diff --git a/drift v3/docs/superpowers/plans/2026-05-25-security-boundary-p1-corrective-plan.md b/drift v3/docs/superpowers/plans/2026-05-25-security-boundary-p1-corrective-plan.md new file mode 100644 index 00000000..5e010f78 --- /dev/null +++ b/drift v3/docs/superpowers/plans/2026-05-25-security-boundary-p1-corrective-plan.md @@ -0,0 +1,1016 @@ +# Security Boundary Phase 1 Corrective Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:test-driven-development` plus `superpowers:executing-plans` or `superpowers:subagent-driven-development` to execute this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Correct PR #77 so Phase 1 auth-boundary enforcement is actually production-ready and matches `docs/architecture/security-boundary-enforcement-100-tdd.md`. + +**Architecture:** Rust must be the deterministic authority for facts, accepted-pattern normalization, control-flow proof, parser gaps, missing proofs, and blocking rule evaluation. TypeScript must only carry product/control-plane responsibilities: schema validation, contract transport, storage/query/MCP/CLI envelopes, governance, lifecycle, and output formatting. `check_repo` may dispatch and map results, but it must not rebuild security proof inline. + +**Tech Stack:** Rust `drift-engine`, TypeScript workspace packages, SQLite storage/query surfaces, CLI/MCP, Vitest, Cargo tests, fixture-driven e2e. + +--- + +## Current Verdict + +PR #77 is blocked. Do not start Phase 2. Do not add middleware coverage. Correct Phase 1 first. + +The core failure is architectural: the real product path added in PR #77 does not consume the Rust proof/control-flow authority it introduced. `crates/drift-engine/src/check_command.rs` reconstructs auth proof inline from file-level line ordering and raw symbol names, which creates false passes and bypasses parser-gap/missing-proof semantics. + +## Non-Negotiable Corrective Rules + +- No production code before a failing test proves the defect. +- Every corrective item must follow RED -> GREEN -> REFACTOR. +- Do not keep inline auth proof logic in `check_command.rs`. +- Do not trust raw `symbol_called` names as accepted auth helpers. +- Do not let candidate-only or heuristic evidence block. +- Do not silently pass unsupported control flow. +- Do not overstate capabilities. +- Do not persist or expose proof summaries until proof shape is stable and schema-validated. +- Do not add Phase 2 work in this correction branch. +- Do not include source snippets, request payloads, header/cookie values, secret values, raw SQL values, env values, or tokens in outputs. + +## Correct Target Shape + +`check_repo` security auth flow must be: + +```text +CheckRequest + -> canonical/legacy contract normalization in Rust + -> neutral facts grouped by route handler + -> accepted auth helper normalization + -> file-local control-flow proof + -> SecurityBoundaryProof with missing_proof/parser_gaps + -> deterministic rule evaluation + -> CheckFinding + proof output + -> TypeScript mapping/lifecycle/output without duplicating security logic +``` + +`check_repo` must not: + +- choose the first guard in a file and apply it to every route +- treat a `GET` guard as proof for `POST` +- let a branch-local guard dominate a bypass path +- let a callback-local guard dominate an outer sink +- mark `parser_gaps: []` by default +- treat `symbol_called("requireUser")` as trusted without accepted contract normalization + +## Corrective Finding Map + +| Finding | Corrective tasks | +| --- | --- | +| File-level first guard proves whole file | Tasks 1, 4, 5 | +| Raw `symbol_called` name trusted | Tasks 2, 3, 5 | +| Parser gaps hard-coded empty | Tasks 4, 6 | +| Canonical contract fields not passed | Tasks 2, 3 | +| Scan uses `extract_security_facts(..., &[])` | Tasks 3, 5 | +| Endpoint/method exceptions not honored | Task 7 | +| Waivers dropped before Rust/auth mapping | Task 8 | +| `if` without `else` not modeled | Task 4 | +| Capabilities still direct-data-access only | Task 10 | +| Parser-gap schema accepts arbitrary records | Task 2 | +| Query/storage/MCP not durable truth | Task 11 | +| E2E only checks fixture existence | Task 9 | +| Golden count-only update masks behavior | Task 9 | + +## Files And Responsibilities + +### Rust + +- `crates/drift-engine/src/security_patterns.rs` + - Normalize accepted auth helper contracts. + - Normalize canonical `requires.auth_helpers`. + - Normalize legacy `matcher.required_calls` only through compatibility-tested code. + - Resolve trusted guard calls from import facts and accepted contract input. + +- `crates/drift-engine/src/security_facts.rs` + - Extract neutral facts only. + - It may emit calls, response sinks, callback-boundary indicators, dynamic-flow indicators, and route-related evidence. + - It must not decide whether a call is trusted unless passed explicit accepted contract input by the check path. + +- `crates/drift-engine/src/security_control_flow.rs` + - Own handler-local dominance and path-sensitive summaries for Phase 1. + - Model the Phase 1 required cases: straight-line, guard after sink, branch bypass, `if` without `else`, callback boundary, unsupported dynamic control flow. + +- `crates/drift-engine/src/security_proof.rs` + - Build `SecurityBoundaryProof`. + - Emit `missing_proof`. + - Emit `parser_gaps`. + - Never silently pass unsupported cases. + +- `crates/drift-engine/src/security_rules.rs` + - Evaluate deterministic auth contracts from proof results. + - Candidate-only and heuristic evidence cannot block. + +- `crates/drift-engine/src/check_command.rs` + - Dispatch only. + - Convert `CheckRequest` to normalized security inputs. + - Call `security_patterns`, `security_control_flow`, `security_proof`, and `security_rules`. + - Map returned findings/proofs into `CheckResult`. + - No inline auth line-order proof logic. + +- `crates/drift-engine/src/protocol.rs` + - Carry canonical security contract fields, legacy compatibility fields, exceptions, and waivers if Rust needs them for deterministic decisions. + - Preserve schema versions. + +### TypeScript + +- `packages/engine-contract/src/index.ts` + - Strictly validate security proof, parser gap, missing proof, and check result schemas. + - Reject malformed parser gaps. + - Carry canonical contract fields without flattening away semantics. + +- `packages/core/src/security.ts`, `packages/core/src/domain.ts`, `packages/core/src/schemas.ts` + - Define canonical security contract and proof shapes. + - Validate that blocking security contracts require deterministic capability. + - Preserve legacy compatibility only where explicitly tested. + +- `packages/cli/src/engine/engine-check.ts` + - Send full accepted contract input needed by Rust. + - Do not hard-code security contract schema behavior. + - Do not drop exceptions/waivers where Rust needs them. + +- `packages/cli/src/check/run-check.ts` + - Call engine-owned auth checks. + - Apply existing finding lifecycle/governance conventions without duplicating Rust rule logic. + - Map proof/finding output into CLI payloads. + +- `packages/cli/src/check/security-check.ts` + - Output shaping only. + - No deterministic security decisions. + +- `packages/query/src/security-boundary-proof.ts` + - Read model only after proof shape is stable. + +- `packages/storage/src/migrations.ts`, `packages/storage/src/sqlite-storage.ts` + - Add persistence only after proof shape is corrected and schema-stable. + +- `packages/mcp/src/security-context.ts` + - Read-only proof summaries only after query/storage truth exists. + - No duplicated rule logic. + +## Task 0: Freeze Scope And Baseline + +**Files:** +- Read: `AGENTS.md` +- Read: `docs/architecture/security-boundary-enforcement-100-tdd.md` +- Read: current PR #77 diff + +- [ ] **Step 0.1: Confirm branch and dirty state** + +Run: + +```bash +git status --short --branch +``` + +Expected: identify all dirty files before touching anything. Treat unrelated dirty files as user work. + +- [ ] **Step 0.2: Confirm corrective scope** + +Write down this boundary in the implementation notes: + +```text +Correct Phase 1 only. Do not implement Phase 2 middleware coverage. +``` + +- [ ] **Step 0.3: Record existing blocked findings** + +Create or update the PR notes with the 13 findings from the blocking audit. Each finding must map to a task in this plan. + +## Task 1: Product-Path RED Tests For False Passes + +**Files:** +- Create or modify: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `test/e2e/security-auth.test.ts` +- Use fixtures under: `test/fixtures/security-auth-*` + +- [ ] **Step 1.1: Add Rust `check_repo` RED test for multi-handler false pass** + +Test name: + +```rust +check_repo_does_not_use_get_auth_guard_for_unguarded_post +``` + +Scenario: + +```text +app/api/projects/route.ts: +- GET calls accepted auth helper before sinks. +- POST reaches a data operation/response sink without auth. +- Accepted blocking `api_route_requires_auth_helper` contract applies to GET and POST. +``` + +Expected RED command: + +```bash +cargo test -p drift-engine check_repo_does_not_use_get_auth_guard_for_unguarded_post -- --nocapture +``` + +Expected RED failure: PR #77 reports no blocking POST auth finding because `check_command.rs` uses first file-level guard line. + +- [ ] **Step 1.2: Add Rust `check_repo` RED test for branch bypass** + +Test name: + +```rust +check_repo_blocks_auth_guard_in_only_one_branch +``` + +Expected RED command: + +```bash +cargo test -p drift-engine check_repo_blocks_auth_guard_in_only_one_branch -- --nocapture +``` + +Expected RED failure: PR #77 marks the route proven because first guard line appears before sink. + +- [ ] **Step 1.3: Add Rust `check_repo` RED test for callback guard** + +Test name: + +```rust +check_repo_blocks_callback_auth_guard_for_outer_sink +``` + +Expected RED command: + +```bash +cargo test -p drift-engine check_repo_blocks_callback_auth_guard_for_outer_sink -- --nocapture +``` + +Expected RED failure: PR #77 marks callback guard as dominating an outer sink. + +- [ ] **Step 1.4: Add Rust `check_repo` RED test for `if` without `else`** + +Test name: + +```rust +check_repo_blocks_conditional_guard_without_else_before_sink +``` + +Expected RED command: + +```bash +cargo test -p drift-engine check_repo_blocks_conditional_guard_without_else_before_sink -- --nocapture +``` + +Expected RED failure: PR #77 does not model the path that skips the guard. + +- [ ] **Step 1.5: Add CLI product-path RED test for real `drift check --json`** + +Test file: `packages/cli/test/security-check.test.ts` + +Test name: + +```ts +it("runs auth dominance through Rust proof authority in drift check JSON output", async () => { + // fixture must run through runCheck, not build synthetic output +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED failure: output lacks correct parser gap/missing proof/finding for at least one false-pass case. + +## Task 2: Strict Contract And Engine Schema Transport + +**Files:** +- Modify: `packages/core/src/security.ts` +- Modify: `packages/core/src/domain.ts` +- Modify: `packages/core/src/schemas.ts` +- Modify: `packages/engine-contract/src/index.ts` +- Modify: `packages/engine-contract/test/security-contract.test.ts` +- Modify: `packages/core/test/security.test.ts` +- Modify: `crates/drift-engine/src/protocol.rs` +- Modify: `packages/cli/src/engine/engine-check.ts` + +- [ ] **Step 2.1: Add RED test for canonical `requires.auth_helpers`** + +Test name: + +```ts +it("sends canonical security contract requires.auth_helpers to the Rust check request", () => { + // engineCheckRequest must preserve requires.auth_helpers, scope, exceptions, capability, and schema version +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED failure: request only carries legacy matcher shape and loses canonical fields. + +- [ ] **Step 2.2: Add RED schema test rejecting arbitrary parser gaps** + +Test name: + +```ts +it("rejects malformed security parser gaps in engine check results", () => { + // parser_gaps: [{ anything: true }] must fail +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected RED failure: `parser_gaps` currently accepts arbitrary records. + +- [ ] **Step 2.3: Implement strict schema transport** + +Implementation requirements: + +- Add canonical `requires`, `forbids`, `scope`, `exceptions`, and `governance` fields to engine check request schema where needed. +- Preserve `contract_schema_version`. +- Keep legacy `matcher.required_calls` only as compatibility input. +- Replace loose parser-gap records with strict parser-gap schema. + +Expected GREEN commands: + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- security-contract +pnpm --filter @drift/cli test -- security-check +``` + +## Task 3: Accepted Auth Helper Normalization + +**Files:** +- Modify: `crates/drift-engine/src/security_patterns.rs` +- Modify: `crates/drift-engine/src/security_facts.rs` +- Modify: `crates/drift-engine/tests/security_facts.rs` +- Modify: `crates/drift-engine/tests/security_rules.rs` +- Modify: `crates/drift-engine/src/protocol.rs` + +- [ ] **Step 3.1: Add RED test for canonical accepted helper** + +Test name: + +```rust +canonical_requires_auth_helpers_normalizes_trusted_guard_calls +``` + +Expected RED command: + +```bash +cargo test -p drift-engine canonical_requires_auth_helpers_normalizes_trusted_guard_calls -- --nocapture +``` + +Expected RED failure: canonical `requires.auth_helpers` is ignored. + +- [ ] **Step 3.2: Add RED test for import alias** + +Test name: + +```rust +accepted_auth_helper_import_alias_is_trusted +``` + +Scenario: + +```text +import { requireUser as requireAuth } from "@/auth"; +await requireAuth(); +``` + +Expected RED command: + +```bash +cargo test -p drift-engine accepted_auth_helper_import_alias_is_trusted -- --nocapture +``` + +Expected RED failure: PR #77 scan/check path does not trust accepted helper aliases. + +- [ ] **Step 3.3: Add RED test for name-only non-contract helper** + +Test name: + +```rust +name_only_auth_looking_helper_cannot_satisfy_or_block +``` + +Expected RED command: + +```bash +cargo test -p drift-engine name_only_auth_looking_helper_cannot_satisfy_or_block -- --nocapture +``` + +Expected RED failure: raw `symbol_called` name can satisfy auth in PR #77. + +- [ ] **Step 3.4: Implement normalization** + +Implementation requirements: + +- `security_patterns.rs` returns trusted guard calls only from accepted contract input plus import facts. +- `security_facts.rs` emits neutral facts; it does not decide final trust from names. +- Legacy `matcher.required_calls` must normalize into the same accepted helper representation and be covered by tests. + +Expected GREEN command: + +```bash +cargo test -p drift-engine security_ +``` + +## Task 4: Route-Scoped Control Flow And Parser Gaps + +**Files:** +- Modify: `crates/drift-engine/src/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` +- Modify: `crates/drift-engine/tests/security_control_flow.rs` +- Modify: `crates/drift-engine/tests/security_rules.rs` + +- [ ] **Step 4.1: Add RED tests for route-scoped handlers** + +Required test names: + +```rust +separates_get_and_post_auth_proofs_in_one_route_file +conditional_guard_without_else_does_not_dominate_later_sink +callback_guard_does_not_dominate_outer_sink_in_check_proof +dynamic_control_flow_creates_parser_gap_in_check_proof +``` + +Expected RED command: + +```bash +cargo test -p drift-engine security_control_flow -- --nocapture +``` + +Expected RED failure: current proof logic is file/line based and parser gaps do not flow through the check proof. + +- [ ] **Step 4.2: Implement route-scoped summaries** + +Implementation requirements: + +- Build one proof per route handler/export. +- Attach sinks to the handler they occur in. +- A guard in `GET` cannot dominate `POST`. +- A guard inside callback cannot dominate outer sink. +- A guard inside `if` without a guaranteed else/path cannot dominate later sink. +- Unsupported dynamic control flow emits parser gap and missing proof under blocking contract. + +Expected GREEN command: + +```bash +cargo test -p drift-engine security_control_flow -- --nocapture +``` + +## Task 5: Replace Inline Auth Proof In `check_command.rs` + +**Files:** +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` +- Modify: `crates/drift-engine/src/security_rules.rs` +- Modify: `crates/drift-engine/tests/security_check_repo_auth.rs` + +- [ ] **Step 5.1: Delete the inline first-guard auth proof path** + +Remove any `check_command.rs` logic that: + +- computes `first_guard_line` +- builds `dominated_sinks` from only line comparison +- hard-codes `parser_gaps: []` +- trusts raw `SymbolCalled` names as guards + +- [ ] **Step 5.2: Add a focused assertion that `check_repo` uses module proof** + +Test name: + +```rust +check_repo_uses_security_proof_parser_gaps_and_missing_proofs +``` + +Expected RED command before implementation: + +```bash +cargo test -p drift-engine check_repo_uses_security_proof_parser_gaps_and_missing_proofs -- --nocapture +``` + +Expected RED failure: `CheckResult.security_boundary_proofs[0].parser_gaps` is empty for unsupported dynamic control flow. + +- [ ] **Step 5.3: Implement dispatcher-only `check_command.rs`** + +Implementation requirements: + +- Convert `CheckConvention` into normalized security auth contract input. +- Pass facts and contract to `security_proof.rs`. +- Pass proof to `security_rules.rs`. +- Map returned findings/proofs into `CheckResult`. +- Keep direct-data-access and service-delegation behavior unchanged. + +Expected GREEN commands: + +```bash +cargo test -p drift-engine check_repo_uses_security_proof_parser_gaps_and_missing_proofs -- --nocapture +cargo test -p drift-engine graph_backed_check +cargo test -p drift-engine security_ +``` + +## Task 6: Product Output Parser Gaps And Missing Proofs + +**Files:** +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `packages/cli/src/check/run-check.ts` +- Modify: `packages/cli/src/check/security-check.ts` +- Modify: `packages/engine-contract/src/index.ts` + +- [ ] **Step 6.1: Add RED test for parser gap in `drift check --json`** + +Test name: + +```ts +it("returns parser-gap-backed auth proof from drift check json for dynamic control flow", async () => { + // run through runCheck or CLI, not synthetic proof JSON +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED failure: parser gap is missing or malformed in real check JSON. + +- [ ] **Step 6.2: Add RED test for missing proof in `drift check --json`** + +Test name: + +```ts +it("returns missing-proof-backed auth finding from drift check json", async () => { + // assert missing_proof code, finding id, contract id, route file, enforcement result +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED failure: output lacks complete missing proof/finding linkage. + +- [ ] **Step 6.3: Implement output mapping** + +Implementation requirements: + +- Preserve proof ID, proof status, parser gap IDs, missing proof IDs, finding IDs, route file, contract ID, and capability status. +- Do not include snippets or source values. + +Expected GREEN command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +## Task 7: Exceptions And Public Route Handling + +**Files:** +- Modify: `crates/drift-engine/src/protocol.rs` +- Modify: `crates/drift-engine/src/security_rules.rs` +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `packages/cli/src/engine/engine-check.ts` +- Modify: `packages/cli/test/security-check.test.ts` + +- [ ] **Step 7.1: Add RED test for method-specific exception** + +Test name: + +```ts +it("does not block an auth finding when a method-specific public route exception applies", async () => { + // GET public exception must not suppress POST unless POST is excepted too +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED failure: exception handling is path-only or dropped before Rust. + +- [ ] **Step 7.2: Implement exception transport and evaluation** + +Implementation requirements: + +- Transport path, endpoint, method, helper, and reason fields needed by Rust. +- Do not suppress sibling methods or sibling routes. +- Expired exceptions must not suppress. + +Expected GREEN command: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +## Task 8: Waiver, Baseline, Lifecycle, And Persistence Semantics + +**Files:** +- Modify: `packages/cli/src/check/run-check.ts` +- Modify: `packages/cli/test/cli.test.ts` +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `crates/drift-engine/src/protocol.rs` only if Rust needs waiver context before finding emission + +- [ ] **Step 8.1: Add RED test for active waiver suppression** + +Test name: + +```ts +it("honors active auth waivers during checks and reports waived security findings", async () => { + // use real check path and stored waiver +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- "auth waivers" +``` + +Expected RED failure: auth mapping persists a finding instead of applying waiver semantics. + +- [ ] **Step 8.2: Add RED test for baseline `pre_existing`** + +Test name: + +```ts +it("marks existing auth findings as pre_existing from baseline", async () => { + // use real baseline table and auth finding fingerprint +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- "existing auth findings" +``` + +Expected RED failure: auth findings ignore existing baseline semantics. + +- [ ] **Step 8.3: Implement lifecycle integration** + +Implementation requirements: + +- Use existing waiver/baseline/finding status helpers. +- Preserve `accepted_drift`, `suppressed`, `fixed`, and human-governed statuses where existing code does so. +- Do not create a parallel lifecycle model for auth. + +Expected GREEN command: + +```bash +pnpm --filter @drift/cli test -- security-check +pnpm --filter @drift/cli test +``` + +## Task 9: Real E2E Fixture Matrix + +**Files:** +- Modify: `test/e2e/security-auth.test.ts` +- Modify fixtures: + - `test/fixtures/security-auth-missing` + - `test/fixtures/security-auth-before-sink` + - `test/fixtures/security-auth-after-data` + - `test/fixtures/security-auth-branch-bypass` + - `test/fixtures/security-auth-callback-bypass` + - `test/fixtures/security-dynamic-control-flow` +- Modify: `test/e2e/golden.test.ts` + +- [ ] **Step 9.1: Replace fixture-existence test with real enforcement matrix** + +Test name: + +```ts +it("runs Phase 1 auth fixtures through real drift check enforcement", async () => { + // each fixture must run scan/start or seeded contract + check path and assert outcome +}); +``` + +Expected RED command: + +```bash +pnpm test:e2e -- security-auth +``` + +Expected RED failure: fixture harness does not yet run real checks or expected outcomes fail. + +- [ ] **Step 9.2: Assert each fixture outcome** + +Required fixture assertions: + +- `security-auth-missing`: blocks with `missing_auth_guard` +- `security-auth-before-sink`: passes +- `security-auth-after-data`: blocks with `guard_after_sink` +- `security-auth-branch-bypass`: blocks with branch bypass/missing proof +- `security-auth-callback-bypass`: blocks with callback boundary +- `security-dynamic-control-flow`: blocks with parser gap + +- [ ] **Step 9.3: Strengthen golden coverage** + +Replace count-only golden validation with assertions that identify the new fact/proof behavior. The golden test must not only assert `facts_count`. + +Expected GREEN command: + +```bash +pnpm test:e2e -- security-auth +pnpm test:e2e -- golden +``` + +## Task 10: Capability Truth + +**Files:** +- Modify: `crates/drift-engine/src/security_capabilities.rs` +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/tests/security_capabilities.rs` +- Modify: `packages/cli/src/domain/scan-status.ts` only if check/scan status exposes security capabilities + +- [ ] **Step 10.1: Add RED test for auth capabilities in check result** + +Test name: + +```rust +check_repo_reports_auth_security_capabilities_when_auth_contract_runs +``` + +Expected RED command: + +```bash +cargo test -p drift-engine check_repo_reports_auth_security_capabilities_when_auth_contract_runs -- --nocapture +``` + +Expected RED failure: result stats/completeness only report `direct_data_access_check`. + +- [ ] **Step 10.2: Implement honest capability reporting** + +Required capabilities: + +- `security_facts` +- `auth_boundary_facts` +- `control_flow_guard_dominance` + +Statuses must distinguish: + +- `complete` +- `partial` +- `unsupported` +- `failed` + +Expected GREEN command: + +```bash +cargo test -p drift-engine security_capabilities -- --nocapture +``` + +## Task 11: Durable Query, Storage, MCP After Proof Stabilizes + +Do not start this task until Tasks 1 through 10 are green. + +**Files:** +- Modify: `packages/storage/src/migrations.ts` +- Modify: `packages/storage/src/sqlite-storage.ts` +- Modify: `packages/storage/test/sqlite-storage.test.ts` +- Modify: `packages/query/src/security-boundary-proof.ts` +- Modify: `packages/query/test/security-boundary-proof.test.ts` +- Modify: `packages/mcp/src/security-context.ts` +- Modify: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 11.1: Add RED storage round-trip test** + +Test name: + +```ts +it("persists security boundary proof summaries without snippets", () => { + // round-trip proof id, route, contract id, proof status, parser gaps, missing proof ids, finding ids +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/storage test -- security +``` + +Expected RED failure: no durable proof storage/read method exists. + +- [ ] **Step 11.2: Add RED query read-model test** + +Test name: + +```ts +it("builds route security proof read model with capability and parser-gap truth", () => { + // no snippets, no source values +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +``` + +Expected RED failure: query model is too thin. + +- [ ] **Step 11.3: Add RED MCP parity test** + +Test name: + +```ts +it("exposes auth proof summaries from query truth without duplicating rules", () => { + // MCP output matches query summary and contains no snippets +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/mcp test -- security +``` + +Expected RED failure: MCP does not expose durable proof truth. + +- [ ] **Step 11.4: Implement additive persistence and read models** + +Implementation requirements: + +- Add only additive migrations. +- Persist proof summary, not full source or snippets. +- Query is the shared read source for CLI/MCP. +- MCP remains read-only and rule-free. + +Expected GREEN commands: + +```bash +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/mcp test +``` + +## Task 12: Output Safety Tests + +**Files:** +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `packages/query/test/security-boundary-proof.test.ts` +- Modify: `packages/storage/test/sqlite-storage.test.ts` +- Modify: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 12.1: Add RED no-leak matrix** + +Every output surface must be tested against these strings: + +```text +SECRET_VALUE +Authorization: Bearer +Cookie: +process.env +DATABASE_URL +raw-user-id-123 +SELECT * FROM users WHERE id = 'raw-user-id-123' +request.body.password +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- security-check +pnpm --filter @drift/query test -- security-boundary-proof +pnpm --filter @drift/storage test -- security +pnpm --filter @drift/mcp test -- security +``` + +Expected RED failure: at least one surface lacks explicit no-leak coverage. + +- [ ] **Step 12.2: Implement redaction/output discipline** + +Implementation requirements: + +- Evidence references use fact IDs, file paths, stable hashes, line ranges, classifications. +- No snippets or sensitive values in proof/finding/query/MCP output. + +Expected GREEN commands: + +```bash +pnpm --filter @drift/cli test -- security-check +pnpm --filter @drift/query test -- security-boundary-proof +pnpm --filter @drift/storage test +pnpm --filter @drift/mcp test +``` + +## Task 13: Human Output Parity + +**Files:** +- Modify: `packages/cli/src/formatters/checks.ts` +- Modify: `packages/cli/test/cli.test.ts` + +- [ ] **Step 13.1: Add RED test for human auth finding output** + +Test name: + +```ts +it("prints auth proof status, capability, route, and next command in human check output", async () => { + // no snippets, no secret values +}); +``` + +Expected RED command: + +```bash +pnpm --filter @drift/cli test -- "auth proof status" +``` + +Expected RED failure: human output omits proof/capability/route detail. + +- [ ] **Step 13.2: Implement human formatter parity** + +Implementation requirements: + +- Human output must include contract, route/file, reason, evidence lines, lifecycle, capability, and next command. +- Human output must not include snippets. +- JSON remains the richer machine-readable surface. + +Expected GREEN command: + +```bash +pnpm --filter @drift/cli test -- "auth proof status" +``` + +## Task 14: Final Production Gate + +Run only after all corrective tasks are green. + +- [ ] **Step 14.1: Rust gates** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +cargo test -p drift-engine security_ +cargo test -p drift-engine +``` + +Expected: all pass. + +- [ ] **Step 14.2: TypeScript package gates** + +Run: + +```bash +pnpm typecheck +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/storage test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +``` + +Expected: all pass. + +- [ ] **Step 14.3: E2E and diff hygiene** + +Run: + +```bash +pnpm test:e2e +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 14.4: PR readiness checklist** + +Confirm: + +- No inline auth proof logic remains in `check_command.rs`. +- Canonical TDD contract shape enforces. +- Legacy contract fields are compatibility-tested. +- Branch/callback/dynamic-flow product-path tests pass. +- Parser gaps and missing proofs appear in real check output. +- Candidate-only evidence cannot block. +- Waiver, baseline, exception, lifecycle behavior is proven for auth. +- Human/JSON/query/MCP outputs are snippet-safe. +- Capability reporting is honest. +- Direct-data-access and service-delegation tests still pass. + +## Expected Final State + +The corrected PR is production-ready only when: + +- `api_route_requires_auth_helper` enforcement in `drift check` depends on Rust proof modules, not inline line-order logic. +- Accepted contract input is the only source of blocking auth truth. +- Unsupported control flow produces parser gaps and blocks under accepted blocking contracts. +- Missing proof is explicit and linked to findings. +- Existing Drift governance semantics continue to work. +- Product-path tests prove every Phase 1 TDD claim. +- Storage/query/MCP are wired only after the proof schema is stable. + diff --git a/drift v3/docs/superpowers/plans/2026-05-25-security-middleware-phase2-correction-plan.md b/drift v3/docs/superpowers/plans/2026-05-25-security-middleware-phase2-correction-plan.md new file mode 100644 index 00000000..1cdcb8ae --- /dev/null +++ b/drift v3/docs/superpowers/plans/2026-05-25-security-middleware-phase2-correction-plan.md @@ -0,0 +1,907 @@ +# Phase 2 Middleware Security Correction Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Correct the Phase 2 middleware security-boundary PR so deterministic middleware coverage is enforced in real `drift check`, repo-map/MCP/query read paths do not overstate proof, parser gaps remain blocking, and all review findings are covered by tests. + +**Architecture:** Rust remains the deterministic authority for middleware parsing, matcher normalization, route coverage, proof construction, parser gaps, missing proof, and blocking rule evaluation. TypeScript only dispatches accepted contracts to the Rust engine, validates engine contracts, stores/query-formats persisted proof, and exposes read models without reinterpreting deterministic proof from raw facts. + +**Tech Stack:** Rust `drift-engine`, TypeScript packages `@drift/cli`, `@drift/core`, `@drift/engine-contract`, `@drift/query`, `@drift/mcp`, Vitest, Cargo tests, e2e tests. + +--- + +## Current Review Findings Covered + +This plan accounts for every review item: + +- P1: real `drift check` skips `middleware_must_cover_routes`. +- P1: `api_route_requires_auth_helper` check-run ignores deterministic middleware proof. +- P1: scan/repo-map emits `middleware_protects_route` from unaccepted helper context. +- P1: dynamic `config.matcher` expressions with string literals can become static proof. +- P1: middleware auth helper existence is treated as proof without dominance. +- P1: reused route facts can retain stale derived middleware coverage. +- P1: reused middleware files can drop parser-gap diagnostics. +- P2: route scoping normalizer is narrower than route detection. +- P2: middleware contract `methods` are stored but not evaluated. +- P2: query and MCP derive `proven: true` from raw `middleware_protects_route` facts. +- P2: MCP security context lacks scan freshness/readiness metadata. +- P2: CLI flag readers reject `middleware_must_cover_routes`. +- P2: engine-contract test blesses impossible proven-plus-parser-gap middleware state. +- P2: e2e middleware tests lack exclusion fixture and collapse mismatch reasons. +- P3: CLI scan-status middleware capability test does not assert `required` and `complete`. +- P3: PR lacks visible RED/GREEN evidence despite the TDD requirement. + +## Scope Guardrails + +- Do not implement Phase 3+ request validation, SSRF, SQL, tenant scope, sensitive data, CORS, CSRF, or rate-limit work. +- Do not add deterministic middleware coverage logic to TypeScript. +- Candidate-only and heuristic evidence must never block. +- Middleware existence alone must never satisfy auth. +- Dynamic or unsupported middleware matchers must create parser-gap-backed proof/finding evidence and must not silently pass. +- Blocking middleware/security findings require accepted contracts or accepted agent contracts. +- Outputs/storage/MCP/CLI must not include source snippets, secret values, request payloads, cookie/header values, raw SQL values, env values, or tokens. +- Preserve waiver, baseline, lifecycle, diff-scope, check-run, audit, policy egress, direct-data-access, service-delegation, and Phase 1 auth behavior. +- Do not include the local Phase 3 TDD expansion or unrelated dirty code changes in the Phase 2 correction PR. + +## Required Branch Hygiene Before Implementation + +Current observed state when this plan was written: + +```text +## codex/security-middleware-phase2...origin/codex/security-middleware-phase2 + M crates/drift-engine/src/facts.rs + M crates/drift-engine/src/lib.rs + M crates/drift-engine/src/main.rs + M crates/drift-engine/src/security_control_flow.rs + M crates/drift-engine/src/security_facts.rs + M crates/drift-engine/src/security_patterns.rs + M crates/drift-engine/tests/security_facts.rs + M docs/architecture/security-boundary-enforcement-100-tdd.md +?? docs/superpowers/plans/2026-05-25-security-boundary-p1-corrective-plan.md +``` + +The implementation agent must not mix those dirty files into the correction unless each change is verified as part of this review-fix scope. + +Use a clean worktree from the remote Phase 2 PR branch: + +```bash +cd "/Users/geoffreyfernald/Downloads/driftv3" +git worktree add "drift v3 phase2 corrections" origin/codex/security-middleware-phase2 -b codex/security-middleware-phase2-corrections +cd "drift v3 phase2 corrections" +git status --short --branch +``` + +Expected: clean branch `codex/security-middleware-phase2-corrections` based on `origin/codex/security-middleware-phase2`. + +If the correction must land on the existing PR branch instead of a follow-up branch, finish and verify in the clean worktree, then fast-forward or cherry-pick the correction commit onto `codex/security-middleware-phase2`. Do not carry unrelated local dirty files. + +## File Responsibility Map + +- `crates/drift-engine/src/security_facts.rs`: extraction only. It may record accepted helper call facts and middleware-return/control-flow facts, but it must not mark auth as proven from existence alone. +- `crates/drift-engine/src/security_patterns.rs`: accepted helper/sink/policy/middleware matcher normalization only. It must parse matcher literals deterministically and gap all non-literal matcher expressions. +- `crates/drift-engine/src/security_control_flow.rs`: route and middleware coverage summaries, matcher/path/method coverage, and dominance summaries only. +- `crates/drift-engine/src/security_proof.rs`: proof, parser-gap, missing-proof construction only. +- `crates/drift-engine/src/security_rules.rs`: deterministic accepted-contract rule evaluation only. +- `crates/drift-engine/src/check_command.rs`: Rust check-run bridge for accepted security conventions and engine check output. +- `crates/drift-engine/src/main.rs`: scan orchestration and fact serialization only; no unaccepted proof promotion and no stale derived-fact reuse. +- `packages/cli/src/check/run-check.ts`: CLI orchestration, lifecycle/diff/baseline/waiver mapping, and Rust engine dispatch only. +- `packages/cli/src/check/security-check.ts`: CLI output mapping only, no deterministic rule logic. +- `packages/cli/src/args/flag-readers.ts`: CLI argument validation only. +- `packages/query/src/security-boundary-proof.ts`: query/read model over persisted `SecurityBoundaryProof`. +- `packages/query/src/index.ts`: exports and compatibility wrappers only; no proof derivation from raw middleware facts. +- `packages/mcp/src/security-context.ts`: MCP read model over persisted proof plus freshness/readiness metadata. +- `packages/engine-contract/src/index.ts`: schema validation for engine output states. +- Tests stay near the behavior they protect. + +--- + +## Task 1: Wire `middleware_must_cover_routes` Through Real `drift check` + +**Findings covered:** P1 skip in `run-check.ts`, P2 flag-reader rejection. + +**Files:** +- Modify: `packages/cli/src/check/run-check.ts` +- Modify: `packages/cli/src/args/flag-readers.ts` +- Test: `packages/cli/test/security-check.test.ts` +- Test: `packages/cli/test/cli.test.ts` + +- [ ] **Step 1: Write the failing CLI dispatch test** + +Add a `security-check.test.ts` case that creates a repo with: + +- `app/api/projects/route.ts` +- `middleware.ts` +- an accepted `middleware_must_cover_routes` convention +- changed-file or changed-hunk diff scope covering the route + +Assert: + +- `runCheck(... --json)` invokes Rust-owned security check behavior for `middleware_must_cover_routes`. +- output includes `security_boundary_proofs[0].middleware.required === true`. +- a missing or uncovered route produces a blocking finding with `rule_id === "middleware_must_cover_routes"`. +- JSON output contains no middleware or route source snippet. + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED: fail because `runEngineOwnedAuthCheck` filters out every convention whose kind is not `api_route_requires_auth_helper`, so `middleware_must_cover_routes` produces no engine findings and no security boundary proofs in real `drift check`. + +- [ ] **Step 3: Write the failing flag-reader test** + +Add or extend a `cli.test.ts`/flag-reader coverage case so `--kind middleware_must_cover_routes` is accepted anywhere convention kind filters are accepted. + +- [ ] **Step 4: Run RED** + +```bash +pnpm --filter @drift/cli test -- cli +``` + +Expected RED: fail because `optionalConventionKindFlag` rejects `middleware_must_cover_routes` and the error text omits it. + +- [ ] **Step 5: Implement minimal GREEN** + +In `run-check.ts`: + +- Rename `runEngineOwnedAuthCheck` to a security-check dispatcher name such as `runEngineOwnedSecurityCheck`. +- Dispatch accepted deterministic conventions for both `api_route_requires_auth_helper` and `middleware_must_cover_routes`. +- Preserve lifecycle filtering, `enforcement_mode !== "off"`, `enforcement_capability === "deterministic_check"`, diff scope, baseline, waivers, and existing finding status handling. +- For `middleware_must_cover_routes`, include route files and middleware files required for deterministic proof. Do not filter the Rust request down to only route-local facts when middleware proof is needed. +- Do not implement matcher or coverage decisions in TypeScript. + +In `flag-readers.ts`: + +- Add `middleware_must_cover_routes` to the accepted convention kind set. +- Update the error text to include it. + +- [ ] **Step 6: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- security-check +pnpm --filter @drift/cli test -- cli +``` + +Expected GREEN: new tests pass, existing auth check tests still pass, and blocked findings keep existing lifecycle/diff/baseline/waiver semantics. + +--- + +## Task 2: Allow Auth Helper Contract to Accept Proven Middleware Coverage in Check-Run + +**Findings covered:** P1 `check_command.rs` builds only route-local auth proofs for `api_route_requires_auth_helper`. + +**Files:** +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify only if needed: `crates/drift-engine/src/security_rules.rs` +- Test: `crates/drift-engine/tests/security_check_repo_auth.rs` +- Test: `packages/cli/test/security-check.test.ts` + +- [ ] **Step 1: Write the failing Rust check-command test** + +Add a `security_check_repo_auth.rs` integration case with: + +- route file lacking route-local auth helper +- accepted `api_route_requires_auth_helper` convention +- accepted middleware helper contract data +- deterministic middleware proof covering the route and method + +Assert: + +- no blocking auth finding is returned for the covered route +- `security_boundary_proofs[0].auth.proven === true` +- `security_boundary_proofs[0].auth.proof_kind === "middleware"` +- middleware proof references accepted middleware evidence only +- no candidate or heuristic evidence satisfies the contract + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_check_repo_auth -- --nocapture +``` + +Expected RED: fail because check-run evaluates `api_route_requires_auth_helper` from route-local facts only, so middleware coverage does not satisfy auth in the actual Rust check command. + +- [ ] **Step 3: Write the failing CLI bridge test** + +Add a `security-check.test.ts` case proving the same behavior through `runCheck(... --json)`: a route-local missing helper is allowed only when accepted deterministic middleware coverage is present. + +- [ ] **Step 4: Run RED** + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED: fail because the CLI receives route-local missing auth output from Rust and cannot satisfy auth from middleware proof. + +- [ ] **Step 5: Implement minimal GREEN** + +In `check_command.rs`: + +- Build middleware-aware auth proof using existing Rust proof/rule helpers. +- Accept middleware proof for `api_route_requires_auth_helper` only when coverage is deterministic, accepted, parser-gap-free, method/path-matched, and `protection_kind === "auth"`. +- Keep missing proof and parser-gap proof blocking. +- Preserve route-local auth helper behavior for existing tests. + +- [ ] **Step 6: Run GREEN** + +```bash +cargo test -p drift-engine security_check_repo_auth -- --nocapture +pnpm --filter @drift/cli test -- security-check +``` + +Expected GREEN: route-local auth still works, missing auth still blocks, and accepted deterministic middleware proof satisfies the auth contract through real check-run. + +--- + +## Task 3: Stop Emitting Proven Route Coverage From Unaccepted Middleware Context + +**Findings covered:** P1 `main.rs` extracts scan security facts with `[]` accepted helpers and emits `middleware_protects_route` anyway. + +**Files:** +- Modify: `crates/drift-engine/src/main.rs` +- Modify if needed: `crates/drift-engine/src/security_proof.rs` +- Test: `crates/drift-engine/tests/security_facts.rs` +- Test: `test/e2e/security-middleware.test.ts` + +- [ ] **Step 1: Write the failing Rust scan fact test** + +Add a test where `middleware.ts` contains a helper-like call that is not in accepted helper contracts. Assert scan facts do not include a deterministic `middleware_protects_route` proof for `protection_kind: "auth"`. + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_facts -- --nocapture +``` + +Expected RED: fail because scan orchestration currently emits `middleware_protects_route` even though the scan extraction used no accepted helper set. + +- [ ] **Step 3: Write the failing e2e test** + +Add an e2e fixture where middleware exists and matcher covers the route, but the helper is unaccepted. Assert repo-map/MCP-visible facts do not claim proven auth coverage. + +- [ ] **Step 4: Run RED** + +```bash +pnpm test:e2e -- security-middleware +``` + +Expected RED: fail because raw facts expose `middleware_protects_route` as if unaccepted middleware protection were proven. + +- [ ] **Step 5: Implement minimal GREEN** + +In `main.rs`: + +- Emit deterministic `middleware_protects_route` facts only when proof was built from accepted helper or accepted agent-contract evidence. +- If static matcher coverage is useful without accepted protection, emit a nonblocking/static coverage representation that cannot be interpreted as auth proof, or omit the fact entirely. +- Do not let repo-map/MCP infer `proven: true` from middleware existence. + +- [ ] **Step 6: Run GREEN** + +```bash +cargo test -p drift-engine security_facts -- --nocapture +pnpm test:e2e -- security-middleware +``` + +Expected GREEN: accepted middleware proof still emits deterministic coverage, unaccepted middleware no longer appears as proven protection. + +--- + +## Task 4: Gap All Dynamic Middleware Matcher Expressions + +**Findings covered:** P1 dynamic `config.matcher` expressions with string literals can silently become static proof. + +**Files:** +- Modify: `crates/drift-engine/src/security_patterns.rs` +- Test: `crates/drift-engine/tests/security_facts.rs` +- Test: `test/e2e/security-middleware.test.ts` + +- [ ] **Step 1: Write the failing matcher tests** + +Add Rust tests for these matcher forms: + +```ts +export const config = { matcher: process.env.MATCHER ?? "/api/:path*" }; +export const config = { matcher: isProd ? ["/api/:path*"] : ["/health"] }; +const matcher = "/api/:path*"; +export const config = { matcher }; +``` + +Assert: + +- each creates a parser gap for unsupported/dynamic matcher expression +- none creates static `middleware_matcher_declared` coverage +- none creates `middleware_protects_route` + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_facts -- --nocapture +``` + +Expected RED: fail because quoted path extraction currently pulls literal strings out of non-literal matcher expressions and treats them as static matcher proof. + +- [ ] **Step 3: Implement minimal GREEN** + +In `security_patterns.rs`: + +- Parse only direct string literals and arrays of direct string literals from `config.matcher`. +- Treat identifiers, member expressions, conditional expressions, logical expressions, call expressions, spreads, template expressions with interpolation, and imported values as parser gaps. +- Preserve support for direct literal matchers: + +```ts +export const config = { matcher: "/api/:path*" }; +export const config = { matcher: ["/api/:path*", "/admin/:path*"] }; +``` + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine security_facts -- --nocapture +pnpm test:e2e -- security-middleware +``` + +Expected GREEN: literal matchers still work, dynamic matcher expressions produce parser-gap evidence and do not silently prove coverage. + +--- + +## Task 5: Require Middleware-Local Dominance Before `protection_kind = "auth"` + +**Findings covered:** P1 helper existence anywhere in `middleware.ts` proves auth even after `NextResponse.next()`, in a branch, or in a callback. + +**Files:** +- Modify: `crates/drift-engine/src/security_facts.rs` +- Modify: `crates/drift-engine/src/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` +- Test: `crates/drift-engine/tests/security_control_flow.rs` +- Test: `crates/drift-engine/tests/security_facts.rs` +- Test: `crates/drift-engine/tests/security_rules.rs` + +- [ ] **Step 1: Write failing dominance tests** + +Add cases proving these do not satisfy middleware auth: + +```ts +export function middleware(req) { + const response = NextResponse.next(); + requireUser(req); + return response; +} +``` + +```ts +export function middleware(req) { + if (req.nextUrl.pathname.startsWith("/admin")) { + requireUser(req); + } + return NextResponse.next(); +} +``` + +```ts +export function middleware(req) { + const fn = () => requireUser(req); + return NextResponse.next(); +} +``` + +Also add a positive case where the accepted guard dominates the return path for covered routes. + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_control_flow security_facts security_rules -- --nocapture +``` + +Expected RED: fail because middleware facts currently mark auth from any accepted helper call in the file, and proof construction treats that as proven. + +- [ ] **Step 3: Implement minimal GREEN** + +In Rust: + +- Extract helper calls as facts without marking middleware protection proven. +- Add or extend middleware control-flow summary so accepted guard dominance is required before auth protection is proven. +- Produce missing-proof or parser-gap evidence when dominance cannot be established. +- Keep this logic in Rust only. + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine security_control_flow security_facts security_rules -- --nocapture +``` + +Expected GREEN: helper after response, branch-only helper, and callback-only helper do not prove middleware auth; dominating accepted guard does. + +--- + +## Task 6: Remove Stale Derived Middleware Facts From Reuse + +**Findings covered:** P1 stale `middleware_protects_route` can survive when only middleware changes. + +**Files:** +- Modify: `crates/drift-engine/src/main.rs` +- Modify if needed: `packages/cli/src/domain/scan-status.ts` +- Test: `test/e2e/security-middleware.test.ts` +- Test if Rust helper exists: `crates/drift-engine/tests/security_facts.rs` + +- [ ] **Step 1: Write the failing reuse test** + +Add an e2e test with two scans: + +1. scan route plus middleware that proves coverage +2. change only `middleware.ts` so coverage no longer proves auth + +Assert second scan does not retain old `middleware_protects_route` for the route. + +- [ ] **Step 2: Run RED** + +```bash +pnpm test:e2e -- security-middleware +``` + +Expected RED: fail because reused route facts can include stale derived cross-file middleware coverage before recomputed middleware coverage is appended. + +- [ ] **Step 3: Implement minimal GREEN** + +In scan reuse code: + +- Strip derived cross-file facts from reused facts before appending current middleware coverage. +- At minimum strip `middleware_protects_route` from reused facts. +- Prefer making derived fact kinds non-reusable in the reuse manifest so future derived facts cannot stale across scans. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm test:e2e -- security-middleware +cargo test -p drift-engine security_facts -- --nocapture +``` + +Expected GREEN: second scan reflects current middleware coverage only. + +--- + +## Task 7: Preserve Parser Gaps Across Reused Middleware Files + +**Findings covered:** P1 reused dynamic middleware loses blocking parser gaps because reuse persists only facts. + +**Files:** +- Modify: `crates/drift-engine/src/main.rs` +- Modify: `packages/cli/src/domain/scan-status.ts` +- Test: `test/e2e/security-middleware.test.ts` +- Test if reusable unit exists: `packages/cli/test/cli.test.ts` + +- [ ] **Step 1: Write the failing parser-gap reuse test** + +Add an e2e test with: + +1. scan `middleware.ts` containing a dynamic matcher +2. run a second scan where the middleware file is reused + +Assert the second scan still exposes a blocking parser-gap-backed proof/finding for the accepted middleware contract. + +- [ ] **Step 2: Run RED** + +```bash +pnpm test:e2e -- security-middleware +``` + +Expected RED: fail because reused middleware skips dynamic matcher diagnostics and the reuse manifest persists facts only. + +- [ ] **Step 3: Implement minimal GREEN** + +Choose one production path: + +- persist and replay diagnostics/parser gaps in reuse manifests, or +- disable reuse for middleware files when parser gaps affect deterministic security contracts. + +The lower-risk Phase 2 fix is to disable reuse for middleware files with parser gaps and document the reason in code near reuse filtering. Do not drop parser gaps silently. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm test:e2e -- security-middleware +pnpm --filter @drift/cli test -- cli +``` + +Expected GREEN: parser gaps survive scan reuse and continue to block accepted deterministic middleware contracts. + +--- + +## Task 8: Share API Route Normalization With Middleware Rule Scoping + +**Findings covered:** P2 route scoping misses `.tsx`, `.js`, `.jsx`, and some pages routes. + +**Files:** +- Modify: `crates/drift-engine/src/facts.rs` +- Modify: `crates/drift-engine/src/security_rules.rs` +- Modify if cleaner: `crates/drift-engine/src/security_control_flow.rs` +- Test: `crates/drift-engine/tests/security_rules.rs` +- Test: `crates/drift-engine/tests/security_facts.rs` + +- [ ] **Step 1: Write the failing normalizer tests** + +Add cases proving middleware contract route scoping matches API route detection for: + +- `app/api/projects/route.ts` +- `app/api/projects/route.tsx` +- `app/api/projects/route.js` +- `app/api/projects/route.jsx` +- `pages/api/projects.ts` +- `pages/api/projects.js` + +Assert required route path and method are known, not skipped or marked unknown. + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_rules security_facts -- --nocapture +``` + +Expected RED: fail because `security_rules.rs` uses a narrower route normalizer than API route detection. + +- [ ] **Step 3: Implement minimal GREEN** + +Move route-path normalization into a shared Rust helper or expose the existing route detector so middleware rules and fact extraction use the same source of truth. + +Do not fork route normalization logic. + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine security_rules security_facts -- --nocapture +``` + +Expected GREEN: app and pages API route variants receive consistent route path/method scope evaluation. + +--- + +## Task 9: Enforce Middleware Contract Method Scope + +**Findings covered:** P2 `SecurityMiddlewareContract.methods` is stored but ignored. + +**Files:** +- Modify: `crates/drift-engine/src/security_rules.rs` +- Modify if needed: `crates/drift-engine/src/security_control_flow.rs` +- Test: `crates/drift-engine/tests/security_rules.rs` + +- [ ] **Step 1: Write the failing method-scope tests** + +Add cases: + +- contract methods `["POST"]`, route method `GET`, matcher path covers route: should not satisfy GET auth requirement. +- contract methods `["GET"]`, route method `GET`, matcher path covers route: can satisfy when proof is otherwise valid. +- contract methods omitted: applies to all route methods. + +- [ ] **Step 2: Run RED** + +```bash +cargo test -p drift-engine security_rules -- --nocapture +``` + +Expected RED: fail because middleware method scope is ignored and method-scoped contracts can apply to the wrong route method. + +- [ ] **Step 3: Implement minimal GREEN** + +In rule evaluation: + +- Filter middleware coverage by required route method before proof is accepted. +- Emit a method-mismatch proof/finding reason when a contract exists but does not cover the route method. +- Keep method-omitted behavior as all-methods coverage. + +- [ ] **Step 4: Run GREEN** + +```bash +cargo test -p drift-engine security_rules -- --nocapture +``` + +Expected GREEN: method-scoped contracts apply only to matching route methods. + +--- + +## Task 10: Stop Query and MCP From Interpreting Raw Middleware Facts as Proven Proof + +**Findings covered:** P2 query and MCP set `proven: true` from raw `middleware_protects_route` facts. + +**Files:** +- Modify: `packages/query/src/security-boundary-proof.ts` +- Modify: `packages/query/src/index.ts` +- Modify: `packages/mcp/src/security-context.ts` +- Test: `packages/query/test/security-boundary-proof.test.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write the failing query tests** + +Add query tests proving: + +- persisted `SecurityBoundaryProof.middleware.proven === true` returns proven coverage +- raw `middleware_protects_route` fact without persisted proof does not return proven coverage +- parser-gap proof remains unproven and exposes safe gap metadata + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +``` + +Expected RED: fail because query code derives proven middleware coverage directly from raw facts. + +- [ ] **Step 3: Write the failing MCP tests** + +Add MCP tests proving security context uses persisted proof/read model and does not promote raw `middleware_protects_route` facts to `proven: true`. + +- [ ] **Step 4: Run RED** + +```bash +pnpm --filter @drift/mcp test -- mcp +``` + +Expected RED: fail because MCP security context reads latest raw middleware facts and marks them proven. + +- [ ] **Step 5: Implement minimal GREEN** + +In query: + +- Make `buildSecurityBoundaryProofReadModel` the only path that turns persisted proof into route summaries. +- Remove or downgrade compatibility wrappers that infer proof from raw facts. + +In MCP: + +- Read persisted `SecurityBoundaryProof`/query read model where available. +- If only raw facts exist, expose them as static evidence or omit proven coverage; do not set `proven: true`. + +- [ ] **Step 6: Run GREEN** + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +pnpm --filter @drift/mcp test -- mcp +``` + +Expected GREEN: TypeScript no longer duplicates deterministic proof interpretation. + +--- + +## Task 11: Add Freshness and Readiness Metadata to MCP Security Context + +**Findings covered:** P2 MCP uses latest scan coverage without freshness/readiness metadata. + +**Files:** +- Modify: `packages/mcp/src/security-context.ts` +- Test: `packages/mcp/test/mcp.test.ts` + +- [ ] **Step 1: Write the failing MCP freshness test** + +Add a test where latest scan is stale or not ready according to the same semantics used by repo-map/scan-status. Assert the MCP security context includes freshness/readiness metadata and does not present stale coverage as current without that metadata. + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/mcp test -- mcp +``` + +Expected RED: fail because MCP security context returns latest middleware coverage without freshness/readiness state. + +- [ ] **Step 3: Implement minimal GREEN** + +In `security-context.ts`: + +- Mirror existing repo-map freshness/readiness behavior or call the shared scan-status readiness helper. +- Include scan id, scan status, readiness, freshness requirement, and stale reason where available. +- Keep the response source-snippet-free. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/mcp test -- mcp +``` + +Expected GREEN: MCP consumers can distinguish current proven coverage from stale or incomplete scan data. + +--- + +## Task 12: Reject Impossible Engine-Contract Middleware Proof States + +**Findings covered:** P2 engine-contract test blesses `middleware.proven=true` with a blocking parser gap. + +**Files:** +- Modify: `packages/engine-contract/src/index.ts` +- Test: `packages/engine-contract/test/security-contract.test.ts` +- Modify if needed: `packages/core/src/security.ts` +- Test if core schema changes: `packages/core/test/security.test.ts` + +- [ ] **Step 1: Write the failing schema tests** + +Split fixtures: + +- proven middleware proof with no blocking parser gap: accepted +- parser-gap middleware proof with `proven === false`: accepted +- parser-gap middleware proof with `proven === true`: rejected + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected RED: fail because the current schema/test fixture accepts an impossible proven-plus-blocking-gap state. + +- [ ] **Step 3: Implement minimal GREEN** + +Add schema refinement in engine-contract and core if necessary: + +- `middleware.proven === true` must not coexist with blocking parser gaps for the same proof. +- `proof_kind === "parser_gap"` must imply `proven === false`. +- Preserve backwards-compatible parsing for valid Phase 1 auth proof states. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/engine-contract test -- security-contract +pnpm --filter @drift/core test -- security +``` + +Expected GREEN: impossible proof states fail schema validation; valid proven and parser-gap fixtures pass separately. + +--- + +## Task 13: Strengthen Middleware E2E Fixtures for Exclusions and Mismatch Reasons + +**Findings covered:** P2 e2e lacks exclusion fixture and collapses path/method mismatch to one boolean. + +**Files:** +- Modify: `test/e2e/security-middleware.test.ts` + +- [ ] **Step 1: Write the failing e2e assertions** + +Add fixture coverage for: + +- route excluded by middleware matcher +- route path mismatch +- route method mismatch +- parser gap +- accepted deterministic proof + +Assert exact proof/finding reasons, not just presence/absence booleans. + +- [ ] **Step 2: Run RED** + +```bash +pnpm test:e2e -- security-middleware +``` + +Expected RED: fail because current e2e coverage does not expose exclusion and mismatch reasons distinctly. + +- [ ] **Step 3: Implement minimal GREEN** + +Adjust only the behavior required by prior tasks so e2e output contains: + +- path mismatch reason +- method mismatch reason +- matcher exclusion reason +- parser-gap reason +- proven accepted deterministic coverage reason + +Do not add TypeScript deterministic logic to manufacture these reasons. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm test:e2e -- security-middleware +``` + +Expected GREEN: scan-level regressions for exclusion, path mismatch, method mismatch, parser gap, and proven coverage are independently caught. + +--- + +## Task 14: Complete Middleware Scan-Status Capability Assertions + +**Findings covered:** P3 CLI scan-status capability test only partially asserts middleware capability. + +**Files:** +- Modify: `packages/cli/test/cli.test.ts` + +- [ ] **Step 1: Write the failing assertion** + +In the existing middleware scan-status capability test, assert: + +- `required === true` +- `complete === true` when accepted middleware coverage requirements are satisfied +- incomplete or parser-gap cases set `complete === false` + +- [ ] **Step 2: Run RED** + +```bash +pnpm --filter @drift/cli test -- cli +``` + +Expected RED: fail if scan-status does not populate both `required` and `complete` for middleware capability state. + +- [ ] **Step 3: Implement minimal GREEN** + +Use existing scan-status completeness data for `middleware_must_cover_routes`. Do not infer deterministic proof from raw facts in the test or formatter. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm --filter @drift/cli test -- cli +``` + +Expected GREEN: scan-status reports middleware capability required/completion state explicitly. + +--- + +## Task 15: Add PR-Visible RED/GREEN Evidence + +**Findings covered:** P3 TDD requires RED/GREEN evidence, but the remote PR was one implementation commit. + +**Files:** +- Create: `docs/architecture/security-boundary-enforcement-phase2-red-green-evidence.md` + +- [ ] **Step 1: Create evidence log after implementing Tasks 1-14** + +For each task, record: + +- task number +- RED command +- expected failure reason +- actual RED summary +- GREEN command +- actual GREEN summary +- commit hash that contains the fix + +Use concise summaries, not full test logs. + +- [ ] **Step 2: Verify evidence doc has no draft-marker text** + +```bash +node -e 'const fs=require("fs"); const p="docs/architecture/security-boundary-enforcement-phase2-red-green-evidence.md"; const t=fs.readFileSync(p,"utf8"); const markers=["TB"+"D","TO"+"DO","fill"+" in"]; const hits=markers.filter((m)=>t.includes(m)); if (hits.length) { console.error(hits.join("\n")); process.exit(1); }' +``` + +Expected: no matches. + +- [ ] **Step 3: Include evidence in PR** + +Stage the evidence doc with the correction commit or with a final evidence-only commit after all tests pass. + +Expected GREEN: reviewer can see the focused RED/GREEN path without reconstructing it from local shell history. + +--- + +## Final Verification Gates + +Run these from the clean correction worktree before pushing: + +```bash +cargo test -p drift-engine security_ +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +git diff --check +``` + +Do not run `pnpm verify:ci` unless explicitly requested. + +## Production Readiness Exit Criteria + +- `middleware_must_cover_routes` blocks in real `drift check` with lifecycle, diff, baseline, waiver, and check-run behavior intact. +- `api_route_requires_auth_helper` accepts middleware only through accepted deterministic middleware proof. +- Unaccepted middleware, middleware existence, candidate evidence, heuristic evidence, and static matcher coverage alone never prove auth. +- Dynamic matcher expressions create parser-gap-backed evidence and cannot silently become static proof. +- Middleware helper calls prove auth only when middleware-local dominance is established. +- Reuse cannot retain stale derived middleware facts or drop parser-gap diagnostics. +- Route path/method normalization is shared and covers app/pages route variants. +- Contract method scope is enforced. +- Query and MCP consume persisted proof/read model, not raw facts, for `proven: true`. +- MCP exposes freshness/readiness metadata. +- Engine-contract/core schemas reject impossible proven-plus-parser-gap states. +- E2E tests cover exclusion, path mismatch, method mismatch, parser gap, and accepted proof separately. +- Scan-status middleware capability asserts both `required` and `complete`. +- PR includes focused RED/GREEN evidence. +- No Phase 3+ security-boundary implementation appears in the diff. +- No unrelated dirty files, generated build output, lockfile churn, source snippets, secrets, request payloads, cookie/header values, raw SQL values, env values, or tokens appear in output changes. diff --git a/drift v3/docs/superpowers/plans/2026-05-26-security-request-validation-phase3-production-hardening.md b/drift v3/docs/superpowers/plans/2026-05-26-security-request-validation-phase3-production-hardening.md new file mode 100644 index 00000000..e9b05cca --- /dev/null +++ b/drift v3/docs/superpowers/plans/2026-05-26-security-request-validation-phase3-production-hardening.md @@ -0,0 +1,1270 @@ +# Phase 3 Request Validation Production Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring Phase 3 request-validation enforcement to production-grade by closing the review findings without expanding into Phase 4+. + +**Architecture:** Rust remains the deterministic authority for request-input parsing, accepted validator normalization, source-to-sink proof, parser gaps, missing proof, and blocking rule evaluation. TypeScript remains schemas, engine-contract validation, storage/query/read models, CLI/MCP envelopes, governance, candidates, and formatting only. Raw scan facts are evidence, not proof. + +**Tech Stack:** Rust `drift-engine`, TypeScript packages under `packages/*`, Vitest, pnpm, cargo test/fmt/clippy. + +--- + +## Preconditions + +- Base this corrective slice on `codex/security-request-validation-phase3`. +- Keep the PR base as `codex/security-middleware-phase2`. +- Do not touch unrelated untracked files: + - `docs/superpowers/plans/2026-05-25-security-boundary-p1-corrective-plan.md` + - `docs/superpowers/plans/2026-05-25-security-middleware-phase2-correction-plan.md` + - `../drift v3 phase2 corrections/` +- Do not implement Phase 4+. +- No production code before a failing test. + +Initial verification: + +```bash +git fetch --all --prune +git switch codex/security-request-validation-phase3 +git rev-list --left-right --count origin/codex/security-middleware-phase2...HEAD +``` + +Expected: + +```text +0 1 +``` + +## Files And Responsibilities + +- `crates/drift-engine/src/security_patterns.rs`: accepted helper/schema/validator normalization only. +- `crates/drift-engine/src/security_facts.rs`: fact extraction only. +- `crates/drift-engine/src/security_control_flow.rs`: request-input and validated-variable source-to-sink summaries only. +- `crates/drift-engine/src/security_proof.rs`: proof, parser-gap, missing-proof construction only. +- `crates/drift-engine/src/security_rules.rs`: deterministic accepted-contract rule evaluation only. +- `crates/drift-engine/src/check_command.rs`: engine request/response wiring only. +- `crates/drift-engine/src/protocol.rs`: engine protocol shape only. +- `packages/cli/src/engine/engine-check.ts`: convert accepted contract payload into engine request shape only. +- `packages/cli/src/check/run-check.ts`: CLI finding mapping only. +- `packages/core/src/security.ts`: core security proof schema validation. +- `packages/engine-contract/src/index.ts`: engine contract schema validation. +- `packages/query/src/index.ts`: repo-map read model, no deterministic proof synthesis. +- `packages/mcp/src/security-context.ts`: MCP read model, no deterministic proof synthesis. +- `test/e2e/security-validation.test.ts`: e2e proof/assertion matrix. + +--- + +## Task 1: Remove `matcher.required_calls` As Request-Validation Truth + +**Risk addressed:** A convention with only `matcher.required_calls: ["validateInput"]` can currently make `validateInput(body)` prove request validation. Phase 3 accepted validators must come from `requires.validators` and `requires.schemas`. + +**Files:** +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `packages/cli/src/engine/engine-check.ts` +- Create: `crates/drift-engine/tests/security_check_repo_request_validation.rs` +- Modify: `crates/drift-engine/src/check_command.rs` + +- [ ] **Step 1: RED TypeScript request mapping test** + +Add this test to `packages/cli/test/security-check.test.ts` near the existing engine request mapping tests: + +```ts +it("does not convert matcher.required_calls into request validation requires", () => { + const request = engineCheckRequest({ + repoId: "repo_abc", + repoRoot: "/tmp/repo", + scanId: "scan_abc", + snapshots: [], + facts: [], + conventions: [{ + id: "security_api_request_validation", + repo_id: "repo_abc", + contract_id: "contract_abc", + kind: "api_route_requires_request_validation", + statement: "API request input must be validated.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_request_validation", + required_calls: ["validateInput"], + 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-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }], + baseline: [], + diff: { files: [], deletedFiles: [] }, + scope: "full" + }); + + expect(request.contract.conventions[0]?.requires).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run RED TypeScript command** + +Run: + +```bash +pnpm --filter @drift/cli test -- "does not convert matcher.required_calls into request validation requires" +``` + +Expected RED: fail because `engine-check.ts` maps `matcher.required_calls` into `requires.validators`. + +- [ ] **Step 3: GREEN TypeScript mapping** + +In `packages/cli/src/engine/engine-check.ts`, change `securityRequires` so `api_route_requires_request_validation` only returns `requires` when the accepted convention already has a real `requires` object. Do not synthesize validators from `matcher.required_calls`. + +Implementation rule: + +```ts +if (convention.kind === "api_route_requires_request_validation") { + return undefined; +} +``` + +This branch must come after the existing explicit `requires` pass-through. + +- [ ] **Step 4: Run GREEN TypeScript command** + +Run: + +```bash +pnpm --filter @drift/cli test -- "does not convert matcher.required_calls into request validation requires" +``` + +Expected GREEN: pass. + +- [ ] **Step 5: RED Rust engine check test** + +Create `crates/drift-engine/tests/security_check_repo_request_validation.rs` with a helper-shaped contract using only `matcher.required_calls`. + +Test name: + +```rust +#[test] +fn check_repo_does_not_accept_matcher_required_calls_as_request_validators() +``` + +Core fixture: + +```rust +let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const input = validateInput(body); + await db.project.create({ data: input }); + return Response.json({ ok: true }); +} +"#; +``` + +Build a `CheckRequest` with: + +```json +{ + "kind": "api_route_requires_request_validation", + "matcher": { + "required_calls": ["validateInput"], + "applies_to_file_roles": ["api_route"] + }, + "requires": null, + "enforcement_capability": "deterministic_check", + "enforcement_mode": "block" +} +``` + +Assert: + +```rust +assert!(result.findings.is_empty()); +assert!(result.security_boundary_proofs.is_empty()); +``` + +- [ ] **Step 6: Run RED Rust command** + +Run: + +```bash +cargo test -p drift-engine check_repo_does_not_accept_matcher_required_calls_as_request_validators -- --nocapture +``` + +Expected RED: fail because `check_command.rs` currently treats `matcher.required_calls` as accepted request validators. + +- [ ] **Step 7: GREEN Rust check wiring** + +In `crates/drift-engine/src/check_command.rs`, remove the `matcher.required_calls` branch from `accepted_request_validators_for_convention`. Only parse: + +- `requires.validators` +- `requires.schemas` + +Do not add compatibility fallback for request validation. + +- [ ] **Step 8: Run GREEN Rust command** + +Run: + +```bash +cargo test -p drift-engine check_repo_does_not_accept_matcher_required_calls_as_request_validators -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 2: No-Request-Input Routes Must Not Block + +**Risk addressed:** Routes with no request input reads currently become missing-proof findings because the proof is not proven. + +**Files:** +- Modify: `crates/drift-engine/tests/security_rules.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` +- Modify: `crates/drift-engine/src/check_command.rs` + +- [ ] **Step 1: RED proof test** + +Add to `crates/drift-engine/tests/security_rules.rs`: + +```rust +#[test] +fn route_without_request_input_does_not_require_request_validation() { + let source = r#" +const db = { project: { findMany: async () => [] } }; +export async function GET() { + const projects = await db.project.findMany(); + return Response.json(projects); +} +"#; + let findings = evaluate_api_route_requires_request_validation( + "app/api/projects/route.ts", + source, + &SecurityRequestValidationContract { + contract_id: "security_api_request_validation".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_validators: vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }], + }, + ) + .expect("request validation findings"); + + assert!(findings.is_empty(), "no request input should not block: {findings:#?}"); +} +``` + +- [ ] **Step 2: Run RED command** + +Run: + +```bash +cargo test -p drift-engine route_without_request_input_does_not_require_request_validation -- --nocapture +``` + +Expected RED: fail because a missing proof is emitted for a route that does not read request input. + +- [ ] **Step 3: GREEN proof semantics** + +In `crates/drift-engine/src/security_proof.rs`, make `build_request_validation_proof` return a non-blocking proof when `input_reads.is_empty()`: + +- `request_validation.required = false` +- `request_validation.proven = false` +- `result.proof_status = SecurityProofStatus::Proven` +- no missing proof +- no parser gap + +This means: no request input means no request-validation obligation for this phase. + +- [ ] **Step 4: GREEN check wiring** + +In `crates/drift-engine/src/check_command.rs`, keep proof emission optional. If proof has `request_validation.required == false`, do not emit a finding. It is acceptable to omit the proof from `security_boundary_proofs` for no-input routes. + +- [ ] **Step 5: Run GREEN command** + +Run: + +```bash +cargo test -p drift-engine route_without_request_input_does_not_require_request_validation -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 3: Tighten `safeParse` Proof Semantics + +**Risk addressed:** `safeParse` currently accepts bare result use and can be fooled by a string containing `return`. + +**Files:** +- Modify: `crates/drift-engine/tests/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` + +- [ ] **Step 1: RED bare-result test** + +Add to `crates/drift-engine/tests/security_control_flow.rs`: + +```rust +#[test] +fn safe_parse_bare_result_is_not_validated_input() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const result = ProjectInputSchema.safeParse(body); + if (!result.success) { + return Response.json({ ok: false }, { status: 400 }); + } + await db.project.create({ data: result }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(!proof.request_validation.proven, "bare safeParse result must not prove validation"); + assert!(proof.request_validation.unvalidated_uses.iter().any(|use_proof| + use_proof.reason == "validation_result_not_used" + || use_proof.reason == "request_input_not_validated" + )); +} +``` + +- [ ] **Step 2: RED fake-guard test** + +Add: + +```rust +#[test] +fn safe_parse_guard_must_exit_not_contain_return_string() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const result = ProjectInputSchema.safeParse(body); + if (!result.success) { + console.log("return later"); + } + await db.project.create({ data: result.data }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(!proof.request_validation.proven, "fake success guard must not prove validation"); +} +``` + +- [ ] **Step 3: RED `.data` alias pass test** + +Add: + +```rust +#[test] +fn safe_parse_data_alias_after_exit_guard_is_validated_input() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const result = ProjectInputSchema.safeParse(body); + if (!result.success) { + throw new Error("bad input"); + } + const input = result.data; + await db.project.create({ data: input }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(proof.request_validation.proven, "guarded safeParse .data alias should prove validation"); +} +``` + +- [ ] **Step 4: Run RED command** + +Run: + +```bash +cargo test -p drift-engine safe_parse_ -- --nocapture +``` + +Expected RED: the bare-result and fake-guard tests fail, and `.data` aliasing fails if not tracked. + +- [ ] **Step 5: GREEN safeParse control flow** + +In `crates/drift-engine/src/security_control_flow.rs`: + +- Accept `result.data` only after a real local exit guard. +- A real exit guard is one of: + - `if (!result.success) return ...` + - `if (!result.success) { return ... }` + - `if (!result.success) throw ...` + - `if (!result.success) { throw ... }` + - `if (result.success) { sink(result.data) }` +- Strip string literals and line comments before searching for `return` or `throw`. +- Do not treat bare `result` as validated input. +- Track `const input = result.data;` after a valid guard as a validated variable alias. + +- [ ] **Step 6: Run GREEN command** + +Run: + +```bash +cargo test -p drift-engine safe_parse_ -- --nocapture +``` + +Expected GREEN: all safeParse tests pass. + +--- + +## Task 4: Inspect Full Sink Spans, Not Only Sink Start Lines + +**Risk addressed:** A multi-line sink can use validated input on the first line and raw request input on later lines, passing incorrectly. + +**Files:** +- Modify: `crates/drift-engine/tests/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` + +- [ ] **Step 1: RED multi-line raw-mixed sink test** + +Add: + +```rust +#[test] +fn multiline_sink_with_validated_and_raw_values_blocks() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const input = ProjectInputSchema.parse(body); + await db.project.create({ + data: input, + audit: body + }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(!proof.request_validation.proven, "raw body in multi-line sink must block"); + assert!(proof.request_validation.unvalidated_uses.iter().any(|use_proof| + use_proof.reason == "request_input_not_validated" + )); +} +``` + +- [ ] **Step 2: Run RED command** + +Run: + +```bash +cargo test -p drift-engine multiline_sink_with_validated_and_raw_values_blocks -- --nocapture +``` + +Expected RED: fail because only the sink start line is inspected. + +- [ ] **Step 3: GREEN full-span sink inspection** + +In `crates/drift-engine/src/security_control_flow.rs` and `crates/drift-engine/src/security_proof.rs`: + +- Add a helper that reads all source lines from `sink.start_line..=sink.end_line`. +- Use that full text for: + - raw input use detection + - validated variable use detection + - safeParse `.data` and alias checks +- Keep line numbers from the sink fact for evidence. + +- [ ] **Step 4: Run GREEN command** + +Run: + +```bash +cargo test -p drift-engine multiline_sink_with_validated_and_raw_values_blocks -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 5: Stop Query And MCP From Synthesizing Proof From Raw Facts + +**Risk addressed:** TypeScript read models currently infer `proven` from raw facts, violating Rust proof ownership. Parser-gap cases can be reported as proven. + +**Files:** +- Modify: `packages/query/test/security-boundary-proof.test.ts` +- Modify: `packages/query/src/index.ts` +- Modify: `packages/mcp/test/mcp.test.ts` +- Modify: `packages/mcp/src/security-context.ts` + +- [ ] **Step 1: RED query repo-map test** + +Add a test to `packages/query/test/security-boundary-proof.test.ts` or the repo-map query test file: + +```ts +it("does not report request validation proven from raw scan facts", () => { + const routeSecurity = routeSecurityFromFacts([ + { + id: "fact_request_body", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "request_input_read", + file_path: "app/api/projects/route.ts", + name: "body", + value: JSON.stringify({ route_id: "route:app/api/projects/route.ts:POST", source: "body", variable: "body" }), + start_line: 3, + end_line: 3 + }, + { + id: "fact_validated_use", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "validated_input_used", + file_path: "app/api/projects/route.ts", + name: "input", + value: JSON.stringify({ route_id: "route:app/api/projects/route.ts:POST", sink_kind: "data_operation" }), + start_line: 5, + end_line: 5 + } + ] as never); + + expect(routeSecurity.request_validation.status).not.toBe("proven"); + expect(routeSecurity.request_validation.status).toBe("not_evaluated"); +}); +``` + +- [ ] **Step 2: RED MCP security-context test** + +In `packages/mcp/test/mcp.test.ts`, change or add the request-validation security-context test so raw facts alone produce: + +```ts +expect(securityContext.request_validation.routes[0]).toMatchObject({ + proof_status: "not_evaluated", + proven: false +}); +``` + +If parser gaps are present: + +```ts +expect(securityContext.request_validation.routes[0].proof_status).not.toBe("proven"); +``` + +- [ ] **Step 3: Run RED commands** + +Run: + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +pnpm --filter @drift/mcp test -- "request validation" +``` + +Expected RED: fail because query/MCP currently synthesize proof from facts. + +- [ ] **Step 4: GREEN read model semantics** + +In `packages/query/src/index.ts` and `packages/mcp/src/security-context.ts`: + +- Raw facts may expose evidence summaries: + - input sources + - validated sink kinds + - parser gaps +- Raw facts must not produce: + - `status: "proven"` + - `proof_status: "proven"` + - `proven: true` +- Use `not_evaluated` for scan-only read models unless a Rust check proof is explicitly provided by the caller. + +- [ ] **Step 5: Run GREEN commands** + +Run: + +```bash +pnpm --filter @drift/query test -- security-boundary-proof +pnpm --filter @drift/mcp test -- "request validation" +``` + +Expected GREEN: pass. + +--- + +## Task 6: Reject Impossible Request-Validation Proof States In Schemas + +**Risk addressed:** TypeScript schemas accept impossible states, such as `proven: true` with `unvalidated_uses`. + +**Files:** +- Modify: `packages/core/test/security.test.ts` +- Modify: `packages/core/src/security.ts` +- Modify: `packages/engine-contract/test/security-contract.test.ts` +- Modify: `packages/engine-contract/src/index.ts` + +- [ ] **Step 1: RED core schema tests** + +Add to `packages/core/test/security.test.ts`: + +```ts +it("rejects impossible request validation proof states", () => { + const proof = validSecurityBoundaryProof({ + request_validation: { + required: true, + proven: true, + input_reads: [{ fact_id: "fact_body", source: "body", variable: "body" }], + validations: [], + validated_uses: [], + unvalidated_uses: [{ + input_fact_id: "fact_body", + sink_fact_id: "fact_sink", + sink_kind: "data_operation", + reason: "request_input_not_validated" + }] + }, + result: { + proof_status: "proven", + enforcement_result: "pass", + can_block: false, + finding_ids: [] + } + }); + + expect(() => SecurityBoundaryProofSchema.parse(proof)).toThrow(/request validation/i); +}); +``` + +If no `validSecurityBoundaryProof` helper exists, create a local helper in the test file that returns the current minimal valid proof object used by existing tests. + +- [ ] **Step 2: RED engine-contract schema test** + +Mirror the same impossible proof in `packages/engine-contract/test/security-contract.test.ts` and assert `EngineSecurityProofEventSchema.safeParse(event).success === false`. + +- [ ] **Step 3: Run RED commands** + +Run: + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected RED: schemas accept impossible proof states. + +- [ ] **Step 4: GREEN schema refinements** + +In both `packages/core/src/security.ts` and `packages/engine-contract/src/index.ts`, add schema-level refinement: + +- If `request_validation.required && request_validation.proven`, then: + - `unvalidated_uses.length === 0` + - no request-validation `missing_proof` entries + - no request-validation parser gaps with `blocks_enforcement === true` + - `validated_uses.length > 0` + - `result.proof_status === "proven"` + - `result.enforcement_result === "pass"` + +- If `request_validation.unvalidated_uses.length > 0`, then: + - `request_validation.proven === false` + - `result.proof_status !== "proven"` + +- [ ] **Step 5: Run GREEN commands** + +Run: + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- security-contract +``` + +Expected GREEN: pass. + +--- + +## Task 7: Preserve Specific Missing-Proof And Parser-Gap Reasons In CLI Findings + +**Risk addressed:** CLI findings flatten request-validation reasons to `request_input_not_validated`, losing actionable information for parser gaps and unknown validators. + +**Files:** +- Modify: `packages/cli/test/security-check.test.ts` +- Modify: `packages/cli/src/check/run-check.ts` + +- [ ] **Step 1: RED CLI reason mapping test** + +Add to `packages/cli/test/security-check.test.ts`: + +```ts +it("maps request validation parser gap reason into finding actual_layer", async () => { + const result = await runEngineCheck(/* fixture where body spread emits unsupported_request_input_spread */); + const finding = result.findings.find((entry) => + entry.rule_id === "api_route_requires_request_validation" + ); + + expect(finding?.rule_id).toBe("api_route_requires_request_validation"); + expect(finding?.message).toContain("Accepted request validation"); +}); +``` + +Then add a `runCheck` JSON assertion against the stored CLI finding: + +```ts +expect(payload.findings[0]).toMatchObject({ + expected_layer: "request_validation", + actual_layer: "unsupported_request_input_spread" +}); +``` + +- [ ] **Step 2: Run RED command** + +Run: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected RED: actual layer is always `request_input_not_validated`. + +- [ ] **Step 3: GREEN CLI mapping** + +In `packages/cli/src/check/run-check.ts`, derive `actual_layer` from engine output: + +Priority: + +1. first parser gap code from `proof.parser_gaps` +2. first missing proof code from `proof.missing_proof` +3. first unvalidated use reason from `proof.request_validation.unvalidated_uses` +4. fallback `request_input_not_validated` + +Do not parse source text. + +- [ ] **Step 4: Run GREEN command** + +Run: + +```bash +pnpm --filter @drift/cli test -- security-check +``` + +Expected GREEN: pass. + +--- + +## Task 8: Enforce Method, Input Source, And Sink Scope In Engine Path + +**Risk addressed:** Contract method/input/sink scope is not represented or enforced in the engine request path. + +**Files:** +- Modify: `packages/core/test/security.test.ts` +- Modify: `packages/core/src/security.ts` +- Modify: `packages/engine-contract/test/engine-contract.test.ts` +- Modify: `packages/engine-contract/src/index.ts` +- Modify: `crates/drift-engine/src/protocol.rs` +- Modify: `crates/drift-engine/src/check_command.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` +- Modify: `crates/drift-engine/tests/security_rules.rs` + +- [ ] **Step 1: RED schema test for accepted contract scope** + +In `packages/core/test/security.test.ts`, assert this contract is valid: + +```ts +const contract = SecurityConventionSchema.parse({ + contract_id: "security_api_request_validation", + kind: "api_route_requires_request_validation", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { + file_roles: ["api_route"], + methods: ["POST"] + }, + scope: { + check_scope: "changed-hunks", + applies_to: "route" + }, + requires: { + input_sources: ["body"], + sinks: ["data_operation"], + schemas: ["ProjectInputSchema"] + } +}); + +expect(contract.requires?.input_sources).toEqual(["body"]); +``` + +- [ ] **Step 2: RED engine protocol test** + +In `packages/engine-contract/test/engine-contract.test.ts`, assert `EngineCheckRequestSchema` accepts a request-validation convention with: + +```json +"matcher": { "methods": ["POST"], "applies_to_file_roles": ["api_route"] }, +"requires": { + "input_sources": ["body"], + "sinks": ["data_operation"], + "schemas": ["ProjectInputSchema"] +} +``` + +- [ ] **Step 3: RED Rust method filtering test** + +Add to `crates/drift-engine/tests/security_rules.rs`: + +```rust +#[test] +fn request_validation_contract_applies_only_to_configured_methods() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function GET(request: Request) { + const body = await request.json(); + await db.project.create({ data: body }); + return Response.json({ ok: true }); +} +"#; + // Contract requires POST only. + // Expected: no finding for GET. +} +``` + +- [ ] **Step 4: Run RED commands** + +Run: + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- engine-contract +cargo test -p drift-engine request_validation_contract_applies_only_to_configured_methods -- --nocapture +``` + +Expected RED: method/input/sink scope is not enforced. + +- [ ] **Step 5: GREEN protocol and rule wiring** + +Implement: + +- `CheckMatcher.methods: Option>` +- request-validation requires parsing for: + - `input_sources` + - `sinks` + - `validators` + - `schemas` +- Route method filtering in `check_command.rs` before building proof. +- Input-source filtering in `security_proof.rs` before unvalidated-use evaluation. +- Sink-kind filtering in `security_proof.rs` before unvalidated-use evaluation. + +- [ ] **Step 6: Run GREEN commands** + +Run: + +```bash +pnpm --filter @drift/core test -- security +pnpm --filter @drift/engine-contract test -- engine-contract +cargo test -p drift-engine request_validation_contract_applies_only_to_configured_methods -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 9: Support Throwing Validators Without Accepting Raw Input Incorrectly + +**Risk addressed:** Throwing validators cannot prove original input despite Phase 3 requirements. + +**Files:** +- Modify: `crates/drift-engine/tests/security_rules.rs` +- Modify: `crates/drift-engine/src/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` + +- [ ] **Step 1: RED throwing validator dominance pass test** + +Add: + +```rust +#[test] +fn throwing_validator_dominating_sink_allows_original_input_use() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + assertProjectInput(body); + await db.project.create({ data: body }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "assert_project_input".to_string(), + symbol: "assertProjectInput".to_string(), + kind: RequestValidatorKind::Helper, + behavior: RequestValidatorBehavior::Throws, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(proof.request_validation.proven, "throwing validator before sink should prove original input"); +} +``` + +- [ ] **Step 2: RED non-throwing raw input still blocks** + +Add: + +```rust +#[test] +fn returns_parsed_validator_does_not_allow_raw_input_use() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + validateProjectInput(body); + await db.project.create({ data: body }); + return Response.json({ ok: true }); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "validate_project_input".to_string(), + symbol: "validateProjectInput".to_string(), + kind: RequestValidatorKind::Helper, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &validators) + .expect("request validation proof"); + + assert!(!proof.request_validation.proven, "returns-parsed validator must not bless raw input"); +} +``` + +- [ ] **Step 3: Run RED command** + +Run: + +```bash +cargo test -p drift-engine validator_dominating_sink -- --nocapture +``` + +Expected RED: throwing validator dominance is not supported. + +- [ ] **Step 4: GREEN throwing validator source-to-sink rule** + +In Rust: + +- For accepted validators with `behavior == Throws`, treat original `input_var` as validated only if the validator call line dominates the sink line. +- Do not apply this to `ReturnsParsed`, `Boolean`, or `Unknown`. +- Do not accept throwing validators inside only one branch unless existing dominance logic can prove the sink is protected. + +- [ ] **Step 5: Run GREEN command** + +Run: + +```bash +cargo test -p drift-engine validator_dominating_sink -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 10: Namespace Imports And Destructured Request Inputs + +**Risk addressed:** Namespace-imported validators are missed; destructured params/request-derived values are silently omitted instead of extracted or parser-gapped. + +**Files:** +- Modify: `crates/drift-engine/tests/security_facts.rs` +- Modify: `crates/drift-engine/tests/security_control_flow.rs` +- Modify: `crates/drift-engine/src/security_patterns.rs` +- Modify: `crates/drift-engine/src/security_facts.rs` +- Modify: `crates/drift-engine/src/security_proof.rs` + +- [ ] **Step 1: RED namespace schema import test** + +Add to `crates/drift-engine/tests/security_facts.rs`: + +```rust +#[test] +fn extracts_request_validation_called_for_namespace_imported_schema() { + let source = r#" +import * as validation from "@/server/validation"; +export async function POST(request: Request) { + const body = await request.json(); + const input = validation.ProjectInputSchema.parse(body); + return Response.json(input); +} +"#; + let validators = vec![AcceptedRequestValidator { + validator_id: "schema_project_input".to_string(), + symbol: "ProjectInputSchema".to_string(), + kind: RequestValidatorKind::Schema, + behavior: RequestValidatorBehavior::ReturnsParsed, + }]; + let facts = extract_security_facts_with_validation( + "app/api/projects/route.ts", + source, + &[], + &validators, + ) + .expect("security facts"); + + assert!(facts.iter().any(|fact| fact.kind == FactKind::RequestValidationCalled)); +} +``` + +- [ ] **Step 2: RED destructured params extraction test** + +Add: + +```rust +#[test] +fn extracts_destructured_params_as_request_input_read() { + let source = r#" +export async function GET(_request: Request, { params }: { params: { projectId: string } }) { + const { projectId } = params; + return Response.json({ projectId }); +} +"#; + let facts = extract_security_facts("app/api/projects/route.ts", source, &[]) + .expect("security facts"); + + assert!(facts.iter().any(|fact| + fact.kind == FactKind::RequestInputRead + && fact.name == "projectId" + && fact.value.as_deref().is_some_and(|value| value.contains("\"source\":\"params\"")) + )); +} +``` + +- [ ] **Step 3: RED body destructuring parser gap test** + +Add to `crates/drift-engine/tests/security_control_flow.rs`: + +```rust +#[test] +fn destructured_body_input_emits_parser_gap() { + let source = r#" +const db = { project: { create: async (input) => input } }; +export async function POST(request: Request) { + const body = await request.json(); + const { name } = body; + await db.project.create({ data: { name } }); + return Response.json({ ok: true }); +} +"#; + let proof = build_request_validation_proof("app/api/projects/route.ts", source, &[]) + .expect("request validation proof"); + + assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); + assert!(proof.parser_gaps.iter().any(|gap| + gap.code == "unsupported_request_input_destructure" && gap.blocks_enforcement + )); +} +``` + +- [ ] **Step 4: Run RED command** + +Run: + +```bash +cargo test -p drift-engine namespace_imported_schema destructured -- --nocapture +``` + +Expected RED: namespace import and destructured request inputs are missed. + +- [ ] **Step 5: GREEN extraction and parser gaps** + +Implement: + +- Namespace import recognition in `security_patterns.rs` for `validation.ProjectInputSchema.parse(body)` when `ProjectInputSchema` is accepted. +- Destructured `params` extraction in `security_facts.rs`. +- Parser gap `unsupported_request_input_destructure` for destructuring from body/query/header/cookie variables when deterministic propagation is not implemented. +- Add the parser gap code to Rust/TS schemas in Task 6 if not already done there. + +- [ ] **Step 6: Run GREEN command** + +Run: + +```bash +cargo test -p drift-engine namespace_imported_schema destructured -- --nocapture +``` + +Expected GREEN: pass. + +--- + +## Task 11: Strengthen E2E Assertions + +**Risk addressed:** E2E assertions can pass for weak reasons and leak checks are partly tautological. + +**Files:** +- Modify: `test/e2e/security-validation.test.ts` +- Modify: `test/fixtures/security-validation-*` + +- [ ] **Step 1: RED e2e assertions** + +Update each e2e case to assert all of: + +- `payload.check.status === "fail"` for blocking cases. +- `payload.summary.blocking_count === 1` for blocking cases. +- `payload.findings[0].expected_layer === "request_validation"`. +- `payload.findings[0].actual_layer` equals the exact expected reason. +- `payload.security_boundary_proofs[0].result.proof_status` equals: + - `missing_proof` + - `parser_gap` + - `proven` +- parser-gap case includes `unsupported_request_input_spread`. +- pass case has `payload.summary.findings_count === 0`. + +Add explicit canary strings to fixtures: + +```ts +const secretCanary = "SECRET_VALUE_SHOULD_NOT_LEAK"; +const cookieCanary = request.headers.get("cookie"); +``` + +Then assert: + +```ts +expect(JSON.stringify(payload)).not.toContain("SECRET_VALUE_SHOULD_NOT_LEAK"); +expect(JSON.stringify(payload)).not.toContain("session="); +expect(JSON.stringify(payload)).not.toContain("request.json()"); +``` + +- [ ] **Step 2: Run RED command** + +Run: + +```bash +pnpm exec vitest run test/e2e/security-validation.test.ts --no-file-parallelism --maxWorkers=1 +``` + +Expected RED: fail where current behavior reports pass/non-blocking or weak reason values. + +- [ ] **Step 3: GREEN e2e plus implementation fixes** + +Apply only the implementation fixes from Tasks 1-10 required to satisfy the stronger e2e assertions. Do not weaken the assertions. + +- [ ] **Step 4: Run GREEN command** + +Run: + +```bash +pnpm exec vitest run test/e2e/security-validation.test.ts --no-file-parallelism --maxWorkers=1 +``` + +Expected GREEN: pass. + +--- + +## Task 12: Final Production-Grade Gate + +**Files:** no production files unless prior tasks require final formatting. + +- [ ] **Step 1: Rebuild local package dist for package-pack/e2e truth** + +Run: + +```bash +pnpm --filter @drift/core build +pnpm --filter @drift/engine-contract build +pnpm --filter @drift/query build +pnpm --filter @drift/cli build +pnpm --filter @drift/mcp build +``` + +Expected: all pass. Generated `dist` output should remain untracked unless the repo policy changes. + +- [ ] **Step 2: Run final gates** + +Run: + +```bash +cargo test -p drift-engine security_ +cargo test -p drift-engine +pnpm --filter @drift/core test +pnpm --filter @drift/engine-contract test +pnpm --filter @drift/query test +pnpm --filter @drift/cli test +pnpm --filter @drift/mcp test +pnpm test:e2e +pnpm typecheck +cargo fmt --all -- --check +cargo clippy -p drift-engine --all-targets -- -D warnings +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 3: Confirm branch hygiene** + +Run: + +```bash +git status --short --branch +git diff --stat origin/codex/security-request-validation-phase3...HEAD +git diff --name-only origin/codex/security-request-validation-phase3...HEAD +``` + +Expected: + +- Only corrective Phase 3 hardening files are changed. +- No unrelated untracked files are staged. +- No Phase 4+ files or concepts are implemented. + +- [ ] **Step 4: Commit** + +Commit message: + +```bash +git add +git commit -m "Harden Phase 3 request validation enforcement" +``` + +Expected: one corrective commit on top of `codex/security-request-validation-phase3`. + +--- + +## Completion Criteria + +Phase 3 is production-ready only when: + +- `matcher.required_calls` cannot accept request validators. +- `requires.validators` and `requires.schemas` are the only accepted request-validation contract truth. +- `safeParse` proof requires real success guard semantics and `.data` use, including aliases. +- Multi-line sinks cannot hide raw request input. +- No-request-input routes do not block. +- Query/MCP read models never synthesize proof from raw facts. +- TS schemas reject impossible request-validation proof states. +- CLI findings preserve exact request-validation missing-proof/parser-gap reasons. +- Method/input-source/sink scope is represented and enforced. +- Throwing validators prove original input only when accepted and dominant. +- Namespace imports and destructured request input are handled or parser-gapped. +- E2E tests assert real blocking/proof behavior, not just proof shape. +- Phase 4+ remains untouched. diff --git a/drift v3/packages/cli/src/app/router.ts b/drift v3/packages/cli/src/app/router.ts index 69e16fbe..52649275 100644 --- a/drift v3/packages/cli/src/app/router.ts +++ b/drift v3/packages/cli/src/app/router.ts @@ -15,6 +15,7 @@ import { checkPolicyContext,grantAgentPermission,revokeAgentPermission,setEgress import { prepareTask } from "../commands/prepare.js"; import { showRepoMap } from "../commands/repo-map.js"; import { scanRepo,scanStatus } from "../commands/scan.js"; +import { securityAudit } from "../commands/security.js"; import { startRepo } from "../commands/start.js"; import { supportBundle } from "../commands/support.js"; import { CommandPayload,ParsedArgs } from "./command-types.js"; @@ -50,6 +51,10 @@ export async function runCommand(storage: SqliteDriftStorage, parsed: ParsedArgs return showRepoMap(storage, parsed); } + if (group === "security" && command === "audit") { + return securityAudit(storage, parsed); + } + if (group === "checks" && command === "list") { return listChecks(storage, parsed); } diff --git a/drift v3/packages/cli/src/args/command-shape.ts b/drift v3/packages/cli/src/args/command-shape.ts index c5645039..1018773a 100644 --- a/drift v3/packages/cli/src/args/command-shape.ts +++ b/drift v3/packages/cli/src/args/command-shape.ts @@ -23,6 +23,9 @@ export function unknownCommandError(parsed: ParsedArgs): string | null { if (group === "repo") { return exact(["map"]); } + if (group === "security") { + return exact(["audit"]); + } if (group === "checks") { return exact(["list", "run"]); } @@ -107,6 +110,10 @@ export function validateCommandShape(parsed: ParsedArgs): void { exact("repo map", 2); return; } + if (group === "security" && command === "audit") { + exact("security audit", 2); + return; + } if (group === "checks" && (command === "list" || command === "run")) { exact(`checks ${command}`, 2); return; diff --git a/drift v3/packages/cli/src/args/help.ts b/drift v3/packages/cli/src/args/help.ts index ae5c9e49..b9c40697 100644 --- a/drift v3/packages/cli/src/args/help.ts +++ b/drift v3/packages/cli/src/args/help.ts @@ -144,6 +144,21 @@ export function helpText(parsed: ParsedArgs): string { ].join("\n"); } + if (parsed.positional[0] === "security") { + return [ + "Audit repo security architecture", + "", + "Usage:", + " drift --db security audit --repo --json", + "", + "What security audit returns:", + " proof-safe inventory of observed security patterns across auth, middleware, data access, request validation, session trust, authorization, tenant scope, response safety, SSRF, SQL, CORS, CSRF, and rate limits.", + " candidate-only patterns are labeled as inventory and never treated as blocking proof.", + " output includes file paths and line numbers, not source snippets or raw fact values.", + "" + ].join("\n"); + } + if (parsed.positional[0] === "checks") { return [ "List repo checks and safe commands", diff --git a/drift v3/packages/cli/src/commands/security.ts b/drift v3/packages/cli/src/commands/security.ts new file mode 100644 index 00000000..9eec9619 --- /dev/null +++ b/drift v3/packages/cli/src/commands/security.ts @@ -0,0 +1,32 @@ +import { buildSecurityArchitectureAudit } from "@drift/query"; +import type { SqliteDriftStorage } from "@drift/storage"; +import { CommandPayload, ParsedArgs } from "../app/command-types.js"; +import { resolveRepoId } from "../args/repo-flags.js"; +import { latestIndexedScan } from "../domain/scan-status.js"; +import { formatSecurityAuditText } from "../formatters/security-audit.js"; + +export function securityAudit(storage: SqliteDriftStorage, parsed: ParsedArgs): CommandPayload { + const repoId = resolveRepoId(parsed); + const repo = storage.getRepo(repoId); + if (!repo) { + throw new Error(`Unknown repo ${repoId}. Run drift scan --repo-root first.`); + } + const latestScan = latestIndexedScan(storage.listScanManifests(repoId)); + const facts = latestScan ? storage.listFacts(latestScan.id) : []; + const proofRuns = storage.listLatestSecurityBoundaryProofRunsForRepo({ repo_id: repoId }); + const fallbackProofs = proofRuns.length === 0 && latestScan + ? storage.listSecurityBoundaryProofs(repoId, latestScan.id) + : []; + const payload = buildSecurityArchitectureAudit({ + repo_id: repoId, + scan_id: proofRuns[0]?.scan_id ?? latestScan?.id ?? null, + facts, + candidates: storage.listConventionCandidates(repoId), + accepted_conventions: storage.listAcceptedConventions(repoId), + parser_gaps: latestScan ? storage.listParserGaps(repoId, latestScan.id) : [], + proofs: proofRuns.length > 0 ? proofRuns.map((run) => run.proof) : fallbackProofs + }); + return { + payload: parsed.flags.has("json") ? payload : formatSecurityAuditText(payload) + }; +} diff --git a/drift v3/packages/cli/src/formatters/security-audit.ts b/drift v3/packages/cli/src/formatters/security-audit.ts new file mode 100644 index 00000000..9b5d6c69 --- /dev/null +++ b/drift v3/packages/cli/src/formatters/security-audit.ts @@ -0,0 +1,39 @@ +import type { SecurityArchitectureAudit } from "@drift/query"; + +export function formatSecurityAuditText(payload: SecurityArchitectureAudit): string { + const areaRows = Object.values(payload.areas) + .filter((area) => area.observed) + .map((area) => { + const patterns = area.priority_patterns.slice(0, 5).map((pattern) => + ` ${pattern.pattern} priority:${pattern.priority} role:${pattern.semantic_role} files:${pattern.file_count} facts:${pattern.fact_count} truth:${pattern.proof_truth}` + ); + return [ + ` ${area.title}: priority ${area.priority_count}, inventory ${area.inventory_count}, candidate-only ${area.candidate_only_count}, accepted ${area.accepted_count}, proofs ${area.proof_count}`, + ...(patterns.length > 0 ? patterns : [" no priority signals"]) + ].join("\n"); + }); + return [ + "Drift security audit", + "", + `Repo: ${payload.repo_id}`, + `Scan: ${payload.scan_id ?? "none"}`, + `Areas: ${payload.summary.observed_area_count} observed of ${payload.summary.area_count}`, + `API routes: ${payload.summary.api_route_file_count}`, + `Facts: ${payload.summary.fact_count}`, + `Candidates: ${payload.summary.candidate_count}`, + `Accepted conventions: ${payload.summary.accepted_convention_count}`, + `Proof runs: ${payload.summary.proof_count}`, + `Parser gaps: ${payload.summary.parser_gap_count}`, + `Candidate-only patterns: ${payload.summary.candidate_only_pattern_count}`, + `Priority signals: ${payload.summary.priority_pattern_count}`, + `Inventory-only patterns: ${payload.summary.inventory_pattern_count}`, + `Signal/noise ratio: ${payload.summary.signal_to_noise_ratio}`, + "", + "Priority areas:", + ...(areaRows.length > 0 ? areaRows : [" none"]), + "", + "Next steps:", + ...payload.next_steps.map((step) => ` ${step}`), + "" + ].join("\n"); +} diff --git a/drift v3/packages/cli/test/cli.test.ts b/drift v3/packages/cli/test/cli.test.ts index 222f5cb7..99058f32 100644 --- a/drift v3/packages/cli/test/cli.test.ts +++ b/drift v3/packages/cli/test/cli.test.ts @@ -2176,7 +2176,7 @@ describe("drift CLI convention review", () => { "--json" ]); - expect(result.exitCode).toBe(0); + expect(result.exitCode, result.stderr).toBe(0); const payload = JSON.parse(result.stdout); expect(payload.summary).toMatchObject({ files_indexed: 1, @@ -10499,6 +10499,210 @@ describe("drift CLI convention review", () => { ])); }); + it("reports proof-safe security architecture audit without leaking raw fact values", async () => { + const databasePath = await seedDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + storage.upsertScanManifest({ + id: "scan_security_audit", + repo_id: "repo_abc", + branch: "main", + commit: "abc123", + dirty: false, + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + rule_engine_version: "0.1.0", + status: "completed", + file_count: 2, + fact_count: 8, + finding_count: 0, + started_at: "2026-05-27T00:00:00.000Z", + completed_at: "2026-05-27T00:00:01.000Z" + }); + storage.upsertFacts([ + { + id: "fact_security_audit_role", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "file_role_detected", + file_path: "apps/web/app/api/apps/route.ts", + name: "api_route", + start_line: 1, + end_line: 1, + ...factQuality("scan_security_audit") + }, + { + id: "fact_security_audit_auth", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "symbol_called", + file_path: "apps/web/app/api/apps/route.ts", + name: "withWorkspace", + start_line: 4, + end_line: 4, + ...factQuality("scan_security_audit") + }, + { + id: "fact_security_audit_parser", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "symbol_called", + file_path: "apps/web/app/api/apps/route.ts", + name: "parseRequestBody", + start_line: 8, + end_line: 8, + ...factQuality("scan_security_audit") + }, + { + id: "fact_security_audit_validator", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "symbol_called", + file_path: "apps/web/app/api/apps/route.ts", + name: "parseAsync", + value: "createOAuthAppSchema", + start_line: 8, + end_line: 8, + ...factQuality("scan_security_audit") + }, + { + id: "fact_security_audit_rate_error", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "symbol_called", + file_path: "apps/web/app/api/public/route.ts", + name: "exceededLimitError", + start_line: 9, + end_line: 9, + ...factQuality("scan_security_audit") + }, + { + id: "fact_security_audit_ssrf", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "outbound_request_called", + file_path: "apps/web/app/api/import/route.ts", + name: "fetch", + value: JSON.stringify({ + url_source: "request_input", + url_var: "url", + raw_url: "https://token@example.com" + }), + start_line: 12, + end_line: 12, + ...factQuality("scan_security_audit") + } + ]); + storage.upsertConventionCandidate({ + id: "candidate_security_audit_body_parser", + repo_id: "repo_abc", + scan_id: "scan_security_audit", + kind: "api_route_requires_request_validation", + statement: "Uses parseRequestBody.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_request_validation", + required_calls: ["parseRequestBody"] + }, + requires: { validators: [{ symbol: "parseRequestBody" }] }, + suggested_severity: "warning", + suggested_enforcement_mode: "warn", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + scoring: { + supporting_examples_count: 2, + counterexamples_count: 0, + scope_files_count: 3, + coverage_ratio: 0.67, + heuristic_id: "test-security-audit" + }, + evidence_refs: [], + counterexample_refs: [], + matcher_fingerprint: "security-audit-body-parser", + scope_fingerprint: "security-audit-scope", + graph_fingerprint: "security-audit-graph", + evidence_fingerprint: "security-audit-evidence", + required_capabilities: ["syntax_facts", "request_validation"], + reason_not_blocking: "candidate_not_accepted", + status: "candidate", + created_at: "2026-05-27T00:00:02.000Z" + }); + storage.upsertAcceptedConvention("repo_abc", { + id: "convention_security_audit_auth", + contract_id: "contract_security_audit", + kind: "api_route_requires_auth_helper", + statement: "Use withWorkspace.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_auth_helper", + required_calls: ["withWorkspace"] + }, + requires: { auth_helpers: [{ symbol: "withWorkspace" }] }, + severity: "warning", + enforcement_mode: "warn", + enforcement_capability: "deterministic_check", + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-27T00:00:03.000Z", + updated_at: "2026-05-27T00:00:03.000Z" + }); + storage.close(); + + const jsonResult = await runCli([ + "--db", databasePath, + "security", "audit", + "--repo", "repo_abc", + "--json" + ]); + const textResult = await runCli([ + "--db", databasePath, + "security", "audit", + "--repo", "repo_abc" + ]); + + expect(jsonResult.stderr).toBe(""); + expect(jsonResult.exitCode).toBe(0); + const payload = JSON.parse(jsonResult.stdout); + expect(payload.response_schema).toBe("drift.security.audit.v1"); + expect(payload.areas.auth_boundary.patterns[0]).toMatchObject({ + pattern: "withWorkspace", + accepted: true, + candidate_only: false + }); + expect(payload.areas.request_validation.patterns).toEqual(expect.arrayContaining([ + expect.objectContaining({ + pattern: "parseRequestBody", + semantic_role: "body_parser", + proof_truth: "candidate_only" + }), + expect.objectContaining({ + pattern: "createOAuthAppSchema.parseAsync", + semantic_role: "validator" + }) + ])); + expect(payload.areas.rate_limit.patterns).toEqual(expect.arrayContaining([ + expect.objectContaining({ + pattern: "exceededLimitError", + semantic_role: "error_helper" + }) + ])); + expect(payload.areas.ssrf.patterns[0]).toMatchObject({ + pattern: "request_input" + }); + expect(jsonResult.stdout).not.toContain("https://token@example.com"); + expect(jsonResult.stdout).not.toContain("raw_url"); + expect(textResult.exitCode).toBe(0); + expect(textResult.stdout).toContain("Drift security audit"); + expect(textResult.stdout).toContain("Candidate-only patterns: 2"); + expect(textResult.stdout).toContain("Priority signals:"); + expect(textResult.stdout).toContain("Signal/noise ratio:"); + expect(textResult.stdout).not.toContain("parseRequestBody priority:"); + expect(textResult.stdout).not.toContain("exceededLimitError priority:"); + expect(textResult.stdout).not.toContain("https://token@example.com"); + }); + it("repo map reports route middleware coverage summary", async () => { const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-middleware-"); const storage = openDriftStorage({ databasePath }); diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index 69119d60..b336d51d 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -31,6 +31,7 @@ export { scoreHelperSimilarity } from "./helper-similarity.js"; export { buildRepoTopology } from "./repo-topology.js"; export { buildReadiness } from "./readiness.js"; export { buildSecurityBoundaryProofReadModel, buildSecurityPhase8ReadModel } from "./security-boundary-proof.js"; +export { buildSecurityArchitectureAudit } from "./security-architecture-audit.js"; export type { BuildEntrypointFlowProofInput } from "./flow-proof.js"; export type { BuildChangeImpactInput, ChangeImpactRouteFlow } from "./change-impact.js"; export type { ClassifyDataOperationRiskInput } from "./data-operation-risk.js"; @@ -57,6 +58,15 @@ export type { SecurityCapabilitySummary, SecurityPhase8Route } from "./security-boundary-proof.js"; +export type { + BuildSecurityArchitectureAuditInput, + SecurityArchitectureAudit, + SecurityArchitectureAuditArea, + SecurityArchitectureAuditAreaKey, + SecurityArchitectureAuditPattern, + SecurityArchitectureProofTruth, + SecurityArchitectureSemanticRole +} from "./security-architecture-audit.js"; export interface GraphRepoMapFile { path: string; diff --git a/drift v3/packages/query/src/security-architecture-audit.ts b/drift v3/packages/query/src/security-architecture-audit.ts new file mode 100644 index 00000000..107b6461 --- /dev/null +++ b/drift v3/packages/query/src/security-architecture-audit.ts @@ -0,0 +1,784 @@ +import type { + AcceptedConvention, + ConventionCandidate, + FactRecord, + ParserGap, + SecurityBoundaryProof +} from "@drift/core"; + +export type SecurityArchitectureAuditAreaKey = + | "auth_boundary" + | "middleware_coverage" + | "data_access" + | "request_validation" + | "session_trust" + | "authorization" + | "tenant_scope" + | "sensitive_response" + | "secret_exposure" + | "ssrf" + | "raw_sql" + | "cors" + | "csrf" + | "rate_limit"; + +export type SecurityArchitectureSemanticRole = + | "auth_wrapper" + | "session_source" + | "body_parser" + | "validator" + | "rate_limiter" + | "error_helper" + | "tenant_precondition" + | "tenant_predicate" + | "data_access" + | "outbound_request" + | "raw_sql" + | "parameterized_sql" + | "cors_policy" + | "csrf_guard" + | "sensitive_field" + | "response_field" + | "middleware" + | "authorization_guard" + | "secret_reference" + | "unknown"; + +export type SecurityArchitectureProofTruth = + | "accepted_proof" + | "candidate_only" + | "fact_inventory" + | "not_observed"; + +export type SecurityArchitecturePriority = "high" | "medium" | "low"; +export type SecurityArchitectureReportSurface = "priority" | "inventory"; + +export interface BuildSecurityArchitectureAuditInput { + repo_id: string; + scan_id: string | null; + facts: FactRecord[]; + candidates: ConventionCandidate[]; + accepted_conventions: AcceptedConvention[]; + parser_gaps: ParserGap[]; + proofs: SecurityBoundaryProof[]; +} + +export interface SecurityArchitectureAuditPattern { + pattern: string; + semantic_role: SecurityArchitectureSemanticRole; + fact_count: number; + file_count: number; + files: SecurityArchitectureAuditFileRef[]; + accepted: boolean; + candidate_only: boolean; + candidate_ids: string[]; + accepted_convention_ids: string[]; + proof_truth: SecurityArchitectureProofTruth; + priority: SecurityArchitecturePriority; + report_surface: SecurityArchitectureReportSurface; +} + +export interface SecurityArchitectureAuditFileRef { + file_path: string; + start_line: number; +} + +export interface SecurityArchitectureAuditArea { + key: SecurityArchitectureAuditAreaKey; + title: string; + observed: boolean; + pattern_count: number; + fact_count: number; + candidate_only_count: number; + accepted_count: number; + proof_count: number; + parser_gap_count: number; + priority_count: number; + inventory_count: number; + patterns: SecurityArchitectureAuditPattern[]; + priority_patterns: SecurityArchitectureAuditPattern[]; +} + +export interface SecurityArchitectureAudit { + response_schema: "drift.security.audit.v1"; + repo_id: string; + scan_id: string | null; + summary: { + area_count: number; + observed_area_count: number; + api_route_file_count: number; + fact_count: number; + candidate_count: number; + accepted_convention_count: number; + proof_count: number; + parser_gap_count: number; + candidate_only_pattern_count: number; + priority_pattern_count: number; + inventory_pattern_count: number; + signal_to_noise_ratio: number; + }; + areas: Record; + next_steps: string[]; + redactions: { + source_content_included: false; + raw_fact_values_included: false; + snippets_included: false; + }; +} + +interface PatternSeed { + area: SecurityArchitectureAuditAreaKey; + pattern: string; + semanticRole: SecurityArchitectureSemanticRole; + file?: SecurityArchitectureAuditFileRef; + candidateId?: string; + acceptedConventionId?: string; + proofBacked?: boolean; +} + +const AREA_TITLES: Record = { + auth_boundary: "Auth boundary", + middleware_coverage: "Middleware coverage", + data_access: "Data access", + request_validation: "Request validation", + session_trust: "Session trust", + authorization: "Authorization", + tenant_scope: "Tenant scope", + sensitive_response: "Sensitive response", + secret_exposure: "Secret exposure", + ssrf: "SSRF", + raw_sql: "Raw SQL", + cors: "CORS", + csrf: "CSRF", + rate_limit: "Rate limit" +}; + +const AREA_KEYS = Object.keys(AREA_TITLES) as SecurityArchitectureAuditAreaKey[]; + +const CONVENTION_AREA: Record = { + api_route_requires_auth_helper: "auth_boundary", + middleware_must_cover_routes: "middleware_coverage", + api_route_no_direct_data_access: "data_access", + api_route_requires_service_delegation: "data_access", + api_route_requires_request_validation: "request_validation", + session_object_must_come_from_trusted_helper: "session_trust", + api_route_requires_authorization: "authorization", + api_route_requires_tenant_scope: "tenant_scope", + api_route_forbids_sensitive_response_fields: "sensitive_response", + api_route_forbids_secret_exposure: "secret_exposure", + api_route_forbids_untrusted_ssrf: "ssrf", + api_route_forbids_raw_sql_without_params: "raw_sql", + api_route_cors_must_match_policy: "cors", + api_route_requires_csrf_for_mutation: "csrf", + api_route_requires_rate_limit: "rate_limit" +}; + +export function buildSecurityArchitectureAudit(input: BuildSecurityArchitectureAuditInput): SecurityArchitectureAudit { + const patternMap = new Map(); + const proofedAreas = proofedAreaCounts(input.proofs); + + for (const fact of input.facts) { + for (const seed of classifyFact(fact)) { + upsertPattern(patternMap, seed); + } + } + + for (const candidate of input.candidates) { + const area = CONVENTION_AREA[candidate.kind]; + if (!area) { + continue; + } + for (const pattern of candidatePatterns(candidate)) { + upsertPattern(patternMap, { + area, + pattern, + semanticRole: semanticRoleForConvention(area, pattern), + candidateId: candidate.id + }); + } + } + + for (const convention of input.accepted_conventions) { + const area = CONVENTION_AREA[convention.kind]; + if (!area) { + continue; + } + for (const pattern of acceptedConventionPatterns(convention)) { + upsertPattern(patternMap, { + area, + pattern, + semanticRole: semanticRoleForConvention(area, pattern), + acceptedConventionId: convention.id, + proofBacked: true + }); + } + } + + const areas = Object.fromEntries(AREA_KEYS.map((key) => { + const patterns = [...patternMap.values()] + .filter((pattern) => patternKeyArea(pattern) === key) + .map(finalizePattern) + .sort(comparePatterns); + return [key, { + key, + title: AREA_TITLES[key], + observed: patterns.length > 0 || (proofedAreas.get(key) ?? 0) > 0, + pattern_count: patterns.length, + fact_count: patterns.reduce((count, pattern) => count + pattern.fact_count, 0), + candidate_only_count: patterns.filter((pattern) => pattern.candidate_only).length, + accepted_count: patterns.filter((pattern) => pattern.accepted).length, + proof_count: proofedAreas.get(key) ?? 0, + parser_gap_count: input.parser_gaps.filter((gap) => parserGapArea(gap) === key).length, + priority_count: patterns.filter((pattern) => pattern.report_surface === "priority").length, + inventory_count: patterns.filter((pattern) => pattern.report_surface === "inventory").length, + patterns, + priority_patterns: patterns.filter((pattern) => pattern.report_surface === "priority") + }]; + })) as Record; + + const candidateOnlyPatternCount = Object.values(areas) + .reduce((count, area) => count + area.candidate_only_count, 0); + const priorityPatternCount = Object.values(areas) + .reduce((count, area) => count + area.priority_count, 0); + const inventoryPatternCount = Object.values(areas) + .reduce((count, area) => count + area.inventory_count, 0); + + return { + response_schema: "drift.security.audit.v1", + repo_id: input.repo_id, + scan_id: input.scan_id, + summary: { + area_count: AREA_KEYS.length, + observed_area_count: Object.values(areas).filter((area) => area.observed).length, + api_route_file_count: new Set(input.facts + .filter((fact) => fact.kind === "file_role_detected" && fact.name === "api_route") + .map((fact) => fact.file_path)).size, + fact_count: input.facts.length, + candidate_count: input.candidates.length, + accepted_convention_count: input.accepted_conventions.length, + proof_count: input.proofs.length, + parser_gap_count: input.parser_gaps.length, + candidate_only_pattern_count: candidateOnlyPatternCount, + priority_pattern_count: priorityPatternCount, + inventory_pattern_count: inventoryPatternCount, + signal_to_noise_ratio: Number((priorityPatternCount / Math.max(1, inventoryPatternCount)).toFixed(2)) + }, + areas, + next_steps: nextSteps(candidateOnlyPatternCount, input.proofs.length, input.parser_gaps.length), + redactions: { + source_content_included: false, + raw_fact_values_included: false, + snippets_included: false + } + }; +} + +function upsertPattern(patternMap: Map, seed: PatternSeed): void { + const key = `${seed.area}:${seed.pattern}`; + const existing = patternMap.get(key); + const pattern = existing ?? { + pattern: seed.pattern, + semantic_role: seed.semanticRole, + fact_count: 0, + file_count: 0, + files: [], + accepted: false, + candidate_only: false, + candidate_ids: [], + accepted_convention_ids: [], + proof_truth: "not_observed", + priority: "low", + report_surface: "inventory" + }; + Object.defineProperty(pattern, "__area", { value: seed.area, enumerable: false, configurable: true }); + pattern.semantic_role = strongestSemanticRole(pattern.semantic_role, seed.semanticRole); + if (seed.file) { + pattern.fact_count += 1; + if (!pattern.files.some((file) => file.file_path === seed.file?.file_path && file.start_line === seed.file.start_line)) { + pattern.files.push(seed.file); + } + } + if (seed.candidateId && !pattern.candidate_ids.includes(seed.candidateId)) { + pattern.candidate_ids.push(seed.candidateId); + } + if (seed.acceptedConventionId && !pattern.accepted_convention_ids.includes(seed.acceptedConventionId)) { + pattern.accepted_convention_ids.push(seed.acceptedConventionId); + } + if (seed.proofBacked) { + pattern.accepted = true; + } + pattern.file_count = new Set(pattern.files.map((file) => file.file_path)).size; + patternMap.set(key, pattern); +} + +function finalizePattern(pattern: SecurityArchitectureAuditPattern): SecurityArchitectureAuditPattern { + const accepted = pattern.accepted || pattern.accepted_convention_ids.length > 0; + const candidateOnly = pattern.candidate_ids.length > 0 && !accepted; + pattern.accepted = accepted; + pattern.candidate_only = candidateOnly; + pattern.proof_truth = accepted + ? "accepted_proof" + : candidateOnly + ? "candidate_only" + : pattern.fact_count > 0 + ? "fact_inventory" + : "not_observed"; + pattern.priority = patternPriority(pattern); + pattern.report_surface = pattern.priority === "low" ? "inventory" : "priority"; + pattern.files.sort((left, right) => left.file_path.localeCompare(right.file_path) || left.start_line - right.start_line); + pattern.candidate_ids.sort(); + pattern.accepted_convention_ids.sort(); + return pattern; +} + +function patternKeyArea(pattern: SecurityArchitectureAuditPattern): SecurityArchitectureAuditAreaKey { + return (pattern as SecurityArchitectureAuditPattern & { __area: SecurityArchitectureAuditAreaKey }).__area; +} + +function classifyFact(fact: FactRecord): PatternSeed[] { + const file = { file_path: fact.file_path, start_line: fact.start_line }; + if (fact.kind === "symbol_called") { + return classifySymbolCall(fact, file); + } + if (fact.kind === "request_validation_called") { + return [{ area: "request_validation", pattern: fact.name, semanticRole: "validator", file, proofBacked: true }]; + } + if (fact.kind === "authorization_guard_called") { + return [{ area: "authorization", pattern: fact.name, semanticRole: "authorization_guard", file, proofBacked: true }]; + } + if (fact.kind === "tenant_source") { + return [{ area: "tenant_scope", pattern: fact.name, semanticRole: "tenant_precondition", file }]; + } + if (fact.kind === "tenant_guard_called") { + return [{ area: "tenant_scope", pattern: fact.name, semanticRole: "tenant_predicate", file, proofBacked: true }]; + } + if (fact.kind === "middleware_protects_route") { + return [{ area: "middleware_coverage", pattern: fact.name, semanticRole: "middleware", file, proofBacked: true }]; + } + if (fact.kind === "data_operation_detected") { + if (isGenericDataOperationName(fact.name)) { + return []; + } + return [{ area: "data_access", pattern: fact.name, semanticRole: "data_access", file }]; + } + if (fact.kind === "outbound_request_called") { + return [{ + area: "ssrf", + pattern: outboundUrlSource(fact.value), + semanticRole: "outbound_request", + file + }]; + } + if (fact.kind === "raw_sql_called") { + return [{ area: "raw_sql", pattern: fact.name, semanticRole: "raw_sql", file }]; + } + if (fact.kind === "parameterized_sql_used") { + return [{ area: "raw_sql", pattern: fact.name, semanticRole: "parameterized_sql", file, proofBacked: true }]; + } + if (fact.kind === "cors_policy_declared") { + return [{ area: "cors", pattern: fact.name, semanticRole: "cors_policy", file }]; + } + if (fact.kind === "sensitive_field_declared" || fact.kind === "response_emits_field") { + return [{ area: "sensitive_response", pattern: fact.name, semanticRole: "sensitive_field", file }]; + } + if (fact.kind === "secret_read") { + return [{ area: "secret_exposure", pattern: fact.name, semanticRole: "secret_reference", file }]; + } + return []; +} + +function classifySymbolCall(fact: FactRecord, file: SecurityArchitectureAuditFileRef): PatternSeed[] { + const name = fact.name; + const lower = name.toLowerCase(); + const seeds: PatternSeed[] = []; + + if (name === "parseRequestBody") { + seeds.push({ area: "request_validation", pattern: name, semanticRole: "body_parser", file }); + return seeds; + } + if (isParserMethod(name) && fact.value) { + seeds.push({ area: "request_validation", pattern: `${safePattern(fact.value)}.${name}`, semanticRole: "validator", file }); + return seeds; + } + if (looksLikeValidator(lower)) { + seeds.push({ area: "request_validation", pattern: name, semanticRole: "validator", file }); + } + if (looksLikeAuthBoundary(lower)) { + seeds.push({ area: "auth_boundary", pattern: name, semanticRole: "auth_wrapper", file }); + } + if (looksLikeSessionSource(lower)) { + seeds.push({ area: "session_trust", pattern: name, semanticRole: "session_source", file }); + } + if (looksLikeAuthorizationGuard(lower)) { + seeds.push({ area: "authorization", pattern: name, semanticRole: "authorization_guard", file }); + } + if (looksLikeTenantPrecondition(lower)) { + seeds.push({ area: "tenant_scope", pattern: name, semanticRole: "tenant_precondition", file }); + } + if (looksLikeCsrfGuard(lower)) { + seeds.push({ area: "csrf", pattern: name, semanticRole: "csrf_guard", file }); + } + if (name === "exceededLimitError") { + seeds.push({ area: "rate_limit", pattern: name, semanticRole: "error_helper", file }); + return seeds; + } + if (looksLikeRateLimiter(lower)) { + seeds.push({ area: "rate_limit", pattern: name, semanticRole: "rate_limiter", file }); + } + if (looksLikeCorsPolicy(lower)) { + seeds.push({ area: "cors", pattern: name, semanticRole: "cors_policy", file }); + } + return seeds; +} + +function candidatePatterns(candidate: ConventionCandidate): string[] { + if (candidate.status === "rejected") { + return []; + } + const candidates = [ + ...unknownStringArray(candidate.matcher, "required_calls"), + ...unknownStringArray(candidate.matcher, "forbidden_imports"), + ...requiresSymbols(candidate.requires) + ]; + return uniqueSorted(candidates.map(safePattern).filter(Boolean)); +} + +function acceptedConventionPatterns(convention: AcceptedConvention): string[] { + const candidates = [ + ...unknownStringArray(convention.matcher, "required_calls"), + ...unknownStringArray(convention.matcher, "forbidden_imports"), + ...requiresSymbols(convention.requires) + ]; + return uniqueSorted(candidates.map(safePattern).filter(Boolean)); +} + +function requiresSymbols(value: unknown): string[] { + if (!value || typeof value !== "object") { + return []; + } + const record = value as Record; + const symbolKeys = new Set([ + "auth_helpers", + "validators", + "schemas", + "authorization_guards", + "tenant_guards", + "response_serializers", + "secret_sanitizers", + "ssrf_sanitizers", + "allowlist_proofs", + "parameterized_sql_helpers", + "csrf_guards", + "rate_limit_helpers", + "cors_policies" + ]); + return Object.entries(record).flatMap(([key, entry]) => { + if (!symbolKeys.has(key)) { + return []; + } + if (!Array.isArray(entry)) { + return typeof entry === "string" ? [entry] : []; + } + return entry.flatMap((item) => { + if (typeof item === "string") { + return [item]; + } + if (item && typeof item === "object" && typeof (item as Record).symbol === "string") { + return [(item as Record).symbol]; + } + if (item && typeof item === "object" && typeof (item as Record).imported_name === "string") { + return [(item as Record).imported_name]; + } + if (item && typeof item === "object" && typeof (item as Record).local_name === "string") { + return [(item as Record).local_name]; + } + return []; + }); + }); +} + +function unknownStringArray(value: unknown, key: string): string[] { + if (!value || typeof value !== "object") { + return []; + } + const raw = (value as Record)[key]; + return Array.isArray(raw) ? raw.filter((entry): entry is string => typeof entry === "string") : []; +} + +function semanticRoleForConvention( + area: SecurityArchitectureAuditAreaKey, + pattern: string +): SecurityArchitectureSemanticRole { + const lower = pattern.toLowerCase(); + if (area === "request_validation") { + return pattern === "parseRequestBody" ? "body_parser" : "validator"; + } + if (area === "auth_boundary") { + return "auth_wrapper"; + } + if (area === "session_trust") { + return "session_source"; + } + if (area === "authorization") { + return "authorization_guard"; + } + if (area === "tenant_scope") { + return lower.includes("where") || lower.includes("predicate") ? "tenant_predicate" : "tenant_precondition"; + } + if (area === "rate_limit") { + return pattern === "exceededLimitError" ? "error_helper" : "rate_limiter"; + } + if (area === "data_access") { + return "data_access"; + } + if (area === "ssrf") { + return "outbound_request"; + } + if (area === "raw_sql") { + return "raw_sql"; + } + if (area === "cors") { + return "cors_policy"; + } + if (area === "csrf") { + return "csrf_guard"; + } + if (area === "sensitive_response") { + if (lower.includes("sanitize") || lower.includes("redact") || lower.includes("mask") || lower.includes("serializer")) { + return "response_field"; + } + return "sensitive_field"; + } + if (area === "secret_exposure") { + return "secret_reference"; + } + if (area === "middleware_coverage") { + return "middleware"; + } + return "unknown"; +} + +function proofedAreaCounts(proofs: SecurityBoundaryProof[]): Map { + const counts = new Map(); + for (const proof of proofs) { + for (const contract of proof.contracts) { + const area = CONVENTION_AREA[contract.kind]; + if (area && contract.matched && proof.result.proof_status === "proven") { + counts.set(area, (counts.get(area) ?? 0) + 1); + } + } + } + return counts; +} + +function parserGapArea(gap: ParserGap): SecurityArchitectureAuditAreaKey | null { + const kinds = (gap as ParserGap & { affected_contract_kinds?: string[] }).affected_contract_kinds ?? []; + for (const kind of kinds) { + const area = CONVENTION_AREA[kind]; + if (area) { + return area; + } + } + return null; +} + +function outboundUrlSource(value: string | null | undefined): string { + if (!value) { + return "unknown"; + } + try { + const parsed = JSON.parse(value) as Record; + const source = parsed.url_source; + return typeof source === "string" && source.length > 0 ? source : "unknown"; + } catch { + return "unknown"; + } +} + +function safePattern(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + return value + .replace(/https?:\/\/\S+/g, "url") + .replace(/['"`]/g, "") + .replace(/\s+/g, " ") + .slice(0, 120); +} + +function isParserMethod(name: string): boolean { + return name === "parse" || name === "parseAsync" || name === "safeParse" || name === "safeParseAsync"; +} + +function looksLikeValidator(lower: string): boolean { + if (lower.startsWith("revalidate") || lower.includes("permission") || lower.includes("role")) { + return false; + } + return lower.includes("schema") || lower.includes("validate") || lower.includes("validator"); +} + +function looksLikeAuthBoundary(lower: string): boolean { + return lower === "withworkspace" || + lower === "withsession" || + lower.includes("requireauth") || + lower.includes("requireuser") || + lower.includes("authenticate"); +} + +function looksLikeSessionSource(lower: string): boolean { + return lower.includes("session") || lower.includes("getuser") || lower.includes("currentuser"); +} + +function looksLikeAuthorizationGuard(lower: string): boolean { + if (isLifecycleEventLike(lower) || lower.includes("uri") || lower.endsWith("url")) { + return false; + } + return lower.includes("requirepermission") || + lower.includes("requiredpermission") || + lower.includes("requirerole") || + lower.includes("requiredrole") || + lower.includes("canaccess") || + lower.includes("authorize"); +} + +function looksLikeTenantPrecondition(lower: string): boolean { + return lower.includes("tenant") && + (lower.includes("scope") || + lower.includes("guard") || + lower.includes("filter") || + lower.includes("where") || + lower.startsWith("require")); +} + +function looksLikeCsrfGuard(lower: string): boolean { + return lower.includes("csrf"); +} + +function looksLikeRateLimiter(lower: string): boolean { + if (lower.includes("error") || lower.includes("exceeded")) { + return false; + } + return lower.includes("ratelimit") || lower.includes("rate_limit") || lower.includes("throttle"); +} + +function looksLikeCorsPolicy(lower: string): boolean { + return lower === "cors" || lower.includes("cors"); +} + +function strongestSemanticRole( + current: SecurityArchitectureSemanticRole, + next: SecurityArchitectureSemanticRole +): SecurityArchitectureSemanticRole { + if (current === "unknown") { + return next; + } + if (current === "body_parser" || next === "body_parser") { + return current === "validator" ? current : next; + } + return current; +} + +function comparePatterns(left: SecurityArchitectureAuditPattern, right: SecurityArchitectureAuditPattern): number { + const priorityWeight = { high: 3, medium: 2, low: 1 }; + return Number(right.accepted) - Number(left.accepted) || + Number(right.candidate_only) - Number(left.candidate_only) || + priorityWeight[right.priority] - priorityWeight[left.priority] || + right.fact_count - left.fact_count || + left.pattern.localeCompare(right.pattern); +} + +function patternPriority(pattern: SecurityArchitectureAuditPattern): SecurityArchitecturePriority { + if (pattern.accepted || pattern.proof_truth === "accepted_proof") { + return "high"; + } + if (pattern.candidate_only) { + return isWeakCandidatePattern(pattern) ? "low" : "high"; + } + if (pattern.proof_truth !== "fact_inventory") { + return "low"; + } + if (pattern.semantic_role === "raw_sql" || + pattern.semantic_role === "secret_reference" || + pattern.semantic_role === "tenant_predicate" || + pattern.semantic_role === "authorization_guard") { + return "high"; + } + if (pattern.semantic_role === "outbound_request") { + return pattern.pattern === "request_input" || pattern.pattern === "dynamic" ? "high" : "low"; + } + if (pattern.semantic_role === "sensitive_field") { + return isGenericSensitiveField(pattern.pattern) ? "low" : "medium"; + } + if (pattern.semantic_role === "data_access" || + pattern.semantic_role === "rate_limiter" || + pattern.semantic_role === "cors_policy" || + pattern.semantic_role === "parameterized_sql") { + return "medium"; + } + return "low"; +} + +function isWeakCandidatePattern(pattern: SecurityArchitectureAuditPattern): boolean { + return pattern.semantic_role === "body_parser" || + pattern.semantic_role === "error_helper" || + pattern.semantic_role === "response_field" || + pattern.semantic_role === "tenant_precondition" || + pattern.semantic_role === "session_source"; +} + +function isGenericSensitiveField(pattern: string): boolean { + return new Set([ + "id", + "ids", + "_id", + "name", + "slug", + "success", + "ok", + "error", + "message", + "status", + "count", + "data", + "deleted", + "applications", + "domains", + "url", + "urls", + "clickid", + "inviteids", + "iframeable" + ]).has(pattern.toLowerCase()); +} + +function isGenericDataOperationName(name: string): boolean { + return new Set(["then", "catch", "finally", "map", "filter", "forEach", "reduce", "array", "json"]).has(name); +} + +function isLifecycleEventLike(lower: string): boolean { + return lower.endsWith("authorized") || + lower.endsWith("deauthorized") || + lower.endsWith("completed") || + lower.endsWith("created") || + lower.endsWith("updated") || + lower.endsWith("deleted") || + lower.endsWith("failed"); +} + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values)].sort(); +} + +function nextSteps(candidateOnlyPatternCount: number, proofCount: number, parserGapCount: number): string[] { + const steps = ["Run drift check --json to verify proof-backed enforcement status."]; + if (candidateOnlyPatternCount > 0) { + steps.push("Review candidate-only security patterns before accepting enforcement."); + } + if (proofCount === 0) { + steps.push("Run a proof-backed security check before treating audit inventory as enforcement truth."); + } + if (parserGapCount > 0) { + steps.push("Resolve parser gaps before relying on complete route security coverage."); + } + return steps; +} diff --git a/drift v3/packages/query/test/security-architecture-audit.test.ts b/drift v3/packages/query/test/security-architecture-audit.test.ts new file mode 100644 index 00000000..00a5f014 --- /dev/null +++ b/drift v3/packages/query/test/security-architecture-audit.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { buildSecurityArchitectureAudit } from "../src/index.js"; +import type { AcceptedConvention, ConventionCandidate, FactRecord } from "@drift/core"; + +function fact(input: Partial & Pick): FactRecord { + return { + id: `fact_${input.kind}_${input.file_path}_${input.name}_${input.start_line}`.replace(/[^A-Za-z0-9_]/g, "_"), + repo_id: "repo_abc", + scan_id: "scan_abc", + end_line: input.start_line, + source_span: { start_line: input.start_line, start_column: 1, end_line: input.start_line, end_column: 1 }, + ast_node_kind: null, + extraction_method: "test", + extractor_version: "0.1.0", + parser_version: "0.1.0", + confidence: 1, + confidence_label: "certain", + evidence_level: "text", + resolution_status: "resolved", + staleness_status: "fresh", + last_seen_scan_id: "scan_abc", + ...input + }; +} + +function candidate(input: Pick): ConventionCandidate { + return { + repo_id: "repo_abc", + scan_id: "scan_abc", + rationale: "test candidate", + scope: { path_globs: ["**/app/api/**/route.ts"], file_roles: ["api_route"] }, + suggested_severity: "warning", + suggested_enforcement_mode: "warn", + enforcement_capability: "deterministic_check", + confidence_label: "medium", + scoring: { + supporting_examples_count: 2, + counterexamples_count: 0, + scope_files_count: 4, + coverage_ratio: 0.5, + heuristic_id: "test" + }, + evidence_refs: [], + counterexample_refs: [], + created_at: "2026-05-27T00:00:00.000Z", + ...input + }; +} + +function accepted(input: Pick): AcceptedConvention { + return { + contract_id: "contract_abc", + rationale: "accepted", + scope: { path_globs: ["**/app/api/**/route.ts"], file_roles: ["api_route"] }, + severity: "warning", + enforcement_mode: "warn", + enforcement_capability: "deterministic_check", + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-27T00:00:00.000Z", + updated_at: "2026-05-27T00:00:00.000Z", + ...input + }; +} + +describe("security architecture audit", () => { + it("summarizes repo security patterns without treating body parsers as validation proof", () => { + const model = buildSecurityArchitectureAudit({ + repo_id: "repo_abc", + scan_id: "scan_abc", + facts: [ + fact({ kind: "file_role_detected", file_path: "app/api/apps/route.ts", name: "api_route", start_line: 1 }), + fact({ kind: "file_role_detected", file_path: "app/api/tokens/route.ts", name: "api_route", start_line: 1 }), + fact({ kind: "file_role_detected", file_path: "app/api/public/route.ts", name: "api_route", start_line: 1 }), + fact({ kind: "symbol_called", file_path: "app/api/apps/route.ts", name: "withWorkspace", start_line: 5 }), + fact({ kind: "symbol_called", file_path: "app/api/tokens/route.ts", name: "withSession", start_line: 5 }), + fact({ kind: "symbol_called", file_path: "app/api/apps/route.ts", name: "parseRequestBody", start_line: 8 }), + fact({ kind: "symbol_called", file_path: "app/api/apps/route.ts", name: "parseAsync", value: "createOAuthAppSchema", start_line: 8 }), + fact({ kind: "symbol_called", file_path: "app/api/public/route.ts", name: "ratelimitOrThrow", start_line: 3 }), + fact({ kind: "symbol_called", file_path: "app/api/public/route.ts", name: "exceededLimitError", start_line: 8 }), + fact({ kind: "symbol_called", file_path: "app/api/public/route.ts", name: "accountApplicationDeauthorized", start_line: 9 }), + fact({ kind: "data_operation_detected", file_path: "app/api/public/route.ts", name: "then", start_line: 10 }), + fact({ kind: "sensitive_field_declared", file_path: "app/api/apps/route.ts", name: "success", start_line: 11 }), + fact({ kind: "sensitive_field_declared", file_path: "app/api/apps/route.ts", name: "accessToken", start_line: 12 }), + fact({ kind: "request_input_read", file_path: "app/api/apps/route.ts", name: "name", value: "{\"source\":\"body\",\"variable\":\"name\",\"source_value\":\"secret\"}", start_line: 8 }), + fact({ kind: "outbound_request_called", file_path: "app/api/import/route.ts", name: "fetch", value: "{\"url_source\":\"request_input\",\"url_var\":\"url\",\"raw_url\":\"https://token@example.com\"}", start_line: 12 }) + ], + candidates: [ + candidate({ + id: "candidate_auth_workspace", + kind: "api_route_requires_auth_helper", + status: "accepted", + statement: "Use withWorkspace.", + matcher: { kind: "api_route_requires_auth_helper", required_calls: ["withWorkspace"] }, + requires: { auth_helpers: [{ symbol: "withWorkspace" }] } + }), + candidate({ + id: "candidate_body_parser", + kind: "api_route_requires_request_validation", + status: "candidate", + statement: "Uses parseRequestBody.", + matcher: { kind: "api_route_requires_request_validation", required_calls: ["parseRequestBody"] }, + requires: { validators: [{ symbol: "parseRequestBody" }] } + }), + candidate({ + id: "candidate_rate_error", + kind: "api_route_requires_rate_limit", + status: "candidate", + statement: "Uses exceededLimitError.", + matcher: { kind: "api_route_requires_rate_limit", required_calls: ["exceededLimitError"] }, + requires: { rate_limit_helpers: [{ symbol: "exceededLimitError" }] } + }), + candidate({ + id: "candidate_response_sanitizer", + kind: "api_route_forbids_sensitive_response_fields", + status: "candidate", + statement: "Uses sanitizer helper.", + matcher: { kind: "api_route_forbids_sensitive_response_fields", required_calls: ["sanitizeFullTextSearch"] }, + requires: { response_serializers: [{ symbol: "sanitizeFullTextSearch" }] } + }) + ], + accepted_conventions: [ + accepted({ + id: "convention_auth_workspace", + kind: "api_route_requires_auth_helper", + statement: "Use withWorkspace.", + matcher: { kind: "api_route_requires_auth_helper", required_calls: ["withWorkspace"] }, + requires: { auth_helpers: [{ symbol: "withWorkspace" }] } + }) + ], + parser_gaps: [], + proofs: [] + }); + + expect(model.summary.area_count).toBeGreaterThan(10); + expect(model.summary.priority_pattern_count).toBeGreaterThan(0); + expect(model.summary.inventory_pattern_count).toBeGreaterThan(0); + expect(model.summary.signal_to_noise_ratio).toBeGreaterThan(0); + expect(model.areas.auth_boundary.patterns[0]).toMatchObject({ + pattern: "withWorkspace", + fact_count: 1, + file_count: 1, + accepted: true, + candidate_only: false, + priority: "high", + report_surface: "priority" + }); + expect(model.areas.request_validation.patterns.find((pattern) => pattern.pattern === "parseRequestBody")).toMatchObject({ + semantic_role: "body_parser", + proof_truth: "candidate_only", + priority: "low", + report_surface: "inventory" + }); + expect(model.areas.request_validation.patterns.find((pattern) => pattern.pattern === "createOAuthAppSchema.parseAsync")).toMatchObject({ + semantic_role: "validator", + report_surface: "inventory" + }); + expect(model.areas.rate_limit.patterns.find((pattern) => pattern.pattern === "exceededLimitError")).toMatchObject({ + semantic_role: "error_helper", + proof_truth: "candidate_only", + report_surface: "inventory" + }); + expect(model.areas.sensitive_response.patterns.find((pattern) => pattern.pattern === "accessToken")).toMatchObject({ + semantic_role: "sensitive_field", + priority: "medium", + report_surface: "priority" + }); + expect(model.areas.sensitive_response.patterns.find((pattern) => pattern.pattern === "success")).toMatchObject({ + semantic_role: "sensitive_field", + priority: "low", + report_surface: "inventory" + }); + expect(model.areas.sensitive_response.patterns.find((pattern) => pattern.pattern === "sanitizeFullTextSearch")).toMatchObject({ + semantic_role: "response_field", + proof_truth: "candidate_only", + report_surface: "inventory" + }); + expect(model.areas.ssrf.patterns[0]).toMatchObject({ + pattern: "request_input", + fact_count: 1, + priority: "high", + report_surface: "priority" + }); + expect(model.areas.authorization.patterns).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ pattern: "accountApplicationDeauthorized" }) + ])); + expect(model.areas.data_access.patterns).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ pattern: "then" }) + ])); + expect(model.areas.request_validation.priority_patterns.map((pattern) => pattern.pattern)).not.toContain("parseRequestBody"); + expect(model.areas.sensitive_response.priority_patterns.map((pattern) => pattern.pattern)).not.toContain("success"); + expect(model.areas.sensitive_response.priority_patterns.map((pattern) => pattern.pattern)).not.toContain("sanitizeFullTextSearch"); + expect(JSON.stringify(model)).not.toContain("source_value"); + expect(JSON.stringify(model)).not.toContain("https://token@example.com"); + expect(model.next_steps).toContain("Review candidate-only security patterns before accepting enforcement."); + }); +});