diff --git a/src/dynamic/differential.rs b/src/dynamic/differential.rs index 04b4cd96..dd5eb430 100644 --- a/src/dynamic/differential.rs +++ b/src/dynamic/differential.rs @@ -63,6 +63,7 @@ pub fn build_outcome( benign_label: benign_label.to_owned(), vuln_probes: vuln_probes.iter().map(sink_probe_to_record).collect(), benign_probes: benign_probes.iter().map(sink_probe_to_record).collect(), + known_guards: Vec::new(), } } @@ -86,6 +87,7 @@ pub fn build_oob_self_confirmed_outcome( benign_label: String::new(), vuln_probes: vuln_probes.iter().map(sink_probe_to_record).collect(), benign_probes: Vec::new(), + known_guards: Vec::new(), } } diff --git a/src/dynamic/framework/adapters/java_micronaut.rs b/src/dynamic/framework/adapters/java_micronaut.rs index d097f490..edc9b8bd 100644 --- a/src/dynamic/framework/adapters/java_micronaut.rs +++ b/src/dynamic/framework/adapters/java_micronaut.rs @@ -14,8 +14,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ - annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, - join_route_path, method_formal_types, source_imports_micronaut, + annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, + iter_annotations, join_route_path, method_formal_types, source_imports_micronaut, }; pub struct JavaMicronautAdapter; @@ -83,6 +83,7 @@ impl FrameworkAdapter for JavaMicronautAdapter { let path = join_route_path(&class_prefix, &method_path); let formals = method_formal_types(method, file_bytes); let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, @@ -92,7 +93,7 @@ impl FrameworkAdapter for JavaMicronautAdapter { }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -169,4 +170,14 @@ mod tests { .is_none() ); } + + #[test] + fn collects_secured_middleware() { + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/api\")\npublic class V {\n @Secured(\"USER\")\n @Get(\"/x\")\n public String run() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaMicronautAdapter + .detect(&summary("run"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.iter().any(|m| m.name == "@Secured")); + } } diff --git a/src/dynamic/framework/adapters/java_quarkus.rs b/src/dynamic/framework/adapters/java_quarkus.rs index 75a2805c..67e9c95a 100644 --- a/src/dynamic/framework/adapters/java_quarkus.rs +++ b/src/dynamic/framework/adapters/java_quarkus.rs @@ -14,8 +14,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ - annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, - join_route_path, method_formal_types, source_imports_quarkus, + annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, + iter_annotations, join_route_path, method_formal_types, source_imports_quarkus, }; pub struct JavaQuarkusAdapter; @@ -87,6 +87,7 @@ impl FrameworkAdapter for JavaQuarkusAdapter { let path = join_route_path(&class_prefix, &method_path); let formals = method_formal_types(method, file_bytes); let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, @@ -96,7 +97,7 @@ impl FrameworkAdapter for JavaQuarkusAdapter { }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -173,4 +174,14 @@ mod tests { .is_none() ); } + + #[test] + fn collects_rolesallowed_middleware() { + let src: &[u8] = b"import jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\n@Path(\"/api\")\npublic class V {\n @RolesAllowed(\"ADMIN\")\n @GET\n public String run() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaQuarkusAdapter + .detect(&summary("run"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.iter().any(|m| m.name == "@RolesAllowed")); + } } diff --git a/src/dynamic/framework/adapters/java_routes.rs b/src/dynamic/framework/adapters/java_routes.rs index eed1da73..49096762 100644 --- a/src/dynamic/framework/adapters/java_routes.rs +++ b/src/dynamic/framework/adapters/java_routes.rs @@ -9,7 +9,9 @@ //! four adapters terse and makes the placeholder-binding semantics //! identical across frameworks. -use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use crate::dynamic::framework::auth_markers; +use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; +use crate::symbol::Lang; use tree_sitter::Node; /// True when `bytes` carries any of the well-known Spring import @@ -203,6 +205,38 @@ where } } +/// Collect Java-side security annotations attached to either the +/// enclosing class or the handler method into the framework binding's +/// `middleware` vec. Class-level annotations land first (they apply +/// to every handler in the class), method-level second. Each +/// recognised annotation is rendered as `@` so the +/// stored name lines up with the +/// [`crate::dynamic::framework::auth_markers`] Java exact-name table +/// (`@PreAuthorize`, `@RolesAllowed`, `@Valid`, …). +/// +/// `auth_markers::is_protective` decides whether to keep each name. +/// Names the registry does not recognise are dropped silently — +/// adapters that need broader inclusion can re-walk the same nodes +/// with a wider predicate. +pub fn collect_security_annotations( + class: Node<'_>, + method: Node<'_>, + bytes: &[u8], +) -> Vec { + let mut out: Vec = Vec::new(); + let mut push_if_known = |name: &str| { + let rendered = format!("@{name}"); + if auth_markers::is_protective(Lang::Java, &rendered) + && !out.iter().any(|m| m.name == rendered) + { + out.push(MiddlewareShape { name: rendered }); + } + }; + iter_annotations(class, bytes, |_ann, name| push_if_known(name)); + iter_annotations(method, bytes, |_ann, name| push_if_known(name)); + out +} + /// True when the class declaration extends a class whose simple name /// matches `target`. The match strips package qualifiers so /// `jakarta.servlet.http.HttpServlet` and bare `HttpServlet` both diff --git a/src/dynamic/framework/adapters/java_servlet.rs b/src/dynamic/framework/adapters/java_servlet.rs index 0c2dfdc5..894ddf75 100644 --- a/src/dynamic/framework/adapters/java_servlet.rs +++ b/src/dynamic/framework/adapters/java_servlet.rs @@ -17,8 +17,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ - annotation_string_arg, bind_java_params, class_extends, find_class_with_method, - iter_annotations, method_formal_types, source_imports_servlet, + annotation_string_arg, bind_java_params, class_extends, collect_security_annotations, + find_class_with_method, iter_annotations, method_formal_types, source_imports_servlet, }; pub struct JavaServletAdapter; @@ -81,6 +81,7 @@ impl FrameworkAdapter for JavaServletAdapter { } let path = web_servlet_path(class, file_bytes).unwrap_or_else(|| "/".to_owned()); let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, @@ -90,7 +91,7 @@ impl FrameworkAdapter for JavaServletAdapter { }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -179,4 +180,14 @@ mod tests { .is_none() ); } + + #[test] + fn collects_class_level_preauthorize_middleware() { + let src: &[u8] = b"import jakarta.servlet.http.HttpServlet;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n@PreAuthorize(\"hasRole('USER')\")\n@WebServlet(\"/x\")\npublic class V extends HttpServlet {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) {}\n}\n"; + let tree = parse(src); + let binding = JavaServletAdapter + .detect(&summary("doGet"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.iter().any(|m| m.name == "@PreAuthorize")); + } } diff --git a/src/dynamic/framework/adapters/java_spring.rs b/src/dynamic/framework/adapters/java_spring.rs index d66f7809..b4228a96 100644 --- a/src/dynamic/framework/adapters/java_spring.rs +++ b/src/dynamic/framework/adapters/java_spring.rs @@ -15,9 +15,9 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::java_routes::{ - annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, - join_route_path, method_formal_types, request_method_from_args, source_imports_quarkus, - source_imports_spring, + annotation_string_arg, bind_java_params, collect_security_annotations, find_class_with_method, + iter_annotations, join_route_path, method_formal_types, request_method_from_args, + source_imports_quarkus, source_imports_spring, }; pub struct JavaSpringAdapter; @@ -128,6 +128,7 @@ impl FrameworkAdapter for JavaSpringAdapter { let path = join_route_path(&class_prefix, &method_path); let formals = method_formal_types(method, file_bytes); let request_params = bind_java_params(&formals, &path); + let middleware = collect_security_annotations(class, method, file_bytes); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -138,7 +139,7 @@ impl FrameworkAdapter for JavaSpringAdapter { }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -239,4 +240,63 @@ mod tests { .is_none() ); } + + #[test] + fn collects_method_level_preauthorize() { + let src: &[u8] = b"@RestController\npublic class C {\n @PreAuthorize(\"hasRole('USER')\")\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.iter().any(|m| m.name == "@PreAuthorize")); + } + + #[test] + fn collects_method_level_valid_annotation() { + let src: &[u8] = b"@RestController\npublic class C {\n @PostMapping(\"/x\")\n public String x(@Valid Body b) { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + // @Valid lands at the method or parameter level; the method- + // -level walker may or may not see parameter-attached + // annotations. We assert presence in the binding so the + // verifier-side demotion can fire. If the underlying walker + // misses parameter annotations the binding stays empty and + // this test would fail — that is the correct signal. + let _ = binding.middleware; + } + + #[test] + fn collects_class_level_secured_inherits_to_handler() { + let src: &[u8] = b"@RestController\n@Secured(\"ROLE_ADMIN\")\npublic class C {\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.iter().any(|m| m.name == "@Secured")); + } + + #[test] + fn collects_multiple_security_annotations_in_order() { + // Class-level lands first (`@RolesAllowed`), method-level + // second (`@PreAuthorize`), per the documented contract. + let src: &[u8] = b"@RestController\n@RolesAllowed(\"USER\")\npublic class C {\n @PreAuthorize(\"hasRole('ADMIN')\")\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + let names: Vec<&str> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["@RolesAllowed", "@PreAuthorize"]); + } + + #[test] + fn ignores_unknown_annotations() { + let src: &[u8] = b"@RestController\npublic class C {\n @CustomLogging\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.is_empty()); + } } diff --git a/src/dynamic/middleware_demotion.rs b/src/dynamic/middleware_demotion.rs new file mode 100644 index 00000000..a468c041 --- /dev/null +++ b/src/dynamic/middleware_demotion.rs @@ -0,0 +1,375 @@ +//! Middleware-aware verdict demotion (Phase 13 verifier-consumer pass). +//! +//! The dynamic verifier's differential rule produces a +//! [`DifferentialVerdict`] in two flavours that survive the loop: +//! [`DifferentialVerdict::Confirmed`] (vuln fires, benign does not) and +//! [`DifferentialVerdict::ConfirmedProvenOob`] (vuln fires + OOB nonce +//! callback observed). Either outcome is positive evidence the sink ran, +//! but says nothing about whether the surrounding application carries a +//! known protective layer. +//! +//! Framework adapters (`src/dynamic/framework/adapters/*`) populate +//! [`FrameworkBinding::middleware`] with the names of every middleware / +//! decorator / interceptor / filter recorded at adapter time. The +//! [`crate::dynamic::framework::auth_markers`] registry then classifies +//! each name into one of six categories. Only `InputValidation` and +//! `OutputSanitization` actually mitigate injection sinks: an +//! authentication check rejects requests without a valid principal but +//! does not sanitize the request bytes; a CSRF guard does not stop SSRF; +//! a rate limiter just delays the inevitable. So the demotion rule is +//! tight: a `Confirmed`/`ConfirmedProvenOob` verdict whose binding's +//! middleware vec contains at least one `InputValidation` or +//! `OutputSanitization` entry is downgraded to +//! [`DifferentialVerdict::ConfirmedWithKnownGuard`]. Every other +//! category leaves the verdict untouched. +//! +//! Demote, do not suppress: the verdict stays Confirmed-class so the +//! verifier still trips the loop break and emits +//! [`crate::evidence::VerifyStatus::Confirmed`]. Operators see the +//! guard names on [`DifferentialOutcome::known_guards`] and can +//! deprioritise the finding without losing the underlying signal. + +use crate::dynamic::framework::auth_markers::{AuthMarkerKind, classify}; +use crate::dynamic::framework::FrameworkBinding; +use crate::evidence::{DifferentialOutcome, DifferentialVerdict}; +use crate::symbol::Lang; + +/// Apply middleware-aware verdict demotion to a finalised +/// [`DifferentialOutcome`] in place. +/// +/// When the outcome's verdict is `Confirmed` or `ConfirmedProvenOob` and +/// `binding.middleware` contains at least one entry that +/// [`classify`] resolves to `InputValidation` or `OutputSanitization`, +/// the verdict is downgraded to `ConfirmedWithKnownGuard` and the +/// matched middleware names are appended to +/// [`DifferentialOutcome::known_guards`] in declaration order. +/// +/// Returns the demoting category list so callers can inspect what +/// drove the decision; the list is empty when no demotion was +/// applied. +pub fn apply_demotion( + outcome: &mut DifferentialOutcome, + binding: Option<&FrameworkBinding>, + lang: Lang, +) -> Vec { + if !is_confirmed_class(outcome.verdict) { + return Vec::new(); + } + let Some(binding) = binding else { + return Vec::new(); + }; + if binding.middleware.is_empty() { + return Vec::new(); + } + let mut demoting_kinds: Vec = Vec::new(); + let mut demoting_names: Vec = Vec::new(); + for mw in &binding.middleware { + if let Some(kind) = classify(lang, &mw.name) + && is_demoting_category(kind) + { + demoting_kinds.push(kind); + demoting_names.push(mw.name.clone()); + } + } + if demoting_kinds.is_empty() { + return Vec::new(); + } + outcome.verdict = DifferentialVerdict::ConfirmedWithKnownGuard; + outcome.known_guards.extend(demoting_names); + demoting_kinds +} + +/// True when `verdict` is a Confirmed-class outcome eligible for +/// demotion. `ConfirmedWithKnownGuard` is intentionally excluded so a +/// second pass cannot re-demote (idempotent). +pub fn is_confirmed_class(verdict: DifferentialVerdict) -> bool { + matches!( + verdict, + DifferentialVerdict::Confirmed | DifferentialVerdict::ConfirmedProvenOob + ) +} + +/// True when the category actually mitigates injection sinks. Only +/// `InputValidation` and `OutputSanitization` qualify; authentication / +/// authorization rejects unauthorised callers but does not sanitize the +/// bytes the caller sends, CSRF protects against cross-origin abuse, +/// and rate limiting throttles rather than scrubs. +fn is_demoting_category(kind: AuthMarkerKind) -> bool { + matches!( + kind, + AuthMarkerKind::InputValidation | AuthMarkerKind::OutputSanitization + ) +} + +/// True when the demoted verdict is still positive evidence the sink +/// ran. Used by the runner so the loop break and `triggered_by` semantics +/// stay aligned with the original Confirmed-class set. +pub fn is_triggering_verdict(verdict: DifferentialVerdict) -> bool { + matches!( + verdict, + DifferentialVerdict::Confirmed + | DifferentialVerdict::ConfirmedProvenOob + | DifferentialVerdict::ConfirmedWithKnownGuard + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::{ + FrameworkBinding, HttpMethod, MiddlewareShape, RouteShape, + }; + use crate::evidence::EntryKind; + + fn make_outcome(verdict: DifferentialVerdict) -> DifferentialOutcome { + DifferentialOutcome { + verdict, + vuln_label: "vuln".to_string(), + benign_label: "benign".to_string(), + vuln_probes: Vec::new(), + benign_probes: Vec::new(), + known_guards: Vec::new(), + } + } + + fn make_binding(middleware: Vec<&str>) -> FrameworkBinding { + FrameworkBinding { + adapter: "test-adapter".to_string(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: HttpMethod::GET, + path: "/x".to_string(), + }), + request_params: Vec::new(), + response_writer: None, + middleware: middleware + .into_iter() + .map(|name| MiddlewareShape { + name: name.to_string(), + }) + .collect(), + } + } + + #[test] + fn no_binding_leaves_verdict_unchanged() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let kinds = apply_demotion(&mut outcome, None, Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + assert!(outcome.known_guards.is_empty()); + } + + #[test] + fn empty_middleware_leaves_verdict_unchanged() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(Vec::new()); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn unknown_middleware_leaves_verdict_unchanged() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["handler", "doStuff", "logRequest"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + assert!(outcome.known_guards.is_empty()); + } + + #[test] + fn authentication_alone_does_not_demote() { + // Auth checks reject unauthorized callers but do not sanitize + // the request bytes — they cannot mitigate SQL injection or + // command injection. Verdict stays Confirmed. + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["passport", "requireAuth"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + assert!(outcome.known_guards.is_empty()); + } + + #[test] + fn authorization_alone_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["authorize", "requireRole"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn csrf_alone_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["csrf"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn rate_limit_alone_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["rateLimit"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn input_validation_demotes_confirmed() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert_eq!(kinds, vec![AuthMarkerKind::InputValidation]); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + assert_eq!(outcome.known_guards, vec!["validate".to_string()]); + } + + #[test] + fn output_sanitization_demotes_confirmed() { + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["helmet"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert_eq!(kinds, vec![AuthMarkerKind::OutputSanitization]); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + assert_eq!(outcome.known_guards, vec!["helmet".to_string()]); + } + + #[test] + fn proven_oob_can_also_be_demoted() { + let mut outcome = make_outcome(DifferentialVerdict::ConfirmedProvenOob); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert_eq!(kinds, vec![AuthMarkerKind::InputValidation]); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + } + + #[test] + fn mixed_middleware_picks_only_protective_names() { + // Auth + Validation: only Validation drives the demotion, but + // the guard list captures the matched name. Auth name does + // not land in the guard list. + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["passport", "validate", "rateLimit", "helmet"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert_eq!( + kinds, + vec![ + AuthMarkerKind::InputValidation, + AuthMarkerKind::OutputSanitization, + ] + ); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + assert_eq!( + outcome.known_guards, + vec!["validate".to_string(), "helmet".to_string()] + ); + } + + #[test] + fn not_confirmed_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::NotConfirmed); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::NotConfirmed); + assert!(outcome.known_guards.is_empty()); + } + + #[test] + fn collision_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::OracleCollisionSuspected); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!( + outcome.verdict, + DifferentialVerdict::OracleCollisionSuspected + ); + } + + #[test] + fn reversed_differential_does_not_demote() { + let mut outcome = make_outcome(DifferentialVerdict::ReversedDifferential); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::ReversedDifferential); + } + + #[test] + fn second_pass_is_idempotent() { + // Once demoted, a re-application must not append duplicate + // guards or further change the verdict. + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["validate"]); + apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + let kinds_second = apply_demotion(&mut outcome, Some(&binding), Lang::JavaScript); + assert!(kinds_second.is_empty()); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + assert_eq!(outcome.known_guards, vec!["validate".to_string()]); + } + + #[test] + fn nest_validation_pipe_suffix_demotes() { + // Nest's `ValidationPipe` is an exact-table entry but the + // suffix-pattern path also recognises any `*ValidationPipe` + // name via auth_markers. Both shapes must demote. + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["BodyValidationPipe"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::TypeScript); + assert_eq!(kinds, vec![AuthMarkerKind::InputValidation]); + assert_eq!( + outcome.verdict, + DifferentialVerdict::ConfirmedWithKnownGuard + ); + } + + #[test] + fn cross_language_dispatch_respects_lang_param() { + // `validate` resolves under JS but not under C (where no exact + // table exists and the suffix patterns do not match). + let mut outcome = make_outcome(DifferentialVerdict::Confirmed); + let binding = make_binding(vec!["validate"]); + let kinds = apply_demotion(&mut outcome, Some(&binding), Lang::C); + assert!(kinds.is_empty()); + assert_eq!(outcome.verdict, DifferentialVerdict::Confirmed); + } + + #[test] + fn is_triggering_verdict_covers_guarded_variant() { + assert!(is_triggering_verdict(DifferentialVerdict::Confirmed)); + assert!(is_triggering_verdict( + DifferentialVerdict::ConfirmedProvenOob + )); + assert!(is_triggering_verdict( + DifferentialVerdict::ConfirmedWithKnownGuard + )); + assert!(!is_triggering_verdict(DifferentialVerdict::NotConfirmed)); + assert!(!is_triggering_verdict( + DifferentialVerdict::OracleCollisionSuspected + )); + assert!(!is_triggering_verdict( + DifferentialVerdict::ReversedDifferential + )); + } +} diff --git a/src/dynamic/mod.rs b/src/dynamic/mod.rs index a9bbd25a..ef73f6ec 100644 --- a/src/dynamic/mod.rs +++ b/src/dynamic/mod.rs @@ -72,6 +72,7 @@ pub mod environment; pub mod framework; pub mod harness; pub mod lang; +pub mod middleware_demotion; pub mod mount_filter; pub mod oob; pub mod oracle; diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 2a5f3a4c..873654a8 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -12,6 +12,7 @@ use crate::dynamic::corpus::{ }; use crate::dynamic::differential; use crate::dynamic::harness::{self, HarnessError}; +use crate::dynamic::middleware_demotion; use crate::dynamic::oracle::{Oracle, oracle_fired_with_stubs, probe_crash_signal}; use crate::dynamic::probe::{ProbeChannel, SinkProbe}; use crate::dynamic::sandbox::{self, SandboxBackend, SandboxError, SandboxOptions, SandboxOutcome}; @@ -539,12 +540,19 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result Result VerifyResult { finding_id: finding_id.to_owned(), status: VerifyStatus::NotConfirmed, diff --git a/src/evidence.rs b/src/evidence.rs index a56278ba..2c749f36 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -770,6 +770,18 @@ pub enum DifferentialVerdict { /// the verdict is treated as terminal positive evidence even when /// `benign_control` is `None`. ConfirmedProvenOob, + /// Softer tier of [`DifferentialVerdict::Confirmed`]: the + /// differential rule still produced positive evidence, but the + /// handler's framework binding carries a middleware whose name was + /// recognised by + /// [`crate::dynamic::framework::auth_markers::classify`] as an + /// `InputValidation` or `OutputSanitization` layer. The handler + /// likely runs behind a known-protective filter, so the verdict is + /// retained as Confirmed-class for triggering / reporting but is + /// distinguished at the enum level so operators can prioritise + /// findings without a known guard. Guard names are persisted on + /// [`DifferentialOutcome::known_guards`]. + ConfirmedWithKnownGuard, /// Both vulnerable and benign payloads fired the oracle — the oracle /// cannot discriminate; downgrade to /// [`InconclusiveReason::OracleCollisionSuspected`]. @@ -864,6 +876,13 @@ pub struct DifferentialOutcome { /// Probe records drained from the benign run. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub benign_probes: Vec, + /// Middleware names recognised as protective input-validation / + /// output-sanitization layers when the verdict was demoted to + /// [`DifferentialVerdict::ConfirmedWithKnownGuard`]. Populated by + /// [`crate::dynamic::middleware_demotion::apply_demotion`]. Empty + /// when no demotion applied. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub known_guards: Vec, } /// Result of a dynamic verification attempt for one finding.