mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0022 (20260521T201327Z-3848)
This commit is contained in:
parent
b8773a199d
commit
3a6439c5b0
11 changed files with 555 additions and 19 deletions
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `@<AnnotationName>` 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<MiddlewareShape> {
|
||||
let mut out: Vec<MiddlewareShape> = 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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
375
src/dynamic/middleware_demotion.rs
Normal file
375
src/dynamic/middleware_demotion.rs
Normal file
|
|
@ -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<AuthMarkerKind> {
|
||||
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<AuthMarkerKind> = Vec::new();
|
||||
let mut demoting_names: Vec<String> = 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<RunOutcome,
|
|||
// downgrade and emit
|
||||
// [`DifferentialVerdict::ConfirmedProvenOob`].
|
||||
if payload.oob_nonce_slot && outcome.oob_callback_seen {
|
||||
let outcome_record = differential::build_oob_self_confirmed_outcome(
|
||||
let mut outcome_record = differential::build_oob_self_confirmed_outcome(
|
||||
payload.label,
|
||||
&vuln_probes,
|
||||
);
|
||||
middleware_demotion::apply_demotion(
|
||||
&mut outcome_record,
|
||||
spec.framework.as_ref(),
|
||||
spec.lang,
|
||||
);
|
||||
let confirmed =
|
||||
middleware_demotion::is_triggering_verdict(outcome_record.verdict);
|
||||
differential_outcome = Some(outcome_record);
|
||||
true
|
||||
confirmed
|
||||
} else {
|
||||
no_benign_control = true;
|
||||
false
|
||||
|
|
@ -594,10 +602,13 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
|
|||
{
|
||||
outcome_record.verdict = DifferentialVerdict::ConfirmedProvenOob;
|
||||
}
|
||||
let confirmed = matches!(
|
||||
outcome_record.verdict,
|
||||
DifferentialVerdict::Confirmed | DifferentialVerdict::ConfirmedProvenOob
|
||||
middleware_demotion::apply_demotion(
|
||||
&mut outcome_record,
|
||||
spec.framework.as_ref(),
|
||||
spec.lang,
|
||||
);
|
||||
let confirmed =
|
||||
middleware_demotion::is_triggering_verdict(outcome_record.verdict);
|
||||
differential_outcome = Some(outcome_record);
|
||||
confirmed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1107,6 +1107,7 @@ fn build_verdict(
|
|||
}
|
||||
crate::evidence::DifferentialVerdict::Confirmed
|
||||
| crate::evidence::DifferentialVerdict::ConfirmedProvenOob
|
||||
| crate::evidence::DifferentialVerdict::ConfirmedWithKnownGuard
|
||||
| crate::evidence::DifferentialVerdict::NotConfirmed => VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
status: VerifyStatus::NotConfirmed,
|
||||
|
|
|
|||
|
|
@ -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<DifferentialProbeRecord>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Result of a dynamic verification attempt for one finding.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue