diff --git a/drift v3/crates/drift-engine/src/check_command.rs b/drift v3/crates/drift-engine/src/check_command.rs index 63a740f2..7ff82926 100644 --- a/drift v3/crates/drift-engine/src/check_command.rs +++ b/drift v3/crates/drift-engine/src/check_command.rs @@ -6,12 +6,14 @@ use std::{ }; use drift_engine::{ - AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, BaselineStatus, - BaselineViolation, DiffFile, DiffScope, DirectDataAccessRule, EnforcementMode, Fact, FactKind, - FindingStatus, ParsedDiff, RequestValidatorBehavior, RequestValidatorKind, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedHelperImport, + AcceptedRequestValidator, AcceptedTenantHelper, AuthGuardBehavior, AuthorizationHelperBehavior, + AuthorizationHelperKind, BaselineStatus, BaselineViolation, DiffFile, DiffScope, + DirectDataAccessRule, EnforcementMode, Fact, FactKind, FindingStatus, ParsedDiff, + Phase4SecurityPolicy, RequestValidatorBehavior, RequestValidatorKind, RouteSecurityBoundaryProof, RuleFinding, SecurityBoundaryProof, SecurityProofStatus, Severity, - build_auth_boundary_proofs_for_file, classify_findings_against_diff, - materialize_direct_data_access_findings, + build_auth_boundary_proofs_for_file, build_phase4_security_proof_with_policy, + classify_findings_against_diff, materialize_direct_data_access_findings, }; use serde_json::json; @@ -151,6 +153,27 @@ pub fn check_repo(request: CheckRequest) -> CheckResult { ); security_boundary_proofs.extend(validation_result.proofs); validation_result.findings + } else if convention.kind == "api_route_requires_tenant_scope" + || convention.kind == "api_route_requires_authorization" + || convention.kind == "session_object_must_come_from_trusted_helper" + { + required_capabilities.extend([ + "security_facts".to_string(), + "session_trust".to_string(), + "authorization".to_string(), + "tenant_scope".to_string(), + ]); + let phase4_result = security_phase4_findings_and_proofs( + &facts, + repo_root.as_deref(), + &parsed_diff, + diff_scope, + &convention, + severity, + enforcement_mode, + ); + security_boundary_proofs.extend(phase4_result.proofs); + phase4_result.findings } else { continue; }; @@ -516,6 +539,11 @@ struct SecurityRequestValidationEvaluation { proofs: Vec, } +struct SecurityPhase4Evaluation { + findings: Vec, + proofs: Vec, +} + fn security_auth_findings_and_proofs( facts: &[Fact], repo_root: Option<&str>, @@ -707,6 +735,123 @@ fn security_request_validation_findings_and_proofs( SecurityRequestValidationEvaluation { findings, proofs } } +fn security_phase4_findings_and_proofs( + facts: &[Fact], + repo_root: Option<&str>, + parsed_diff: &ParsedDiff, + diff_scope: DiffScope, + convention: &crate::protocol::CheckConvention, + severity: Severity, + enforcement_mode: EnforcementMode, +) -> SecurityPhase4Evaluation { + let phase4_policy = phase4_policy_for_convention(convention); + if convention + .matcher + .applies_to_file_roles + .as_ref() + .is_some_and(|roles| !roles.iter().any(|role| role == "api_route")) + { + return SecurityPhase4Evaluation { + findings: Vec::new(), + proofs: Vec::new(), + }; + } + let files = security_auth_files(facts, parsed_diff, diff_scope); + let allowed_methods = convention + .matcher + .methods + .clone() + .unwrap_or_default() + .into_iter() + .map(|method| method.to_uppercase()) + .collect::>(); + let path_globs = convention + .scope + .as_ref() + .map(|scope| string_array_field(scope, "path_globs")) + .unwrap_or_default(); + let mut findings = Vec::new(); + let mut proofs = Vec::new(); + + for file_path in files { + if !path_globs.is_empty() + && !path_globs + .iter() + .any(|pattern| path_glob_matches(pattern, &file_path)) + { + continue; + } + if !allowed_methods.is_empty() + && !route_methods_for_file(facts, &file_path) + .iter() + .any(|method| allowed_methods.contains(method)) + { + continue; + } + let Some(source) = read_repo_file(repo_root, &file_path) else { + continue; + }; + let proof = + match build_phase4_security_proof_with_policy(&file_path, &source, &phase4_policy) { + Ok(proof) => proof, + Err(_) => continue, + }; + let required = match convention.kind.as_str() { + "api_route_requires_tenant_scope" => proof.tenant.required, + "api_route_requires_authorization" => proof.authorization.required, + "session_object_must_come_from_trusted_helper" => proof.session_trust.required, + _ => false, + }; + if !required { + continue; + } + let proven = match convention.kind.as_str() { + "api_route_requires_tenant_scope" => proof.tenant.proven, + "api_route_requires_authorization" => proof.authorization.proven, + "session_object_must_come_from_trusted_helper" => proof.session_trust.proven, + _ => false, + }; + let (route_id, handler_symbol) = route_identity_for_file(facts, &file_path) + .unwrap_or_else(|| (format!("route:{file_path}:unknown"), "unknown".to_string())); + let missing_code = phase4_missing_code(&proof, &convention.kind); + let finding_line = phase4_finding_line(&proof).unwrap_or(1); + let finding_fingerprint = stable_hash(&format!( + "{}:{}:{}:{}", + convention.id, route_id, missing_code, finding_line + )); + let finding_id = format!("finding_{}", &finding_fingerprint[..16]); + proofs.push(phase4_proof_json( + &proof, + &route_id, + &file_path, + &handler_symbol, + convention, + &finding_id, + )); + if !proven || proof.result.proof_status == SecurityProofStatus::ParserGap { + findings.push(PendingFinding { + fingerprint: finding_fingerprint, + convention_id: convention.id.clone(), + rule_id: convention.kind.clone(), + title: phase4_finding_title(&convention.kind).to_string(), + message: "Accepted Phase 4 security proof is required for protected route sinks." + .to_string(), + severity, + enforcement_result: enforcement_result_for_mode(enforcement_mode), + file_path: file_path.clone(), + import_name: phase4_expected_layer(&convention.kind).to_string(), + import_source: missing_code, + line: finding_line, + evidence_id: format!("evidence_{}", &finding_id["finding_".len()..]), + legacy_fingerprints: Vec::new(), + related_node_ids: Vec::new(), + }); + } + } + + SecurityPhase4Evaluation { findings, proofs } +} + fn accepted_auth_helpers_for_convention( convention: &crate::protocol::CheckConvention, ) -> Vec { @@ -757,7 +902,11 @@ fn accepted_auth_helpers_for_convention( .unwrap_or(symbol) .to_string(), symbol: symbol.to_string(), - behavior: AuthGuardBehavior::Unknown, + behavior: helper + .get("behavior") + .and_then(|value| value.as_str()) + .map(auth_guard_behavior_from_str) + .unwrap_or(AuthGuardBehavior::Unknown), }, ); } @@ -766,6 +915,213 @@ fn accepted_auth_helpers_for_convention( helpers.into_values().collect() } +fn phase4_policy_for_convention( + convention: &crate::protocol::CheckConvention, +) -> Phase4SecurityPolicy { + let mut helpers = BTreeMap::::new(); + let mut helper_imports = BTreeMap::::new(); + if let Some(auth_helpers) = convention + .requires + .as_ref() + .and_then(|requires| requires.get("auth_helpers")) + .and_then(|value| value.as_array()) + { + for helper in auth_helpers { + if let Some(symbol) = helper.as_str() { + helpers.insert( + symbol.to_string(), + AcceptedAuthHelper { + guard_id: format!("auth:{symbol}"), + symbol: symbol.to_string(), + behavior: AuthGuardBehavior::Unknown, + }, + ); + } else if let Some(symbol) = helper + .get("symbol") + .or_else(|| helper.get("name")) + .and_then(|value| value.as_str()) + { + helpers.insert( + symbol.to_string(), + AcceptedAuthHelper { + guard_id: helper + .get("guard_id") + .and_then(|value| value.as_str()) + .unwrap_or(symbol) + .to_string(), + symbol: symbol.to_string(), + behavior: helper + .get("behavior") + .and_then(|value| value.as_str()) + .or_else(|| helper.get("returns").and_then(|value| value.as_str())) + .map(auth_guard_behavior_from_str) + .unwrap_or(AuthGuardBehavior::Unknown), + }, + ); + helper_imports.insert( + symbol.to_string(), + AcceptedHelperImport { + symbol: symbol.to_string(), + import_source: helper + .get("import") + .or_else(|| helper.get("import_source")) + .and_then(|value| value.as_str()) + .map(str::to_string), + }, + ); + } + } + } + Phase4SecurityPolicy { + accepted_auth_helpers: helpers.into_values().collect(), + auth_helper_imports: helper_imports.into_values().collect(), + authorization_helpers: accepted_authorization_helpers_for_phase4_convention(convention), + tenant_helpers: accepted_tenant_helpers_for_phase4_convention(convention), + tenant_keys: convention + .requires + .as_ref() + .map(|requires| string_array_field(requires, "tenant_keys")) + .unwrap_or_default(), + tenant_sources: convention + .requires + .as_ref() + .map(|requires| string_array_field(requires, "tenant_sources")) + .unwrap_or_default(), + data_operations: convention + .requires + .as_ref() + .map(|requires| string_array_field(requires, "data_operations")) + .unwrap_or_default(), + } +} + +fn auth_guard_behavior_from_str(behavior: &str) -> AuthGuardBehavior { + match behavior { + "throws" => AuthGuardBehavior::Throws, + "returns_user" => AuthGuardBehavior::ReturnsUser, + "user" => AuthGuardBehavior::ReturnsUser, + "returns_session" => AuthGuardBehavior::ReturnsSession, + "session" => AuthGuardBehavior::ReturnsSession, + "boolean" => AuthGuardBehavior::Boolean, + _ => AuthGuardBehavior::Unknown, + } +} + +fn accepted_authorization_helpers_for_phase4_convention( + convention: &crate::protocol::CheckConvention, +) -> Vec { + let Some(requires) = &convention.requires else { + return Vec::new(); + }; + requires + .get("authorization_helpers") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .filter_map(|helper| { + let symbol = helper.as_str().or_else(|| { + helper + .get("symbol") + .or_else(|| helper.get("name")) + .and_then(|value| value.as_str()) + })?; + Some(AcceptedAuthorizationHelper { + guard_id: helper + .get("guard_id") + .and_then(|value| value.as_str()) + .unwrap_or(symbol) + .to_string(), + symbol: symbol.to_string(), + import_source: helper + .get("import") + .or_else(|| helper.get("import_source")) + .and_then(|value| value.as_str()) + .map(str::to_string), + kind: helper + .get("kind") + .and_then(|value| value.as_str()) + .map(authorization_helper_kind_from_str) + .unwrap_or_else(|| { + if symbol.to_ascii_lowercase().contains("role") { + AuthorizationHelperKind::Role + } else { + AuthorizationHelperKind::Policy + } + }), + behavior: helper + .get("behavior") + .and_then(|value| value.as_str()) + .map(authorization_helper_behavior_from_str) + .unwrap_or_else(|| { + if symbol.to_ascii_lowercase().starts_with("can") { + AuthorizationHelperBehavior::Boolean + } else { + AuthorizationHelperBehavior::Throws + } + }), + }) + }) + .collect() +} + +fn accepted_tenant_helpers_for_phase4_convention( + convention: &crate::protocol::CheckConvention, +) -> Vec { + let Some(requires) = &convention.requires else { + return Vec::new(); + }; + let tenant_keys = string_array_field(requires, "tenant_keys"); + requires + .get("tenant_helpers") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .filter_map(|helper| { + let symbol = helper.as_str().or_else(|| { + helper + .get("symbol") + .or_else(|| helper.get("name")) + .and_then(|value| value.as_str()) + })?; + Some(AcceptedTenantHelper { + helper_id: helper + .get("helper_id") + .and_then(|value| value.as_str()) + .unwrap_or(symbol) + .to_string(), + symbol: symbol.to_string(), + import_source: helper + .get("import") + .or_else(|| helper.get("import_source")) + .and_then(|value| value.as_str()) + .map(str::to_string), + tenant_key: helper + .get("tenant_key") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| tenant_keys.first().cloned()) + .unwrap_or_else(|| "tenantId".to_string()), + }) + }) + .collect() +} + +fn authorization_helper_kind_from_str(kind: &str) -> AuthorizationHelperKind { + match kind { + "role" => AuthorizationHelperKind::Role, + "policy" => AuthorizationHelperKind::Policy, + _ => AuthorizationHelperKind::Policy, + } +} + +fn authorization_helper_behavior_from_str(behavior: &str) -> AuthorizationHelperBehavior { + match behavior { + "throws" => AuthorizationHelperBehavior::Throws, + "boolean" => AuthorizationHelperBehavior::Boolean, + _ => AuthorizationHelperBehavior::Throws, + } +} + fn accepted_request_validators_for_convention( convention: &crate::protocol::CheckConvention, ) -> Vec { @@ -939,6 +1295,19 @@ fn route_methods_for_file(facts: &[Fact], file_path: &str) -> Vec { .collect() } +fn path_glob_matches(pattern: &str, file_path: &str) -> bool { + if pattern == file_path { + return true; + } + if let Some((prefix, suffix)) = pattern.split_once("**") { + return file_path.starts_with(prefix) && file_path.ends_with(suffix); + } + if let Some(prefix) = pattern.strip_suffix('*') { + return file_path.starts_with(prefix); + } + false +} + fn request_validation_missing_code(proof: &SecurityBoundaryProof) -> String { proof .parser_gaps @@ -1277,6 +1646,239 @@ fn request_validation_proof_json( }) } +fn phase4_missing_code(proof: &SecurityBoundaryProof, convention_kind: &str) -> String { + match convention_kind { + "api_route_requires_tenant_scope" => proof + .tenant + .missing + .first() + .map(|missing| missing.reason.clone()) + .unwrap_or_else(|| "tenant_predicate_missing".to_string()), + "api_route_requires_authorization" => proof + .authorization + .missing + .first() + .map(|missing| missing.reason.clone()) + .unwrap_or_else(|| "authorization_guard_missing".to_string()), + "session_object_must_come_from_trusted_helper" => proof + .session_trust + .missing_trust + .first() + .map(|missing| { + if missing.reason == "derived_from_request" { + "session_not_trusted".to_string() + } else { + missing.reason.clone() + } + }) + .unwrap_or_else(|| "session_not_trusted".to_string()), + _ => "missing_proof".to_string(), + } +} + +fn phase4_finding_line(proof: &SecurityBoundaryProof) -> Option { + proof + .tenant + .missing + .first() + .and_then(|missing| missing.data_operation_fact_id.rsplit(':').next()) + .and_then(|line| line.parse::().ok()) + .or_else(|| { + proof + .authorization + .missing + .first() + .and_then(|missing| missing.sink_fact_id.as_deref()) + .and_then(|sink_id| sink_id.rsplit(':').next()) + .and_then(|line| line.parse::().ok()) + }) + .or_else(|| { + proof + .session_trust + .missing_trust + .first() + .and_then(|missing| missing.fact_id.rsplit(':').next()) + .and_then(|line| line.parse::().ok()) + }) +} + +fn phase4_finding_title(kind: &str) -> &'static str { + match kind { + "api_route_requires_tenant_scope" => "API route missing required tenant scope proof", + "api_route_requires_authorization" => "API route missing required authorization proof", + "session_object_must_come_from_trusted_helper" => "API route uses untrusted session object", + _ => "API route missing required security proof", + } +} + +fn phase4_expected_layer(kind: &str) -> &'static str { + match kind { + "api_route_requires_tenant_scope" => "tenant_scope", + "api_route_requires_authorization" => "authorization", + "session_object_must_come_from_trusted_helper" => "session_trust", + _ => "security_boundary", + } +} + +fn phase4_proof_json( + proof: &SecurityBoundaryProof, + route_id: &str, + file_path: &str, + handler_symbol: &str, + convention: &crate::protocol::CheckConvention, + finding_id: &str, +) -> serde_json::Value { + let missing_code = phase4_missing_code(proof, &convention.kind); + let missing_proof_ids = if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::new() + } else { + vec![format!("missing_proof:{route_id}:{missing_code}")] + }; + let parser_gap_ids = proof + .parser_gaps + .iter() + .map(|gap| gap.parser_gap_id.clone()) + .collect::>(); + let parser_gaps = proof + .parser_gaps + .iter() + .map(|gap| { + json!({ + "parser_gap_id": gap.parser_gap_id, + "capability": phase4_expected_layer(&convention.kind), + "code": gap.code, + "file_path": gap.file_path, + "reason": gap.reason, + "affected_contract_kinds": [convention.kind.clone()], + "affected_route_ids": [route_id], + "missing_proof_ids": missing_proof_ids.clone(), + "blocks_enforcement": gap.blocks_enforcement + }) + }) + .collect::>(); + let missing_proof = missing_proof_ids + .iter() + .map(|id| { + json!({ + "id": id, + "capability": phase4_expected_layer(&convention.kind), + "code": missing_code, + "blocks_enforcement": true, + "fact_ids": [], + "graph_edge_ids": [] + }) + }) + .collect::>(); + + json!({ + "proof_id": format!("proof:{route_id}:phase4"), + "proof_version": "security-boundary-proof/v1", + "route": { + "route_id": route_id, + "file_path": file_path, + "file_role": "api_route", + "handler_symbol": handler_symbol + }, + "contracts": [{ + "contract_id": convention.id, + "kind": convention.kind, + "enforcement_mode": convention.enforcement_mode, + "capability": convention.enforcement_capability, + "matched": true + }], + "capability_status": [{ + "name": phase4_expected_layer(&convention.kind), + "status": if proof.result.proof_status == SecurityProofStatus::Proven { "complete" } else { "partial" }, + "can_block": true, + "parser_gap_ids": parser_gap_ids, + "missing_proof_ids": missing_proof_ids + }], + "auth": { + "required": false, + "proven": false, + "proof_kind": "none", + "trusted_guard_calls": [], + "dominated_sinks": [], + "undominated_sinks": [] + }, + "session_trust": { + "required": proof.session_trust.required, + "proven": proof.session_trust.proven, + "trusted_sessions": proof.session_trust.trusted_sessions.iter().map(|session| json!({ + "fact_id": session.fact_id, + "variable": session.variable, + "trust": session.trust, + "source": session.derived_from + })).collect::>(), + "missing_trust": proof.session_trust.missing_trust.iter().map(|missing| json!({ + "fact_id": missing.fact_id, + "variable": missing.variable, + "reason": missing.reason + })).collect::>() + }, + "authorization": { + "required": proof.authorization.required, + "proven": proof.authorization.proven, + "role_or_policy_guards": proof.authorization.role_or_policy_guards.iter().map(|guard| { + let mut object = serde_json::Map::new(); + object.insert("fact_id".to_string(), json!(guard.fact_id)); + object.insert("roles".to_string(), json!(guard.roles)); + object.insert("permissions".to_string(), json!(guard.permissions)); + if let Some(policy_id) = &guard.policy_id { + object.insert("policy_id".to_string(), json!(policy_id)); + } + if let Some(resource_var) = &guard.resource_var { + object.insert("resource_var".to_string(), json!(resource_var)); + } + if let Some(subject_var) = &guard.subject_var { + object.insert("subject_var".to_string(), json!(subject_var)); + } + serde_json::Value::Object(object) + }).collect::>(), + "missing": proof.authorization.missing.iter().map(|missing| json!({ + "reason": missing.reason, + "sink_fact_id": missing.sink_fact_id + })).collect::>() + }, + "tenant": { + "required": proof.tenant.required, + "proven": proof.tenant.proven, + "tenant_sources": proof.tenant.tenant_sources.iter().map(|source| json!({ + "fact_id": source.fact_id, + "source": source.source, + "key": source.key, + "trusted": source.trusted + })).collect::>(), + "predicates": proof.tenant.predicates.iter().map(|predicate| json!({ + "fact_id": predicate.fact_id, + "data_operation_fact_id": predicate.data_operation_fact_id, + "tenant_key": predicate.tenant_key, + "predicate_kind": predicate.predicate_kind + })).collect::>(), + "missing": proof.tenant.missing.iter().map(|missing| json!({ + "data_operation_fact_id": missing.data_operation_fact_id, + "reason": missing.reason + })).collect::>() + }, + "missing_proof": missing_proof, + "parser_gaps": parser_gaps, + "result": { + "proof_status": security_proof_status(&proof.result.proof_status), + "enforcement_result": if proof.result.proof_status == SecurityProofStatus::Proven { + "pass" + } else { + convention.enforcement_mode.as_str() + }, + "can_block": proof.result.proof_status != SecurityProofStatus::Proven, + "finding_ids": if proof.result.proof_status == SecurityProofStatus::Proven { + Vec::::new() + } else { + vec![finding_id.to_string()] + } + } + }) +} + fn security_proof_status(status: &SecurityProofStatus) -> &'static str { match status { SecurityProofStatus::Proven => "proven", @@ -1619,6 +2221,13 @@ fn fact_kind_from_str(kind: &str) -> Option { "middleware_declared" => Some(FactKind::MiddlewareDeclared), "middleware_matcher_declared" => Some(FactKind::MiddlewareMatcherDeclared), "middleware_protects_route" => Some(FactKind::MiddlewareProtectsRoute), + "request_input_read" => Some(FactKind::RequestInputRead), + "session_read" => Some(FactKind::SessionRead), + "tenant_source" => Some(FactKind::TenantSource), + "tenant_guard_called" => Some(FactKind::TenantGuardCalled), + "authorization_guard_called" => Some(FactKind::AuthorizationGuardCalled), + "request_validation_called" => Some(FactKind::RequestValidationCalled), + "validated_input_used" => Some(FactKind::ValidatedInputUsed), _ => None, } } diff --git a/drift v3/crates/drift-engine/src/facts.rs b/drift v3/crates/drift-engine/src/facts.rs index 965b5de6..3ce1899f 100644 --- a/drift v3/crates/drift-engine/src/facts.rs +++ b/drift v3/crates/drift-engine/src/facts.rs @@ -20,6 +20,10 @@ pub enum FactKind { MiddlewareMatcherDeclared, MiddlewareProtectsRoute, RequestInputRead, + SessionRead, + TenantSource, + TenantGuardCalled, + AuthorizationGuardCalled, RequestValidationCalled, ValidatedInputUsed, } diff --git a/drift v3/crates/drift-engine/src/lib.rs b/drift v3/crates/drift-engine/src/lib.rs index 00870455..2a364bc0 100644 --- a/drift v3/crates/drift-engine/src/lib.rs +++ b/drift v3/crates/drift-engine/src/lib.rs @@ -37,26 +37,35 @@ pub use security_control_flow::{ validated_input_uses, }; pub use security_facts::extract_security_facts; +pub use security_facts::extract_security_facts_with_policy; pub use security_facts::extract_security_facts_with_validation; pub use security_patterns::{ - AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, RequestValidatorBehavior, - RequestValidatorKind, dynamic_middleware_matcher_line, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedHelperImport, + AcceptedRequestValidator, AcceptedTenantHelper, AuthGuardBehavior, AuthorizationHelperBehavior, + AuthorizationHelperKind, Phase4SecurityPolicy, RequestValidatorBehavior, RequestValidatorKind, + dynamic_middleware_matcher_line, }; pub use security_proof::{ - AuthBoundaryProof, MiddlewareBoundaryProof, RequestInputReadProof, RequestUnvalidatedUseProof, + AuthBoundaryProof, AuthorizationGuardProof, AuthorizationMissingProof, AuthorizationProof, + MiddlewareBoundaryProof, RequestInputReadProof, RequestUnvalidatedUseProof, RequestValidatedUseProof, RequestValidationCallProof, RequestValidationProof, RequestValidationProofScope, RouteSecurityBoundaryProof, SecurityBoundaryProof, - SecurityParserGap, SecurityProofResult, SecurityProofStatus, TrustedGuardCallProof, - UndominatedSinkProof, build_auth_boundary_proof, build_auth_boundary_proofs_for_file, - build_middleware_coverage_proof, build_request_validation_proof, + SecurityParserGap, SecurityProofResult, SecurityProofStatus, SessionMissingTrustProof, + SessionTrustBoundaryProof, SessionTrustProof, TenantMissingProof, TenantPredicateProof, + TenantProof, TenantSourceProof, TrustedGuardCallProof, UndominatedSinkProof, + build_auth_boundary_proof, build_auth_boundary_proofs_for_file, + build_middleware_coverage_proof, build_phase4_security_proof, + build_phase4_security_proof_with_policy, build_request_validation_proof, build_request_validation_proof_with_scope, }; pub use security_rules::{ - SecurityAuthContract, SecurityContractCapability, SecurityEnforcementMode, SecurityFinding, - SecurityFindingResult, SecurityMiddlewareContract, SecurityRequestValidationContract, + SecurityAuthContract, SecurityAuthorizationContract, SecurityContractCapability, + SecurityEnforcementMode, SecurityFinding, SecurityFindingResult, SecurityMiddlewareContract, + SecurityRequestValidationContract, SecurityTenantScopeContract, evaluate_api_route_requires_auth_helper, evaluate_api_route_requires_auth_helper_with_middleware, - evaluate_api_route_requires_request_validation, evaluate_middleware_must_cover_routes, + evaluate_api_route_requires_authorization, evaluate_api_route_requires_request_validation, + evaluate_api_route_requires_tenant_scope, evaluate_middleware_must_cover_routes, }; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/drift v3/crates/drift-engine/src/main.rs b/drift v3/crates/drift-engine/src/main.rs index 7b64de18..e942bd65 100644 --- a/drift v3/crates/drift-engine/src/main.rs +++ b/drift v3/crates/drift-engine/src/main.rs @@ -738,6 +738,10 @@ fn fact_kind(kind: FactKind) -> &'static str { FactKind::MiddlewareMatcherDeclared => "middleware_matcher_declared", FactKind::MiddlewareProtectsRoute => "middleware_protects_route", FactKind::RequestInputRead => "request_input_read", + FactKind::SessionRead => "session_read", + FactKind::TenantSource => "tenant_source", + FactKind::TenantGuardCalled => "tenant_guard_called", + FactKind::AuthorizationGuardCalled => "authorization_guard_called", FactKind::RequestValidationCalled => "request_validation_called", FactKind::ValidatedInputUsed => "validated_input_used", } diff --git a/drift v3/crates/drift-engine/src/security_capabilities.rs b/drift v3/crates/drift-engine/src/security_capabilities.rs index 47349ff8..3d6126fb 100644 --- a/drift v3/crates/drift-engine/src/security_capabilities.rs +++ b/drift v3/crates/drift-engine/src/security_capabilities.rs @@ -38,5 +38,26 @@ pub fn security_capabilities() -> Vec { can_block: true, block_requires_accepted_convention: true, }, + SecurityScanCapability { + name: "session_trust".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "authorization".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, + SecurityScanCapability { + name: "tenant_scope".to_string(), + capability: "deterministic_check".to_string(), + status: SecurityCapabilityStatus::Partial, + can_block: true, + block_requires_accepted_convention: true, + }, ] } diff --git a/drift v3/crates/drift-engine/src/security_facts.rs b/drift v3/crates/drift-engine/src/security_facts.rs index b76b18fa..eacc517f 100644 --- a/drift v3/crates/drift-engine/src/security_facts.rs +++ b/drift v3/crates/drift-engine/src/security_facts.rs @@ -2,8 +2,10 @@ use serde_json::json; use crate::security_control_flow::validated_input_uses; use crate::security_patterns::{ - AcceptedAuthHelper, AcceptedRequestValidator, RequestValidatorKind, - accepted_auth_helper_for_call, accepted_request_validator_for_call, static_middleware_matchers, + AcceptedAuthHelper, AcceptedRequestValidator, Phase4SecurityPolicy, RequestValidatorKind, + accepted_auth_helper_for_call, accepted_authorization_helper_for_call, + accepted_phase4_auth_helper_for_call, accepted_request_validator_for_call, + static_middleware_matchers, }; use crate::{Fact, FactExtractError, FactKind, extract_typescript_facts}; @@ -20,6 +22,20 @@ pub fn extract_security_facts_with_validation( source: &str, accepted_auth_helpers: &[AcceptedAuthHelper], accepted_validators: &[AcceptedRequestValidator], +) -> Result, FactExtractError> { + extract_security_facts_with_policy( + file_path, + source, + &Phase4SecurityPolicy::from_auth_helpers(accepted_auth_helpers), + accepted_validators, + ) +} + +pub fn extract_security_facts_with_policy( + file_path: impl AsRef, + source: &str, + phase4_policy: &Phase4SecurityPolicy, + accepted_validators: &[AcceptedRequestValidator], ) -> Result, FactExtractError> { let normalized_file_path = file_path.as_ref().to_string_lossy().replace('\\', "/"); let facts = extract_typescript_facts(file_path, source)?; @@ -30,8 +46,22 @@ pub fn extract_security_facts_with_validation( .filter(|fact| fact.kind == FactKind::SymbolCalled) { let route = route_for_line(&facts, fact.start_line).unwrap_or("unknown"); - if let Some(helper) = accepted_auth_helper_for_call(fact, &facts, accepted_auth_helpers) { - let route_id = format!("route:{}:{route}", fact.file_path); + let route_id = format!("route:{}:{route}", fact.file_path); + if let Some(helper) = accepted_phase4_auth_helper_for_call(fact, &facts, phase4_policy) + .or_else(|| { + phase4_policy + .auth_helper_imports + .is_empty() + .then(|| { + accepted_auth_helper_for_call( + fact, + &facts, + &phase4_policy.accepted_auth_helpers, + ) + }) + .flatten() + }) + { security_facts.push(Fact { kind: FactKind::AuthGuardCalled, file_path: fact.file_path.clone(), @@ -49,6 +79,32 @@ pub fn extract_security_facts_with_validation( start_line: fact.start_line, end_line: fact.end_line, }); + if matches!( + helper.behavior, + crate::security_patterns::AuthGuardBehavior::ReturnsSession + | crate::security_patterns::AuthGuardBehavior::ReturnsUser + ) && let Some(line) = source_lines.get(fact.start_line.saturating_sub(1)) + && let Some(variable) = assigned_variable(line) + { + security_facts.push(Fact { + kind: FactKind::SessionRead, + file_path: fact.file_path.clone(), + name: variable.clone(), + value: Some( + json!({ + "route_id": route_id, + "source": "auth_result", + "trust": "unknown", + "variable": variable, + "helper_id": helper.guard_id, + }) + .to_string(), + ), + imported_name: Some(helper.symbol.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); + } if line_is_inside_callback(&source_lines, fact.start_line) { security_facts.push(Fact { kind: FactKind::CallbackBoundaryDetected, @@ -68,6 +124,32 @@ pub fn extract_security_facts_with_validation( end_line: fact.end_line, }); } + } else if let Some(line) = source_lines.get(fact.start_line.saturating_sub(1)) + && let Some(variable) = assigned_variable(line) + && is_session_like_variable(&variable) + && line.contains("await") + && line.contains('(') + && !line.contains("await request.json()") + && !line.contains("await request.formData()") + && !line.contains("await request.text()") + { + security_facts.push(Fact { + kind: FactKind::SessionRead, + file_path: fact.file_path.clone(), + name: variable.clone(), + value: Some( + json!({ + "route_id": route_id, + "source": "unknown_helper", + "trust": "untrusted", + "variable": variable, + }) + .to_string(), + ), + imported_name: Some(fact.name.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); } if let Some(validator) = accepted_request_validator_for_call(fact, &facts, accepted_validators) @@ -100,6 +182,65 @@ pub fn extract_security_facts_with_validation( end_line: fact.end_line, }); } + if let Some(helper) = accepted_authorization_helper_for_call( + fact, + &facts, + &phase4_policy.authorization_helpers, + ) && let Some(line) = source_lines.get(fact.start_line.saturating_sub(1)) + { + if helper.behavior == crate::security_patterns::AuthorizationHelperBehavior::Boolean + && !boolean_authorization_failure_branch_exits(&source_lines, fact.start_line) + { + continue; + } + let route_id = format!("route:{}:{route}", fact.file_path); + let arguments = call_arguments(line, &fact.name); + 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 = + if helper.kind == crate::security_patterns::AuthorizationHelperKind::Policy { + arguments + .iter() + .skip(1) + .filter_map(|argument| unquoted_literal(argument)) + .collect::>() + } else { + Vec::new() + }; + security_facts.push(Fact { + kind: FactKind::AuthorizationGuardCalled, + file_path: fact.file_path.clone(), + name: fact.name.clone(), + value: Some( + json!({ + "guard_id": helper.guard_id, + "route_id": route_id, + "guard_kind": helper.kind.as_str(), + "behavior": helper.behavior.as_str(), + "subject_var": subject_var, + "resource_var": resource_var, + "roles": roles, + "permissions": permissions, + "dominates_sinks": true, + }) + .to_string(), + ), + imported_name: Some(helper.symbol.clone()), + start_line: fact.start_line, + end_line: fact.end_line, + }); + } if is_json_response_call(fact) { let route_id = format!("route:{}:{route}", fact.file_path); security_facts.push(Fact { @@ -126,6 +267,28 @@ pub fn extract_security_facts_with_validation( &facts, &source_lines, )); + security_facts.extend(session_read_facts( + &normalized_file_path, + &facts, + &source_lines, + )); + let facts_with_security = facts + .iter() + .cloned() + .chain(security_facts.iter().cloned()) + .collect::>(); + security_facts.extend(tenant_source_facts( + &normalized_file_path, + &facts_with_security, + &source_lines, + phase4_policy, + )); + security_facts.extend(tenant_guard_facts( + &normalized_file_path, + &facts_with_security, + &source_lines, + phase4_policy, + )); let combined_facts = facts .iter() .cloned() @@ -215,6 +378,54 @@ fn call_first_argument(line: &str, call_name: &str) -> Option { (!argument.is_empty() && argument.chars().all(is_identifier_char)).then(|| argument.to_string()) } +fn call_arguments(line: &str, call_name: &str) -> Vec { + let marker = format!("{call_name}("); + let Some(after_marker) = line.split(&marker).nth(1) else { + return Vec::new(); + }; + let Some(arguments) = after_marker.split_once(')').map(|(arguments, _)| arguments) else { + return Vec::new(); + }; + arguments + .split(',') + .map(str::trim) + .filter(|argument| !argument.is_empty()) + .map(str::to_string) + .collect() +} + +fn is_quoted_literal(argument: &str) -> bool { + let trimmed = argument.trim(); + (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) +} + +fn unquoted_literal(argument: &str) -> Option { + let trimmed = argument.trim(); + if !(trimmed.starts_with('"') || trimmed.starts_with('\'')) { + return None; + } + let quote = trimmed.chars().next()?; + let value = trimmed.trim_matches(quote); + (!value.is_empty()).then(|| value.to_string()) +} + +fn boolean_authorization_failure_branch_exits(lines: &[&str], line_number: usize) -> bool { + if line_number == 0 { + return false; + } + let branch_lines = lines + .iter() + .skip(line_number.saturating_sub(1)) + .take(6) + .copied() + .collect::>(); + let branch_text = branch_lines.join("\n"); + branch_text.contains("if") + && branch_text.contains('!') + && (branch_text.contains("return ") || branch_text.contains("throw ")) +} + fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> Vec { let mut request_facts = Vec::new(); for (index, line) in lines.iter().enumerate() { @@ -278,7 +489,7 @@ fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> quoted_argument(line, "headers.get("), )); } - } else if line.contains("cookies().get(") { + } else if line.contains("cookies().get(") || line.contains("request.cookies.get(") { if let Some(variable) = assigned_variable(line) { request_facts.push(request_input_fact( file_path, @@ -286,7 +497,8 @@ fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> route_id, "cookies", variable, - quoted_argument(line, "cookies().get("), + quoted_argument(line, "cookies().get(") + .or_else(|| quoted_argument(line, "request.cookies.get(")), )); } } else if (line.contains("params.") || line.contains("context.params.")) @@ -320,6 +532,349 @@ fn request_input_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> request_facts } +fn session_read_facts(file_path: &str, facts: &[Fact], lines: &[&str]) -> Vec { + let mut session_facts = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let Some(variable) = assigned_variable(line) else { + continue; + }; + let source = if line.contains("request.headers.get(") { + Some("headers") + } else if line.contains("await request.json()") + || line.contains("await request.formData()") + || line.contains("await request.text()") + { + Some("body") + } else if line.contains("cookies().get(") || line.contains("request.cookies.get(") { + Some("cookies") + } else { + None + }; + let Some(source) = source else { + continue; + }; + if !is_session_like_variable(&variable) { + continue; + } + let route = route_for_line(facts, line_number).unwrap_or("unknown"); + session_facts.push(Fact { + kind: FactKind::SessionRead, + file_path: file_path.to_string(), + name: variable.clone(), + value: Some( + json!({ + "route_id": format!("route:{file_path}:{route}"), + "source": source, + "trust": "untrusted", + "variable": variable, + }) + .to_string(), + ), + imported_name: None, + start_line: line_number, + end_line: line_number, + }); + } + session_facts +} + +fn tenant_source_facts( + file_path: &str, + facts: &[Fact], + lines: &[&str], + phase4_policy: &Phase4SecurityPolicy, +) -> Vec { + let trusted_session_variables = facts + .iter() + .filter(|fact| fact.kind == FactKind::SessionRead) + .filter_map(|fact| { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + (value.get("source")?.as_str()? == "auth_result") + .then(|| value.get("variable")?.as_str().map(str::to_string)) + .flatten() + }) + .collect::>(); + let body_variables = facts + .iter() + .filter(|fact| fact.kind == FactKind::RequestInputRead) + .filter_map(|fact| { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + (value.get("source")?.as_str()? == "body") + .then(|| value.get("variable")?.as_str().map(str::to_string)) + .flatten() + }) + .collect::>(); + let mut tenant_facts = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let route = route_for_line(facts, line_number).unwrap_or("unknown"); + let route_id = format!("route:{file_path}:{route}"); + if let Some(variable) = assigned_variable(line) { + for key in &phase4_policy.tenant_keys { + if phase4_policy + .tenant_sources + .iter() + .any(|source| source == "session") + && trusted_session_variables.iter().any(|session_var| { + line.contains(&format!("{session_var}.user.{key}")) + || line.contains(&format!("{session_var}.{key}")) + }) + { + let session_variable = trusted_session_variables + .iter() + .find(|session_var| { + line.contains(&format!("{}.user.{key}", session_var)) + || line.contains(&format!("{}.{key}", session_var)) + }) + .cloned(); + tenant_facts.push(tenant_source_fact( + file_path, + line_number, + route_id.clone(), + &variable, + TenantSourceFactMetadata { + source: "session", + tenant_key: key, + trusted: true, + session_variable, + }, + )); + } else if phase4_policy + .tenant_sources + .iter() + .any(|source| source == "path_param" || source == "params") + && (line.contains(&format!("params.{key}")) + || line.contains(&format!("context.params.{key}"))) + { + tenant_facts.push(tenant_source_fact( + file_path, + line_number, + route_id.clone(), + &variable, + TenantSourceFactMetadata { + source: "path_param", + tenant_key: key, + trusted: false, + session_variable: None, + }, + )); + } else if phase4_policy + .tenant_sources + .iter() + .any(|source| source == "query" || source == "search_params") + && line.contains("searchParams.get(") + && quoted_argument(line, "searchParams.get(").as_deref() == Some(key.as_str()) + { + tenant_facts.push(tenant_source_fact( + file_path, + line_number, + route_id.clone(), + &variable, + TenantSourceFactMetadata { + source: "query", + tenant_key: key, + trusted: false, + session_variable: None, + }, + )); + } else if phase4_policy + .tenant_sources + .iter() + .any(|source| source == "body") + && body_variables + .iter() + .any(|body_var| line.contains(&format!("{body_var}.{key}"))) + { + tenant_facts.push(tenant_source_fact( + file_path, + line_number, + route_id.clone(), + &variable, + TenantSourceFactMetadata { + source: "body", + tenant_key: key, + trusted: false, + session_variable: None, + }, + )); + } + } + } + if line.contains("} = params") { + for (key, variable) in destructured_aliases(line) { + if phase4_policy.tenant_keys.contains(&key) + && phase4_policy + .tenant_sources + .iter() + .any(|source| source == "path_param" || source == "params") + { + tenant_facts.push(tenant_source_fact( + file_path, + line_number, + route_id.clone(), + &variable, + TenantSourceFactMetadata { + source: "path_param", + tenant_key: &key, + trusted: false, + session_variable: None, + }, + )); + } + } + } + } + tenant_facts +} + +fn tenant_guard_facts( + file_path: &str, + facts: &[Fact], + lines: &[&str], + phase4_policy: &Phase4SecurityPolicy, +) -> Vec { + let mut guard_facts = Vec::new(); + for operation in facts + .iter() + .filter(|fact| fact.kind == FactKind::DataOperationDetected) + { + let route = route_for_line(facts, operation.start_line).unwrap_or("unknown"); + let route_id = format!("route:{file_path}:{route}"); + let operation_text = lines + .iter() + .skip(operation.start_line.saturating_sub(1)) + .take( + operation + .end_line + .saturating_sub(operation.start_line) + .saturating_add(1), + ) + .copied() + .collect::>() + .join("\n"); + let operation_name = operation + .value + .as_deref() + .map(|receiver| format!("{receiver}.{}", operation.name)) + .unwrap_or_else(|| operation.name.clone()); + for key in &phase4_policy.tenant_keys { + if operation_text.contains("where:") + && operation_text.contains(&format!("{key}:")) + && (operation_text.contains(&format!(".user.{key}")) + || operation_text.contains(&format!(".{key}"))) + { + guard_facts.push(tenant_guard_fact( + file_path, + operation.start_line, + route_id.clone(), + &operation_name, + "equality", + key, + None, + )); + } + } + } + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + let route = route_for_line(facts, line_number).unwrap_or("unknown"); + let route_id = format!("route:{file_path}:{route}"); + for helper in &phase4_policy.tenant_helpers { + if line.contains(&format!("{}(", helper.symbol)) + && helper.import_source.as_deref().is_none_or(|expected| { + facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == helper.symbol + && fact.imported_name.as_deref() == Some(helper.symbol.as_str()) + && fact.value.as_deref() == Some(expected) + }) + }) + { + guard_facts.push(tenant_guard_fact( + file_path, + line_number, + route_id.clone(), + &helper.symbol, + "scoped_helper", + &helper.tenant_key, + Some(helper.symbol.clone()), + )); + } + } + } + guard_facts +} + +fn tenant_guard_fact( + file_path: &str, + line_number: usize, + route_id: String, + name: &str, + predicate_kind: &str, + tenant_key: &str, + helper_symbol: Option, +) -> Fact { + Fact { + kind: FactKind::TenantGuardCalled, + file_path: file_path.to_string(), + name: name.to_string(), + value: Some( + json!({ + "route_id": route_id, + "predicate_kind": predicate_kind, + "tenant_key": tenant_key, + "data_operation": if predicate_kind == "equality" { Some(name) } else { None }, + "helper_symbol": helper_symbol, + }) + .to_string(), + ), + imported_name: helper_symbol, + start_line: line_number, + end_line: line_number, + } +} + +fn tenant_source_fact( + file_path: &str, + line_number: usize, + route_id: String, + variable: &str, + metadata: TenantSourceFactMetadata<'_>, +) -> Fact { + Fact { + kind: FactKind::TenantSource, + file_path: file_path.to_string(), + name: variable.to_string(), + value: Some( + json!({ + "route_id": route_id, + "source": metadata.source, + "variable": variable, + "tenant_key": metadata.tenant_key, + "trusted": metadata.trusted, + "session_variable": metadata.session_variable, + }) + .to_string(), + ), + imported_name: None, + start_line: line_number, + end_line: line_number, + } +} + +struct TenantSourceFactMetadata<'a> { + source: &'a str, + tenant_key: &'a str, + trusted: bool, + session_variable: Option, +} + +fn is_session_like_variable(variable: &str) -> bool { + let lower = variable.to_ascii_lowercase(); + lower.contains("session") || lower.contains("user") || lower.contains("token") +} + fn request_input_fact( file_path: &str, line_number: usize, @@ -398,6 +953,25 @@ fn destructured_names(line: &str) -> Vec { .collect() } +fn destructured_aliases(line: &str) -> Vec<(String, String)> { + let Some(start) = line.find('{') else { + return Vec::new(); + }; + let Some(end) = line[start + 1..].find('}') else { + return Vec::new(); + }; + line[start + 1..start + 1 + end] + .split(',') + .filter_map(|part| { + let mut pieces = part.split(':'); + let key = pieces.next()?.trim().trim_start_matches("...").trim(); + let variable = pieces.next().map(str::trim).unwrap_or(key); + (key.chars().all(is_identifier_char) && variable.chars().all(is_identifier_char)) + .then(|| (key.to_string(), variable.to_string())) + }) + .collect() +} + fn is_identifier_char(value: char) -> bool { value == '_' || value == '$' || value.is_ascii_alphanumeric() } diff --git a/drift v3/crates/drift-engine/src/security_patterns.rs b/drift v3/crates/drift-engine/src/security_patterns.rs index d13ae811..56b85d5e 100644 --- a/drift v3/crates/drift-engine/src/security_patterns.rs +++ b/drift v3/crates/drift-engine/src/security_patterns.rs @@ -7,6 +7,32 @@ pub struct AcceptedAuthHelper { pub behavior: AuthGuardBehavior, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Phase4SecurityPolicy { + pub accepted_auth_helpers: Vec, + pub auth_helper_imports: Vec, + pub authorization_helpers: Vec, + pub tenant_helpers: Vec, + pub tenant_keys: Vec, + pub tenant_sources: Vec, + pub data_operations: Vec, +} + +impl Phase4SecurityPolicy { + pub fn from_auth_helpers(accepted_auth_helpers: &[AcceptedAuthHelper]) -> Self { + Self { + accepted_auth_helpers: accepted_auth_helpers.to_vec(), + ..Self::default() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedHelperImport { + pub symbol: String, + pub import_source: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthGuardBehavior { Throws, @@ -42,6 +68,28 @@ pub fn accepted_auth_helper_for_call<'a>( }) } +pub fn accepted_phase4_auth_helper_for_call<'a>( + call: &Fact, + facts: &[Fact], + policy: &'a Phase4SecurityPolicy, +) -> Option<&'a AcceptedAuthHelper> { + policy.accepted_auth_helpers.iter().find(|helper| { + facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == call.name + && fact.imported_name.as_deref() == Some(helper.symbol.as_str()) + && helper_import_matches( + fact, + policy + .auth_helper_imports + .iter() + .find(|contract| contract.symbol == helper.symbol) + .and_then(|contract| contract.import_source.as_deref()), + ) + }) + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcceptedRequestValidator { pub validator_id: String, @@ -114,6 +162,24 @@ fn imported_symbol_matches(facts: &[Fact], local_name: &str, accepted_symbol: &s }) } +fn imported_symbol_matches_with_source( + facts: &[Fact], + local_name: &str, + accepted_symbol: &str, + import_source: Option<&str>, +) -> bool { + facts.iter().any(|fact| { + fact.kind == FactKind::ImportUsed + && fact.name == local_name + && fact.imported_name.as_deref() == Some(accepted_symbol) + && helper_import_matches(fact, import_source) + }) +} + +fn helper_import_matches(fact: &Fact, import_source: Option<&str>) -> bool { + import_source.is_none_or(|expected| fact.value.as_deref() == Some(expected)) +} + fn receiver_root(receiver: &str) -> &str { receiver.split('.').next().unwrap_or(receiver) } @@ -195,6 +261,71 @@ pub fn dynamic_middleware_matcher_line(source: &str) -> Option { }) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedTenantHelper { + pub helper_id: String, + pub symbol: String, + pub import_source: Option, + pub tenant_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedAuthorizationHelper { + pub guard_id: String, + pub symbol: String, + pub import_source: Option, + pub kind: AuthorizationHelperKind, + pub behavior: AuthorizationHelperBehavior, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationHelperKind { + Role, + Policy, +} + +impl AuthorizationHelperKind { + pub fn as_str(self) -> &'static str { + match self { + AuthorizationHelperKind::Role => "role", + AuthorizationHelperKind::Policy => "policy", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationHelperBehavior { + Throws, + Boolean, +} + +impl AuthorizationHelperBehavior { + pub fn as_str(self) -> &'static str { + match self { + AuthorizationHelperBehavior::Throws => "throws", + AuthorizationHelperBehavior::Boolean => "boolean", + } + } +} + +pub fn accepted_authorization_helper_for_call<'a>( + call: &Fact, + facts: &[Fact], + accepted_helpers: &'a [AcceptedAuthorizationHelper], +) -> Option<&'a AcceptedAuthorizationHelper> { + accepted_helpers.iter().find(|helper| { + if helper.import_source.is_some() { + return imported_symbol_matches_with_source( + facts, + &call.name, + &helper.symbol, + helper.import_source.as_deref(), + ); + } + call.name == helper.symbol || imported_symbol_matches(facts, &call.name, &helper.symbol) + }) +} + fn quoted_values(value: &str) -> Vec { let mut values = Vec::new(); let mut chars = value.char_indices().peekable(); diff --git a/drift v3/crates/drift-engine/src/security_proof.rs b/drift v3/crates/drift-engine/src/security_proof.rs index f3c736b6..c5bec654 100644 --- a/drift v3/crates/drift-engine/src/security_proof.rs +++ b/drift v3/crates/drift-engine/src/security_proof.rs @@ -1,5 +1,6 @@ use crate::{ - AcceptedAuthHelper, AcceptedRequestValidator, Fact, FactExtractError, extract_security_facts, + AcceptedAuthHelper, AcceptedRequestValidator, Fact, FactExtractError, Phase4SecurityPolicy, + extract_security_facts, extract_security_facts_with_policy, extract_security_facts_with_validation, extract_typescript_facts, security_control_flow::{ DominatedSink, MatchedMiddleware, MiddlewareMismatch, branch_bypass_reasons, @@ -15,6 +16,9 @@ pub struct SecurityBoundaryProof { pub auth: AuthBoundaryProof, pub middleware: MiddlewareBoundaryProof, pub request_validation: RequestValidationProof, + pub session_trust: SessionTrustProof, + pub authorization: AuthorizationProof, + pub tenant: TenantProof, pub parser_gaps: Vec, pub result: SecurityProofResult, } @@ -75,6 +79,86 @@ pub struct RequestValidationProof { pub unvalidated_uses: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionTrustProof { + pub required: bool, + pub proven: bool, + pub trusted_sessions: Vec, + pub missing_trust: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionTrustBoundaryProof { + pub fact_id: String, + pub variable: String, + pub trust: String, + pub derived_from: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionMissingTrustProof { + pub fact_id: String, + pub variable: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationProof { + pub required: bool, + pub proven: bool, + pub role_or_policy_guards: Vec, + pub missing: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationGuardProof { + pub fact_id: String, + pub policy_id: Option, + pub roles: Vec, + pub permissions: Vec, + pub resource_var: Option, + pub subject_var: Option, + pub dominates_sinks: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationMissingProof { + pub reason: String, + pub sink_fact_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantProof { + pub required: bool, + pub proven: bool, + pub tenant_sources: Vec, + pub predicates: Vec, + pub missing: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantSourceProof { + pub fact_id: String, + pub source: String, + pub key: Option, + pub trusted: bool, + pub variable: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantPredicateProof { + pub fact_id: String, + pub data_operation_fact_id: String, + pub tenant_key: String, + pub predicate_kind: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantMissingProof { + pub data_operation_fact_id: String, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestInputReadProof { pub fact_id: String, @@ -186,6 +270,9 @@ pub fn build_auth_boundary_proof( mismatches: Vec::new(), }, request_validation: RequestValidationProof::not_required(), + session_trust: build_session_trust_proof_from_facts(&facts), + authorization: AuthorizationProof::not_required(), + tenant: TenantProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status: if dynamic_control_flow { @@ -486,6 +573,9 @@ pub fn build_middleware_coverage_proof( mismatches, }, request_validation: RequestValidationProof::not_required(), + session_trust: build_session_trust_proof_from_facts(&middleware_facts), + authorization: AuthorizationProof::not_required(), + tenant: TenantProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status: if dynamic_matcher_line.is_some() { @@ -573,6 +663,9 @@ pub fn build_request_validation_proof_with_scope( mismatches: Vec::new(), }, request_validation: RequestValidationProof::not_required(), + session_trust: build_session_trust_proof_from_facts(&facts), + authorization: AuthorizationProof::not_required(), + tenant: TenantProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status: SecurityProofStatus::Proven, @@ -620,11 +713,522 @@ pub fn build_request_validation_proof_with_scope( validated_uses, unvalidated_uses, }, + session_trust: build_session_trust_proof_from_facts(&facts), + authorization: AuthorizationProof::not_required(), + tenant: TenantProof::not_required(), parser_gaps, result: SecurityProofResult { proof_status }, }) } +pub fn build_phase4_security_proof( + file_path: impl AsRef, + source: &str, + accepted_auth_helpers: &[AcceptedAuthHelper], +) -> Result { + build_phase4_security_proof_with_policy( + file_path, + source, + &Phase4SecurityPolicy::from_auth_helpers(accepted_auth_helpers), + ) +} + +pub fn build_phase4_security_proof_with_policy( + file_path: impl AsRef, + source: &str, + phase4_policy: &Phase4SecurityPolicy, +) -> Result { + let base_facts = extract_typescript_facts(&file_path, source)?; + let security_facts = extract_security_facts_with_policy(file_path, source, phase4_policy, &[])?; + let mut facts: Vec = base_facts.into_iter().chain(security_facts).collect(); + facts.sort_by_key(|fact| fact.start_line); + + let session_trust = build_session_trust_proof_from_facts(&facts); + let authorization = + build_authorization_proof_from_facts(&facts, source, &session_trust, phase4_policy); + let tenant = build_tenant_proof_from_facts(&facts, &session_trust, phase4_policy); + let parser_gaps = phase4_parser_gaps(&file_path_string(&facts), source); + let proven = session_trust.proven + && (!authorization.required || authorization.proven) + && (!tenant.required || tenant.proven) + && parser_gaps.is_empty(); + + Ok(SecurityBoundaryProof { + auth: AuthBoundaryProof { + required: false, + proven: false, + dominated_sinks: Vec::new(), + undominated_sinks: Vec::new(), + }, + middleware: MiddlewareBoundaryProof { + required: false, + proven: false, + matched_middleware: Vec::new(), + mismatches: Vec::new(), + }, + request_validation: RequestValidationProof::not_required(), + session_trust, + authorization, + tenant, + parser_gaps: parser_gaps.clone(), + result: SecurityProofResult { + proof_status: if !parser_gaps.is_empty() { + SecurityProofStatus::ParserGap + } else if proven { + SecurityProofStatus::Proven + } else { + SecurityProofStatus::MissingProof + }, + }, + }) +} + +fn phase4_parser_gaps(file_path: &str, source: &str) -> Vec { + let lines = source.lines().collect::>(); + let mut gaps = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let line_number = index + 1; + if line.contains("where:") && line.contains('[') && line.contains(']') { + gaps.push(phase4_parser_gap( + file_path, + line_number, + "unsupported_tenant_dynamic_property", + "Computed tenant predicate key prevents deterministic tenant proof", + )); + } + if line.contains("const ") + && line.contains("where:") + && line.contains("tenantId") + && line.contains(".user.") + && let Some(variable) = assigned_variable(line) + { + let marker = format!("({variable})"); + if lines + .iter() + .skip(index + 1) + .any(|candidate| candidate.contains(&marker)) + { + gaps.push(phase4_parser_gap( + file_path, + line_number, + "unsupported_tenant_query_object_alias", + "Tenant query object alias prevents deterministic tenant proof", + )); + } + } + if line.contains("user:") && line.contains("tenantId") && line.contains("} = session") { + gaps.push(phase4_parser_gap( + file_path, + line_number, + "unsupported_session_nested_destructure", + "Nested session destructuring prevents deterministic session trust proof", + )); + } + } + gaps.sort_by(|left, right| { + (&left.code, &left.parser_gap_id).cmp(&(&right.code, &right.parser_gap_id)) + }); + gaps.dedup_by(|left, right| { + left.code == right.code && left.parser_gap_id == right.parser_gap_id + }); + gaps +} + +fn phase4_parser_gap( + file_path: &str, + line_number: usize, + code: &str, + reason: &str, +) -> SecurityParserGap { + SecurityParserGap { + parser_gap_id: format!("parser_gap:{file_path}:{line_number}:{code}"), + code: code.to_string(), + file_path: file_path.to_string(), + reason: reason.to_string(), + blocks_enforcement: true, + } +} + +fn build_authorization_proof_from_facts( + facts: &[Fact], + source: &str, + session_trust: &SessionTrustProof, + phase4_policy: &Phase4SecurityPolicy, +) -> AuthorizationProof { + let data_operations = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::DataOperationDetected) + .filter(|fact| data_operation_matches_policy(fact, phase4_policy)) + .collect::>(); + let guards = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::AuthorizationGuardCalled) + .filter_map(|fact| { + authorization_guard_proof( + fact, + authorization_guard_dominates(fact, facts, source, phase4_policy), + ) + }) + .collect::>(); + let mut missing = Vec::new(); + if !data_operations.is_empty() && guards.is_empty() { + missing.push(AuthorizationMissingProof { + reason: "authorization_guard_missing".to_string(), + sink_fact_id: data_operations.first().map(|fact| sink_id(fact)), + }); + } + for guard in &guards { + if guard + .subject_var + .as_deref() + .is_some_and(|subject| !subject_uses_trusted_session(subject, session_trust)) + { + missing.push(AuthorizationMissingProof { + reason: "session_not_trusted".to_string(), + sink_fact_id: data_operations.first().map(|fact| sink_id(fact)), + }); + } + if !guard.dominates_sinks { + missing.push(AuthorizationMissingProof { + reason: "authorization_guard_not_dominating_sink".to_string(), + sink_fact_id: data_operations.first().map(|fact| sink_id(fact)), + }); + } + } + missing.sort_by(|left, right| { + (&left.reason, &left.sink_fact_id).cmp(&(&right.reason, &right.sink_fact_id)) + }); + missing.dedup(); + let required = !data_operations.is_empty(); + AuthorizationProof { + required, + proven: required && !guards.is_empty() && missing.is_empty(), + role_or_policy_guards: guards, + missing, + } +} + +fn build_tenant_proof_from_facts( + facts: &[Fact], + session_trust: &SessionTrustProof, + phase4_policy: &Phase4SecurityPolicy, +) -> TenantProof { + let data_operations = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::DataOperationDetected) + .filter(|fact| data_operation_matches_policy(fact, phase4_policy)) + .collect::>(); + let tenant_sources = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::TenantSource) + .filter_map(tenant_source_proof) + .collect::>(); + let tenant_guard_facts = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::TenantGuardCalled) + .collect::>(); + let helper_operations = tenant_guard_facts + .iter() + .filter(|fact| { + fact.value + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .and_then(|value| { + value + .get("predicate_kind") + .and_then(|kind| kind.as_str()) + .map(|kind| kind == "scoped_helper") + }) + .unwrap_or(false) + }) + .collect::>(); + let predicates = tenant_guard_facts + .iter() + .filter_map(|fact| tenant_predicate_proof(fact, &data_operations)) + .collect::>(); + let protected_operation_count = data_operations.len() + helper_operations.len(); + let mut missing = Vec::new(); + if protected_operation_count > 0 && predicates.is_empty() && tenant_sources.is_empty() { + missing.push(TenantMissingProof { + data_operation_fact_id: data_operations + .first() + .map(|fact| fact_id(fact)) + .unwrap_or_default(), + reason: "tenant_predicate_missing".to_string(), + }); + } + let has_untrusted_session_use = (!session_trust.missing_trust.is_empty() + || (session_trust.trusted_sessions.is_empty() && !predicates.is_empty())) + && (!predicates.is_empty() + || tenant_sources + .iter() + .any(|source| !source.trusted && source.source != "path_param")); + if has_untrusted_session_use { + missing.push(TenantMissingProof { + data_operation_fact_id: data_operations + .first() + .map(|fact| fact_id(fact)) + .unwrap_or_default(), + reason: "tenant_source_untrusted".to_string(), + }); + } + if protected_operation_count > 0 && predicates.is_empty() && !tenant_sources.is_empty() { + missing.push(TenantMissingProof { + data_operation_fact_id: data_operations + .first() + .map(|fact| fact_id(fact)) + .unwrap_or_default(), + reason: "tenant_predicate_not_bound_to_query".to_string(), + }); + } + missing.sort_by(|left, right| { + (&left.reason, &left.data_operation_fact_id) + .cmp(&(&right.reason, &right.data_operation_fact_id)) + }); + missing.dedup(); + let required = protected_operation_count > 0; + TenantProof { + required, + proven: required && !predicates.is_empty() && missing.is_empty(), + tenant_sources, + predicates, + missing, + } +} + +fn authorization_guard_proof( + fact: &Fact, + dominates_sinks: bool, +) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + Some(AuthorizationGuardProof { + fact_id: fact_id(fact), + policy_id: value + .get("policy_id") + .and_then(|policy| policy.as_str()) + .or_else(|| value.get("guard_id").and_then(|guard| guard.as_str())) + .map(str::to_string), + roles: string_array(value.get("roles")), + permissions: string_array(value.get("permissions")), + resource_var: value + .get("resource_var") + .and_then(|resource| resource.as_str()) + .map(str::to_string), + subject_var: value + .get("subject_var") + .and_then(|subject| subject.as_str()) + .map(str::to_string), + dominates_sinks, + }) +} + +fn authorization_guard_dominates( + guard: &Fact, + facts: &[Fact], + source: &str, + phase4_policy: &Phase4SecurityPolicy, +) -> bool { + let data_operations = facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::DataOperationDetected) + .filter(|fact| data_operation_matches_policy(fact, phase4_policy)) + .collect::>(); + if data_operations.is_empty() + || data_operations + .iter() + .any(|operation| guard.start_line > operation.start_line) + { + return false; + } + !authorization_guard_is_one_branch_only(guard, &data_operations, source) +} + +fn data_operation_matches_policy(fact: &Fact, phase4_policy: &Phase4SecurityPolicy) -> bool { + if phase4_policy.data_operations.is_empty() { + return true; + } + let operation_kind = fact + .imported_name + .as_deref() + .and_then(|metadata| metadata.split_once(':').map(|(kind, _)| kind)) + .unwrap_or("unknown"); + let receiver_operation = fact + .value + .as_deref() + .map(|receiver| format!("{receiver}.{}", fact.name)); + phase4_policy.data_operations.iter().any(|accepted| { + accepted == &fact.name + || accepted == operation_kind + || receiver_operation.as_deref() == Some(accepted.as_str()) + }) +} + +fn authorization_guard_is_one_branch_only( + guard: &Fact, + data_operations: &[&Fact], + source: &str, +) -> bool { + let lines = source.lines().collect::>(); + let guard_index = guard.start_line.saturating_sub(1); + let Some(if_line_index) = lines + .iter() + .enumerate() + .take(guard_index.saturating_add(1)) + .rev() + .take(4) + .find(|(_, line)| line.contains("if") && line.contains('{')) + .map(|(index, _)| index) + else { + return false; + }; + let block_end = + closing_block_line_for_source(&lines, if_line_index + 1).unwrap_or(if_line_index + 1); + let has_else = lines + .iter() + .skip(block_end) + .take(2) + .any(|line| line.contains("else")); + !has_else + && data_operations + .iter() + .any(|operation| operation.start_line > block_end) +} + +fn closing_block_line_for_source(lines: &[&str], start_line: usize) -> Option { + let mut depth = 0_i32; + let mut saw_open = false; + for (index, line) in lines.iter().enumerate().skip(start_line.saturating_sub(1)) { + for character in line.chars() { + match character { + '{' => { + depth += 1; + saw_open = true; + } + '}' if saw_open => { + depth -= 1; + if depth <= 0 { + return Some(index + 1); + } + } + _ => {} + } + } + } + None +} + +fn tenant_source_proof(fact: &Fact) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + Some(TenantSourceProof { + fact_id: fact_id(fact), + source: value.get("source")?.as_str()?.to_string(), + key: value + .get("tenant_key") + .and_then(|key| key.as_str()) + .map(str::to_string), + trusted: value + .get("trusted") + .and_then(|trusted| trusted.as_bool()) + .unwrap_or(false), + variable: value.get("variable")?.as_str()?.to_string(), + }) +} + +fn tenant_predicate_proof(fact: &Fact, data_operations: &[&Fact]) -> Option { + let value = serde_json::from_str::(fact.value.as_deref()?).ok()?; + let predicate_kind = value.get("predicate_kind")?.as_str()?.to_string(); + let data_operation_fact_id = data_operations + .iter() + .find(|operation| operation.start_line == fact.start_line) + .or_else(|| data_operations.first()) + .map(|operation| fact_id(operation)) + .unwrap_or_else(|| { + format!( + "fact:{}:tenant_scope_helper:{}", + fact.file_path, fact.start_line + ) + }); + Some(TenantPredicateProof { + fact_id: fact_id(fact), + data_operation_fact_id, + tenant_key: value.get("tenant_key")?.as_str()?.to_string(), + predicate_kind, + }) +} + +fn subject_uses_trusted_session(subject: &str, session_trust: &SessionTrustProof) -> bool { + session_trust.trusted_sessions.iter().any(|session| { + subject == session.variable || subject.starts_with(&format!("{}.", session.variable)) + }) +} + +fn string_array(value: Option<&serde_json::Value>) -> Vec { + value + .and_then(|value| value.as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn build_session_trust_proof_from_facts(facts: &[Fact]) -> SessionTrustProof { + let mut trusted_sessions = Vec::new(); + let mut missing_trust = Vec::new(); + for fact in facts + .iter() + .filter(|fact| fact.kind == crate::FactKind::SessionRead) + { + let Some(value) = fact + .value + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + else { + continue; + }; + let variable = value + .get("variable") + .and_then(|variable| variable.as_str()) + .unwrap_or(&fact.name) + .to_string(); + match ( + value.get("source").and_then(|source| source.as_str()), + value.get("trust").and_then(|trust| trust.as_str()), + ) { + (Some("auth_result"), Some("unknown")) => { + trusted_sessions.push(SessionTrustBoundaryProof { + fact_id: fact_id(fact), + variable, + trust: "trusted".to_string(), + derived_from: "auth_guard".to_string(), + }); + } + (source, Some("untrusted")) => { + missing_trust.push(SessionMissingTrustProof { + fact_id: fact_id(fact), + variable, + reason: if source == Some("unknown_helper") { + "session_not_trusted" + } else { + "derived_from_request" + } + .to_string(), + }); + } + _ => {} + } + } + let required = !trusted_sessions.is_empty() || !missing_trust.is_empty(); + SessionTrustProof { + required, + proven: required && !trusted_sessions.is_empty() && missing_trust.is_empty(), + trusted_sessions, + missing_trust, + } +} + fn file_path_string(facts: &[Fact]) -> String { facts .first() @@ -690,6 +1294,29 @@ impl RequestValidationProof { } } +impl AuthorizationProof { + fn not_required() -> Self { + Self { + required: false, + proven: false, + role_or_policy_guards: Vec::new(), + missing: Vec::new(), + } + } +} + +impl TenantProof { + fn not_required() -> Self { + Self { + required: false, + proven: false, + tenant_sources: Vec::new(), + predicates: Vec::new(), + missing: Vec::new(), + } + } +} + fn request_unvalidated_uses( facts: &[Fact], lines: &[&str], diff --git a/drift v3/crates/drift-engine/src/security_rules.rs b/drift v3/crates/drift-engine/src/security_rules.rs index aa6d2dcb..a04d844f 100644 --- a/drift v3/crates/drift-engine/src/security_rules.rs +++ b/drift v3/crates/drift-engine/src/security_rules.rs @@ -1,7 +1,9 @@ use crate::{ - AcceptedAuthHelper, AcceptedRequestValidator, FactExtractError, RequestValidationProofScope, - SecurityProofStatus, build_auth_boundary_proof, build_middleware_coverage_proof, - build_request_validation_proof_with_scope, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedRequestValidator, + AcceptedTenantHelper, AuthorizationHelperBehavior, AuthorizationHelperKind, FactExtractError, + Phase4SecurityPolicy, RequestValidationProofScope, SecurityProofStatus, + build_auth_boundary_proof, build_middleware_coverage_proof, + build_phase4_security_proof_with_policy, build_request_validation_proof_with_scope, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,6 +34,28 @@ pub struct SecurityRequestValidationContract { pub accepted_validators: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityTenantScopeContract { + pub contract_id: String, + pub capability: SecurityContractCapability, + pub enforcement_mode: SecurityEnforcementMode, + pub accepted_auth_helpers: Vec, + pub tenant_helpers: Vec, + pub tenant_keys: Vec, + pub tenant_sources: Vec, + pub data_operations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityAuthorizationContract { + pub contract_id: String, + pub capability: SecurityContractCapability, + pub enforcement_mode: SecurityEnforcementMode, + pub accepted_auth_helpers: Vec, + pub authorization_helpers: Vec, + pub data_operations: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecurityContractCapability { BriefingOnly, @@ -249,6 +273,140 @@ pub fn evaluate_api_route_requires_request_validation( }]) } +pub fn evaluate_api_route_requires_tenant_scope( + file_path: impl AsRef, + source: &str, + contract: &SecurityTenantScopeContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.capability != SecurityContractCapability::DeterministicCheck + { + return Ok(Vec::new()); + } + + let proof = build_phase4_security_proof_with_policy( + file_path, + source, + &tenant_phase4_policy(contract), + )?; + if !proof.tenant.required || proof.tenant.proven { + return Ok(Vec::new()); + } + + Ok(vec![SecurityFinding { + contract_id: contract.contract_id.clone(), + title: "API route missing required tenant scope proof".to_string(), + expected_layer: "tenant_scope".to_string(), + actual_layer: proof + .tenant + .missing + .first() + .map(|missing| missing.reason.clone()) + .unwrap_or_else(|| "tenant_predicate_missing".to_string()), + enforcement_result: match contract.enforcement_mode { + SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, + SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, + SecurityEnforcementMode::Block => SecurityFindingResult::Block, + SecurityEnforcementMode::Off => return Ok(Vec::new()), + }, + drift_category: "missing_proof".to_string(), + confidence_label: "certain".to_string(), + }]) +} + +pub fn evaluate_api_route_requires_authorization( + file_path: impl AsRef, + source: &str, + contract: &SecurityAuthorizationContract, +) -> Result, FactExtractError> { + if contract.enforcement_mode == SecurityEnforcementMode::Off + || contract.capability != SecurityContractCapability::DeterministicCheck + { + return Ok(Vec::new()); + } + + let proof = build_phase4_security_proof_with_policy( + file_path, + source, + &authorization_phase4_policy(contract), + )?; + if !proof.authorization.required || proof.authorization.proven { + return Ok(Vec::new()); + } + + Ok(vec![SecurityFinding { + contract_id: contract.contract_id.clone(), + title: "API route missing required authorization proof".to_string(), + expected_layer: "authorization".to_string(), + actual_layer: proof + .authorization + .missing + .first() + .map(|missing| missing.reason.clone()) + .unwrap_or_else(|| "authorization_guard_missing".to_string()), + enforcement_result: match contract.enforcement_mode { + SecurityEnforcementMode::Brief => SecurityFindingResult::Brief, + SecurityEnforcementMode::Warn => SecurityFindingResult::Warn, + SecurityEnforcementMode::Block => SecurityFindingResult::Block, + SecurityEnforcementMode::Off => return Ok(Vec::new()), + }, + drift_category: "missing_proof".to_string(), + confidence_label: "certain".to_string(), + }]) +} + +fn tenant_phase4_policy(contract: &SecurityTenantScopeContract) -> Phase4SecurityPolicy { + Phase4SecurityPolicy { + accepted_auth_helpers: contract.accepted_auth_helpers.clone(), + tenant_helpers: contract + .tenant_helpers + .iter() + .map(|symbol| AcceptedTenantHelper { + helper_id: format!("tenant:{symbol}"), + symbol: symbol.clone(), + import_source: None, + tenant_key: contract + .tenant_keys + .first() + .cloned() + .unwrap_or_else(|| "tenantId".to_string()), + }) + .collect(), + tenant_keys: contract.tenant_keys.clone(), + tenant_sources: contract.tenant_sources.clone(), + data_operations: contract.data_operations.clone(), + ..Phase4SecurityPolicy::default() + } +} + +fn authorization_phase4_policy(contract: &SecurityAuthorizationContract) -> Phase4SecurityPolicy { + Phase4SecurityPolicy { + accepted_auth_helpers: contract.accepted_auth_helpers.clone(), + authorization_helpers: contract + .authorization_helpers + .iter() + .map(|symbol| AcceptedAuthorizationHelper { + guard_id: format!("authorization:{symbol}"), + symbol: symbol.clone(), + import_source: None, + kind: if symbol.to_ascii_lowercase().contains("role") { + AuthorizationHelperKind::Role + } else { + AuthorizationHelperKind::Policy + }, + behavior: if symbol.to_ascii_lowercase().starts_with("can") { + AuthorizationHelperBehavior::Boolean + } else { + AuthorizationHelperBehavior::Throws + }, + }) + .collect(), + tenant_keys: Vec::new(), + data_operations: contract.data_operations.clone(), + ..Phase4SecurityPolicy::default() + } +} + fn first_route_method(source: &str) -> Option { source.lines().find_map(|line| { let trimmed = line.trim_start(); diff --git a/drift v3/crates/drift-engine/tests/security_capabilities.rs b/drift v3/crates/drift-engine/tests/security_capabilities.rs index 7dd9ef29..78a5af18 100644 --- a/drift v3/crates/drift-engine/tests/security_capabilities.rs +++ b/drift v3/crates/drift-engine/tests/security_capabilities.rs @@ -33,3 +33,35 @@ fn reports_phase_one_security_capabilities() { "Phase 1 guard dominance should report partial, not overclaim complete: {capabilities:#?}" ); } + +#[test] +fn phase4_capabilities_reflect_supported_parser_gaps_and_contracts() { + let capabilities = security_capabilities(); + + for expected in ["session_trust", "authorization", "tenant_scope"] { + let capability = capabilities + .iter() + .find(|capability| capability.name == expected) + .unwrap_or_else(|| panic!("missing {expected}: {capabilities:#?}")); + assert_eq!( + capability.capability, "deterministic_check", + "{expected} must report deterministic authority: {capabilities:#?}" + ); + assert!( + capability.can_block, + "{expected} must be able to block accepted contracts: {capabilities:#?}" + ); + assert!( + capability.block_requires_accepted_convention, + "{expected} must require accepted contracts: {capabilities:#?}" + ); + } + + assert!( + capabilities + .iter() + .any(|capability| capability.name == "tenant_scope" + && capability.status == SecurityCapabilityStatus::Partial), + "tenant scope must stay partial while dynamic tenant shapes are parser-gap backed: {capabilities:#?}" + ); +} diff --git a/drift v3/crates/drift-engine/tests/security_check_repo_phase4.rs b/drift v3/crates/drift-engine/tests/security_check_repo_phase4.rs new file mode 100644 index 00000000..4377d511 --- /dev/null +++ b/drift v3/crates/drift-engine/tests/security_check_repo_phase4.rs @@ -0,0 +1,513 @@ +use std::{ + fs, + io::Write, + process::{Command, Stdio}, +}; + +use serde_json::{Value, json}; + +#[test] +fn engine_blocks_tenant_missing_predicate_from_accepted_phase4_contract() { + let repo_root = temp_repo("phase4_tenant_missing"); + 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, + [ + "import { requireUser } from '@/server/auth';", + "const db = { project: { findMany: async () => [] } };", + "export async function GET(request: Request) {", + " const session = await requireUser(request);", + " await db.project.findMany();", + " return Response.json({ ok: true, session: Boolean(session) });", + "}", + "", + ] + .join("\n"), + ) + .expect("write route"); + + let payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_phase4", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_phase4", + "facts": [ + fact("file_role_detected", "api_route", 1, 7, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("route_declared", "GET", 3, 7, None, None), + fact("symbol_called", "requireUser", 4, 4, None, None), + fact("symbol_called", "findMany", 5, 5, Some("db.project"), None), + fact("data_operation_detected", "findMany", 5, 5, Some("db.project"), Some("read:project")), + fact("route_returns_response", "json", 6, 6, Some("Response"), None) + ] + }, + "contract": { + "contract_id": "contract_phase4", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_api_tenant_scope", + "kind": "api_route_requires_tenant_scope", + "matcher": { "applies_to_file_roles": ["api_route"] }, + "requires": { + "auth_helpers": [{ "guard_id": "auth_require_user", "symbol": "requireUser", "behavior": "returns_session" }], + "tenant_helpers": ["scopeProjectToTenant"], + "tenant_keys": ["tenantId"], + "tenant_sources": ["session"], + "data_operations": ["findMany"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + + let findings = payload["findings"].as_array().expect("findings"); + assert_eq!(findings.len(), 1, "{payload:#?}"); + assert_eq!(findings[0]["rule_id"], "api_route_requires_tenant_scope"); + assert_eq!(findings[0]["enforcement_result"], "block"); + assert_eq!( + findings[0]["evidence"][0]["file_path"], + "app/api/projects/route.ts" + ); + assert!( + payload["security_boundary_proofs"][0]["tenant"]["missing"] + .as_array() + .expect("tenant missing") + .iter() + .any(|missing| missing["reason"] == "tenant_predicate_missing"), + "{payload:#?}" + ); +} + +#[test] +fn engine_does_not_accept_phase4_legacy_matcher_required_calls_as_session_trust() { + let repo_root = temp_repo("phase4_legacy_required_calls"); + 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, + [ + "import { requireUser } from '@/server/auth';", + "export async function GET(request: Request) {", + " const session = await requireUser(request);", + " return Response.json({ ok: Boolean(session) });", + "}", + "", + ] + .join("\n"), + ) + .expect("write route"); + + let payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_phase4", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_phase4", + "facts": [ + fact("file_role_detected", "api_route", 1, 5, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("route_declared", "GET", 2, 5, None, None), + fact("symbol_called", "requireUser", 3, 3, None, None), + fact("route_returns_response", "json", 4, 4, Some("Response"), None) + ] + }, + "contract": { + "contract_id": "contract_phase4", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_session_trust", + "kind": "session_object_must_come_from_trusted_helper", + "matcher": { + "applies_to_file_roles": ["api_route"], + "required_calls": ["requireUser"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + + let findings = payload["findings"].as_array().expect("findings"); + assert_eq!(findings.len(), 1, "{payload:#?}"); + assert_eq!( + findings[0]["rule_id"], + "session_object_must_come_from_trusted_helper" + ); + assert!( + payload["security_boundary_proofs"][0]["session_trust"]["missing_trust"] + .as_array() + .expect("missing trust") + .iter() + .any(|missing| missing["reason"] == "session_not_trusted"), + "{payload:#?}" + ); +} + +#[test] +fn security_phase4_unaccepted_helpers_rejects_wrong_import_contract() { + let repo_root = temp_repo("phase4_wrong_import"); + 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, + [ + "import { requireUser } from '@/server/auth';", + "import { requireRole } from '@/server/unsafe-authz';", + "const db = { project: { delete: async () => ({}) } };", + "export async function DELETE(request: Request) {", + " const session = await requireUser(request);", + " requireRole(session.user, 'admin');", + " await db.project.delete({ where: { tenantId: session.user.tenantId } });", + " return Response.json({ ok: true });", + "}", + "", + ] + .join("\n"), + ) + .expect("write route"); + + let payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_phase4", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_phase4", + "facts": [ + fact("file_role_detected", "api_route", 1, 9, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("import_used", "requireRole", 2, 2, Some("@/server/unsafe-authz"), Some("requireRole")), + fact("route_declared", "DELETE", 4, 9, None, None), + fact("symbol_called", "requireUser", 5, 5, None, None), + fact("symbol_called", "requireRole", 6, 6, None, None), + fact("symbol_called", "delete", 7, 7, Some("db.project"), None), + fact("data_operation_detected", "delete", 7, 7, Some("db.project"), Some("delete:project")), + fact("route_returns_response", "json", 8, 8, Some("Response"), None) + ] + }, + "contract": { + "contract_id": "contract_phase4", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_api_authorization", + "kind": "api_route_requires_authorization", + "matcher": { "applies_to_file_roles": ["api_route"] }, + "requires": { + "auth_helpers": [{ "guard_id": "auth_require_user", "symbol": "requireUser", "import": "@/server/auth", "behavior": "returns_session" }], + "authorization_helpers": [{ "guard_id": "authorization_require_role", "symbol": "requireRole", "import": "@/server/authorization", "roles": ["admin"], "behavior": "throws" }], + "data_operations": ["delete"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }, + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + + let findings = payload["findings"].as_array().expect("findings"); + assert_eq!(findings.len(), 1, "{payload:#?}"); + assert_eq!(findings[0]["rule_id"], "api_route_requires_authorization"); + assert!( + payload["security_boundary_proofs"][0]["authorization"]["missing"] + .as_array() + .expect("authorization missing") + .iter() + .any(|missing| missing["reason"] == "authorization_guard_missing"), + "{payload:#?}" + ); +} + +#[test] +fn security_phase4_auth_helper_returns_contract_accepts_documented_shape() { + let repo_root = temp_repo("phase4_returns_session"); + 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, + [ + "import { getServerSession } from 'next-auth';", + "export async function GET() {", + " const session = await getServerSession();", + " return Response.json({ ok: Boolean(session) });", + "}", + "", + ] + .join("\n"), + ) + .expect("write route"); + + let payload = run_check_repo(json!({ + "repo": { + "repo_id": "repo_phase4", + "repo_root": repo_root.to_string_lossy() + }, + "scan": { + "scan_id": "scan_phase4", + "facts": [ + fact("file_role_detected", "api_route", 1, 5, None, None), + fact("import_used", "getServerSession", 1, 1, Some("next-auth"), Some("getServerSession")), + fact("route_declared", "GET", 2, 5, None, None), + fact("symbol_called", "getServerSession", 3, 3, None, None), + fact("route_returns_response", "json", 4, 4, Some("Response"), None) + ] + }, + "contract": { + "contract_id": "contract_phase4", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_session_trust", + "kind": "session_object_must_come_from_trusted_helper", + "matcher": { "applies_to_file_roles": ["api_route"] }, + "requires": { + "auth_helpers": [{ "name": "getServerSession", "import": "next-auth", "returns": "session" }] + }, + "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:#?}" + ); + assert_eq!( + payload["security_boundary_proofs"][0]["session_trust"]["proven"], true, + "{payload:#?}" + ); +} + +#[test] +fn security_phase4_scope_filtering_honors_method_path_and_data_operation() { + let method_repo = temp_repo("phase4_method_scope"); + write_route( + &method_repo, + "app/api/projects/route.ts", + &[ + "import { requireUser } from '@/server/auth';", + "const db = { project: { findMany: async () => [] } };", + "export async function POST(request: Request) {", + " const session = await requireUser(request);", + " await db.project.findMany();", + " return Response.json({ ok: Boolean(session) });", + "}", + "", + ], + ); + let payload = run_check_repo(json!({ + "repo": { "repo_id": "repo_phase4", "repo_root": method_repo.to_string_lossy() }, + "scan": { "scan_id": "scan_phase4", "facts": [ + fact("file_role_detected", "api_route", 1, 7, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("route_declared", "POST", 3, 7, None, None), + fact("symbol_called", "requireUser", 4, 4, None, None), + fact("symbol_called", "findMany", 5, 5, Some("db.project"), None), + fact("data_operation_detected", "findMany", 5, 5, Some("db.project"), Some("read:project")), + fact("route_returns_response", "json", 6, 6, Some("Response"), None) + ] }, + "contract": phase4_tenant_contract(json!({ "methods": ["GET"], "applies_to_file_roles": ["api_route"] }), json!({})), + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + assert_eq!( + payload["findings"].as_array().expect("findings").len(), + 0, + "POST route must not be blocked by GET-only Phase 4 contract: {payload:#?}" + ); + + let path_repo = temp_repo("phase4_path_scope"); + write_route( + &path_repo, + "app/api/admin/route.ts", + &[ + "import { requireUser } from '@/server/auth';", + "const db = { project: { findMany: async () => [] } };", + "export async function GET(request: Request) {", + " const session = await requireUser(request);", + " await db.project.findMany();", + " return Response.json({ ok: Boolean(session) });", + "}", + "", + ], + ); + let admin_path = "app/api/admin/route.ts"; + let payload = run_check_repo(json!({ + "repo": { "repo_id": "repo_phase4", "repo_root": path_repo.to_string_lossy() }, + "scan": { "scan_id": "scan_phase4", "facts": [ + fact_for_path(admin_path, "file_role_detected", "api_route", 1, 7, None, None), + fact_for_path(admin_path, "import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact_for_path(admin_path, "route_declared", "GET", 3, 7, None, None), + fact_for_path(admin_path, "symbol_called", "requireUser", 4, 4, None, None), + fact_for_path(admin_path, "symbol_called", "findMany", 5, 5, Some("db.project"), None), + fact_for_path(admin_path, "data_operation_detected", "findMany", 5, 5, Some("db.project"), Some("read:project")), + fact_for_path(admin_path, "route_returns_response", "json", 6, 6, Some("Response"), None) + ] }, + "contract": phase4_tenant_contract( + json!({ "methods": ["GET"], "applies_to_file_roles": ["api_route"] }), + json!({ "path_globs": ["app/api/projects/**/route.ts"] }) + ), + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + assert_eq!( + payload["findings"].as_array().expect("findings").len(), + 0, + "admin route must not be blocked by projects-only Phase 4 contract: {payload:#?}" + ); + + let operation_repo = temp_repo("phase4_operation_scope"); + write_route( + &operation_repo, + "app/api/projects/route.ts", + &[ + "import { requireUser } from '@/server/auth';", + "const db = { project: { findMany: async () => [] } };", + "export async function GET(request: Request) {", + " const session = await requireUser(request);", + " await db.project.findMany();", + " return Response.json({ ok: Boolean(session) });", + "}", + "", + ], + ); + let payload = run_check_repo(json!({ + "repo": { "repo_id": "repo_phase4", "repo_root": operation_repo.to_string_lossy() }, + "scan": { "scan_id": "scan_phase4", "facts": [ + fact("file_role_detected", "api_route", 1, 7, None, None), + fact("import_used", "requireUser", 1, 1, Some("@/server/auth"), Some("requireUser")), + fact("route_declared", "GET", 3, 7, None, None), + fact("symbol_called", "requireUser", 4, 4, None, None), + fact("symbol_called", "findMany", 5, 5, Some("db.project"), None), + fact("data_operation_detected", "findMany", 5, 5, Some("db.project"), Some("read:project")), + fact("route_returns_response", "json", 6, 6, Some("Response"), None) + ] }, + "contract": phase4_tenant_contract(json!({ "methods": ["GET"], "applies_to_file_roles": ["api_route"] }), json!({ + "path_globs": ["app/api/projects/route.ts"] + })), + "baseline": [], + "diff": { "mode": "full", "files": [] } + })); + assert_eq!( + payload["findings"].as_array().expect("findings").len(), + 0, + "findMany must not be blocked by delete-only Phase 4 data operation scope: {payload:#?}" + ); +} + +fn fact( + kind: &str, + name: &str, + start_line: usize, + end_line: usize, + value: Option<&str>, + imported_name: Option<&str>, +) -> Value { + fact_for_path( + "app/api/projects/route.ts", + kind, + name, + start_line, + end_line, + value, + imported_name, + ) +} + +fn fact_for_path( + file_path: &str, + kind: &str, + name: &str, + start_line: usize, + end_line: usize, + value: Option<&str>, + imported_name: Option<&str>, +) -> Value { + json!({ + "kind": kind, + "file_path": file_path, + "name": name, + "value": value, + "imported_name": imported_name, + "start_line": start_line, + "end_line": end_line + }) +} + +fn write_route(repo_root: &std::path::Path, path: &str, lines: &[&str]) { + let route_path = repo_root.join(path); + fs::create_dir_all(route_path.parent().expect("route parent")).expect("create route parent"); + fs::write(&route_path, lines.join("\n")).expect("write route"); +} + +fn phase4_tenant_contract(matcher: Value, scope: Value) -> Value { + json!({ + "contract_id": "contract_phase4", + "contract_schema_version": 1, + "conventions": [{ + "id": "security_api_tenant_scope", + "kind": "api_route_requires_tenant_scope", + "matcher": matcher, + "scope": scope, + "requires": { + "auth_helpers": [{ "guard_id": "auth_require_user", "symbol": "requireUser", "import": "@/server/auth", "behavior": "returns_session" }], + "tenant_helpers": [{ "symbol": "scopeProjectToTenant", "import": "@/server/tenant", "tenant_arg": "tenantId", "data_operation_arg": "query" }], + "tenant_keys": ["tenantId"], + "tenant_sources": ["session"], + "data_operations": ["delete"] + }, + "severity": "error", + "enforcement_mode": "block", + "enforcement_capability": "deterministic_check" + }] + }) +} + +fn run_check_repo(request: Value) -> Value { + let mut child = Command::new(env!("CARGO_BIN_EXE_drift-engine")) + .arg("check-repo") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn drift-engine"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(request.to_string().as_bytes()) + .expect("write request"); + let output = child.wait_with_output().expect("wait output"); + assert!( + output.status.success(), + "engine failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("json output") +} + +fn temp_repo(name: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "drift-security-check-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create temp repo"); + path +} diff --git a/drift v3/crates/drift-engine/tests/security_control_flow.rs b/drift v3/crates/drift-engine/tests/security_control_flow.rs index d8fa42e5..5c9201a4 100644 --- a/drift v3/crates/drift-engine/tests/security_control_flow.rs +++ b/drift v3/crates/drift-engine/tests/security_control_flow.rs @@ -1,7 +1,10 @@ use drift_engine::{ - AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, FactKind, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedRequestValidator, AuthGuardBehavior, + AuthorizationHelperBehavior, AuthorizationHelperKind, FactKind, Phase4SecurityPolicy, RequestValidatorBehavior, RequestValidatorKind, SecurityProofStatus, build_auth_boundary_proof, - build_middleware_coverage_proof, build_request_validation_proof, extract_security_facts, + build_middleware_coverage_proof, build_phase4_security_proof, + build_phase4_security_proof_with_policy, build_request_validation_proof, + extract_security_facts, }; #[test] @@ -50,6 +53,198 @@ export async function GET() { assert_eq!(proof.result.proof_status, SecurityProofStatus::Proven); } +#[test] +fn trusted_session_derives_only_from_accepted_auth_helper_or_middleware() { + let trusted_source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + let untrusted_source = r#" +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await request.json(); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + let helpers = [AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }]; + + let trusted_proof = + build_auth_boundary_proof("app/api/projects/route.ts", trusted_source, &helpers) + .expect("trusted session proof"); + assert!( + trusted_proof.session_trust.proven, + "accepted auth helper should establish trusted session: {trusted_proof:#?}" + ); + assert!( + trusted_proof + .session_trust + .trusted_sessions + .iter() + .any(|session| session.variable == "session" + && session.trust == "trusted" + && session.derived_from == "auth_guard"), + "missing trusted session boundary proof: {trusted_proof:#?}" + ); + + let untrusted_proof = + build_auth_boundary_proof("app/api/projects/route.ts", untrusted_source, &helpers) + .expect("untrusted session proof"); + assert!( + !untrusted_proof.session_trust.proven, + "request-derived session must not be trusted: {untrusted_proof:#?}" + ); + assert!( + untrusted_proof + .session_trust + .missing_trust + .iter() + .any( + |missing| missing.variable == "session" && missing.reason == "derived_from_request" + ), + "missing derived_from_request trust failure: {untrusted_proof:#?}" + ); +} + +#[test] +fn authorization_guard_after_sink_or_in_one_branch_does_not_dominate() { + let guard_after_sink = r#" +import { requireUser } from "@/server/auth"; +import { requireRole } from "@/server/authorization"; +import { db } from "@/server/db"; + +export async function DELETE(request: Request) { + const session = await requireUser(request); + await db.project.delete({ where: { tenantId: session.user.tenantId } }); + requireRole(session.user, "admin"); + return Response.json({}); +} +"#; + let one_branch_guard = r#" +import { requireUser } from "@/server/auth"; +import { requireRole } from "@/server/authorization"; +import { db } from "@/server/db"; + +export async function DELETE(request: Request) { + const session = await requireUser(request); + if (new URL(request.url).searchParams.get("preview")) { + requireRole(session.user, "admin"); + } + await db.project.delete({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + let helpers = [AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }]; + + for source in [guard_after_sink, one_branch_guard] { + let proof = build_phase4_security_proof_with_policy( + "app/api/projects/route.ts", + source, + &Phase4SecurityPolicy { + accepted_auth_helpers: helpers.to_vec(), + authorization_helpers: vec![AcceptedAuthorizationHelper { + guard_id: "authorization_require_role".to_string(), + symbol: "requireRole".to_string(), + import_source: None, + kind: AuthorizationHelperKind::Role, + behavior: AuthorizationHelperBehavior::Throws, + }], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + ..Phase4SecurityPolicy::default() + }, + ) + .expect("phase4 proof"); + assert!( + !proof.authorization.proven, + "authorization must require guard dominance: {proof:#?}" + ); + assert!( + proof + .authorization + .missing + .iter() + .any(|missing| missing.reason == "authorization_guard_not_dominating_sink"), + "missing dominance failure proof: {proof:#?}" + ); + } +} + +#[test] +fn tenant_authorization_dynamic_shapes_emit_parser_gaps() { + let dynamic_property = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const key = "tenantId"; + await db.project.findMany({ where: { [key]: session.user.tenantId } }); + return Response.json({}); +} +"#; + let query_object_alias = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const args = { where: { tenantId: session.user.tenantId } }; + await db.project.findMany(args); + return Response.json({}); +} +"#; + let nested_destructure = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const { user: { tenantId } } = session; + await db.project.findMany({ where: { tenantId } }); + return Response.json({}); +} +"#; + let helpers = [AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }]; + + let cases = [ + (dynamic_property, "unsupported_tenant_dynamic_property"), + (query_object_alias, "unsupported_tenant_query_object_alias"), + (nested_destructure, "unsupported_session_nested_destructure"), + ]; + for (source, expected_code) in cases { + let proof = build_phase4_security_proof("app/api/projects/route.ts", source, &helpers) + .expect("phase4 proof"); + assert!( + proof + .parser_gaps + .iter() + .any(|gap| gap.code == expected_code && gap.blocks_enforcement), + "missing parser gap {expected_code}: {proof:#?}" + ); + assert_eq!(proof.result.proof_status, SecurityProofStatus::ParserGap); + } +} + #[test] fn auth_after_data_operation_blocks() { let source = r#" diff --git a/drift v3/crates/drift-engine/tests/security_facts.rs b/drift v3/crates/drift-engine/tests/security_facts.rs index b8b830f2..090b8928 100644 --- a/drift v3/crates/drift-engine/tests/security_facts.rs +++ b/drift v3/crates/drift-engine/tests/security_facts.rs @@ -1,9 +1,51 @@ use drift_engine::{ - AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, FactKind, - RequestValidatorBehavior, RequestValidatorKind, extract_security_facts, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedRequestValidator, + AcceptedTenantHelper, AuthGuardBehavior, AuthorizationHelperBehavior, AuthorizationHelperKind, + FactKind, Phase4SecurityPolicy, RequestValidatorBehavior, RequestValidatorKind, + extract_security_facts, extract_security_facts_with_policy, extract_security_facts_with_validation, }; +fn accepted_phase4_policy() -> Phase4SecurityPolicy { + Phase4SecurityPolicy { + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + authorization_helpers: vec![ + AcceptedAuthorizationHelper { + guard_id: "authorization_require_role".to_string(), + symbol: "requireRole".to_string(), + import_source: None, + kind: AuthorizationHelperKind::Role, + behavior: AuthorizationHelperBehavior::Throws, + }, + AcceptedAuthorizationHelper { + guard_id: "authorization_can_access_project".to_string(), + symbol: "canAccessProject".to_string(), + import_source: None, + kind: AuthorizationHelperKind::Policy, + behavior: AuthorizationHelperBehavior::Boolean, + }, + ], + tenant_helpers: vec![AcceptedTenantHelper { + helper_id: "tenant_scope_project".to_string(), + symbol: "scopeProjectToTenant".to_string(), + import_source: None, + tenant_key: "tenantId".to_string(), + }], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec![ + "session".to_string(), + "path_param".to_string(), + "query".to_string(), + "body".to_string(), + ], + ..Phase4SecurityPolicy::default() + } +} + #[test] fn extracts_request_input_read_facts() { let source = r#" @@ -290,6 +332,318 @@ export async function GET() { ); } +#[test] +fn extracts_session_read_facts_from_trusted_and_untrusted_sources() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/server/auth"; + +export async function POST(request: Request) { + const session = await requireUser(request); + const nextAuthSession = await getServerSession(authOptions); + const user = request.headers.get("x-user"); + const bodySession = await request.json(); + const token = request.cookies.get("session"); + return Response.json({ ok: true }); +} +"#; + + let facts = extract_security_facts( + "app/api/projects/route.ts", + source, + &[ + AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }, + AcceptedAuthHelper { + guard_id: "auth_next_session".to_string(), + symbol: "getServerSession".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }, + ], + ) + .expect("security facts"); + + let session_reads = facts + .iter() + .filter(|fact| format!("{:?}", fact.kind) == "SessionRead") + .collect::>(); + assert_eq!( + session_reads.len(), + 5, + "expected five sanitized session_read facts: {facts:#?}" + ); + assert!( + session_reads.iter().any(|fact| { + fact.name == "session" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"auth_result\"") + && value.contains("\"trust\":\"unknown\"") + && value.contains("\"variable\":\"session\"") + }) + }), + "missing accepted requireUser session read fact: {facts:#?}" + ); + assert!( + session_reads.iter().any(|fact| { + fact.name == "nextAuthSession" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"auth_result\"") + && value.contains("\"trust\":\"unknown\"") + && value.contains("\"variable\":\"nextAuthSession\"") + }) + }), + "missing accepted getServerSession read fact: {facts:#?}" + ); + for variable in ["user", "bodySession", "token"] { + assert!( + session_reads.iter().any(|fact| { + fact.name == variable + && fact + .value + .as_deref() + .is_some_and(|value| value.contains("\"trust\":\"untrusted\"")) + }), + "missing untrusted session read fact for {variable}: {facts:#?}" + ); + } + for fact in session_reads { + let value = fact.value.as_deref().unwrap_or_default(); + for forbidden in ["x-user", "tenant-123", "user-123", "payload-secret"] { + assert!( + !value.contains(forbidden), + "session read fact leaked sensitive/source value {forbidden}: {fact:#?}" + ); + } + } +} + +#[test] +fn extracts_tenant_sources_from_session_params_and_query() { + let source = r#" +import { requireUser } from "@/server/auth"; + +export async function GET(request: Request, { params }: { params: { tenantId: string } }) { + const session = await requireUser(request); + const sessionTenant = session.user.tenantId; + const tenantId = params.tenantId; + const queryTenant = request.nextUrl.searchParams.get("tenantId"); + const { tenantId: destructuredTenantId } = params; + const body = await request.json(); + const bodyTenant = body.tenantId; + return Response.json({ sessionTenant, tenantId, queryTenant, destructuredTenantId, bodyTenant }); +} +"#; + + let facts = extract_security_facts_with_policy( + "app/api/projects/route.ts", + source, + &accepted_phase4_policy(), + &[], + ) + .expect("security facts"); + + let tenant_sources = facts + .iter() + .filter(|fact| format!("{:?}", fact.kind) == "TenantSource") + .collect::>(); + assert!( + tenant_sources.iter().any(|fact| { + fact.name == "sessionTenant" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"session\"") + && value.contains("\"trusted\":true") + && value.contains("\"tenant_key\":\"tenantId\"") + && value.contains("\"session_variable\":\"session\"") + }) + }), + "missing trusted session tenant source: {facts:#?}" + ); + assert!( + tenant_sources.iter().any(|fact| { + fact.name == "tenantId" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"path_param\"") + && value.contains("\"trusted\":false") + && value.contains("\"tenant_key\":\"tenantId\"") + }) + }), + "missing path param tenant source: {facts:#?}" + ); + assert!( + tenant_sources.iter().any(|fact| { + fact.name == "queryTenant" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"query\"") + && value.contains("\"trusted\":false") + && value.contains("\"tenant_key\":\"tenantId\"") + }) + }), + "missing query tenant source: {facts:#?}" + ); + assert!( + tenant_sources.iter().any(|fact| { + fact.name == "bodyTenant" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"source\":\"body\"") + && value.contains("\"trusted\":false") + && value.contains("\"tenant_key\":\"tenantId\"") + }) + }), + "missing body tenant source: {facts:#?}" + ); + assert!( + tenant_sources + .iter() + .any(|fact| fact.name == "destructuredTenantId") + || facts.iter().any(|fact| { + format!("{:?}", fact.kind) == "ParserGap" + && fact.value.as_deref().is_some_and(|value| { + value.contains("unsupported_tenant_source_destructure") + }) + }), + "destructured path tenant source must be extracted or parser-gapped: {facts:#?}" + ); +} + +#[test] +fn extracts_tenant_predicates_and_accepted_tenant_helpers() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; +import { scopeProjectToTenant, unknownTenantScope } from "@/server/tenant"; + +export async function GET(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + await db.project.findUnique({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + await scopeProjectToTenant(db.project, session.user.tenantId).findMany(); + await unknownTenantScope(db.project, session.user.tenantId).findMany(); + return Response.json({}); +} +"#; + + let facts = extract_security_facts_with_policy( + "app/api/projects/route.ts", + source, + &accepted_phase4_policy(), + &[], + ) + .expect("security facts"); + + let tenant_guards = facts + .iter() + .filter(|fact| format!("{:?}", fact.kind) == "TenantGuardCalled") + .collect::>(); + assert!( + tenant_guards.iter().any(|fact| { + fact.value.as_deref().is_some_and(|value| { + value.contains("\"predicate_kind\":\"equality\"") + && value.contains("\"tenant_key\":\"tenantId\"") + && value.contains("\"data_operation\":\"db.project.findMany\"") + }) + }), + "missing equality tenant guard for findMany: {facts:#?}" + ); + assert!( + tenant_guards.iter().any(|fact| { + fact.value.as_deref().is_some_and(|value| { + value.contains("\"predicate_kind\":\"equality\"") + && value.contains("\"data_operation\":\"db.project.findUnique\"") + }) + }), + "missing equality tenant guard for findUnique: {facts:#?}" + ); + assert!( + tenant_guards.iter().any(|fact| { + fact.name == "scopeProjectToTenant" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"predicate_kind\":\"scoped_helper\"") + && value.contains("\"helper_symbol\":\"scopeProjectToTenant\"") + }) + }), + "missing accepted scoped helper tenant guard: {facts:#?}" + ); + assert!( + !tenant_guards + .iter() + .any(|fact| fact.name == "unknownTenantScope"), + "unknown tenant helper must not emit accepted tenant guard: {facts:#?}" + ); +} + +#[test] +fn extracts_authorization_guard_called_for_accepted_role_and_policy_helpers() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; +import { requireRole, canAccessProject } from "@/server/authorization"; + +export async function GET(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + requireRole(session.user, "admin"); + await db.project.findMany(); + if (!canAccessProject(session.user, params.projectId, "project:read")) { + return new Response("forbidden", { status: 403 }); + } + await db.project.findUnique({ where: { id: params.projectId } }); + if (session.user.role === "admin") { + await db.project.findFirst(); + } + return Response.json({}); +} +"#; + + let facts = extract_security_facts_with_policy( + "app/api/projects/route.ts", + source, + &accepted_phase4_policy(), + &[], + ) + .expect("security facts"); + + let authorization_guards = facts + .iter() + .filter(|fact| format!("{:?}", fact.kind) == "AuthorizationGuardCalled") + .collect::>(); + assert!( + authorization_guards.iter().any(|fact| { + fact.name == "requireRole" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"guard_kind\":\"role\"") + && value.contains("\"subject_var\":\"session.user\"") + && value.contains("\"roles\":[\"admin\"]") + }) + }), + "missing accepted role authorization guard: {facts:#?}" + ); + assert!( + authorization_guards.iter().any(|fact| { + fact.name == "canAccessProject" + && fact.value.as_deref().is_some_and(|value| { + value.contains("\"guard_kind\":\"policy\"") + && value.contains("\"subject_var\":\"session.user\"") + && value.contains("\"resource_var\":\"params.projectId\"") + && value.contains("\"permissions\":[\"project:read\"]") + && value.contains("\"dominates_sinks\":true") + }) + }), + "missing accepted boolean policy authorization guard: {facts:#?}" + ); + assert!( + !authorization_guards + .iter() + .any(|fact| fact.value.as_deref().is_some_and(|value| { + value.contains("inline_role_check") + || value.contains("\"roles\":[\"admin\"]") && fact.name != "requireRole" + })), + "inline role comparison must not emit accepted authorization proof: {facts:#?}" + ); +} + #[test] fn extracts_route_returns_response_fact() { let next_response_source = r#" diff --git a/drift v3/crates/drift-engine/tests/security_rules.rs b/drift v3/crates/drift-engine/tests/security_rules.rs index fb2ca4ce..0271a65a 100644 --- a/drift v3/crates/drift-engine/tests/security_rules.rs +++ b/drift v3/crates/drift-engine/tests/security_rules.rs @@ -1,11 +1,14 @@ use drift_engine::{ - AcceptedAuthHelper, AcceptedRequestValidator, AuthGuardBehavior, RequestValidatorBehavior, - RequestValidatorKind, SecurityAuthContract, SecurityContractCapability, - SecurityEnforcementMode, SecurityFindingResult, SecurityMiddlewareContract, - SecurityRequestValidationContract, build_request_validation_proof, - evaluate_api_route_requires_auth_helper, + AcceptedAuthHelper, AcceptedAuthorizationHelper, AcceptedRequestValidator, + AcceptedTenantHelper, AuthGuardBehavior, AuthorizationHelperBehavior, AuthorizationHelperKind, + Phase4SecurityPolicy, RequestValidatorBehavior, RequestValidatorKind, SecurityAuthContract, + SecurityAuthorizationContract, SecurityContractCapability, SecurityEnforcementMode, + SecurityFindingResult, SecurityMiddlewareContract, SecurityRequestValidationContract, + SecurityTenantScopeContract, build_phase4_security_proof_with_policy, + build_request_validation_proof, evaluate_api_route_requires_auth_helper, evaluate_api_route_requires_auth_helper_with_middleware, - evaluate_api_route_requires_request_validation, evaluate_middleware_must_cover_routes, + evaluate_api_route_requires_authorization, evaluate_api_route_requires_request_validation, + evaluate_api_route_requires_tenant_scope, evaluate_middleware_must_cover_routes, }; #[test] @@ -41,6 +44,570 @@ export async function GET() { assert_eq!(findings[0].confidence_label, "certain"); } +#[test] +fn untrusted_session_cannot_satisfy_tenant_or_authorization_proof() { + let source = r#" +import { db } from "@/server/db"; +import { requireRole } from "@/server/authorization"; + +export async function GET(request: Request) { + const session = await request.json(); + requireRole(session.user, "admin"); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + + let proof = build_phase4_security_proof_with_policy( + "app/api/projects/route.ts", + source, + &Phase4SecurityPolicy { + authorization_helpers: vec![AcceptedAuthorizationHelper { + guard_id: "authorization_require_role".to_string(), + symbol: "requireRole".to_string(), + import_source: None, + kind: AuthorizationHelperKind::Role, + behavior: AuthorizationHelperBehavior::Throws, + }], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + ..Phase4SecurityPolicy::default() + }, + ) + .expect("phase4 proof"); + + assert!( + !proof.session_trust.proven, + "request-derived session must not be trusted: {proof:#?}" + ); + assert!( + !proof.authorization.proven, + "authorization must not be proven from untrusted session: {proof:#?}" + ); + assert!( + proof + .authorization + .missing + .iter() + .any(|missing| missing.reason == "session_not_trusted"), + "authorization missing proof must include session_not_trusted: {proof:#?}" + ); + assert!( + !proof.tenant.proven, + "tenant proof must not be proven from untrusted session: {proof:#?}" + ); + assert!( + proof + .tenant + .missing + .iter() + .any(|missing| missing.reason == "tenant_source_untrusted"), + "tenant missing proof must include tenant_source_untrusted: {proof:#?}" + ); +} + +#[test] +fn tenant_scoped_route_without_tenant_predicate_blocks() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + await db.project.findMany(); + return Response.json({}); +} +"#; + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: vec!["scopeProjectToTenant".to_string()], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].contract_id, "security_api_tenant_scope"); + assert_eq!( + findings[0].title, + "API route missing required tenant scope proof" + ); + assert_eq!(findings[0].expected_layer, "tenant_scope"); + assert_eq!(findings[0].actual_layer, "tenant_predicate_missing"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); + assert_eq!(findings[0].drift_category, "missing_proof"); + assert_eq!(findings[0].confidence_label, "certain"); +} + +#[test] +fn tenant_param_read_but_not_bound_to_data_operation_blocks() { + let source = r#" +import { db } from "@/server/db"; + +export async function GET(request: Request, { params }: { params: { tenantId: string } }) { + const tenantId = params.tenantId; + await db.project.findMany({ where: { archived: false } }); + return Response.json({}); +} +"#; + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: Vec::new(), + tenant_helpers: vec!["scopeProjectToTenant".to_string()], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["path_param".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!( + findings[0].actual_layer, + "tenant_predicate_not_bound_to_query" + ); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); +} + +#[test] +fn trusted_tenant_source_bound_to_data_predicate_passes() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const projects = await db.project.findMany({ + where: { tenantId: session.user.tenantId } + }); + return Response.json(projects); +} +"#; + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: vec!["scopeProjectToTenant".to_string()], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert!( + findings.is_empty(), + "trusted tenant predicate should satisfy tenant scope: {findings:#?}" + ); +} + +#[test] +fn accepted_tenant_scope_helper_bound_to_data_operation_passes() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; +import { scopeProjectToTenant } from "@/server/tenant"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const scoped = scopeProjectToTenant(db.project, session.user.tenantId); + const projects = await db.project.findMany(); + return Response.json(projects); +} +"#; + + let proof = build_phase4_security_proof_with_policy( + "app/api/projects/route.ts", + source, + &Phase4SecurityPolicy { + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: vec![AcceptedTenantHelper { + helper_id: "tenant_scope_project".to_string(), + symbol: "scopeProjectToTenant".to_string(), + import_source: None, + tenant_key: "tenantId".to_string(), + }], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + ..Phase4SecurityPolicy::default() + }, + ) + .expect("phase4 proof"); + + assert!( + proof.tenant.required, + "tenant proof must be required: {proof:#?}" + ); + assert!( + proof.tenant.proven, + "tenant helper should prove scope: {proof:#?}" + ); + assert!( + proof + .tenant + .predicates + .iter() + .any(|predicate| predicate.predicate_kind == "scoped_helper"), + "scoped helper predicate must be preserved: {proof:#?}" + ); + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: vec!["scopeProjectToTenant".to_string()], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert!( + findings.is_empty(), + "accepted tenant helper should satisfy tenant scope: {findings:#?}" + ); +} + +#[test] +fn authorization_required_route_without_guard_blocks() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function DELETE(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + await db.project.delete({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + + let findings = evaluate_api_route_requires_authorization( + "app/api/projects/route.ts", + source, + &SecurityAuthorizationContract { + contract_id: "security_api_authorization".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + authorization_helpers: vec!["requireRole".to_string(), "canAccessProject".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("authorization findings"); + + assert_eq!(findings.len(), 1, "expected one finding: {findings:#?}"); + assert_eq!(findings[0].contract_id, "security_api_authorization"); + assert_eq!( + findings[0].title, + "API route missing required authorization proof" + ); + assert_eq!(findings[0].expected_layer, "authorization"); + assert_eq!(findings[0].actual_layer, "authorization_guard_missing"); + assert_eq!(findings[0].enforcement_result, SecurityFindingResult::Block); + assert_eq!(findings[0].drift_category, "missing_proof"); + assert_eq!(findings[0].confidence_label, "certain"); +} + +#[test] +fn accepted_authorization_guard_with_trusted_session_passes() { + let source = r#" +import { requireUser } from "@/server/auth"; +import { requireRole } from "@/server/authorization"; +import { db } from "@/server/db"; + +export async function DELETE(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + requireRole(session.user, "admin"); + await db.project.delete({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + + let proof = build_phase4_security_proof_with_policy( + "app/api/projects/route.ts", + source, + &Phase4SecurityPolicy { + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + authorization_helpers: vec![AcceptedAuthorizationHelper { + guard_id: "authorization_require_role".to_string(), + symbol: "requireRole".to_string(), + import_source: None, + kind: AuthorizationHelperKind::Role, + behavior: AuthorizationHelperBehavior::Throws, + }], + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + ..Phase4SecurityPolicy::default() + }, + ) + .expect("phase4 proof"); + + assert!( + proof.authorization.required, + "authorization required: {proof:#?}" + ); + assert!( + proof.authorization.proven, + "authorization should be proven: {proof:#?}" + ); + assert!( + proof + .authorization + .role_or_policy_guards + .iter() + .any(|guard| { + guard.policy_id.as_deref() == Some("authorization_require_role") + && guard.subject_var.as_deref() == Some("session.user") + && guard.roles == vec!["admin".to_string()] + }), + "accepted role guard metadata must be preserved: {proof:#?}" + ); + + let findings = evaluate_api_route_requires_authorization( + "app/api/projects/route.ts", + source, + &SecurityAuthorizationContract { + contract_id: "security_api_authorization".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + authorization_helpers: vec!["requireRole".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("authorization findings"); + + assert!( + findings.is_empty(), + "accepted authorization guard should satisfy contract: {findings:#?}" + ); +} + +#[test] +fn candidate_only_role_and_tenant_evidence_does_not_block() { + let source = r#" +import { getSession } from "@/server/session"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await getSession(request); + if (session.user.role === "admin") { + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + } + return Response.json({}); +} +"#; + + let proof = build_phase4_security_proof_with_policy( + "app/api/projects/route.ts", + source, + &Phase4SecurityPolicy { + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + ..Phase4SecurityPolicy::default() + }, + ) + .expect("phase4 proof"); + + assert!( + !proof.authorization.proven, + "inline role comparison must not satisfy authorization proof: {proof:#?}" + ); + assert!( + proof.authorization.role_or_policy_guards.is_empty(), + "inline role comparison must not emit accepted authorization guard: {proof:#?}" + ); + assert!( + !proof.tenant.proven, + "tenant-looking variable from unknown session helper must not satisfy tenant proof: {proof:#?}" + ); + assert!( + proof + .tenant + .missing + .iter() + .any(|missing| missing.reason == "tenant_source_untrusted"), + "candidate tenant evidence must remain missing proof, not deterministic proof: {proof:#?}" + ); +} + +#[test] +fn security_phase4_unaccepted_helpers_do_not_satisfy_proof() { + let authorization_source = r#" +import { requireUser } from "@/server/auth"; +import { requireRole, canAccessProject } from "@/server/authorization"; +import { db } from "@/server/db"; + +export async function DELETE(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + requireRole(session.user, "admin"); + if (!canAccessProject(session.user, params.projectId, "project:delete")) { + return new Response("forbidden", { status: 403 }); + } + await db.project.delete({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + return Response.json({}); +} +"#; + + for authorization_helpers in [Vec::new(), vec!["someOtherGuard".to_string()]] { + let findings = evaluate_api_route_requires_authorization( + "app/api/projects/route.ts", + authorization_source, + &SecurityAuthorizationContract { + contract_id: "security_api_authorization".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + authorization_helpers, + data_operations: Vec::new(), + }, + ) + .expect("authorization findings"); + + assert_eq!( + findings.len(), + 1, + "unaccepted requireRole/canAccessProject must not satisfy authorization proof: {findings:#?}" + ); + assert_eq!(findings[0].actual_layer, "authorization_guard_missing"); + } + + let tenant_helper_source = r#" +import { requireUser } from "@/server/auth"; +import { scopeProjectToTenant } from "@/server/tenant"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const scoped = scopeProjectToTenant(db.project, session.user.tenantId); + const projects = await db.project.findMany(); + return Response.json(projects); +} +"#; + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + tenant_helper_source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: Vec::new(), + tenant_keys: vec!["tenantId".to_string()], + tenant_sources: vec!["session".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert_eq!( + findings.len(), + 1, + "unaccepted scopeProjectToTenant must not satisfy tenant proof: {findings:#?}" + ); + + let tenant_key_source = r#" +import { requireUser } from "@/server/auth"; +import { db } from "@/server/db"; + +export async function GET(request: Request) { + const session = await requireUser(request); + const projects = await db.project.findMany({ + where: { tenantId: session.user.tenantId } + }); + return Response.json(projects); +} +"#; + + let findings = evaluate_api_route_requires_tenant_scope( + "app/api/projects/route.ts", + tenant_key_source, + &SecurityTenantScopeContract { + contract_id: "security_api_tenant_scope".to_string(), + capability: SecurityContractCapability::DeterministicCheck, + enforcement_mode: SecurityEnforcementMode::Block, + accepted_auth_helpers: vec![AcceptedAuthHelper { + guard_id: "auth_require_user".to_string(), + symbol: "requireUser".to_string(), + behavior: AuthGuardBehavior::ReturnsSession, + }], + tenant_helpers: Vec::new(), + tenant_keys: vec!["orgId".to_string()], + tenant_sources: vec!["session".to_string()], + data_operations: Vec::new(), + }, + ) + .expect("tenant findings"); + + assert_eq!( + findings.len(), + 1, + "tenantId must not satisfy tenant proof when only orgId is accepted: {findings:#?}" + ); +} + #[test] fn request_body_reaches_data_operation_without_validation_blocks() { let source = r#" diff --git a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md index 3f40e096..489f60d4 100644 --- a/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md +++ b/drift v3/docs/architecture/security-boundary-enforcement-100-tdd.md @@ -3017,6 +3017,50 @@ Done when: Purpose: prove trusted subject, role/permission checks, and tenant binding. +Scope: + +- Implement only: + - `session_object_must_come_from_trusted_helper` + - `api_route_requires_authorization` + - `api_route_requires_tenant_scope` +- Do not implement Phase 5 sensitive response/secrets work. +- Do not implement Phase 6 SSRF, SQL, CORS, CSRF, or rate-limit work. +- Rust is the deterministic authority for session trust, tenant predicate proof, + authorization guard proof, parser gaps, missing proof, and blocking rule + evaluation. +- TypeScript is product/control plane only: schemas, engine-contract validation, + storage/query/read models, CLI/MCP envelopes, governance, candidates, and + output formatting. +- TypeScript must not synthesize trusted session, tenant, role, permission, or + IDOR proof from raw facts. +- Blocking findings require accepted deterministic contracts. +- Candidate-only and heuristic role/tenant evidence must never block. +- Name-only helpers such as `requireRole`, `canAccess`, `scopeTenant`, or + `getSession` must not satisfy proof unless accepted. +- Inline role comparisons such as `user.role === "admin"` must not satisfy proof + unless the accepted contract explicitly allows that policy shape. +- Tenant-looking variable names such as `tenantId`, `orgId`, or `accountId` must + not satisfy proof unless the value is tied to a trusted source and bound to + the protected data predicate. +- Session/user objects from request body, query, headers, cookies, params, or + unaccepted framework helpers are untrusted until Rust proves trusted derivation + from an accepted auth helper or accepted middleware proof. +- A trusted session alone must not satisfy tenant proof. Tenant proof must bind + the trusted tenant source to the protected data operation predicate or accepted + scoped data helper. +- A trusted session alone must not satisfy authorization proof. Authorization + proof must show an accepted role, permission, policy, or resource guard + dominates the protected sink. +- Unsupported destructuring, dynamic property access, dynamic query helpers, + unknown ORM wrappers, unresolved aliases, and branch/control-flow ambiguity + must emit parser-gap-backed evidence and must not silently pass. +- Outputs, storage, MCP, and CLI must never include source snippets, session + values, user IDs, tenant IDs, header/cookie/request values, tokens, secrets, + raw SQL values, or request payloads. +- Preserve existing waiver, baseline, lifecycle, diff-scope, check-run, audit, + policy egress, direct-data-access, service-delegation, Phase 1 auth, Phase 2 + middleware, and Phase 3 request-validation behavior. + Add facts: - `authorization_guard_called` @@ -3030,6 +3074,62 @@ Add contracts: - `api_route_requires_tenant_scope` - `session_object_must_come_from_trusted_helper` +Accepted contract input shape: + +```json +{ + "kind": "security_boundary", + "id": "accepted_security_phase4", + "rule": "api_route_requires_tenant_scope", + "mode": "block", + "scope": { + "path_globs": ["app/api/**/route.ts"], + "file_roles": ["api_route"] + }, + "matcher": { + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE"] + }, + "requires": { + "auth_helpers": [ + { + "symbol": "requireUser", + "import": "@/server/auth", + "returns": "session" + } + ], + "authorization_helpers": [ + { + "symbol": "requireRole", + "import": "@/server/authz", + "roles": ["admin"], + "behavior": "throws" + }, + { + "symbol": "canAccessProject", + "import": "@/server/authz", + "permissions": ["project:read"], + "behavior": "boolean" + } + ], + "tenant_helpers": [ + { + "symbol": "scopeProjectToTenant", + "import": "@/server/tenant", + "tenant_arg": "tenantId", + "data_operation_arg": "query" + } + ], + "tenant_keys": ["tenantId", "orgId"], + "tenant_sources": ["session", "path_param"], + "data_operations": ["db.project.findMany", "db.project.findUnique", "db.project.update", "db.project.delete"] + } +} +``` + +Rust must normalize accepted symbols and imports from `requires.*`. It must not +use `matcher.required_calls`, helper names, or candidate evidence as deterministic +truth for Phase 4 proof. + Create fixtures: - `test/fixtures/security-tenant-missing` @@ -3038,6 +3138,10 @@ Create fixtures: - `test/fixtures/security-role-missing` - `test/fixtures/security-role-guard-present` - `test/fixtures/security-session-from-request-untrusted` +- `test/fixtures/security-tenant-untrusted-source` +- `test/fixtures/security-tenant-parser-gap` +- `test/fixtures/security-role-branch-bypass` +- `test/fixtures/security-session-trusted-helper` Required RED tests: @@ -3047,11 +3151,1272 @@ Required RED tests: - Role-required route without role guard blocks. - Session object from request/header is untrusted. - Role/tenant proof cannot use untrusted session. +- Accepted auth helper can establish trusted session derivation. +- Accepted authorization helper must dominate the protected sink. +- Authorization guard after sink blocks. +- Authorization guard in only one branch blocks. +- Accepted tenant predicate must bind the trusted tenant source to the data + operation predicate. +- Unknown tenant helper emits missing proof, not pass. +- Dynamic tenant predicate emits parser gap, not pass. +- Candidate-only tenant or role evidence does not block. Done when: - Tenant proof connects trusted tenant source to data predicate. - Role/permission proof requires accepted helper or accepted policy shape. +- Session proof distinguishes trusted, untrusted, and unknown session sources. +- `SecurityBoundaryProof.session_trust`, `SecurityBoundaryProof.authorization`, + and `SecurityBoundaryProof.tenant` are populated by Rust only. +- Parser gaps and missing proof are surfaced through CLI, query, MCP, storage, + scan status, and repo map without snippets or sensitive values. +- Phase 4 capability output is only marked deterministic after the full path is + tested. +- Candidate-only Phase 4 evidence remains advisory. + +### Phase 4 Executable Task Ledger + +Execute these tasks in order. For every RED task, add only the failing test +first, run the focused command, and record the exact expected failure before +editing implementation files. For every GREEN task, edit only the listed +implementation files and run the listed command. + +- [ ] **Task 4.1: RED session read fact extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_session_read_facts_from_trusted_and_untrusted_sources` + + Add source fixtures covering: + + - `const session = await requireUser(request);` + - `const session = await getServerSession(authOptions);` + - `const user = request.headers.get("x-user");` + - `const session = await request.json();` + - `const token = request.cookies.get("session");` + + Assert facts: + + - `session_read` from accepted auth result starts as `source="auth_result"` and + `trust="unknown"` until proof construction. + - Header, body, and cookie-derived session/user reads are emitted as + `trust="untrusted"`. + - No fact value contains header names, cookie values, token values, user IDs, + tenant IDs, request payload values, or source snippets. + + Run: + + ```bash + cargo test -p drift-engine extracts_session_read_facts_from_trusted_and_untrusted_sources -- --nocapture + ``` + + Expected RED: fail because Rust does not emit Phase 4 `session_read` facts. + +- [ ] **Task 4.2: GREEN session read fact extraction** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_facts.rs` + + Implement extraction only: + + - Normalize accepted auth/session helper imports from `requires.auth_helpers`. + - Emit `session_read` for accepted auth-helper result variables with + `source="auth_result"` and `trust="unknown"`. + - Emit `session_read` for request-derived session/user/token reads with + `trust="untrusted"`. + - Keep secret/session/request values out of fact metadata. + + Run: + + ```bash + cargo test -p drift-engine extracts_session_read_facts_from_trusted_and_untrusted_sources -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.3: RED session trust proof construction** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `trusted_session_derives_only_from_accepted_auth_helper_or_middleware` + + Fixture shapes: + + ```ts + import { requireUser } from "@/server/auth"; + + export async function GET(request: Request) { + const session = await requireUser(request); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + ```ts + export async function GET(request: Request) { + const session = await request.json(); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + Assert: + + - Accepted auth helper creates a `session_trust_boundary` proof record with + `trust="trusted"` and `derived_from="auth_guard"`. + - Request-derived session creates missing trust with + `reason="derived_from_request"`. + + Run: + + ```bash + cargo test -p drift-engine trusted_session_derives_only_from_accepted_auth_helper_or_middleware -- --nocapture + ``` + + Expected RED: fail because Rust does not construct `session_trust_boundary` + proof. + +- [ ] **Task 4.4: GREEN session trust proof construction** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + + Implement proof only: + + - Connect accepted auth-helper calls from Phase 1 proof to session variables. + - Accept middleware-derived trusted session only when Phase 2 middleware proof + has accepted protection kind `auth`. + - Mark request/header/cookie/body-derived session values as untrusted. + - Emit missing proof code `session_not_trusted` when a session/user object is + used for tenant or authorization proof without trusted derivation. + + Run: + + ```bash + cargo test -p drift-engine trusted_session_derives_only_from_accepted_auth_helper_or_middleware -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.5: RED tenant source extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_tenant_sources_from_session_params_and_query` + + Add fixtures for: + + - `session.user.tenantId` + - `params.tenantId` + - `request.nextUrl.searchParams.get("tenantId")` + - `const { tenantId } = params` + - `const tenantId = body.tenantId` + + Assert: + + - Session tenant source references the trusted session fact when available. + - Path param source is `source="path_param"` and `trusted=false` until a + contract accepts path params as a tenant source. + - Query/body tenant source is emitted but `trusted=false`. + - Destructured path params are either extracted or parser-gapped; they are not + silently omitted. + + Run: + + ```bash + cargo test -p drift-engine extracts_tenant_sources_from_session_params_and_query -- --nocapture + ``` + + Expected RED: fail because Rust does not extract tenant source evidence. + +- [ ] **Task 4.6: GREEN tenant source extraction** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/security_control_flow.rs` + + Implement source extraction: + + - Normalize accepted tenant keys from `requires.tenant_keys`. + - Track session property paths for accepted tenant keys. + - Track path params and query/body/header tenant-looking reads as sources, + while preserving trusted/untrusted status. + - Emit parser gap `unsupported_tenant_source_destructure` for destructuring + forms that cannot be resolved deterministically. + + Run: + + ```bash + cargo test -p drift-engine extracts_tenant_sources_from_session_params_and_query -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.7: RED tenant predicate and tenant helper extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_tenant_predicates_and_accepted_tenant_helpers` + + Add fixtures for: + + ```ts + const session = await requireUser(request); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + ``` + + ```ts + const session = await requireUser(request); + await db.project.findUnique({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + ``` + + ```ts + const session = await requireUser(request); + await scopeProjectToTenant(db.project, session.user.tenantId).findMany(); + ``` + + Assert: + + - Equality predicates produce `tenant_guard_called` with + `predicate_kind="equality"`. + - Accepted scoped helper produces `tenant_guard_called` with + `predicate_kind="scoped_helper"`. + - Unknown helper names are not emitted as accepted tenant guard facts. + + Run: + + ```bash + cargo test -p drift-engine extracts_tenant_predicates_and_accepted_tenant_helpers -- --nocapture + ``` + + Expected RED: fail because tenant predicate/helper facts are not emitted. + +- [ ] **Task 4.8: GREEN tenant predicate and tenant helper extraction** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/security_control_flow.rs` + + Implement extraction: + + - Normalize accepted tenant helper symbols and imports from + `requires.tenant_helpers`. + - Recognize simple ORM `where` equality predicates for accepted tenant keys. + - Recognize accepted scoped helpers only when symbol and import match the + contract. + - Emit unknown helper evidence as candidate/missing proof, not as accepted + `tenant_guard_called`. + + Run: + + ```bash + cargo test -p drift-engine extracts_tenant_predicates_and_accepted_tenant_helpers -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.9: RED authorization guard extraction** + + Test file: `crates/drift-engine/tests/security_facts.rs` + + Test name: `extracts_authorization_guard_called_for_accepted_role_and_policy_helpers` + + Add fixtures for: + + ```ts + const session = await requireUser(request); + requireRole(session.user, "admin"); + await db.project.findMany(); + ``` + + ```ts + const session = await requireUser(request); + if (!canAccessProject(session.user, params.projectId, "project:read")) { + return new Response("forbidden", { status: 403 }); + } + await db.project.findUnique({ where: { id: params.projectId } }); + ``` + + Assert: + + - Accepted throwing role helper emits `authorization_guard_called`. + - Accepted boolean policy helper emits `authorization_guard_called` only when + the failing branch exits before the protected sink. + - `if (session.user.role === "admin")` does not emit accepted authorization + proof unless an accepted policy shape explicitly allows inline role checks. + + Run: + + ```bash + cargo test -p drift-engine extracts_authorization_guard_called_for_accepted_role_and_policy_helpers -- --nocapture + ``` + + Expected RED: fail because accepted authorization guard facts are not emitted. + +- [ ] **Task 4.10: GREEN authorization guard extraction** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_facts.rs` + - `crates/drift-engine/src/security_control_flow.rs` + + Implement extraction: + + - Normalize accepted authorization helpers from + `requires.authorization_helpers`. + - Track accepted role, permission, policy, resource variable, and subject + variable metadata without storing user IDs or tenant IDs. + - Record boolean helper dominance only when the non-authorized branch returns + or throws before the protected sink. + + Run: + + ```bash + cargo test -p drift-engine extracts_authorization_guard_called_for_accepted_role_and_policy_helpers -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.11: RED untrusted session cannot satisfy tenant or authorization proof** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `untrusted_session_cannot_satisfy_tenant_or_authorization_proof` + + Fixture shape: + + ```ts + export async function GET(request: Request) { + const session = await request.json(); + requireRole(session.user, "admin"); + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + Assert: + + - `session_trust.proven` is false. + - `authorization.proven` is false with `session_not_trusted`. + - `tenant.proven` is false with `tenant_source_untrusted`. + - Blocking findings are emitted only when accepted Phase 4 contracts are in + `mode="block"`. + + Run: + + ```bash + cargo test -p drift-engine untrusted_session_cannot_satisfy_tenant_or_authorization_proof -- --nocapture + ``` + + Expected RED: fail because role/tenant proof does not reject untrusted session + sources. + +- [ ] **Task 4.12: GREEN untrusted session rejection** + + Implementation files: + + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement rule/proof behavior: + + - Require trusted session derivation before session-derived role or tenant + facts can satisfy Phase 4 proof. + - Emit `session_not_trusted`, `tenant_source_untrusted`, and + `authorization_guard_missing` as distinct missing-proof codes. + - Do not block candidate-only evidence. + + Run: + + ```bash + cargo test -p drift-engine untrusted_session_cannot_satisfy_tenant_or_authorization_proof -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.13: RED tenant route without predicate blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `tenant_scoped_route_without_tenant_predicate_blocks` + + Fixture shape: + + ```ts + export async function GET(request: Request) { + const session = await requireUser(request); + await db.project.findMany(); + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine tenant_scoped_route_without_tenant_predicate_blocks -- --nocapture + ``` + + Expected RED: fail because `api_route_requires_tenant_scope` is not evaluated + as a blocking deterministic rule. + +- [ ] **Task 4.14: GREEN tenant missing-predicate rule** + + Implementation files: + + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Implement rule behavior: + + - Apply only to accepted `api_route_requires_tenant_scope` contracts. + - Require at least one protected data operation in scope. + - Emit missing proof code `tenant_predicate_missing` when a protected data + operation has no accepted tenant predicate or scoped helper. + - Do not emit a tenant finding for routes with no protected data operation. + + Run: + + ```bash + cargo test -p drift-engine tenant_scoped_route_without_tenant_predicate_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.15: RED tenant param read but unused blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `tenant_param_read_but_not_bound_to_data_operation_blocks` + + Fixture shape: + + ```ts + export async function GET(request: Request, { params }: { params: { tenantId: string } }) { + const tenantId = params.tenantId; + await db.project.findMany({ where: { archived: false } }); + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine tenant_param_read_but_not_bound_to_data_operation_blocks -- --nocapture + ``` + + Expected RED: fail because the engine does not distinguish tenant source + existence from tenant predicate binding. + +- [ ] **Task 4.16: GREEN tenant predicate binding rule** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement binding: + + - Tenant source presence is insufficient. + - Tenant predicate must reference the trusted/accepted tenant source and the + protected data operation. + - Emit missing proof code `tenant_predicate_not_bound_to_query` when the tenant + source is read but not used in the data predicate. + + Run: + + ```bash + cargo test -p drift-engine tenant_param_read_but_not_bound_to_data_operation_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.17: RED accepted tenant predicate passes** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `trusted_tenant_source_bound_to_data_predicate_passes` + + Fixture shape: + + ```ts + export async function GET(request: Request) { + const session = await requireUser(request); + const projects = await db.project.findMany({ + where: { tenantId: session.user.tenantId } + }); + return Response.json(projects); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine trusted_tenant_source_bound_to_data_predicate_passes -- --nocapture + ``` + + Expected RED: fail because accepted tenant predicate proof is not marked + `proven`. + +- [ ] **Task 4.18: GREEN accepted tenant predicate proof** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement pass proof: + + - Mark `tenant.required=true`. + - Mark `tenant.proven=true` only when every protected data operation in scope + has accepted tenant predicate/helper proof. + - Keep individual predicate fact IDs and data-operation fact IDs in proof. + + Run: + + ```bash + cargo test -p drift-engine trusted_tenant_source_bound_to_data_predicate_passes -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.19: RED accepted tenant helper passes** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `accepted_tenant_scope_helper_bound_to_data_operation_passes` + + Fixture shape: + + ```ts + export async function GET(request: Request) { + const session = await requireUser(request); + const projects = await scopeProjectToTenant(db.project, session.user.tenantId).findMany(); + return Response.json(projects); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine accepted_tenant_scope_helper_bound_to_data_operation_passes -- --nocapture + ``` + + Expected RED: fail because accepted tenant scoped helper proof is not + recognized. + +- [ ] **Task 4.20: GREEN accepted tenant helper proof** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement helper proof: + + - Accept only helpers normalized from `requires.tenant_helpers`. + - Bind helper receiver/argument to the protected data operation. + - Bind helper tenant argument to a trusted or accepted tenant source. + - Unknown helper names remain missing proof. + + Run: + + ```bash + cargo test -p drift-engine accepted_tenant_scope_helper_bound_to_data_operation_passes -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.21: RED authorization-required route without guard blocks** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `authorization_required_route_without_guard_blocks` + + Fixture shape: + + ```ts + export async function DELETE(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + await db.project.delete({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine authorization_required_route_without_guard_blocks -- --nocapture + ``` + + Expected RED: fail because `api_route_requires_authorization` is not evaluated. + +- [ ] **Task 4.22: GREEN missing authorization rule** + + Implementation files: + + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + - `crates/drift-engine/src/check_command.rs` + + Implement rule behavior: + + - Apply only to accepted `api_route_requires_authorization` contracts. + - Require protected data operation/resource sink in scope. + - Emit missing proof code `authorization_guard_missing` when no accepted + authorization guard dominates the protected sink. + - Do not treat Phase 1 auth helper or Phase 4 tenant predicate as + authorization proof. + + Run: + + ```bash + cargo test -p drift-engine authorization_required_route_without_guard_blocks -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.23: RED accepted authorization guard passes** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `accepted_authorization_guard_with_trusted_session_passes` + + Fixture shape: + + ```ts + export async function DELETE(request: Request, { params }: { params: { projectId: string } }) { + const session = await requireUser(request); + requireRole(session.user, "admin"); + await db.project.delete({ where: { id: params.projectId, tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine accepted_authorization_guard_with_trusted_session_passes -- --nocapture + ``` + + Expected RED: fail because accepted authorization guard proof is not marked + `proven`. + +- [ ] **Task 4.24: GREEN accepted authorization guard proof** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement pass proof: + + - Mark `authorization.required=true`. + - Mark `authorization.proven=true` only when every protected sink in scope has + accepted authorization guard proof. + - Require trusted session/user subject when the authorization helper uses a + subject variable. + - Preserve roles, permissions, policy ID, subject variable, and resource + variable classifications without storing concrete values. + + Run: + + ```bash + cargo test -p drift-engine accepted_authorization_guard_with_trusted_session_passes -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.25: RED authorization guard must dominate sink** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `authorization_guard_after_sink_or_in_one_branch_does_not_dominate` + + Fixture shapes: + + ```ts + export async function DELETE(request: Request) { + const session = await requireUser(request); + await db.project.delete({ where: { tenantId: session.user.tenantId } }); + requireRole(session.user, "admin"); + return Response.json({}); + } + ``` + + ```ts + export async function DELETE(request: Request) { + const session = await requireUser(request); + if (new URL(request.url).searchParams.get("preview")) { + requireRole(session.user, "admin"); + } + await db.project.delete({ where: { tenantId: session.user.tenantId } }); + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine authorization_guard_after_sink_or_in_one_branch_does_not_dominate -- --nocapture + ``` + + Expected RED: fail because guard existence is treated as proof without + dominance over the protected sink. + +- [ ] **Task 4.26: GREEN authorization dominance** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_rules.rs` + + Implement dominance: + + - Throwing authorization helpers dominate only subsequent protected sinks in + the same route execution path. + - Boolean authorization helpers dominate only when the failure branch exits + before the sink. + - Guard-after-sink emits `authorization_guard_not_dominating_sink`. + - One-branch-only guard emits `authorization_guard_not_dominating_sink`. + + Run: + + ```bash + cargo test -p drift-engine authorization_guard_after_sink_or_in_one_branch_does_not_dominate -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.27: RED candidate-only role and tenant evidence cannot block** + + Test file: `crates/drift-engine/tests/security_rules.rs` + + Test name: `candidate_only_role_and_tenant_evidence_does_not_block` + + Fixture shape: + + ```ts + export async function GET(request: Request) { + const session = await getSession(request); + if (session.user.role === "admin") { + await db.project.findMany({ where: { tenantId: session.user.tenantId } }); + } + return Response.json({}); + } + ``` + + Run: + + ```bash + cargo test -p drift-engine candidate_only_role_and_tenant_evidence_does_not_block -- --nocapture + ``` + + Expected RED: fail if heuristic helper names or inline checks create blocking + Phase 4 proof. + +- [ ] **Task 4.28: GREEN candidate-only Phase 4 boundary** + + Implementation files: + + - `crates/drift-engine/src/security_patterns.rs` + - `crates/drift-engine/src/security_rules.rs` + - `packages/cli/src/domain/convention-candidates.ts` + + Implement boundary: + + - Candidate inference may propose tenant helpers, authorization helpers, and + trusted session helpers. + - Candidate-only evidence must not produce blocking findings. + - Rust blocking proof uses only accepted contract input. + - Candidate output contains evidence refs and confidence, not snippets. + + Run: + + ```bash + cargo test -p drift-engine candidate_only_role_and_tenant_evidence_does_not_block -- --nocapture + pnpm --filter @drift/cli test -- convention-candidates + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.29: RED Phase 4 parser gaps** + + Test file: `crates/drift-engine/tests/security_control_flow.rs` + + Test name: `tenant_authorization_dynamic_shapes_emit_parser_gaps` + + Fixture shapes: + + ```ts + const key = "tenantId"; + await db.project.findMany({ where: { [key]: session.user.tenantId } }); + ``` + + ```ts + const args = { where: { tenantId: session.user.tenantId } }; + await db.project.findMany(args); + ``` + + ```ts + const { user: { tenantId } } = session; + await db.project.findMany({ where: { tenantId } }); + ``` + + Run: + + ```bash + cargo test -p drift-engine tenant_authorization_dynamic_shapes_emit_parser_gaps -- --nocapture + ``` + + Expected RED: fail because unsupported dynamic tenant/query shapes are + silently omitted or treated as proof. + +- [ ] **Task 4.30: GREEN Phase 4 parser gaps** + + Implementation files: + + - `crates/drift-engine/src/security_control_flow.rs` + - `crates/drift-engine/src/security_proof.rs` + - `crates/drift-engine/src/security_capabilities.rs` + + Implement parser gaps: + + - Emit `unsupported_tenant_dynamic_property`. + - Emit `unsupported_tenant_query_object_alias`. + - Emit `unsupported_session_nested_destructure`. + - Parser gaps under blocking accepted Phase 4 contracts set + `blocks_enforcement=true`. + - Capability report marks the affected Phase 4 proof sub-capability + `partial` or `unsupported` for the file/route. + + Run: + + ```bash + cargo test -p drift-engine tenant_authorization_dynamic_shapes_emit_parser_gaps -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.31: RED TypeScript schemas and engine contract** + + Test files: + + - `packages/core/test/security.test.ts` + - `packages/engine-contract/test/security-contract.test.ts` + + Test names: + + - `validates phase4 tenant authorization and session trust contracts` + - `rejects impossible phase4 proof states` + - `validates phase4 parser gaps from engine output` + + Required schema assertions: + + - `session_trust.proven=true` is invalid when `missing_trust` is non-empty. + - `authorization.proven=true` is invalid when `missing` is non-empty. + - `tenant.proven=true` is invalid when `missing` is non-empty. + - `authorization.proven=true` is invalid when any referenced session source is + untrusted. + - `tenant.proven=true` is invalid when every tenant source is untrusted. + - Parser gaps use normalized codes and carry no snippets or sensitive values. + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected RED: fail because Phase 4 contract, proof, missing-proof, and + parser-gap fields are not fully validated. + +- [ ] **Task 4.32: GREEN TypeScript schemas and engine contract** + + Implementation files: + + - `packages/core/src/security.ts` + - `packages/core/src/domain.ts` + - `packages/core/src/schemas.ts` + - `packages/engine-contract/src/index.ts` + - `crates/drift-engine/src/protocol.rs` + + Implement schemas only: + + - Add Phase 4 proof/event fields and parser-gap codes. + - Validate impossible proof states. + - Validate accepted contract fields under `requires.auth_helpers`, + `requires.authorization_helpers`, `requires.tenant_helpers`, + `requires.tenant_keys`, `requires.tenant_sources`, and + `requires.data_operations`. + - Do not add deterministic Phase 4 proof logic in TypeScript. + + Run: + + ```bash + pnpm --filter @drift/core test -- security + pnpm --filter @drift/engine-contract test -- security-contract + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.33: RED engine request path carries accepted Phase 4 contracts to Rust** + + Test files: + + - `packages/cli/test/security-check.test.ts` + - `crates/drift-engine/tests/security_check_repo_phase4.rs` + + Test names: + + - `passes accepted phase4 requires fields to rust engine` + - `engine blocks tenant missing predicate from accepted phase4 contract` + + Run: + + ```bash + pnpm --filter @drift/cli test -- security-check + cargo test -p drift-engine engine_blocks_tenant_missing_predicate_from_accepted_phase4_contract -- --nocapture + ``` + + Expected RED: fail because accepted Phase 4 contract fields are not wired from + TypeScript check orchestration into Rust check evaluation. + +- [ ] **Task 4.34: GREEN engine request path** + + Implementation files: + + - `packages/cli/src/engine/engine-check.ts` + - `crates/drift-engine/src/protocol.rs` + - `crates/drift-engine/src/check_command.rs` + + Implement wiring only: + + - Preserve accepted Phase 4 `requires.*` fields in the engine request. + - Preserve matcher path/method/file-role scope. + - Reject legacy `matcher.required_calls` as Phase 4 proof truth. + - Keep candidate evidence out of deterministic rule inputs. + + Run: + + ```bash + pnpm --filter @drift/cli test -- security-check + cargo test -p drift-engine engine_blocks_tenant_missing_predicate_from_accepted_phase4_contract -- --nocapture + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.35: RED query, CLI, scan status, and repo map output** + + Test files: + + - `packages/query/test/security-boundary-proof.test.ts` + - `packages/cli/test/security-check.test.ts` + - `packages/cli/test/cli.test.ts` + + Test names: + + - `summarizes phase4 proof without synthesizing trust from raw facts` + - `returns phase4 proof in drift check json output` + - `scan status reports tenant authorization and session trust capabilities` + - `repo map reports route tenant authorization and session summaries` + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports tenant authorization and session trust capabilities" + pnpm --filter @drift/cli test -- "repo map reports route tenant authorization and session summaries" + ``` + + Expected RED: fail because read models and CLI output do not expose Phase 4 + Rust-owned proof truth. + +- [ ] **Task 4.36: GREEN query, CLI, scan status, and repo map output** + + Implementation files: + + - `packages/query/src/security-boundary-proof.ts` + - `packages/query/src/index.ts` + - `packages/cli/src/check/security-check.ts` + - `packages/cli/src/check/run-check.ts` + - `packages/cli/src/domain/scan-status.ts` + - `packages/cli/src/commands/scan.ts` + - `packages/cli/src/commands/repo-map.ts` + + Implement read/output wiring only: + + - Query consumes Rust proof and parser gaps; it does not infer proof from raw + facts. + - CLI JSON includes `session_trust`, `authorization`, and `tenant` proof + summaries. + - Human CLI output names contract, route/file, line ranges, proof status, + missing-proof code, capability, and lifecycle. + - Output contains no snippets or sensitive values. + + Run: + + ```bash + pnpm --filter @drift/query test -- security-boundary-proof + pnpm --filter @drift/cli test -- security-check + pnpm --filter @drift/cli test -- "scan status reports tenant authorization and session trust capabilities" + pnpm --filter @drift/cli test -- "repo map reports route tenant authorization and session summaries" + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.37: RED MCP Phase 4 read model** + + Test file: `packages/mcp/test/mcp.test.ts` + + Test name: `exposes phase4 security proof summaries without snippets` + + Run: + + ```bash + pnpm --filter @drift/mcp test -- "phase4 security proof" + ``` + + Expected RED: fail because MCP read-only context does not expose Phase 4 proof + summaries from query output. + +- [ ] **Task 4.38: GREEN MCP Phase 4 read model** + + Implementation files: + + - `packages/mcp/src/security-context.ts` + - `packages/mcp/src/index.ts` + - `packages/mcp/src/tools.ts` + - `packages/query/src/security-boundary-proof.ts` + + Implement read model only: + + - MCP surfaces accepted Phase 4 contracts, route proof status, missing proof, + parser gaps, and capabilities. + - MCP does not duplicate rule logic. + - MCP output does not include snippets, session values, tenant values, user + values, headers, cookies, request payloads, tokens, or secrets. + + Run: + + ```bash + pnpm --filter @drift/mcp test -- "phase4 security proof" + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.39: RED lifecycle, waiver, baseline, and diff-scope preservation** + + Test files: + + - `packages/cli/test/security-check.test.ts` + - `test/e2e/security-validation.test.ts` + + Test names: + + - `phase4 findings respect waivers baselines and lifecycle` + - `phase4 findings respect changed hunk scope` + + Run: + + ```bash + pnpm --filter @drift/cli test -- security-check + pnpm test:e2e -- security-validation + ``` + + Expected RED: fail if Phase 4 findings bypass existing waiver, baseline, + lifecycle, check-run, or diff-scope behavior. + +- [ ] **Task 4.40: GREEN lifecycle, waiver, baseline, and diff-scope preservation** + + Implementation files: + + - `packages/cli/src/check/run-check.ts` + - `packages/cli/src/check/security-check.ts` + - `packages/query/src/security-boundary-proof.ts` + - `packages/storage/src/sqlite-storage.ts` + + Implement preservation: + + - Reuse existing finding fingerprint and lifecycle machinery. + - Include stable Phase 4 finding metadata: contract ID, route ID, file path, + fact IDs, missing-proof code, parser-gap ID, capability, and proof status. + - Do not persist snippets or sensitive values. + + Run: + + ```bash + pnpm --filter @drift/cli test -- security-check + pnpm test:e2e -- security-validation + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.41: RED Phase 4 e2e fixture matrix** + + Fixture names: + + - `test/fixtures/security-tenant-missing` + - `test/fixtures/security-tenant-param-unused` + - `test/fixtures/security-tenant-bound-to-query` + - `test/fixtures/security-tenant-untrusted-source` + - `test/fixtures/security-tenant-parser-gap` + - `test/fixtures/security-role-missing` + - `test/fixtures/security-role-guard-present` + - `test/fixtures/security-role-branch-bypass` + - `test/fixtures/security-session-from-request-untrusted` + - `test/fixtures/security-session-trusted-helper` + + Test file: `test/e2e/security-tenant-authorization.test.ts` + + Test name: + `security tenant authorization fixture matrix proves phase4 trust and gaps` + + Run: + + ```bash + pnpm test:e2e -- security-tenant-authorization + ``` + + Expected RED: fail because Phase 4 fixtures and end-to-end assertions do not + exist. + +- [ ] **Task 4.42: GREEN Phase 4 e2e fixture matrix** + + Implementation files: + + - `test/e2e/security-tenant-authorization.test.ts` + - `test/fixtures/security-tenant-missing/package.json` + - `test/fixtures/security-tenant-missing/app/api/projects/route.ts` + - `test/fixtures/security-tenant-param-unused/package.json` + - `test/fixtures/security-tenant-param-unused/app/api/projects/route.ts` + - `test/fixtures/security-tenant-bound-to-query/package.json` + - `test/fixtures/security-tenant-bound-to-query/app/api/projects/route.ts` + - `test/fixtures/security-tenant-untrusted-source/package.json` + - `test/fixtures/security-tenant-untrusted-source/app/api/projects/route.ts` + - `test/fixtures/security-tenant-parser-gap/package.json` + - `test/fixtures/security-tenant-parser-gap/app/api/projects/route.ts` + - `test/fixtures/security-role-missing/package.json` + - `test/fixtures/security-role-missing/app/api/projects/route.ts` + - `test/fixtures/security-role-guard-present/package.json` + - `test/fixtures/security-role-guard-present/app/api/projects/route.ts` + - `test/fixtures/security-role-branch-bypass/package.json` + - `test/fixtures/security-role-branch-bypass/app/api/projects/route.ts` + - `test/fixtures/security-session-from-request-untrusted/package.json` + - `test/fixtures/security-session-from-request-untrusted/app/api/projects/route.ts` + - `test/fixtures/security-session-trusted-helper/package.json` + - `test/fixtures/security-session-trusted-helper/app/api/projects/route.ts` + + Fixture expectations: + + - Tenant missing predicate blocks. + - Tenant param read but unused blocks. + - Trusted session tenant bound to data predicate passes. + - Untrusted request-derived tenant source blocks. + - Dynamic tenant predicate emits parser-gap-backed evidence. + - Role-required route without accepted authorization guard blocks. + - Accepted role guard with trusted session passes. + - Role guard in only one branch blocks. + - Session object from request is untrusted. + - Accepted auth helper creates trusted session proof. + - No fixture expected output includes snippets, session values, tenant values, + user values, headers, cookies, request payloads, tokens, secrets, or raw SQL + values. + + Run: + + ```bash + pnpm test:e2e -- security-tenant-authorization + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.43: RED capability truth for Phase 4** + + Test file: `crates/drift-engine/tests/security_capabilities.rs` + + Test name: `phase4_capabilities_reflect_supported_parser_gaps_and_contracts` + + Run: + + ```bash + cargo test -p drift-engine phase4_capabilities_reflect_supported_parser_gaps_and_contracts -- --nocapture + ``` + + Expected RED: fail because capability reporting does not account for Phase 4 + supported, partial, parser-gap, and unsupported states. + +- [ ] **Task 4.44: GREEN capability truth for Phase 4** + + Implementation files: + + - `crates/drift-engine/src/security_capabilities.rs` + - `crates/drift-engine/src/security_proof.rs` + - `packages/cli/src/domain/scan-status.ts` + + Implement capability truth: + + - Report `session_trust`, `authorization`, and `tenant_scope` separately. + - Mark deterministic support only when accepted contract input, proof + construction, parser-gap reporting, and rule evaluation are wired. + - Mark partial/unsupported for dynamic tenant shapes and unresolved wrappers. + - Do not mark candidate-only evidence as deterministic support. + + Run: + + ```bash + cargo test -p drift-engine phase4_capabilities_reflect_supported_parser_gaps_and_contracts -- --nocapture + pnpm --filter @drift/cli test -- "scan status reports tenant authorization and session trust capabilities" + ``` + + Expected GREEN: pass. + +- [ ] **Task 4.45: Phase 4 full gate** + + Run all commands, no shortcuts: + + ```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. + + Required completion notes: + + - Phase 4 tasks completed. + - Files changed. + - Exact RED/GREEN commands run and pass/fail status. + - Exact final gates run and pass/fail status. + - Any baseline failures or blockers with exact command output summary. + - Any intentional snapshot/output changes. + - Confirmation that Phase 5+ was not implemented. ## Phase 5: Sensitive Response And Secrets Exposure diff --git a/drift v3/packages/cli/src/args/flag-readers.ts b/drift v3/packages/cli/src/args/flag-readers.ts index 858eba91..092f051a 100644 --- a/drift v3/packages/cli/src/args/flag-readers.ts +++ b/drift v3/packages/cli/src/args/flag-readers.ts @@ -179,12 +179,17 @@ export function optionalConventionKindFlag(parsed: ParsedArgs, name: string): Co value === "api_route_no_direct_data_access" || value === "api_route_requires_service_delegation" || value === "api_route_requires_auth_helper" || + value === "middleware_must_cover_routes" || + value === "api_route_requires_request_validation" || + value === "session_object_must_come_from_trusted_helper" || + value === "api_route_requires_authorization" || + value === "api_route_requires_tenant_scope" || value === "test_expected_for_changed_module" || value === "custom_briefing" ) { return value; } - throw new Error("--kind must be api_route_no_direct_data_access, api_route_requires_service_delegation, api_route_requires_auth_helper, test_expected_for_changed_module, or custom_briefing."); + throw new Error("--kind must be a supported accepted convention kind."); } export function optionalEnforcementCapabilityFlag(parsed: ParsedArgs, name: string): EnforcementCapability | undefined { diff --git a/drift v3/packages/cli/src/check/run-check.ts b/drift v3/packages/cli/src/check/run-check.ts index adc7b56a..5057278c 100644 --- a/drift v3/packages/cli/src/check/run-check.ts +++ b/drift v3/packages/cli/src/check/run-check.ts @@ -326,6 +326,8 @@ export async function runCheck(storage: SqliteDriftStorage, parsed: ParsedArgs): checkScanId }); findings.push(...engineOwnedAuth.findings); + waivedFindings.push(...engineOwnedAuth.waivedFindings); + waivedFindingsCount += engineOwnedAuth.waivedFindingsCount; securityBoundaryProofs.push(...engineOwnedAuth.securityBoundaryProofs); for (const finding of engineOwnedAuth.findings) { storage.upsertFinding(finding); @@ -1913,15 +1915,25 @@ async function runEngineOwnedAuthCheck(input: { snapshotsByPath: Map; checkId: string; checkScanId: string; -}): Promise<{ findings: Finding[]; securityBoundaryProofs: SecurityBoundaryProof[] }> { +}): Promise<{ + findings: Finding[]; + waivedFindings: WaivedFinding[]; + waivedFindingsCount: number; + securityBoundaryProofs: SecurityBoundaryProof[]; +}> { const findings: Finding[] = []; + const waivedFindings: WaivedFinding[] = []; + let waivedFindingsCount = 0; const securityBoundaryProofs: SecurityBoundaryProof[] = []; for (const convention of input.contract.conventions) { if ( ( convention.kind !== "api_route_requires_auth_helper" && - convention.kind !== "api_route_requires_request_validation" + convention.kind !== "api_route_requires_request_validation" && + convention.kind !== "session_object_must_come_from_trusted_helper" && + convention.kind !== "api_route_requires_authorization" && + convention.kind !== "api_route_requires_tenant_scope" ) || convention.enforcement_mode === "off" || convention.enforcement_capability !== "deterministic_check" || @@ -1972,9 +1984,54 @@ async function runEngineOwnedAuthCheck(input: { .map((fact) => fact.id); const preserved = preservedGovernanceStatus(input.existingFindings.get(engineFinding.fingerprint)); const isRequestValidationFinding = engineFinding.rule_id === "api_route_requires_request_validation"; + const isPhase4Finding = isPhase4SecurityFinding(engineFinding.rule_id); const proofForFinding = result.security_boundary_proofs.find((proof) => proof.result.finding_ids.includes(engineFinding.id) ); + const waiver = isPhase4Finding + ? findContractWaiverForImport( + evidence.file_path, + phase4ExpectedLayer(engineFinding.rule_id), + phase4ActualLayer(proofForFinding), + input.contract, + input.now + ) + : undefined; + if (waiver) { + const staleWaiver = waiverRequiresReapproval( + waiver, + evidence.file_path, + snapshot?.content_hash + ); + if (staleWaiver) { + findings.push(waiverReapprovalFinding({ + repoId: input.repoId, + repoContractId: input.contract.id, + conventionId: engineFinding.convention_id, + checkId: input.checkId, + scanId: input.checkData.snapshots[0]?.scan_id ?? input.checkScanId, + filePath: evidence.file_path, + line: evidenceStartLine, + symbol: phase4ExpectedLayer(engineFinding.rule_id), + importSource: phase4ActualLayer(proofForFinding), + fileHash: snapshot?.content_hash ?? "", + waiverId: waiver.id, + now: input.now + })); + } else { + waivedFindingsCount += 1; + waivedFindings.push({ + waiver_id: waiver.id, + convention_id: engineFinding.convention_id, + file_path: evidence.file_path, + symbol: phase4ExpectedLayer(engineFinding.rule_id), + import_source: phase4ActualLayer(proofForFinding), + line: evidenceStartLine, + reason: waiver.reason + }); + } + continue; + } findings.push({ id: engineFinding.id, repo_id: input.repoId, @@ -1999,21 +2056,52 @@ async function runEngineOwnedAuthCheck(input: { file_hash: snapshot?.content_hash ?? "", redaction_state: "none" }], - expected_layer: isRequestValidationFinding ? "request_validation" : "auth_guard", + expected_layer: isRequestValidationFinding + ? "request_validation" + : isPhase4Finding + ? phase4ExpectedLayer(engineFinding.rule_id) + : "auth_guard", actual_layer: isRequestValidationFinding ? requestValidationActualLayer(proofForFinding) - : "missing_auth_guard", + : isPhase4Finding + ? phase4ActualLayer(proofForFinding) + : "missing_auth_guard", graph_path: [evidence.file_path], suggested_fix: isRequestValidationFinding ? "Validate request input with an accepted validator before using it at protected route sinks." - : "Call an accepted auth helper before route data operations or response sinks.", + : isPhase4Finding + ? "Add accepted session trust, authorization, and tenant-scope proof before protected route sinks." + : "Call an accepted auth helper before route data operations or response sinks.", related_node_ids: engineFinding.related_node_ids, created_at: input.now }); } } - return { findings, securityBoundaryProofs }; + return { findings, waivedFindings, waivedFindingsCount, securityBoundaryProofs }; +} + +function isPhase4SecurityFinding(ruleId: string): boolean { + return ruleId === "session_object_must_come_from_trusted_helper" || + ruleId === "api_route_requires_authorization" || + ruleId === "api_route_requires_tenant_scope"; +} + +function phase4ExpectedLayer(ruleId: string): string { + if (ruleId === "session_object_must_come_from_trusted_helper") { + return "session_trust"; + } + if (ruleId === "api_route_requires_authorization") { + return "authorization"; + } + if (ruleId === "api_route_requires_tenant_scope") { + return "tenant_scope"; + } + return "security_boundary"; +} + +function phase4ActualLayer(proof: SecurityBoundaryProof | undefined): string { + return proof?.missing_proof[0]?.code ?? proof?.parser_gaps[0]?.code ?? "missing_proof"; } function requestValidationActualLayer(proof: unknown): string { diff --git a/drift v3/packages/cli/src/check/security-check.ts b/drift v3/packages/cli/src/check/security-check.ts index a98d3f79..c822278f 100644 --- a/drift v3/packages/cli/src/check/security-check.ts +++ b/drift v3/packages/cli/src/check/security-check.ts @@ -25,6 +25,9 @@ export interface SecurityCheckJson { security_blocking_count: number; middleware_coverage_proven_count: number; request_validation_failed_count: number; + session_trust_failed_count: number; + authorization_failed_count: number; + tenant_scope_failed_count: number; }; } @@ -51,6 +54,18 @@ export function buildSecurityCheckJson(input: BuildSecurityCheckJsonInput): Secu request_validation_failed_count: input.proofs.filter((proof) => { const requestValidation = proof.request_validation; return Boolean(requestValidation && requestValidation.required && !requestValidation.proven); + }).length, + session_trust_failed_count: input.proofs.filter((proof) => { + const sessionTrust = proof.session_trust; + return Boolean(sessionTrust && sessionTrust.required && !sessionTrust.proven); + }).length, + authorization_failed_count: input.proofs.filter((proof) => { + const authorization = proof.authorization; + return Boolean(authorization && authorization.required && !authorization.proven); + }).length, + tenant_scope_failed_count: input.proofs.filter((proof) => { + const tenant = proof.tenant; + return Boolean(tenant && tenant.required && !tenant.proven); }).length } }; diff --git a/drift v3/packages/cli/src/domain/scan-status.ts b/drift v3/packages/cli/src/domain/scan-status.ts index 07811396..a3eef206 100644 --- a/drift v3/packages/cli/src/domain/scan-status.ts +++ b/drift v3/packages/cli/src/domain/scan-status.ts @@ -647,6 +647,9 @@ function securityCapabilitySummary(capabilityReport: ReturnType [entry.rule_id, entry])); const middlewareCompleteness = completenessByRule.get("middleware_must_cover_routes"); const requestValidationCompleteness = completenessByRule.get("api_route_requires_request_validation"); + const sessionTrustCompleteness = completenessByRule.get("session_object_must_come_from_trusted_helper"); + const authorizationCompleteness = completenessByRule.get("api_route_requires_authorization"); + const tenantScopeCompleteness = completenessByRule.get("api_route_requires_tenant_scope"); return { middleware_coverage: { certified: certified.has("middleware_coverage"), @@ -661,6 +664,27 @@ function securityCapabilitySummary(capabilityReport: ReturnType { }); }); + it("scan status reports tenant authorization and session trust capabilities", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-scan-status-phase4-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertScanCapabilityReport({ + schema_version: "drift.scan_capability_report.v1", + repo_id: repoId, + scan_id: scanId, + engine_source: "rust", + engine_version: "0.1.0", + scanner_version: "0.1.0", + adapter_versions: { typescript: "0.1.0" }, + certified_capabilities: ["file_discovery", "syntax_facts", "session_trust", "authorization", "tenant_scope"], + required_capabilities: ["file_discovery", "syntax_facts", "session_trust", "authorization", "tenant_scope"], + missing_capabilities: [], + completeness: [{ + scope: "route-flow", + rule_id: "session_object_must_come_from_trusted_helper", + complete: true, + can_block: true, + reasons: [] + }, { + scope: "route-flow", + rule_id: "api_route_requires_authorization", + complete: true, + can_block: true, + reasons: [] + }, { + scope: "route-flow", + rule_id: "api_route_requires_tenant_scope", + complete: false, + can_block: true, + reasons: ["unsupported_tenant_dynamic_property"] + }], + parser_gap_count: 1, + parser_gap_kinds: { unsupported_tenant_dynamic_property: 1 }, + fallback_used: false, + enforcement_degraded: false, + created_at: "2026-05-26T00:00:00.000Z" + }); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "scan", "status", + "--repo", repoId, + "--json" + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.security_capabilities.session_trust).toMatchObject({ + certified: true, + can_block: true, + missing: false + }); + expect(payload.security_capabilities.authorization).toMatchObject({ + certified: true, + can_block: true, + missing: false + }); + expect(payload.security_capabilities.tenant_scope).toMatchObject({ + certified: true, + can_block: true, + missing: false, + complete: false + }); + }); + it("repo map reports route middleware coverage summary", async () => { const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-middleware-"); const storage = openDriftStorage({ databasePath }); @@ -10285,6 +10356,114 @@ describe("drift CLI convention review", () => { expect(result.stdout).not.toContain("request.json()"); }); + it("repo map reports route tenant authorization and session summaries", async () => { + const { databasePath, repoId } = await seedStartedDoctorState("drift-repo-map-phase4-"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const scanId = storage.listScanManifests(repoId) + .find((scan) => scan.status === "completed" && !scan.id.startsWith("scan_baseline_"))!.id; + storage.upsertFacts([{ + id: "fact_session", + repo_id: repoId, + scan_id: scanId, + kind: "session_read", + file_path: "apps/web/app/api/users/route.ts", + name: "session", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + variable: "session", + source: "auth_result", + trust: "trusted" + }), + imported_name: undefined, + start_line: 2, + end_line: 2, + ...factQuality(scanId) + }, { + id: "fact_authorization", + repo_id: repoId, + scan_id: scanId, + kind: "authorization_guard_called", + file_path: "apps/web/app/api/users/route.ts", + name: "requireRole", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + policy_id: "authorization_require_role", + roles: ["admin"], + subject_var: "session.user" + }), + imported_name: undefined, + start_line: 3, + end_line: 3, + ...factQuality(scanId) + }, { + id: "fact_tenant_source", + repo_id: repoId, + scan_id: scanId, + kind: "tenant_source", + file_path: "apps/web/app/api/users/route.ts", + name: "tenantId", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + source: "session", + key: "tenantId", + variable: "session.user.tenantId", + trusted: true + }), + imported_name: undefined, + start_line: 4, + end_line: 4, + ...factQuality(scanId) + }, { + id: "fact_tenant_guard", + repo_id: repoId, + scan_id: scanId, + kind: "tenant_guard_called", + file_path: "apps/web/app/api/users/route.ts", + name: "tenantId", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/users/route.ts:GET", + tenant_key: "tenantId", + predicate_kind: "where_equals", + data_operation_fact_id: "fact_data" + }), + imported_name: undefined, + start_line: 5, + end_line: 5, + ...factQuality(scanId) + }]); + storage.close(); + + const result = await runCli([ + "--db", databasePath, + "repo", "map", + "--repo", repoId, + "--path", "apps/web/app/api/users/route.ts", + "--json" + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.files[0].route_security.session_trust).toMatchObject({ + status: "advisory_only", + advisory_session_variables: ["session"], + advisory_trusted_source_count: 1, + advisory_untrusted_source_count: 0 + }); + expect(payload.files[0].route_security.authorization).toMatchObject({ + status: "advisory_only", + advisory_guard_ids: ["authorization_require_role"], + advisory_role_count: 1 + }); + expect(payload.files[0].route_security.tenant_scope).toMatchObject({ + status: "advisory_only", + advisory_tenant_keys: ["tenantId"], + advisory_trusted_source_count: 1, + advisory_predicate_count: 1 + }); + expect(result.stdout).not.toContain("session.user.tenantId"); + }); + it("prints prepare summary and governance in human output", async () => { const { databasePath } = await seedAcceptedDatabase(); diff --git a/drift v3/packages/cli/test/security-check.test.ts b/drift v3/packages/cli/test/security-check.test.ts index 6e0a5d3f..0f69af1b 100644 --- a/drift v3/packages/cli/test/security-check.test.ts +++ b/drift v3/packages/cli/test/security-check.test.ts @@ -87,6 +87,97 @@ describe("security check bridge", () => { expect(JSON.stringify(payload)).not.toContain("request.json()"); }); + it("returns phase4 proof in drift check json output", () => { + const payload = buildSecurityCheckJson({ + repo_id: "repo_abc", + scope: "changed-files", + changed_files: ["app/api/projects/route.ts"], + proofs: [ + securityProof("proof_phase4", "app/api/projects/route.ts", "finding_phase4", { + session_trust: { + required: true, + proven: false, + trusted_sessions: [], + missing_trust: [{ fact_id: "fact_session", variable: "session", reason: "derived_from_request" }] + }, + authorization: { + required: true, + proven: false, + role_or_policy_guards: [], + missing: [{ reason: "session_not_trusted", sink_fact_id: "sink_delete" }] + }, + tenant: { + required: true, + proven: false, + tenant_sources: [{ fact_id: "fact_tenant", source: "body", key: "tenantId", trusted: false }], + predicates: [], + missing: [{ data_operation_fact_id: "fact_delete", reason: "tenant_source_untrusted" }] + } + }) + ], + findings: [{ + finding_id: "finding_phase4", + title: "API route missing required authorization proof", + file_path: "app/api/projects/route.ts", + enforcement_result: "block" + }] + }); + + expect(payload.security_boundary_proofs[0]).toMatchObject({ + session_trust: { required: true, proven: false }, + authorization: { required: true, proven: false }, + tenant: { required: true, proven: false } + }); + expect(payload.summary.session_trust_failed_count).toBe(1); + expect(payload.summary.authorization_failed_count).toBe(1); + expect(payload.summary.tenant_scope_failed_count).toBe(1); + expect(JSON.stringify(payload)).not.toContain("tenant-"); + }); + + it("phase4 findings respect changed hunk scope", () => { + const payload = buildSecurityCheckJson({ + repo_id: "repo_abc", + scope: "changed-hunks", + changed_files: ["app/api/projects/route.ts"], + proofs: [ + securityProof("proof_phase4_changed", "app/api/projects/route.ts", "finding_phase4_changed", { + session_trust: { + required: true, + proven: false, + trusted_sessions: [], + missing_trust: [{ fact_id: "fact_session", variable: "session", reason: "derived_from_request" }] + } + }), + securityProof("proof_phase4_old", "app/api/archive/route.ts", "finding_phase4_old", { + session_trust: { + required: true, + proven: false, + trusted_sessions: [], + missing_trust: [{ fact_id: "fact_session_old", variable: "session", reason: "derived_from_request" }] + } + }) + ], + findings: [{ + finding_id: "finding_phase4_changed", + title: "API route session object is not trusted", + file_path: "app/api/projects/route.ts", + enforcement_result: "block" + }, { + finding_id: "finding_phase4_old", + title: "API route session object is not trusted", + file_path: "app/api/archive/route.ts", + enforcement_result: "block" + }] + }); + + expect(payload.security_findings).toEqual([expect.objectContaining({ + finding_id: "finding_phase4_changed", + enforcement_result: "block" + })]); + expect(payload.summary.security_blocking_count).toBe(1); + expect(JSON.stringify(payload)).not.toContain("session="); + }); + it("receives SecurityBoundaryProof.auth from engine-owned auth checks", async () => { const dir = await mkdtemp(join(tmpdir(), "drift-security-auth-bridge-")); tempDirs.push(dir); @@ -265,6 +356,59 @@ describe("security check bridge", () => { expect(request.contract.conventions[0]?.requires).toBeUndefined(); }); + it("passes accepted phase4 requires fields to rust engine", () => { + const request = engineCheckRequest({ + repoId: "repo_abc", + repoRoot: "/tmp/repo", + scanId: "scan_abc", + snapshots: [], + facts: [], + conventions: [{ + id: "security_api_tenant_scope", + repo_id: "repo_abc", + contract_id: "contract_abc", + kind: "api_route_requires_tenant_scope", + statement: "API routes require tenant scope.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_tenant_scope", + applies_to_file_roles: ["api_route"], + required_calls: ["scopeProjectToTenant"] + }, + requires: { + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }], + authorization_helpers: ["requireRole", "canAccessProject"], + tenant_helpers: ["scopeProjectToTenant"], + tenant_keys: ["tenantId"], + tenant_sources: ["session"], + data_operations: ["findMany", "delete"] + }, + 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).toMatchObject({ + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }], + authorization_helpers: ["requireRole", "canAccessProject"], + tenant_helpers: ["scopeProjectToTenant"], + tenant_keys: ["tenantId"], + tenant_sources: ["session"], + data_operations: ["findMany", "delete"] + }); + expect(request.contract.conventions[0]?.matcher.required_calls).toEqual(["scopeProjectToTenant"]); + }); + it("returns SecurityBoundaryProof.auth in drift check JSON output", async () => { const { databasePath, repoRoot, diffPath } = await seedAuthCheckDatabase(); const storage = openDriftStorage({ databasePath }); @@ -337,6 +481,62 @@ describe("security check bridge", () => { }); }); + it("applies approved waivers to engine-owned Phase 4 findings before persistence", async () => { + const unwaived = await seedPhase4TenantWaiverCheckDatabase(false); + const unwaivedStorage = openDriftStorage({ databasePath: unwaived.databasePath }); + unwaivedStorage.migrate(); + const blocked = await runCheck(unwaivedStorage, { + positional: ["check"], + flags: new Map([ + ["repo", "repo_abc"], + ["scope", "changed-hunks"], + ["diff-file", unwaived.diffPath], + ["now", "2026-05-26T00:00:00.000Z"], + ["json", true] + ]) + }); + unwaivedStorage.close(); + expect(blocked.exitCode).toBe(1); + + const waived = await seedPhase4TenantWaiverCheckDatabase(true); + const waivedStorage = openDriftStorage({ databasePath: waived.databasePath }); + waivedStorage.migrate(); + const result = await runCheck(waivedStorage, { + positional: ["check"], + flags: new Map([ + ["repo", "repo_abc"], + ["scope", "changed-hunks"], + ["diff-file", waived.diffPath], + ["now", "2026-05-26T00:00:00.000Z"], + ["json", true] + ]) + }); + const storedFindings = waivedStorage.listFindings("repo_abc"); + waivedStorage.close(); + + expect(result.exitCode).toBe(0); + const payload = result.payload as { + summary?: { waived_findings_count?: number; blocking_count?: number }; + findings?: Array<{ convention_id: string; enforcement_result: string }>; + waived_findings?: Array<{ waiver_id: string; convention_id: string; file_path: string }>; + }; + expect(payload.summary?.waived_findings_count).toBe(1); + expect(payload.summary?.blocking_count).toBe(0); + expect(payload.waived_findings).toContainEqual(expect.objectContaining({ + waiver_id: "waiver_phase4_tenant", + convention_id: "security_api_tenant_scope", + file_path: "app/api/projects/route.ts" + })); + expect(payload.findings ?? []).not.toContainEqual(expect.objectContaining({ + convention_id: "security_api_tenant_scope", + enforcement_result: "block" + })); + expect(storedFindings).not.toContainEqual(expect.objectContaining({ + convention_id: "security_api_tenant_scope", + status: "new" + })); + }); + it("returns middleware coverage proof in drift check JSON output", () => { const payload = buildSecurityCheckJson({ repo_id: "repo_abc", @@ -583,6 +783,130 @@ async function seedAuthCheckDatabase(): Promise<{ return { databasePath, repoRoot, diffPath }; } +async function seedPhase4TenantWaiverCheckDatabase(withWaiver: boolean): Promise<{ + databasePath: string; + repoRoot: string; + diffPath: string; +}> { + const dir = await mkdtemp(join(tmpdir(), "drift-security-phase4-waiver-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const routePath = "app/api/projects/route.ts"; + await mkdir(join(repoRoot, "app/api/projects"), { recursive: true }); + await writeFile(join(repoRoot, routePath), [ + "import { requireUser } from '@/server/auth';", + "const db = { project: { findMany: async () => [] } };", + "", + "export async function GET(request: Request) {", + " const session = await requireUser(request);", + " const projects = await db.project.findMany();", + " return Response.json({ projects, ok: Boolean(session) });", + "}", + "" + ].join("\n")); + const diffPath = join(dir, "diff.patch"); + await writeFile(diffPath, [ + "diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts", + "--- a/app/api/projects/route.ts", + "+++ b/app/api/projects/route.ts", + "@@ -0,0 +1,8 @@", + "+import { requireUser } from '@/server/auth';", + "+const db = { project: { findMany: async () => [] } };", + "+", + "+export async function GET(request: Request) {", + "+ const session = await requireUser(request);", + "+ const projects = await db.project.findMany();", + "+ return Response.json({ projects, ok: Boolean(session) });", + "+}", + "" + ].join("\n")); + const databasePath = join(dir, "drift.sqlite"); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + storage.upsertRepo({ + id: "repo_abc", + root_path: repoRoot, + fingerprint: "repo-fp", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }); + const phase4Convention = { + id: "security_api_tenant_scope", + contract_id: "contract_abc", + kind: "api_route_requires_tenant_scope", + statement: "API routes require accepted tenant scope proof.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route"] }, + matcher: { + kind: "api_route_requires_tenant_scope", + applies_to_file_roles: ["api_route"], + methods: ["GET"] + }, + requires: { + auth_helpers: [{ + guard_id: "auth_require_user", + symbol: "requireUser", + behavior: "returns_session" + }], + tenant_keys: ["tenantId"], + tenant_sources: ["session"], + data_operations: ["read"] + }, + 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" + } as const; + storage.upsertAcceptedConvention("repo_abc", phase4Convention); + storage.upsertRepoContract({ + id: "contract_abc", + repo_id: "repo_abc", + contract_schema_version: 1, + repo_fingerprint: "repo-fp", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z", + conventions: [phase4Convention], + rejected_inferences: [], + waivers: withWaiver ? [{ + id: "waiver_phase4_tenant", + reason: "Temporary accepted Phase 4 tenant-scope remediation window.", + path_globs: [routePath], + created_by: "test", + created_at: "2026-05-26T00:00:00.000Z" + }] : [], + risky_areas: [], + layer_architecture: { + schema_version: "drift.layer_architecture.v1", + architecture_id: "architecture_security_phase4", + repo_id: "repo_abc", + version: 1, + layers: [ + { id: "route", role: "route", position: "entrypoint" }, + { id: "auth", role: "auth", position: "middle" }, + { id: "data_access", role: "data_access", position: "terminal" } + ], + allowed_edges: [], + forbidden_edges: [], + soft_edges: [] + }, + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.close(); + return { databasePath, repoRoot, diffPath }; +} + async function seedRequestValidationGapCheckDatabase(): Promise<{ databasePath: string; repoRoot: string; diff --git a/drift v3/packages/core/src/domain.ts b/drift v3/packages/core/src/domain.ts index ef819f57..bdea5b17 100644 --- a/drift v3/packages/core/src/domain.ts +++ b/drift v3/packages/core/src/domain.ts @@ -4,6 +4,9 @@ export type ConventionKind = | "api_route_requires_auth_helper" | "middleware_must_cover_routes" | "api_route_requires_request_validation" + | "session_object_must_come_from_trusted_helper" + | "api_route_requires_authorization" + | "api_route_requires_tenant_scope" | "test_expected_for_changed_module" | "custom_briefing" | AgentContractKind; @@ -263,6 +266,10 @@ export type FactKind = | "middleware_matcher_declared" | "middleware_protects_route" | "request_input_read" + | "session_read" + | "tenant_source" + | "tenant_guard_called" + | "authorization_guard_called" | "request_validation_called" | "validated_input_used"; diff --git a/drift v3/packages/core/src/schemas.ts b/drift v3/packages/core/src/schemas.ts index 7d8efd6e..430ed9bc 100644 --- a/drift v3/packages/core/src/schemas.ts +++ b/drift v3/packages/core/src/schemas.ts @@ -22,6 +22,9 @@ export const ConventionKindSchema = z.enum([ "api_route_requires_auth_helper", "middleware_must_cover_routes", "api_route_requires_request_validation", + "session_object_must_come_from_trusted_helper", + "api_route_requires_authorization", + "api_route_requires_tenant_scope", "test_expected_for_changed_module", "custom_briefing", "file_role", @@ -286,6 +289,10 @@ export const FactKindSchema = z.enum([ "middleware_matcher_declared", "middleware_protects_route", "request_input_read", + "session_read", + "tenant_source", + "tenant_guard_called", + "authorization_guard_called", "request_validation_called", "validated_input_used" ]); diff --git a/drift v3/packages/core/src/security.ts b/drift v3/packages/core/src/security.ts index b35e9594..78dbded0 100644 --- a/drift v3/packages/core/src/security.ts +++ b/drift v3/packages/core/src/security.ts @@ -5,7 +5,10 @@ export const SecurityCapabilityNameSchema = z.enum([ "auth_boundary_facts", "control_flow_guard_dominance", "middleware_coverage", - "request_validation_facts" + "request_validation_facts", + "session_trust", + "authorization", + "tenant_scope" ]); export const SecurityMissingProofCodeSchema = z.enum([ @@ -16,6 +19,12 @@ export const SecurityMissingProofCodeSchema = z.enum([ "request_input_not_validated", "validation_result_not_used", "unknown_validator", + "session_not_trusted", + "authorization_guard_missing", + "authorization_guard_not_dominating_sink", + "tenant_predicate_missing", + "tenant_source_untrusted", + "tenant_predicate_not_bound_to_query", "unsupported_callback_boundary", "unsupported_dynamic_control_flow", "route_binding_unresolved", @@ -29,13 +38,19 @@ export const SecurityParserGapCodeSchema = z.enum([ "unsupported_dynamic_middleware_matcher", "unsupported_request_input_spread", "unsupported_request_input_destructure", + "unsupported_tenant_dynamic_property", + "unsupported_tenant_query_object_alias", + "unsupported_session_nested_destructure", "unsupported_callback_boundary" ]); const SecurityContractKindSchema = z.enum([ "api_route_requires_auth_helper", "middleware_must_cover_routes", - "api_route_requires_request_validation" + "api_route_requires_request_validation", + "session_object_must_come_from_trusted_helper", + "api_route_requires_authorization", + "api_route_requires_tenant_scope" ]); export const SecurityConventionSchema = z.object({ @@ -174,6 +189,60 @@ const SecurityRequestValidationProofSchema = z.object({ })) }); +const SecuritySessionTrustProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + trusted_sessions: z.array(z.object({ + fact_id: z.string().min(1), + variable: z.string().min(1), + source: z.string().min(1).optional(), + trust: z.enum(["trusted", "untrusted", "unknown"]) + }).passthrough()), + missing_trust: z.array(z.object({ + fact_id: z.string().min(1), + variable: z.string().min(1), + reason: z.enum(["derived_from_request", "unknown_helper", "missing_auth_guard", "parser_gap"]) + })) +}); + +const SecurityAuthorizationProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + role_or_policy_guards: z.array(z.object({ + fact_id: z.string().min(1), + policy_id: z.string().min(1).optional(), + roles: z.array(z.string().min(1)).optional().default([]), + permissions: z.array(z.string().min(1)).optional().default([]), + resource_var: z.string().min(1).optional(), + subject_var: z.string().min(1).optional() + })), + missing: z.array(z.object({ + reason: z.enum(["no_authorization_guard", "guard_not_dominating_sink", "unknown_policy_helper", "session_not_trusted", "authorization_guard_missing", "authorization_guard_not_dominating_sink"]), + sink_fact_id: z.string().min(1).optional() + })) +}); + +const SecurityTenantProofSchema = z.object({ + required: z.boolean(), + proven: z.boolean(), + tenant_sources: z.array(z.object({ + fact_id: z.string().min(1), + source: z.enum(["session", "path_param", "header", "body", "query"]), + key: z.string().min(1).optional(), + trusted: z.boolean() + })), + predicates: z.array(z.object({ + fact_id: z.string().min(1), + data_operation_fact_id: z.string().min(1), + tenant_key: z.string().min(1), + predicate_kind: z.enum(["equality", "scoped_helper", "policy_helper"]) + })), + missing: z.array(z.object({ + data_operation_fact_id: z.string().min(1), + reason: z.enum(["no_tenant_predicate", "untrusted_tenant_source", "predicate_not_bound_to_query", "parser_gap", "tenant_predicate_missing", "tenant_source_untrusted", "tenant_predicate_not_bound_to_query"]) + })) +}); + const SecurityMissingProofSchema = z.object({ id: z.string().min(1), capability: z.string().min(1), @@ -231,6 +300,25 @@ export const SecurityBoundaryProofSchema = z.object({ validated_uses: [], unvalidated_uses: [] }), + session_trust: SecuritySessionTrustProofSchema.optional().default({ + required: false, + proven: false, + trusted_sessions: [], + missing_trust: [] + }), + authorization: SecurityAuthorizationProofSchema.optional().default({ + required: false, + proven: false, + role_or_policy_guards: [], + missing: [] + }), + tenant: SecurityTenantProofSchema.optional().default({ + required: false, + proven: false, + tenant_sources: [], + predicates: [], + missing: [] + }), missing_proof: z.array(SecurityMissingProofSchema), parser_gaps: z.array(SecurityParserGapSchema), result: z.object({ @@ -275,6 +363,35 @@ export const SecurityBoundaryProofSchema = z.object({ message: "request validation unvalidated uses require a non-proven proof status" }); } + + if ( + (proof.session_trust.proven && proof.session_trust.missing_trust.length > 0) || + (proof.authorization.proven && proof.authorization.missing.length > 0) || + (proof.tenant.proven && proof.tenant.missing.length > 0) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "phase4 proven proof cannot include missing trust, authorization, or tenant proof" + }); + } + + if (proof.authorization.proven && proof.session_trust.missing_trust.length > 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "authorization proven proof cannot reference untrusted session sources" + }); + } + + if ( + proof.tenant.proven && + proof.tenant.tenant_sources.length > 0 && + proof.tenant.tenant_sources.every((source) => !source.trusted) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "tenant proven proof requires at least one trusted tenant source" + }); + } }); export type SecurityConvention = z.infer; diff --git a/drift v3/packages/core/test/security.test.ts b/drift v3/packages/core/test/security.test.ts index 33e5c296..53bffe13 100644 --- a/drift v3/packages/core/test/security.test.ts +++ b/drift v3/packages/core/test/security.test.ts @@ -292,6 +292,99 @@ describe("security domain schemas", () => { expect(JSON.stringify(proof)).not.toContain("secret"); }); + it("validates phase4 tenant authorization and session trust contracts", () => { + expect(SecurityMissingProofCodeSchema.parse("session_not_trusted")).toBe("session_not_trusted"); + expect(SecurityMissingProofCodeSchema.parse("authorization_guard_missing")).toBe("authorization_guard_missing"); + expect(SecurityMissingProofCodeSchema.parse("tenant_predicate_missing")).toBe("tenant_predicate_missing"); + + const authorization = SecurityConventionSchema.parse({ + contract_id: "security_api_authorization", + kind: "api_route_requires_authorization", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { file_roles: ["api_route"], methods: ["DELETE"] }, + scope: { check_scope: "changed-files", applies_to: "route" }, + requires: { + auth_helpers: ["requireUser"], + authorization_helpers: ["requireRole", "canAccessProject"], + data_operations: ["delete"] + } + }); + const tenant = SecurityConventionSchema.parse({ + contract_id: "security_api_tenant_scope", + kind: "api_route_requires_tenant_scope", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { file_roles: ["api_route"] }, + scope: { check_scope: "changed-files", applies_to: "route" }, + requires: { + auth_helpers: ["requireUser"], + tenant_helpers: ["scopeProjectToTenant"], + tenant_keys: ["tenantId"], + tenant_sources: ["session"], + data_operations: ["findMany", "delete"] + } + }); + const session = SecurityConventionSchema.parse({ + contract_id: "security_session_trust", + kind: "session_object_must_come_from_trusted_helper", + capability: "deterministic_check", + enforcement_mode: "block", + matcher: { file_roles: ["api_route"] }, + scope: { check_scope: "changed-files", applies_to: "route" }, + requires: { auth_helpers: ["requireUser"] } + }); + + expect(authorization.kind).toBe("api_route_requires_authorization"); + expect(tenant.kind).toBe("api_route_requires_tenant_scope"); + expect(session.kind).toBe("session_object_must_come_from_trusted_helper"); + }); + + it("rejects impossible phase4 proof states", () => { + const proof = { + proof_id: "proof_phase4", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_delete", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [], + capability_status: [], + auth: { required: false, proven: false, proof_kind: "none", trusted_guard_calls: [], dominated_sinks: [], undominated_sinks: [] }, + session_trust: { + required: true, + proven: true, + trusted_sessions: [], + missing_trust: [{ fact_id: "fact_session", variable: "session", reason: "derived_from_request" }] + }, + authorization: { + required: true, + proven: true, + role_or_policy_guards: [{ fact_id: "fact_role", policy_id: "authorization_require_role", roles: ["admin"], permissions: [], subject_var: "session.user" }], + missing: [{ reason: "session_not_trusted", sink_fact_id: "sink_delete" }] + }, + tenant: { + required: true, + proven: true, + tenant_sources: [{ fact_id: "fact_tenant", source: "body", key: "tenantId", trusted: false }], + predicates: [{ fact_id: "fact_predicate", data_operation_fact_id: "fact_delete", tenant_key: "tenantId", predicate_kind: "equality" }], + missing: [{ data_operation_fact_id: "fact_delete", reason: "tenant_source_untrusted" }] + }, + missing_proof: [], + parser_gaps: [], + result: { proof_status: "proven", enforcement_result: "pass", can_block: false, finding_ids: [] } + }; + + expect(() => SecurityBoundaryProofSchema.parse(proof)).toThrow(/phase4 proven proof cannot include missing/i); + }); + + it("validates phase4 parser gaps from engine output", () => { + expect(SecurityParserGapCodeSchema.parse("unsupported_tenant_dynamic_property")).toBe("unsupported_tenant_dynamic_property"); + expect(SecurityParserGapCodeSchema.parse("unsupported_tenant_query_object_alias")).toBe("unsupported_tenant_query_object_alias"); + expect(SecurityParserGapCodeSchema.parse("unsupported_session_nested_destructure")).toBe("unsupported_session_nested_destructure"); + }); + it("rejects impossible request validation proof states", () => { const proof = validSecurityBoundaryProof({ request_validation: { diff --git a/drift v3/packages/engine-contract/src/index.ts b/drift v3/packages/engine-contract/src/index.ts index d720ba87..b01d421e 100644 --- a/drift v3/packages/engine-contract/src/index.ts +++ b/drift v3/packages/engine-contract/src/index.ts @@ -99,6 +99,10 @@ export const EngineFactSchema = z.object({ "middleware_matcher_declared", "middleware_protects_route", "request_input_read", + "session_read", + "tenant_source", + "tenant_guard_called", + "authorization_guard_called", "request_validation_called", "validated_input_used" ]), @@ -299,6 +303,12 @@ const EngineSecurityMissingProofCodeSchema = z.enum([ "request_input_not_validated", "validation_result_not_used", "unknown_validator", + "session_not_trusted", + "authorization_guard_missing", + "authorization_guard_not_dominating_sink", + "tenant_predicate_missing", + "tenant_source_untrusted", + "tenant_predicate_not_bound_to_query", "unsupported_callback_boundary", "unsupported_dynamic_control_flow", "route_binding_unresolved", @@ -315,6 +325,9 @@ const EngineSecurityParserGapSchema = z.object({ "unsupported_dynamic_middleware_matcher", "unsupported_request_input_spread", "unsupported_request_input_destructure", + "unsupported_tenant_dynamic_property", + "unsupported_tenant_query_object_alias", + "unsupported_session_nested_destructure", "unsupported_callback_boundary" ]), file_path: z.string().min(1), @@ -433,6 +446,73 @@ const EngineSecurityBoundaryProofSchema = z.object({ validated_uses: [], unvalidated_uses: [] }), + session_trust: z.object({ + required: z.boolean(), + proven: z.boolean(), + trusted_sessions: z.array(z.object({ + fact_id: z.string().min(1), + variable: z.string().min(1), + source: z.string().min(1).optional(), + trust: z.enum(["trusted", "untrusted", "unknown"]) + }).passthrough()), + missing_trust: z.array(z.object({ + fact_id: z.string().min(1), + variable: z.string().min(1), + reason: z.enum(["derived_from_request", "unknown_helper", "missing_auth_guard", "parser_gap"]) + })) + }).optional().default({ + required: false, + proven: false, + trusted_sessions: [], + missing_trust: [] + }), + authorization: z.object({ + required: z.boolean(), + proven: z.boolean(), + role_or_policy_guards: z.array(z.object({ + fact_id: z.string().min(1), + policy_id: z.string().min(1).optional(), + roles: z.array(z.string().min(1)).optional().default([]), + permissions: z.array(z.string().min(1)).optional().default([]), + resource_var: z.string().min(1).optional(), + subject_var: z.string().min(1).optional() + })), + missing: z.array(z.object({ + reason: z.enum(["no_authorization_guard", "guard_not_dominating_sink", "unknown_policy_helper", "session_not_trusted", "authorization_guard_missing", "authorization_guard_not_dominating_sink"]), + sink_fact_id: z.string().min(1).optional() + })) + }).optional().default({ + required: false, + proven: false, + role_or_policy_guards: [], + missing: [] + }), + tenant: z.object({ + required: z.boolean(), + proven: z.boolean(), + tenant_sources: z.array(z.object({ + fact_id: z.string().min(1), + source: z.enum(["session", "path_param", "header", "body", "query"]), + key: z.string().min(1).optional(), + trusted: z.boolean() + })), + predicates: z.array(z.object({ + fact_id: z.string().min(1), + data_operation_fact_id: z.string().min(1), + tenant_key: z.string().min(1), + predicate_kind: z.enum(["equality", "scoped_helper", "policy_helper"]) + })), + missing: z.array(z.object({ + data_operation_fact_id: z.string().min(1), + reason: z.enum(["no_tenant_predicate", "untrusted_tenant_source", "predicate_not_bound_to_query", "parser_gap", "tenant_predicate_missing", "tenant_source_untrusted", "tenant_predicate_not_bound_to_query"]) + })) + }).optional().default({ + required: false, + proven: false, + tenant_sources: [], + predicates: [], + missing: [] + }), missing_proof: z.array(z.object({ id: z.string().min(1), capability: z.string().min(1), @@ -484,6 +564,35 @@ const EngineSecurityBoundaryProofSchema = z.object({ message: "request validation unvalidated uses require a non-proven proof status" }); } + + if ( + (proof.session_trust.proven && proof.session_trust.missing_trust.length > 0) || + (proof.authorization.proven && proof.authorization.missing.length > 0) || + (proof.tenant.proven && proof.tenant.missing.length > 0) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "phase4 proven proof cannot include missing trust, authorization, or tenant proof" + }); + } + + if (proof.authorization.proven && proof.session_trust.missing_trust.length > 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "authorization proven proof cannot reference untrusted session sources" + }); + } + + if ( + proof.tenant.proven && + proof.tenant.tenant_sources.length > 0 && + proof.tenant.tenant_sources.every((source) => !source.trusted) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "tenant proven proof requires at least one trusted tenant source" + }); + } }); export const EngineSecurityProofEventSchema = z.object({ diff --git a/drift v3/packages/engine-contract/test/security-contract.test.ts b/drift v3/packages/engine-contract/test/security-contract.test.ts index 50633f9c..592a7470 100644 --- a/drift v3/packages/engine-contract/test/security-contract.test.ts +++ b/drift v3/packages/engine-contract/test/security-contract.test.ts @@ -220,6 +220,91 @@ describe("engine security contract schemas", () => { expect(JSON.stringify(event)).not.toContain("cookie="); }); + it("validates phase4 parser gaps from engine output", () => { + const event = parseEngineSecurityProofEvent({ + event: "SecurityProof", + schema_version: "engine.security.proof/v1", + proofs: [{ + proof_id: "proof_phase4_parser_gap", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_get", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_tenant_scope", + kind: "api_route_requires_tenant_scope", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "tenant_scope", + status: "partial", + can_block: true, + parser_gap_ids: ["gap_tenant_dynamic"], + missing_proof_ids: ["missing_tenant"] + }], + auth: { + required: false, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + session_trust: { + required: true, + proven: true, + trusted_sessions: [{ fact_id: "fact_session", variable: "session", source: "auth_result", trust: "trusted" }], + missing_trust: [] + }, + authorization: { + required: false, + proven: false, + role_or_policy_guards: [], + missing: [] + }, + tenant: { + required: true, + proven: false, + tenant_sources: [{ fact_id: "fact_tenant", source: "session", key: "tenantId", trusted: true }], + predicates: [], + missing: [{ data_operation_fact_id: "fact_find_many", reason: "parser_gap" }] + }, + missing_proof: [{ + id: "missing_tenant", + capability: "tenant_scope", + code: "tenant_predicate_missing", + blocks_enforcement: true, + fact_ids: ["fact_find_many"], + graph_edge_ids: [] + }], + parser_gaps: [{ + parser_gap_id: "gap_tenant_dynamic", + capability: "tenant_scope", + code: "unsupported_tenant_dynamic_property", + file_path: "app/api/projects/route.ts", + reason: "Computed tenant predicate key prevents deterministic tenant proof", + affected_contract_kinds: ["api_route_requires_tenant_scope"], + affected_route_ids: ["route_projects_get"], + missing_proof_ids: ["missing_tenant"], + blocks_enforcement: true + }], + result: { + proof_status: "parser_gap", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_tenant"] + } + }] + }); + + expect(event.proofs[0]?.parser_gaps[0]?.code).toBe("unsupported_tenant_dynamic_property"); + expect(JSON.stringify(event)).not.toContain("tenant-"); + }); + it("rejects impossible request validation proof states", () => { const event = { event: "SecurityProof", diff --git a/drift v3/packages/mcp/src/index.ts b/drift v3/packages/mcp/src/index.ts index 354416ae..21e052fb 100644 --- a/drift v3/packages/mcp/src/index.ts +++ b/drift v3/packages/mcp/src/index.ts @@ -1244,6 +1244,9 @@ function securityCapabilitySummary(capabilityReport: ScanCapabilityReport | null .map((entry) => [entry.rule_id, entry])); const middlewareCompleteness = completenessByRule.get("middleware_must_cover_routes"); const requestValidationCompleteness = completenessByRule.get("api_route_requires_request_validation"); + const sessionTrustCompleteness = completenessByRule.get("session_object_must_come_from_trusted_helper"); + const authorizationCompleteness = completenessByRule.get("api_route_requires_authorization"); + const tenantScopeCompleteness = completenessByRule.get("api_route_requires_tenant_scope"); return { middleware_coverage: { certified: certified.has("middleware_coverage"), @@ -1258,6 +1261,27 @@ function securityCapabilitySummary(capabilityReport: ScanCapabilityReport | null missing: missing.has("request_validation_facts"), can_block: Boolean(requestValidationCompleteness?.can_block), complete: Boolean(requestValidationCompleteness?.complete) + }, + session_trust: { + certified: certified.has("session_trust"), + required: required.has("session_trust"), + missing: missing.has("session_trust"), + can_block: Boolean(sessionTrustCompleteness?.can_block), + complete: Boolean(sessionTrustCompleteness?.complete) + }, + authorization: { + certified: certified.has("authorization"), + required: required.has("authorization"), + missing: missing.has("authorization"), + can_block: Boolean(authorizationCompleteness?.can_block), + complete: Boolean(authorizationCompleteness?.complete) + }, + tenant_scope: { + certified: certified.has("tenant_scope"), + required: required.has("tenant_scope"), + missing: missing.has("tenant_scope"), + can_block: Boolean(tenantScopeCompleteness?.can_block), + complete: Boolean(tenantScopeCompleteness?.complete) } }; } @@ -2506,12 +2530,17 @@ function validateConventionKind(kind: ConventionKind | undefined): ConventionKin kind === "api_route_no_direct_data_access" || kind === "api_route_requires_service_delegation" || kind === "api_route_requires_auth_helper" || + kind === "middleware_must_cover_routes" || + kind === "api_route_requires_request_validation" || + kind === "session_object_must_come_from_trusted_helper" || + kind === "api_route_requires_authorization" || + kind === "api_route_requires_tenant_scope" || kind === "test_expected_for_changed_module" || kind === "custom_briefing" ) { return kind; } - throw new Error("kind must be api_route_no_direct_data_access, api_route_requires_service_delegation, api_route_requires_auth_helper, test_expected_for_changed_module, or custom_briefing."); + throw new Error("kind must be a supported accepted convention kind."); } function validateEnforcementCapability(capability: EnforcementCapability | undefined): EnforcementCapability | undefined { diff --git a/drift v3/packages/mcp/src/security-context.ts b/drift v3/packages/mcp/src/security-context.ts index 72015d84..b886ba65 100644 --- a/drift v3/packages/mcp/src/security-context.ts +++ b/drift v3/packages/mcp/src/security-context.ts @@ -19,11 +19,38 @@ interface ValidatedInputUsedValue { sink_kind?: string; } +interface SessionReadValue { + route_id?: string; + trust?: string; +} + +interface AuthorizationGuardValue { + route_id?: string; + guard_id?: string; + policy_id?: string; + roles?: unknown[]; +} + +interface TenantSourceValue { + route_id?: string; + key?: string; + trusted?: boolean; +} + +interface TenantGuardValue { + route_id?: string; + tenant_key?: string; +} + export function buildSecurityContextPayload(storage: DriftStorage, repoId: string, contract: RepoContract) { const latestScan = latestSecurityScan(storage.listScanManifests(repoId)); const facts = latestScan ? storage.listFacts(latestScan.id, { kind: "middleware_protects_route" }) : []; const requestInputFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "request_input_read" }) : []; const validatedUseFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "validated_input_used" }) : []; + const sessionReadFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "session_read" }) : []; + const authorizationGuardFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "authorization_guard_called" }) : []; + const tenantSourceFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "tenant_source" }) : []; + const tenantGuardFacts = latestScan ? storage.listFacts(latestScan.id, { kind: "tenant_guard_called" }) : []; const parserGaps = latestScan ? storage.listParserGaps(repoId, latestScan.id) : []; return { @@ -39,6 +66,16 @@ export function buildSecurityContextPayload(storage: DriftStorage, repoId: strin routes: requestValidationRoutes(requestInputFacts, validatedUseFacts), parser_gaps: requestValidationParserGaps(parserGaps) }, + session_trust: { + routes: sessionTrustRoutes(sessionReadFacts) + }, + authorization: { + routes: authorizationRoutes(authorizationGuardFacts) + }, + tenant_scope: { + routes: tenantScopeRoutes(tenantSourceFacts, tenantGuardFacts), + parser_gaps: tenantParserGaps(parserGaps) + }, redactions: { snippets_included: false, source_content_included: false, @@ -61,7 +98,10 @@ function securityConventions(conventions: AcceptedConvention[]) { .filter((convention) => convention.kind === "middleware_must_cover_routes" || convention.kind === "api_route_requires_auth_helper" || - convention.kind === "api_route_requires_request_validation" + convention.kind === "api_route_requires_request_validation" || + convention.kind === "session_object_must_come_from_trusted_helper" || + convention.kind === "api_route_requires_authorization" || + convention.kind === "api_route_requires_tenant_scope" ) .map((convention) => ({ id: convention.id, @@ -72,6 +112,130 @@ function securityConventions(conventions: AcceptedConvention[]) { })); } +function sessionTrustRoutes(sessionFacts: FactRecord[]) { + const byRoute = new Map(); + + for (const fact of sessionFacts) { + const value = parseSessionReadValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + trusted_source_count: 0, + untrusted_source_count: 0 + }; + if (value.trust === "trusted") { + entry.trusted_source_count += 1; + } else if (value.trust === "untrusted") { + entry.untrusted_source_count += 1; + } + byRoute.set(routeId, entry); + } + + return [...byRoute.values()] + .sort((left, right) => left.route_id.localeCompare(right.route_id)) + .map((entry) => ({ + route_id: entry.route_id, + file_path: entry.file_path, + proof_status: "advisory_only", + advisory_trusted_source_count: entry.trusted_source_count, + advisory_untrusted_source_count: entry.untrusted_source_count + })); +} + +function authorizationRoutes(authorizationFacts: FactRecord[]) { + const byRoute = new Map; + role_count: number; + }>(); + + for (const fact of authorizationFacts) { + const value = parseAuthorizationGuardValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + guard_ids: new Set(), + role_count: 0 + }; + entry.guard_ids.add(value.policy_id ?? value.guard_id ?? fact.name); + entry.role_count += Array.isArray(value.roles) + ? value.roles.filter((role) => typeof role === "string").length + : 0; + byRoute.set(routeId, entry); + } + + return [...byRoute.values()] + .sort((left, right) => left.route_id.localeCompare(right.route_id)) + .map((entry) => ({ + route_id: entry.route_id, + file_path: entry.file_path, + proof_status: "advisory_only", + advisory_guard_ids: [...entry.guard_ids].sort(), + advisory_role_count: entry.role_count + })); +} + +function tenantScopeRoutes(tenantSourceFacts: FactRecord[], tenantGuardFacts: FactRecord[]) { + const byRoute = new Map; + trusted_source_count: number; + predicate_count: number; + }>(); + + for (const fact of tenantSourceFacts) { + const value = parseTenantSourceValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + tenant_keys: new Set(), + trusted_source_count: 0, + predicate_count: 0 + }; + entry.tenant_keys.add(value.key ?? fact.name); + if (value.trusted === true) { + entry.trusted_source_count += 1; + } + byRoute.set(routeId, entry); + } + + for (const fact of tenantGuardFacts) { + const value = parseTenantGuardValue(fact.value); + const routeId = value.route_id ?? `route:${fact.file_path}:unknown`; + const entry = byRoute.get(routeId) ?? { + route_id: routeId, + file_path: fact.file_path, + tenant_keys: new Set(), + trusted_source_count: 0, + predicate_count: 0 + }; + entry.tenant_keys.add(value.tenant_key ?? fact.name); + entry.predicate_count += 1; + byRoute.set(routeId, entry); + } + + return [...byRoute.values()] + .sort((left, right) => left.route_id.localeCompare(right.route_id)) + .map((entry) => ({ + route_id: entry.route_id, + file_path: entry.file_path, + proof_status: "advisory_only", + advisory_tenant_keys: [...entry.tenant_keys].sort(), + advisory_trusted_source_count: entry.trusted_source_count, + advisory_predicate_count: entry.predicate_count + })); +} + function requestValidationRoutes(inputFacts: FactRecord[], validatedUseFacts: FactRecord[]) { const byRoute = new Map(value); +} + +function parseAuthorizationGuardValue(value: string | undefined): AuthorizationGuardValue { + return parseJsonObject(value); +} + +function parseTenantSourceValue(value: string | undefined): TenantSourceValue { + return parseJsonObject(value); +} + +function parseTenantGuardValue(value: string | undefined): TenantGuardValue { + return parseJsonObject(value); +} + +function parseJsonObject(value: string | undefined): T { + if (!value) { + return {} as T; + } + try { + const parsed = JSON.parse(value) as T; + return parsed && typeof parsed === "object" ? parsed : {} as T; + } catch { + return {} as T; + } +} + function middlewareCoverageRoutes(facts: FactRecord[]) { const byPath = new Map + gap.message === "unsupported_tenant_dynamic_property" || + gap.message === "unsupported_tenant_query_object_alias" || + gap.message === "unsupported_session_nested_destructure" + ) + .map((gap) => ({ + reason: gap.message, + blocking: gap.confidence_impact === "blocks_enforcement" + })); +} diff --git a/drift v3/packages/mcp/src/tools.ts b/drift v3/packages/mcp/src/tools.ts index 85ba5a42..7895246c 100644 --- a/drift v3/packages/mcp/src/tools.ts +++ b/drift v3/packages/mcp/src/tools.ts @@ -109,6 +109,9 @@ export const DRIFT_READ_ONLY_MCP_TOOLS: DriftMcpTool[] = [ "api_route_requires_service_delegation", "api_route_requires_auth_helper", "middleware_must_cover_routes", + "session_object_must_come_from_trusted_helper", + "api_route_requires_authorization", + "api_route_requires_tenant_scope", "test_expected_for_changed_module", "custom_briefing" ] diff --git a/drift v3/packages/mcp/test/mcp.test.ts b/drift v3/packages/mcp/test/mcp.test.ts index 31c75248..b38a83f4 100644 --- a/drift v3/packages/mcp/test/mcp.test.ts +++ b/drift v3/packages/mcp/test/mcp.test.ts @@ -1724,6 +1724,212 @@ describe("read-only MCP handlers", () => { expect(JSON.stringify(securityContext)).not.toContain("cookie"); }); + it("exposes phase4 security proof summaries without snippets", async () => { + const databasePath = await seedMcpDatabase(); + const storage = openDriftStorage({ databasePath }); + storage.migrate(); + const phase4Conventions = [{ + id: "security_session_trust", + contract_id: "contract_abc", + kind: "session_object_must_come_from_trusted_helper" as const, + statement: "Session objects must come from accepted auth helpers.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind: "session_object_must_come_from_trusted_helper" as const, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "local-user", + accepted_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }, { + id: "security_api_authorization", + contract_id: "contract_abc", + kind: "api_route_requires_authorization" as const, + statement: "API routes require accepted authorization guard proof.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind: "api_route_requires_authorization" as const, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }], + authorization_helpers: ["requireRole"] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "local-user", + accepted_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }, { + id: "security_api_tenant_scope", + contract_id: "contract_abc", + kind: "api_route_requires_tenant_scope" as const, + statement: "API routes require tenant scoped data access.", + scope: { path_globs: ["apps/web/app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind: "api_route_requires_tenant_scope" as const, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }], + tenant_keys: ["tenantId"], + tenant_sources: ["session"] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "local-user", + accepted_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }]; + for (const convention of phase4Conventions) { + storage.upsertAcceptedConvention("repo_abc", convention); + } + storage.upsertRepoContract({ + id: "contract_abc", + repo_id: "repo_abc", + contract_schema_version: 1, + repo_fingerprint: "repo-fp", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z", + conventions: phase4Conventions, + rejected_inferences: [], + waivers: [], + risky_areas: [], + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.upsertFacts([{ + id: "fact_session", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "session_read", + file_path: "apps/web/app/api/projects/route.ts", + name: "session", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/projects/route.ts:GET", + variable: "session", + source: "auth_result", + trust: "trusted" + }), + imported_name: undefined, + start_line: 3, + end_line: 3, + ...factQuality("scan_abc") + }, { + id: "fact_authz", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "authorization_guard_called", + file_path: "apps/web/app/api/projects/route.ts", + name: "requireRole", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/projects/route.ts:GET", + policy_id: "authorization_require_role", + roles: ["admin"], + subject_var: "session.user" + }), + imported_name: undefined, + start_line: 4, + end_line: 4, + ...factQuality("scan_abc") + }, { + id: "fact_tenant_source", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "tenant_source", + file_path: "apps/web/app/api/projects/route.ts", + name: "tenantId", + value: JSON.stringify({ + route_id: "route:apps/web/app/api/projects/route.ts:GET", + source: "session", + key: "tenantId", + variable: "session.user.tenantId", + trusted: true + }), + imported_name: undefined, + start_line: 5, + end_line: 5, + ...factQuality("scan_abc") + }]); + storage.upsertParserGaps([{ + schema_version: "drift.parser_gap.v1", + gap_id: "parser_gap_tenant_dynamic", + repo_id: "repo_abc", + scan_id: "scan_abc", + kind: "partial_parse", + file_path: "apps/web/app/api/projects/route.ts", + start_line: 8, + end_line: 8, + confidence_impact: "blocks_enforcement", + message: "unsupported_tenant_dynamic_property", + evidence_refs: ["parser_gap_tenant_dynamic"], + created_at: "2026-05-26T00:00:00.000Z" + }]); + storage.close(); + + const securityContext = createReadOnlyMcpHandlers({ databasePath }).get_security_context({ + repo_id: "repo_abc" + } as never) as { + accepted_contracts: Array<{ kind: string }>; + session_trust: { routes: Array<{ proof_status: string; advisory_trusted_source_count: number }> }; + authorization: { routes: Array<{ proof_status: string; advisory_guard_ids: string[]; advisory_role_count: number }> }; + tenant_scope: { + routes: Array<{ proof_status: string; advisory_tenant_keys: string[]; advisory_trusted_source_count: number }>; + parser_gaps: Array<{ reason: string; blocking: boolean }>; + }; + }; + + expect(securityContext.accepted_contracts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "session_object_must_come_from_trusted_helper" }), + expect.objectContaining({ kind: "api_route_requires_authorization" }), + expect.objectContaining({ kind: "api_route_requires_tenant_scope" }) + ])); + expect(securityContext.session_trust.routes).toEqual([expect.objectContaining({ + proof_status: "advisory_only", + advisory_trusted_source_count: 1 + })]); + expect(securityContext.authorization.routes).toEqual([expect.objectContaining({ + proof_status: "advisory_only", + advisory_guard_ids: ["authorization_require_role"], + advisory_role_count: 1 + })]); + expect(securityContext.tenant_scope.routes).toEqual([expect.objectContaining({ + proof_status: "advisory_only", + advisory_tenant_keys: ["tenantId"], + advisory_trusted_source_count: 1 + })]); + expect(securityContext.tenant_scope.parser_gaps).toEqual([ + { reason: "unsupported_tenant_dynamic_property", blocking: true } + ]); + expect(JSON.stringify(securityContext)).not.toContain("session.user.tenantId"); + expect(JSON.stringify(securityContext)).not.toContain("cookie"); + expect(JSON.stringify(securityContext)).not.toContain("request.json()"); + }); + it("includes scan symbol identities in MCP change impact", async () => { const databasePath = await seedMcpDatabase(); const storage = openDriftStorage({ databasePath }); diff --git a/drift v3/packages/query/src/index.ts b/drift v3/packages/query/src/index.ts index 98659972..0233abbb 100644 --- a/drift v3/packages/query/src/index.ts +++ b/drift v3/packages/query/src/index.ts @@ -80,6 +80,23 @@ export interface RepoMapRouteSecurity { status: "not_required" | "not_evaluated" | "missing_proof"; input_sources: string[]; }; + session_trust?: { + status: "advisory_only"; + advisory_session_variables: string[]; + advisory_trusted_source_count: number; + advisory_untrusted_source_count: number; + }; + authorization?: { + status: "advisory_only"; + advisory_guard_ids: string[]; + advisory_role_count: number; + }; + tenant_scope?: { + status: "advisory_only"; + advisory_tenant_keys: string[]; + advisory_trusted_source_count: number; + advisory_predicate_count: number; + }; } export interface GraphRepoMap { @@ -970,7 +987,19 @@ function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | ); const requestInputFacts = fileFacts.filter((fact) => fact.kind === "request_input_read"); const validatedUseFacts = fileFacts.filter((fact) => fact.kind === "validated_input_used"); - if (middlewareProtectionFacts.length === 0 && requestInputFacts.length === 0 && validatedUseFacts.length === 0) { + const sessionReadFacts = fileFacts.filter((fact) => fact.kind === "session_read"); + const authorizationGuardFacts = fileFacts.filter((fact) => fact.kind === "authorization_guard_called"); + const tenantSourceFacts = fileFacts.filter((fact) => fact.kind === "tenant_source"); + const tenantGuardFacts = fileFacts.filter((fact) => fact.kind === "tenant_guard_called"); + if ( + middlewareProtectionFacts.length === 0 && + requestInputFacts.length === 0 && + validatedUseFacts.length === 0 && + sessionReadFacts.length === 0 && + authorizationGuardFacts.length === 0 && + tenantSourceFacts.length === 0 && + tenantGuardFacts.length === 0 + ) { return undefined; } const middlewareIds = middlewareProtectionFacts.map((fact) => { @@ -983,7 +1012,7 @@ function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | ? metadata.protection_kind : fact.imported_name ?? "unknown"; }); - return { + const routeSecurity: RepoMapRouteSecurity = { middleware_coverage: { proven: middlewareProtectionFacts.length > 0, protection_kinds: unique(protectionKinds), @@ -999,6 +1028,49 @@ function routeSecurityFromFacts(fileFacts: FactRecord[]): RepoMapRouteSecurity | })) } }; + if (sessionReadFacts.length > 0) { + routeSecurity.session_trust = { + status: "advisory_only", + advisory_session_variables: unique(sessionReadFacts.map((fact) => fact.name)), + advisory_trusted_source_count: sessionReadFacts.filter((fact) => parseFactValue(fact.value).trust === "trusted").length, + advisory_untrusted_source_count: sessionReadFacts.filter((fact) => parseFactValue(fact.value).trust === "untrusted").length + }; + } + if (authorizationGuardFacts.length > 0) { + routeSecurity.authorization = { + status: "advisory_only", + advisory_guard_ids: unique(authorizationGuardFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.policy_id === "string" + ? metadata.policy_id + : typeof metadata.guard_id === "string" + ? metadata.guard_id + : fact.name; + })), + advisory_role_count: authorizationGuardFacts.reduce((count, fact) => { + const roles = parseFactValue(fact.value).roles; + return count + (Array.isArray(roles) ? roles.filter((role) => typeof role === "string").length : 0); + }, 0) + }; + } + if (tenantSourceFacts.length > 0 || tenantGuardFacts.length > 0) { + routeSecurity.tenant_scope = { + status: "advisory_only", + advisory_tenant_keys: unique([ + ...tenantSourceFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.key === "string" ? metadata.key : fact.name; + }), + ...tenantGuardFacts.map((fact) => { + const metadata = parseFactValue(fact.value); + return typeof metadata.tenant_key === "string" ? metadata.tenant_key : fact.name; + }) + ]), + advisory_trusted_source_count: tenantSourceFacts.filter((fact) => parseFactValue(fact.value).trusted === true).length, + advisory_predicate_count: tenantGuardFacts.length + }; + } + return routeSecurity; } function mergeRouteSecurity( @@ -1011,7 +1083,7 @@ function mergeRouteSecurity( if (!right) { return left; } - return { + const routeSecurity: RepoMapRouteSecurity = { middleware_coverage: { proven: left.middleware_coverage.proven || right.middleware_coverage.proven, protection_kinds: unique([ @@ -1035,6 +1107,10 @@ function mergeRouteSecurity( ]) } }; + routeSecurity.session_trust = left.session_trust ?? right.session_trust; + routeSecurity.authorization = left.authorization ?? right.authorization; + routeSecurity.tenant_scope = left.tenant_scope ?? right.tenant_scope; + return routeSecurity; } function parseFactValue(value: string | undefined): Record { diff --git a/drift v3/packages/query/src/security-boundary-proof.ts b/drift v3/packages/query/src/security-boundary-proof.ts index 04c57365..9ca2cb35 100644 --- a/drift v3/packages/query/src/security-boundary-proof.ts +++ b/drift v3/packages/query/src/security-boundary-proof.ts @@ -23,6 +23,15 @@ export interface SecurityBoundaryProofRouteSummary { request_validation_required: boolean; request_validation_proven: boolean; request_validation_unvalidated_reasons: string[]; + session_trust_required: boolean; + session_trust_proven: boolean; + session_missing_trust_reasons: string[]; + authorization_required: boolean; + authorization_proven: boolean; + authorization_missing_reasons: string[]; + tenant_required: boolean; + tenant_proven: boolean; + tenant_missing_reasons: string[]; proof_status: string; enforcement_result: string; missing_proof_codes: string[]; @@ -59,6 +68,25 @@ export function buildSecurityBoundaryProofReadModel( validated_uses: [], unvalidated_uses: [] }; + const sessionTrust = proof.session_trust ?? { + required: false, + proven: false, + trusted_sessions: [], + missing_trust: [] + }; + const authorization = proof.authorization ?? { + required: false, + proven: false, + role_or_policy_guards: [], + missing: [] + }; + const tenant = proof.tenant ?? { + required: false, + proven: false, + tenant_sources: [], + predicates: [], + missing: [] + }; return { route_id: proof.route.route_id, file_path: proof.route.file_path, @@ -74,6 +102,18 @@ export function buildSecurityBoundaryProofReadModel( request_validation_proven: requestValidation.proven, request_validation_unvalidated_reasons: [...new Set(requestValidation.unvalidated_uses .map((unvalidated) => unvalidated.reason))].sort(), + session_trust_required: sessionTrust.required, + session_trust_proven: sessionTrust.proven, + session_missing_trust_reasons: [...new Set(sessionTrust.missing_trust + .map((missing) => missing.reason))].sort(), + authorization_required: authorization.required, + authorization_proven: authorization.proven, + authorization_missing_reasons: [...new Set(authorization.missing + .map((missing) => missing.reason))].sort(), + tenant_required: tenant.required, + tenant_proven: tenant.proven, + tenant_missing_reasons: [...new Set(tenant.missing + .map((missing) => missing.reason))].sort(), proof_status: proof.result.proof_status, enforcement_result: proof.result.enforcement_result, missing_proof_codes: proof.missing_proof.map((missing) => missing.code), diff --git a/drift v3/packages/query/test/security-boundary-proof.test.ts b/drift v3/packages/query/test/security-boundary-proof.test.ts index 3d8325f1..a2e3fde9 100644 --- a/drift v3/packages/query/test/security-boundary-proof.test.ts +++ b/drift v3/packages/query/test/security-boundary-proof.test.ts @@ -84,6 +84,15 @@ describe("security boundary proof read model", () => { request_validation_required: false, request_validation_proven: false, request_validation_unvalidated_reasons: [], + session_trust_required: false, + session_trust_proven: false, + session_missing_trust_reasons: [], + authorization_required: false, + authorization_proven: false, + authorization_missing_reasons: [], + tenant_required: false, + tenant_proven: false, + tenant_missing_reasons: [], proof_status: "parser_gap", enforcement_result: "block", missing_proof_codes: ["missing_auth_guard"], @@ -168,6 +177,90 @@ describe("security boundary proof read model", () => { expect(JSON.stringify(model)).not.toContain("request.json()"); }); + it("summarizes phase4 proof without synthesizing trust from raw facts", () => { + const model = buildSecurityBoundaryProofReadModel({ + proofs: [{ + proof_id: "proof_phase4", + proof_version: "security-boundary-proof/v1", + route: { + route_id: "route_projects_delete", + file_path: "app/api/projects/route.ts", + file_role: "api_route" + }, + contracts: [{ + contract_id: "security_api_authorization", + kind: "api_route_requires_authorization", + enforcement_mode: "block", + capability: "deterministic_check", + matched: true + }], + capability_status: [{ + name: "authorization", + status: "partial", + can_block: true, + parser_gap_ids: [], + missing_proof_ids: ["missing_authz"] + }], + auth: { + required: false, + proven: false, + proof_kind: "none", + trusted_guard_calls: [], + dominated_sinks: [], + undominated_sinks: [] + }, + session_trust: { + required: true, + proven: false, + trusted_sessions: [], + missing_trust: [{ fact_id: "fact_session", variable: "session", reason: "derived_from_request" }] + }, + authorization: { + required: true, + proven: false, + role_or_policy_guards: [], + missing: [{ reason: "session_not_trusted", sink_fact_id: "sink_delete" }] + }, + tenant: { + required: true, + proven: false, + tenant_sources: [{ fact_id: "fact_tenant", source: "body", key: "tenantId", trusted: false }], + predicates: [], + missing: [{ data_operation_fact_id: "fact_delete", reason: "tenant_source_untrusted" }] + }, + missing_proof: [{ + id: "missing_authz", + capability: "authorization", + code: "session_not_trusted", + blocks_enforcement: true, + fact_ids: ["fact_session"], + graph_edge_ids: [] + }], + parser_gaps: [], + result: { + proof_status: "missing_proof", + enforcement_result: "block", + can_block: true, + finding_ids: ["finding_authz"] + } + }], + findings: [{ finding_id: "finding_authz", title: "missing", lifecycle: "new" }] + }); + + expect(model.routes[0]).toMatchObject({ + session_trust_required: true, + session_trust_proven: false, + authorization_required: true, + authorization_proven: false, + authorization_missing_reasons: ["session_not_trusted"], + tenant_required: true, + tenant_proven: false, + tenant_missing_reasons: ["tenant_source_untrusted"] + }); + expect(JSON.stringify(model)).not.toContain("tenant-"); + expect(JSON.stringify(model)).not.toContain("session="); + }); + it("does not report request validation proven from raw scan facts", () => { const files = fallbackFactRepoMapFiles([{ repo_id: "repo_abc", diff --git a/drift v3/test/e2e/security-tenant-authorization.test.ts b/drift v3/test/e2e/security-tenant-authorization.test.ts new file mode 100644 index 00000000..7d779c74 --- /dev/null +++ b/drift v3/test/e2e/security-tenant-authorization.test.ts @@ -0,0 +1,237 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runCli } from "../../packages/cli/src/index.js"; +import { openDriftStorage } from "../../packages/storage/src/index.js"; + +const tempDirs: string[] = []; +let originalEngineBin: string | undefined; + +async function fixtureRepo(name: string): Promise<{ repoRoot: string; stateRoot: string; diffPath: string }> { + const dir = await mkdtemp(join(tmpdir(), "drift-security-tenant-authorization-")); + tempDirs.push(dir); + const repoRoot = join(dir, "repo"); + const stateRoot = join(dir, "state"); + await cp(resolve("test/fixtures", name), repoRoot, { recursive: true }); + const route = await readFile(join(repoRoot, "app/api/projects/route.ts"), "utf8"); + const diffPath = join(dir, "change.patch"); + await writeFile(diffPath, [ + "diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts", + "--- /dev/null", + "+++ b/app/api/projects/route.ts", + `@@ -0,0 +1,${route.split(/\r?\n/).filter(Boolean).length} @@`, + ...route.trimEnd().split(/\r?\n/).map((line) => `+${line}`), + "" + ].join("\n")); + return { repoRoot, stateRoot, diffPath }; +} + +beforeEach(() => { + originalEngineBin = process.env.DRIFT_ENGINE_BIN; + process.env.DRIFT_ENGINE_BIN = resolve("target/debug/drift-engine"); +}); + +afterEach(async () => { + if (originalEngineBin === undefined) { + delete process.env.DRIFT_ENGINE_BIN; + } else { + process.env.DRIFT_ENGINE_BIN = originalEngineBin; + } + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("security tenant authorization fixture matrix", () => { + it("security tenant authorization fixture matrix proves phase4 trust and gaps", async () => { + const cases = [ + { + name: "security-tenant-missing", + kind: "api_route_requires_tenant_scope", + exitCode: 1, + proof: "tenant", + proven: false, + missingReason: "tenant_predicate_missing", + parserGap: false + }, + { + name: "security-tenant-param-unused", + kind: "api_route_requires_tenant_scope", + exitCode: 1, + proof: "tenant", + proven: false, + missingReason: "tenant_predicate_not_bound_to_query", + parserGap: false + }, + { + name: "security-tenant-bound-to-query", + kind: "api_route_requires_tenant_scope", + exitCode: 0, + proof: "tenant", + proven: true, + parserGap: false + }, + { + name: "security-tenant-untrusted-source", + kind: "api_route_requires_tenant_scope", + exitCode: 1, + proof: "tenant", + proven: false, + missingReason: "tenant_source_untrusted", + parserGap: false + }, + { + name: "security-tenant-parser-gap", + kind: "api_route_requires_tenant_scope", + exitCode: 1, + proof: "tenant", + proven: false, + parserGap: true + }, + { + name: "security-role-missing", + kind: "api_route_requires_authorization", + exitCode: 1, + proof: "authorization", + proven: false, + missingReason: "authorization_guard_missing", + parserGap: false + }, + { + name: "security-role-guard-present", + kind: "api_route_requires_authorization", + exitCode: 0, + proof: "authorization", + proven: true, + parserGap: false + }, + { + name: "security-role-branch-bypass", + kind: "api_route_requires_authorization", + exitCode: 1, + proof: "authorization", + proven: false, + missingReason: "authorization_guard_not_dominating_sink", + parserGap: false + }, + { + name: "security-session-from-request-untrusted", + kind: "session_object_must_come_from_trusted_helper", + exitCode: 1, + proof: "session_trust", + proven: false, + missingReason: "derived_from_request", + parserGap: false + }, + { + name: "security-session-trusted-helper", + kind: "session_object_must_come_from_trusted_helper", + exitCode: 0, + proof: "session_trust", + proven: true, + parserGap: false + } + ] as const; + + for (const entry of cases) { + const { repoRoot, stateRoot, diffPath } = await fixtureRepo(entry.name); + const scan = await runCli([ + "scan", + "--repo-root", repoRoot, + "--state-root", stateRoot, + "--now", "2026-05-26T00:00:00.000Z", + "--json" + ]); + expect(scan.exitCode, `${entry.name} scan stderr:\n${scan.stderr}`).toBe(0); + const scanPayload = JSON.parse(scan.stdout); + + const storage = openDriftStorage({ databasePath: scanPayload.database_path }); + storage.migrate(); + const convention = phase4Convention(entry.kind); + storage.upsertAcceptedConvention(scanPayload.repo.id, convention); + storage.upsertRepoContract({ + id: "contract_security_phase4", + repo_id: scanPayload.repo.id, + contract_schema_version: 1, + repo_fingerprint: scanPayload.repo.fingerprint, + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z", + conventions: [convention], + rejected_inferences: [], + waivers: [], + risky_areas: [], + safe_commands: [], + required_checks: [], + context_egress: { + default_mode: "local_only", + denied_globs: [".env*", "**/*.pem"], + max_snippet_chars: 1200, + allow_full_file_content: false + }, + agent_permissions: [] + }); + storage.close(); + + const check = await runCli([ + "--db", scanPayload.database_path, + "check", + "--repo", scanPayload.repo.id, + "--scope", "changed-hunks", + "--diff-file", diffPath, + "--now", "2026-05-26T00:00:01.000Z", + "--json" + ]); + expect(check.exitCode, `${entry.name} check stderr:\n${check.stderr}\nstdout:\n${check.stdout}`).toBe(entry.exitCode); + const payload = JSON.parse(check.stdout); + const proof = payload.security_boundary_proofs?.[0]; + expect(proof?.[entry.proof], `${entry.name} proof:\n${JSON.stringify(payload)}`).toMatchObject({ + required: true, + proven: entry.proven + }); + if (entry.missingReason) { + expect(JSON.stringify(proof)).toContain(entry.missingReason); + } + expect((proof?.parser_gaps ?? []).length > 0, `${entry.name} parser gap`).toBe(entry.parserGap); + expect(JSON.stringify(payload)).not.toContain("session.user.tenantId"); + expect(JSON.stringify(payload)).not.toContain("session-concrete-value-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("user-actual-id-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("tenant-actual-value"); + expect(JSON.stringify(payload)).not.toContain("header-actual-value-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("cookie-actual-value-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("payload-actual-value-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("raw-sql-tenant-value-should-not-leak"); + expect(JSON.stringify(payload)).not.toContain("request.json()"); + expect(JSON.stringify(payload)).not.toContain("SECRET_VALUE_SHOULD_NOT_LEAK"); + } + }, 60_000); +}); + +function phase4Convention(kind: "api_route_requires_tenant_scope" | "api_route_requires_authorization" | "session_object_must_come_from_trusted_helper") { + return { + id: `security_${kind}`, + contract_id: "contract_security_phase4", + kind, + statement: "Phase 4 security boundary proof is required.", + scope: { path_globs: ["app/api/**/route.ts"], file_roles: ["api_route" as const] }, + matcher: { + kind, + applies_to_file_roles: ["api_route" as const] + }, + requires: { + auth_helpers: [{ guard_id: "auth_require_user", symbol: "requireUser", behavior: "returns_session" }], + authorization_helpers: ["requireRole", "canAccessProject"], + tenant_helpers: ["scopeProjectToTenant"], + tenant_keys: ["tenantId"], + tenant_sources: ["session", "query", "body"], + data_operations: ["findMany", "findUnique", "delete"] + }, + severity: "error" as const, + enforcement_mode: "block" as const, + enforcement_capability: "deterministic_check" as const, + exceptions: [], + evidence_refs: [], + counterexample_refs: [], + accepted_by: "test", + accepted_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }; +} diff --git a/drift v3/test/fixtures/security-role-branch-bypass/app/api/projects/route.ts b/drift v3/test/fixtures/security-role-branch-bypass/app/api/projects/route.ts new file mode 100644 index 00000000..bba97964 --- /dev/null +++ b/drift v3/test/fixtures/security-role-branch-bypass/app/api/projects/route.ts @@ -0,0 +1,12 @@ +import { requireRole, requireUser } from "@/server/auth"; + +const db = { project: { delete: async (_query?: unknown) => ({ id: "project_1" }) } }; + +export async function DELETE(request: Request) { + const session = await requireUser(request); + if (request.headers.get("x-admin") === "true") { + requireRole(session.user, "admin"); + } + const deleted = await db.project.delete({ where: { id: "project_1" } }); + return Response.json({ ok: true, id: deleted.id }); +} diff --git a/drift v3/test/fixtures/security-role-branch-bypass/package.json b/drift v3/test/fixtures/security-role-branch-bypass/package.json new file mode 100644 index 00000000..db5e994b --- /dev/null +++ b/drift v3/test/fixtures/security-role-branch-bypass/package.json @@ -0,0 +1 @@ +{"name":"security-role-branch-bypass","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-role-guard-present/app/api/projects/route.ts b/drift v3/test/fixtures/security-role-guard-present/app/api/projects/route.ts new file mode 100644 index 00000000..735b8e44 --- /dev/null +++ b/drift v3/test/fixtures/security-role-guard-present/app/api/projects/route.ts @@ -0,0 +1,10 @@ +import { requireRole, requireUser } from "@/server/auth"; + +const db = { project: { delete: async (_query?: unknown) => ({ id: "project_1" }) } }; + +export async function DELETE(request: Request) { + const session = await requireUser(request); + requireRole(session.user, "admin"); + const deleted = await db.project.delete({ where: { id: "project_1" } }); + return Response.json({ ok: true, id: deleted.id }); +} diff --git a/drift v3/test/fixtures/security-role-guard-present/package.json b/drift v3/test/fixtures/security-role-guard-present/package.json new file mode 100644 index 00000000..d7ccb588 --- /dev/null +++ b/drift v3/test/fixtures/security-role-guard-present/package.json @@ -0,0 +1 @@ +{"name":"security-role-guard-present","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-role-missing/app/api/projects/route.ts b/drift v3/test/fixtures/security-role-missing/app/api/projects/route.ts new file mode 100644 index 00000000..1d170410 --- /dev/null +++ b/drift v3/test/fixtures/security-role-missing/app/api/projects/route.ts @@ -0,0 +1,9 @@ +import { requireUser } from "@/server/auth"; + +const db = { project: { delete: async (_query?: unknown) => ({ id: "project_1" }) } }; + +export async function DELETE(request: Request) { + const session = await requireUser(request); + const deleted = await db.project.delete({ where: { id: "project_1" } }); + return Response.json({ ok: true, id: deleted.id, session: Boolean(session) }); +} diff --git a/drift v3/test/fixtures/security-role-missing/package.json b/drift v3/test/fixtures/security-role-missing/package.json new file mode 100644 index 00000000..08a05e73 --- /dev/null +++ b/drift v3/test/fixtures/security-role-missing/package.json @@ -0,0 +1 @@ +{"name":"security-role-missing","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-session-from-request-untrusted/app/api/projects/route.ts b/drift v3/test/fixtures/security-session-from-request-untrusted/app/api/projects/route.ts new file mode 100644 index 00000000..ebe69d0a --- /dev/null +++ b/drift v3/test/fixtures/security-session-from-request-untrusted/app/api/projects/route.ts @@ -0,0 +1,4 @@ +export async function GET(request: Request) { + const session = request.headers.get("authorization"); + return Response.json({ ok: Boolean(session) }); +} diff --git a/drift v3/test/fixtures/security-session-from-request-untrusted/package.json b/drift v3/test/fixtures/security-session-from-request-untrusted/package.json new file mode 100644 index 00000000..e2286793 --- /dev/null +++ b/drift v3/test/fixtures/security-session-from-request-untrusted/package.json @@ -0,0 +1 @@ +{"name":"security-session-from-request-untrusted","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-session-trusted-helper/app/api/projects/route.ts b/drift v3/test/fixtures/security-session-trusted-helper/app/api/projects/route.ts new file mode 100644 index 00000000..9f84e9a3 --- /dev/null +++ b/drift v3/test/fixtures/security-session-trusted-helper/app/api/projects/route.ts @@ -0,0 +1,6 @@ +import { requireUser } from "@/server/auth"; + +export async function GET(request: Request) { + const session = await requireUser(request); + return Response.json({ ok: Boolean(session) }); +} diff --git a/drift v3/test/fixtures/security-session-trusted-helper/package.json b/drift v3/test/fixtures/security-session-trusted-helper/package.json new file mode 100644 index 00000000..e47597b8 --- /dev/null +++ b/drift v3/test/fixtures/security-session-trusted-helper/package.json @@ -0,0 +1 @@ +{"name":"security-session-trusted-helper","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-tenant-bound-to-query/app/api/projects/route.ts b/drift v3/test/fixtures/security-tenant-bound-to-query/app/api/projects/route.ts new file mode 100644 index 00000000..e8452df9 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-bound-to-query/app/api/projects/route.ts @@ -0,0 +1,11 @@ +import { requireUser } from "@/server/auth"; + +const db = { project: { findMany: async (_query?: unknown) => [] } }; + +export async function GET(request: Request) { + const session = await requireUser(request); + const projects = await db.project.findMany({ + where: { tenantId: session.user.tenantId } + }); + return Response.json({ ok: true, count: projects.length }); +} diff --git a/drift v3/test/fixtures/security-tenant-bound-to-query/package.json b/drift v3/test/fixtures/security-tenant-bound-to-query/package.json new file mode 100644 index 00000000..a678e876 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-bound-to-query/package.json @@ -0,0 +1 @@ +{"name":"security-tenant-bound-to-query","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-tenant-missing/app/api/projects/route.ts b/drift v3/test/fixtures/security-tenant-missing/app/api/projects/route.ts new file mode 100644 index 00000000..3e03a066 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-missing/app/api/projects/route.ts @@ -0,0 +1,29 @@ +import { requireUser } from "@/server/auth"; + +const db = { project: { findMany: async (_query?: unknown) => [] } }; + +export async function GET(request: Request) { + const session = await requireUser(request); + const concreteSessionValue = "session-concrete-value-should-not-leak"; + const concreteUserId = "user-actual-id-should-not-leak"; + const concreteTenantId = "tenant-actual-value"; + const concreteHeader = request.headers.get("x-tenant-debug") ?? "header-actual-value-should-not-leak"; + const concreteCookie = request.headers.get("cookie") ?? "cookie-actual-value-should-not-leak"; + const payload = await request.json().catch(() => ({ secret: "payload-actual-value-should-not-leak" })); + const rawSql = "select * from projects where tenant_id = 'raw-sql-tenant-value-should-not-leak'"; + const projects = await db.project.findMany(); + return Response.json({ + ok: true, + count: projects.length, + session: Boolean(session), + debug: Boolean( + concreteSessionValue && + concreteUserId && + concreteTenantId && + concreteHeader && + concreteCookie && + payload && + rawSql + ) + }); +} diff --git a/drift v3/test/fixtures/security-tenant-missing/package.json b/drift v3/test/fixtures/security-tenant-missing/package.json new file mode 100644 index 00000000..39110f60 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-missing/package.json @@ -0,0 +1 @@ +{"name":"security-tenant-missing","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-tenant-param-unused/app/api/projects/route.ts b/drift v3/test/fixtures/security-tenant-param-unused/app/api/projects/route.ts new file mode 100644 index 00000000..f2ae48cd --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-param-unused/app/api/projects/route.ts @@ -0,0 +1,12 @@ +import { requireUser } from "@/server/auth"; + +const db = { project: { findMany: async (_query?: unknown) => [] } }; + +export async function GET(request: Request) { + const session = await requireUser(request); + const url = new URL(request.url); + const tenantId = url.searchParams.get("tenantId"); + void tenantId; + const projects = await db.project.findMany(); + return Response.json({ ok: true, count: projects.length, session: Boolean(session) }); +} diff --git a/drift v3/test/fixtures/security-tenant-param-unused/package.json b/drift v3/test/fixtures/security-tenant-param-unused/package.json new file mode 100644 index 00000000..1832d0b3 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-param-unused/package.json @@ -0,0 +1 @@ +{"name":"security-tenant-param-unused","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-tenant-parser-gap/app/api/projects/route.ts b/drift v3/test/fixtures/security-tenant-parser-gap/app/api/projects/route.ts new file mode 100644 index 00000000..817b530a --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-parser-gap/app/api/projects/route.ts @@ -0,0 +1,12 @@ +import { requireUser } from "@/server/auth"; + +const db = { project: { findMany: async (_query?: unknown) => [] } }; + +export async function GET(request: Request) { + const session = await requireUser(request); + const tenantField = "tenantId"; + const projects = await db.project.findMany({ + where: { [tenantField]: session.user.tenantId } + }); + return Response.json({ ok: true, count: projects.length }); +} diff --git a/drift v3/test/fixtures/security-tenant-parser-gap/package.json b/drift v3/test/fixtures/security-tenant-parser-gap/package.json new file mode 100644 index 00000000..2f273800 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-parser-gap/package.json @@ -0,0 +1 @@ +{"name":"security-tenant-parser-gap","private":true,"type":"module"} diff --git a/drift v3/test/fixtures/security-tenant-untrusted-source/app/api/projects/route.ts b/drift v3/test/fixtures/security-tenant-untrusted-source/app/api/projects/route.ts new file mode 100644 index 00000000..8c121571 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-untrusted-source/app/api/projects/route.ts @@ -0,0 +1,7 @@ +const db = { project: { findMany: async (_query?: unknown) => [] } }; + +export async function POST(request: Request) { + const body = await request.json(); + const projects = await db.project.findMany({ where: { tenantId: body.tenantId } }); + return Response.json({ ok: true, count: projects.length }); +} diff --git a/drift v3/test/fixtures/security-tenant-untrusted-source/package.json b/drift v3/test/fixtures/security-tenant-untrusted-source/package.json new file mode 100644 index 00000000..27c85d36 --- /dev/null +++ b/drift v3/test/fixtures/security-tenant-untrusted-source/package.json @@ -0,0 +1 @@ +{"name":"security-tenant-untrusted-source","private":true,"type":"module"}