mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0003 (20260516T052512Z-20f8)
This commit is contained in:
parent
282acddbbf
commit
678f0f5d48
35 changed files with 737 additions and 109 deletions
278
src/auth_analysis/auth_markers.rs
Normal file
278
src/auth_analysis/auth_markers.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
//! Canonical per-framework authentication-marker registry.
|
||||
//!
|
||||
//! Both the Phase 22 surface probes (`src/surface/lang/*.rs`) and the
|
||||
//! auth-analysis recogniser consult this module so a marker that is
|
||||
//! known to one side cannot drift away from the other. Each constant
|
||||
//! is a flat `&[&str]` of identifier shapes that signal a route is
|
||||
//! gated behind authentication; surface probes match the leaf segment
|
||||
//! of a decorator / middleware / extractor identifier
|
||||
//! (case-insensitive), and the auth analyser folds these into its
|
||||
//! per-language `login_guard_names` / `authorization_check_names`
|
||||
//! tables via [`router_auth_markers_for_lang`].
|
||||
//!
|
||||
//! The lists were lifted verbatim from the per-probe constants that
|
||||
//! shipped with Phase 22; further additions land here and propagate to
|
||||
//! every consumer at once.
|
||||
//!
|
||||
//! Lookups: prefer [`is_router_auth_marker`] for the framework-aware
|
||||
//! dispatch, fall back to [`is_known_router_auth_marker`] when the
|
||||
//! framework is not yet identified at the call site.
|
||||
|
||||
use crate::symbol::Lang;
|
||||
|
||||
/// Frameworks the surface probes recognise. Distinct from
|
||||
/// [`crate::surface::Framework`] (which carries pretty-print metadata)
|
||||
/// so this module stays free of surface-layer types and can be
|
||||
/// imported by `auth_analysis::extract` without a circular dep.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AuthFramework {
|
||||
Flask,
|
||||
FastApi,
|
||||
Django,
|
||||
Spring,
|
||||
JavaServlet,
|
||||
Quarkus,
|
||||
Express,
|
||||
Koa,
|
||||
Gin,
|
||||
ActixWeb,
|
||||
Axum,
|
||||
}
|
||||
|
||||
/// Flask (`@login_required`, `@requires_auth`, …).
|
||||
pub const FLASK_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_login",
|
||||
];
|
||||
|
||||
/// FastAPI (`Depends(get_current_user)`, `@login_required`, …).
|
||||
pub const FASTAPI_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_auth",
|
||||
"require_login",
|
||||
"current_user",
|
||||
];
|
||||
|
||||
/// Django (`@login_required`, `@permission_required`, …).
|
||||
pub const DJANGO_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"user_passes_test",
|
||||
"staff_member_required",
|
||||
"csrf_protect",
|
||||
"require_authenticated",
|
||||
"auth_required",
|
||||
];
|
||||
|
||||
/// Spring (`@PreAuthorize`, `@Secured`, …).
|
||||
pub const SPRING_ANNOTATIONS: &[&str] = &[
|
||||
"PreAuthorize",
|
||||
"PostAuthorize",
|
||||
"Secured",
|
||||
"RolesAllowed",
|
||||
"AuthenticationPrincipal",
|
||||
];
|
||||
|
||||
/// Java Servlet / JAX-RS (`@RolesAllowed`, `@RequiresAuthentication`, …).
|
||||
pub const SERVLET_ANNOTATIONS: &[&str] = &[
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
"RequiresUser",
|
||||
];
|
||||
|
||||
/// Quarkus (`@Authenticated`, `@RolesAllowed`, …).
|
||||
pub const QUARKUS_ANNOTATIONS: &[&str] = &[
|
||||
"Authenticated",
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
];
|
||||
|
||||
/// Express middleware (`app.use(requireAuth)`, `passport.authenticate`, …).
|
||||
pub const EXPRESS_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"ensureLoggedIn",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
];
|
||||
|
||||
/// Koa middleware.
|
||||
pub const KOA_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
"koaJwt",
|
||||
];
|
||||
|
||||
/// Gin middleware (`router.Use(AuthRequired())`, `jwt.JWT()`, …).
|
||||
pub const GIN_MIDDLEWARES: &[&str] = &[
|
||||
"AuthRequired",
|
||||
"JWT",
|
||||
"JWTAuth",
|
||||
"Auth",
|
||||
"RequireAuth",
|
||||
"RequireUser",
|
||||
"VerifyToken",
|
||||
"BasicAuth",
|
||||
];
|
||||
|
||||
/// actix-web extractors (`Identity`, `BearerAuth`, …).
|
||||
pub const ACTIX_EXTRACTORS: &[&str] = &[
|
||||
"Identity",
|
||||
"BearerAuth",
|
||||
"BasicAuth",
|
||||
"JwtClaims",
|
||||
"Authenticated",
|
||||
"User",
|
||||
];
|
||||
|
||||
/// axum extractors (`Extension<User>`, `BearerAuth`, …).
|
||||
pub const AXUM_EXTRACTORS: &[&str] = &[
|
||||
"Extension<User",
|
||||
"BearerAuth",
|
||||
"RequireAuth",
|
||||
"AuthenticatedUser",
|
||||
"JwtClaims",
|
||||
];
|
||||
|
||||
/// Per-framework marker list. Returns the empty slice when the
|
||||
/// framework is not registered yet.
|
||||
pub fn markers_for(framework: AuthFramework) -> &'static [&'static str] {
|
||||
match framework {
|
||||
AuthFramework::Flask => FLASK_DECORATORS,
|
||||
AuthFramework::FastApi => FASTAPI_DECORATORS,
|
||||
AuthFramework::Django => DJANGO_DECORATORS,
|
||||
AuthFramework::Spring => SPRING_ANNOTATIONS,
|
||||
AuthFramework::JavaServlet => SERVLET_ANNOTATIONS,
|
||||
AuthFramework::Quarkus => QUARKUS_ANNOTATIONS,
|
||||
AuthFramework::Express => EXPRESS_MIDDLEWARES,
|
||||
AuthFramework::Koa => KOA_MIDDLEWARES,
|
||||
AuthFramework::Gin => GIN_MIDDLEWARES,
|
||||
AuthFramework::ActixWeb => ACTIX_EXTRACTORS,
|
||||
AuthFramework::Axum => AXUM_EXTRACTORS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive whole-string match against the per-framework list.
|
||||
pub fn is_router_auth_marker(framework: AuthFramework, marker: &str) -> bool {
|
||||
let m = marker.trim();
|
||||
markers_for(framework)
|
||||
.iter()
|
||||
.any(|cand| cand.eq_ignore_ascii_case(m))
|
||||
}
|
||||
|
||||
/// Loose match against every framework's list. Used when the call
|
||||
/// site has the language but not the specific framework — e.g. an
|
||||
/// auth-analyser folding "is this a known router-level guard?" into a
|
||||
/// per-language ruleset where the framework split is opaque.
|
||||
pub fn is_known_router_auth_marker(marker: &str) -> bool {
|
||||
let m = marker.trim();
|
||||
[
|
||||
FLASK_DECORATORS,
|
||||
FASTAPI_DECORATORS,
|
||||
DJANGO_DECORATORS,
|
||||
SPRING_ANNOTATIONS,
|
||||
SERVLET_ANNOTATIONS,
|
||||
QUARKUS_ANNOTATIONS,
|
||||
EXPRESS_MIDDLEWARES,
|
||||
KOA_MIDDLEWARES,
|
||||
GIN_MIDDLEWARES,
|
||||
ACTIX_EXTRACTORS,
|
||||
AXUM_EXTRACTORS,
|
||||
]
|
||||
.iter()
|
||||
.any(|list| list.iter().any(|cand| cand.eq_ignore_ascii_case(m)))
|
||||
}
|
||||
|
||||
/// Every router-auth marker the canonical registry knows for `lang`.
|
||||
/// Used by `auth_analysis::config::default_for` to seed
|
||||
/// `login_guard_names` so a marker added here propagates into the
|
||||
/// per-language guard list without a second edit.
|
||||
pub fn router_auth_markers_for_lang(lang: Lang) -> Vec<&'static str> {
|
||||
let lists: &[&[&str]] = match lang {
|
||||
Lang::Python => &[FLASK_DECORATORS, FASTAPI_DECORATORS, DJANGO_DECORATORS],
|
||||
Lang::Java => &[SPRING_ANNOTATIONS, SERVLET_ANNOTATIONS, QUARKUS_ANNOTATIONS],
|
||||
Lang::JavaScript | Lang::TypeScript => &[EXPRESS_MIDDLEWARES, KOA_MIDDLEWARES],
|
||||
Lang::Go => &[GIN_MIDDLEWARES],
|
||||
Lang::Rust => &[ACTIX_EXTRACTORS, AXUM_EXTRACTORS],
|
||||
_ => &[],
|
||||
};
|
||||
let mut out: Vec<&'static str> = lists.iter().flat_map(|l| l.iter().copied()).collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flask_login_required_resolves_case_insensitively() {
|
||||
assert!(is_router_auth_marker(AuthFramework::Flask, "login_required"));
|
||||
assert!(is_router_auth_marker(AuthFramework::Flask, "Login_Required"));
|
||||
assert!(!is_router_auth_marker(AuthFramework::Flask, "something_else"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_preauthorize_resolves() {
|
||||
assert!(is_router_auth_marker(AuthFramework::Spring, "PreAuthorize"));
|
||||
assert!(!is_router_auth_marker(AuthFramework::Spring, "GetMapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marker_matches_across_frameworks() {
|
||||
// `RolesAllowed` shows up in Spring, Servlet, and Quarkus —
|
||||
// the framework-agnostic helper finds it regardless.
|
||||
assert!(is_known_router_auth_marker("RolesAllowed"));
|
||||
assert!(is_known_router_auth_marker("login_required"));
|
||||
assert!(!is_known_router_auth_marker("not_an_auth_marker_xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_router_markers_cover_every_framework() {
|
||||
let markers = router_auth_markers_for_lang(Lang::Python);
|
||||
for &decorator in FLASK_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing flask: {decorator}");
|
||||
}
|
||||
for &decorator in FASTAPI_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing fastapi: {decorator}");
|
||||
}
|
||||
for &decorator in DJANGO_DECORATORS {
|
||||
assert!(markers.contains(&decorator), "missing django: {decorator}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_markers_for_unknown_lang_is_empty() {
|
||||
assert!(router_auth_markers_for_lang(Lang::Ruby).is_empty());
|
||||
assert!(router_auth_markers_for_lang(Lang::Php).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
//! - [`sql_semantics`]: ACL-join and `user_id`-predicate detection without a
|
||||
//! SQL parser
|
||||
|
||||
pub mod auth_markers;
|
||||
pub mod checks;
|
||||
pub mod config;
|
||||
pub mod extract;
|
||||
|
|
|
|||
|
|
@ -446,6 +446,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ impl CompositeReverifier for DefaultCompositeReverifier {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,6 +254,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -516,6 +516,26 @@ pub enum ReplayResult {
|
|||
},
|
||||
}
|
||||
|
||||
/// Tri-state map of [`ReplayResult`] onto the eval-corpus
|
||||
/// `VerifyResult::replay_stable` field shape.
|
||||
///
|
||||
/// * `Some(true)` — replay matched the recorded outcome.
|
||||
/// * `Some(false)` — replay diverged or aborted in a way that the M7
|
||||
/// Gate-5 inversion treats as instability.
|
||||
/// * `None` — replay was not informative (toolchain mismatched, docker
|
||||
/// unavailable, or the bundle had no `reproduce.sh`). The corpus
|
||||
/// tabulator treats `None` as "no signal" and excludes the row from
|
||||
/// the per-cell `stable_replays` numerator.
|
||||
pub fn replay_stability(result: &ReplayResult) -> Option<bool> {
|
||||
match result {
|
||||
ReplayResult::Pass => Some(true),
|
||||
ReplayResult::Mismatch | ReplayResult::UnexpectedError { .. } => Some(false),
|
||||
ReplayResult::DockerUnavailable
|
||||
| ReplayResult::ToolchainMismatch
|
||||
| ReplayResult::ScriptInvocationFailed { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 28 — Track H.3. Run `reproduce.sh` in `bundle_root` and map the
|
||||
/// shell exit code into a [`ReplayResult`].
|
||||
///
|
||||
|
|
@ -648,6 +668,8 @@ mod tests {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -780,6 +802,28 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_stability_maps_to_eval_corpus_tristate() {
|
||||
// The eval-corpus tabulator wants Pass → stable, anything that
|
||||
// looks like instability → unstable, and infra-blocked variants
|
||||
// → no signal (None) so the per-cell stable_replays denominator
|
||||
// is not inflated by a row that never had a chance to replay.
|
||||
assert_eq!(replay_stability(&ReplayResult::Pass), Some(true));
|
||||
assert_eq!(replay_stability(&ReplayResult::Mismatch), Some(false));
|
||||
assert_eq!(
|
||||
replay_stability(&ReplayResult::UnexpectedError { exit_code: 9 }),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(replay_stability(&ReplayResult::DockerUnavailable), None);
|
||||
assert_eq!(replay_stability(&ReplayResult::ToolchainMismatch), None);
|
||||
assert_eq!(
|
||||
replay_stability(&ReplayResult::ScriptInvocationFailed {
|
||||
message: "missing".into()
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_bundle_reports_missing_script() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -502,6 +502,51 @@ pub fn read_events(path: &Path) -> Result<Vec<serde_json::Value>, TelemetryReadE
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Scan the `verify_feedback` records in an events log for the given
|
||||
/// finding id and return the matching `VerifyResult::wrong` value.
|
||||
///
|
||||
/// * `Some(true)` — most-recent feedback for this finding was
|
||||
/// `wrong:<reason>`.
|
||||
/// * `Some(false)` — most-recent feedback was `right`.
|
||||
/// * `None` — no feedback recorded for this finding.
|
||||
///
|
||||
/// Multiple records for the same finding collapse to the **last** one
|
||||
/// in file order: callers run `nyx verify-feedback` more than once when
|
||||
/// they correct an earlier judgment, and the latest reading is the
|
||||
/// authoritative one. The events log is read via the raw JSONL path
|
||||
/// (NOT [`read_events`]) because `verify_feedback` rows were written
|
||||
/// before the `schema_version`-envelope migration and may legitimately
|
||||
/// pre-date the schema bump; a missing `schema_version` here is not
|
||||
/// fatal.
|
||||
pub fn feedback_wrong_for_finding(path: &Path, finding_id: &str) -> Option<bool> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut latest: Option<bool> = None;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if value.get("event").and_then(|v| v.as_str()) != Some("verify_feedback") {
|
||||
continue;
|
||||
}
|
||||
if value.get("finding_id").and_then(|v| v.as_str()) != Some(finding_id) {
|
||||
continue;
|
||||
}
|
||||
let Some(feedback) = value.get("feedback").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if feedback.starts_with("wrong:") || feedback == "wrong" {
|
||||
latest = Some(true);
|
||||
} else if feedback == "right" {
|
||||
latest = Some(false);
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
// ── Rank delta telemetry ──────────────────────────────────────────────────────
|
||||
|
||||
/// One telemetry event per ranked finding that carries a dynamic verdict delta.
|
||||
|
|
@ -598,6 +643,44 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_wrong_for_finding_returns_latest_record() {
|
||||
use std::io::Write;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let log = dir.path().join("events.jsonl");
|
||||
let mut f = std::fs::File::create(&log).unwrap();
|
||||
// Three records for the same finding: initial wrong, later
|
||||
// overridden by right. The latest wins.
|
||||
writeln!(
|
||||
f,
|
||||
r#"{{"event":"verify_feedback","finding_id":"abc1","feedback":"wrong:sample"}}"#
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"{{"event":"verify_feedback","finding_id":"abc2","feedback":"wrong:other"}}"#
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
f,
|
||||
r#"{{"event":"verify_feedback","finding_id":"abc1","feedback":"right"}}"#
|
||||
)
|
||||
.unwrap();
|
||||
// Non-feedback rows are ignored.
|
||||
writeln!(f, r#"{{"event":"verify","finding_id":"abc1"}}"#).unwrap();
|
||||
f.flush().unwrap();
|
||||
assert_eq!(feedback_wrong_for_finding(&log, "abc1"), Some(false));
|
||||
assert_eq!(feedback_wrong_for_finding(&log, "abc2"), Some(true));
|
||||
assert_eq!(feedback_wrong_for_finding(&log, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_wrong_for_finding_tolerates_missing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let log = dir.path().join("nonexistent.jsonl");
|
||||
assert_eq!(feedback_wrong_for_finding(&log, "abc1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_writes_valid_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -286,6 +286,8 @@ fn entry_kind_unsupported_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -328,6 +330,8 @@ fn spec_derivation_failed_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -344,6 +348,8 @@ fn spec_derivation_failed_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,6 +455,8 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -531,6 +539,8 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -559,6 +569,8 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -734,6 +746,8 @@ fn build_verdict(
|
|||
attempts: attempts.clone(),
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential.clone(),
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
&run.harness_source,
|
||||
&run.entry_source,
|
||||
|
|
@ -754,6 +768,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -767,6 +783,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
} else if run.unrelated_crash {
|
||||
// Phase 08 §C.4: the harness crashed but the death
|
||||
|
|
@ -786,6 +804,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
} else if run.no_benign_control {
|
||||
// Phase 07 §4.1: vuln oracle + sink-hit fired but the
|
||||
|
|
@ -804,6 +824,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
} else if let Some(d) = run.differential.as_ref() {
|
||||
// Differential ran but didn't produce `Confirmed`. Map
|
||||
|
|
@ -825,6 +847,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
crate::evidence::DifferentialVerdict::ReversedDifferential => {
|
||||
|
|
@ -842,6 +866,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
crate::evidence::DifferentialVerdict::Confirmed
|
||||
|
|
@ -855,6 +881,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: run.differential,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
}
|
||||
} else if run.oracle_collision {
|
||||
|
|
@ -871,6 +899,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
} else {
|
||||
VerifyResult {
|
||||
|
|
@ -883,6 +913,8 @@ fn build_verdict(
|
|||
attempts,
|
||||
toolchain_match: Some(toolchain_match.to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -896,6 +928,8 @@ fn build_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
Err(RunError::Harness(e)) => {
|
||||
// Defence-in-depth residual for `EntryKindUnsupported` from the
|
||||
|
|
@ -939,6 +973,8 @@ fn build_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
Err(RunError::BuildFailed { stderr, attempts: build_att }) => VerifyResult {
|
||||
|
|
@ -951,6 +987,8 @@ fn build_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
Err(RunError::Sandbox(e)) => VerifyResult {
|
||||
finding_id: finding_id.to_owned(),
|
||||
|
|
@ -962,6 +1000,8 @@ fn build_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1041,6 +1081,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
// Insert.
|
||||
|
|
@ -1090,6 +1132,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
insert_verdict_cache(&db_path, "spec_aaa", "hash_xyz", "", "python-3.11", &result);
|
||||
|
|
@ -1125,6 +1169,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
insert_verdict_cache(db_path, "spec", "hash", "", "python-3", &result);
|
||||
assert!(!db_path.exists(), "insert must not create a new DB");
|
||||
|
|
@ -1179,6 +1225,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".to_owned()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
// Insert directly with the old corpus_version bypassing the helper.
|
||||
|
|
|
|||
|
|
@ -566,6 +566,24 @@ pub struct VerifyResult {
|
|||
/// `BuildFailed`, `NoBenignControl`, `NotConfirmed` with vuln-only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub differential: Option<DifferentialOutcome>,
|
||||
/// Eval-corpus repro stability flag. `Some(true)` when `reproduce.sh`
|
||||
/// inside the verifier's bundle replayed green (`ReplayResult::Pass`),
|
||||
/// `Some(false)` when it diverged or aborted, `None` when no replay
|
||||
/// has been attempted (host infrastructure missing, backend not
|
||||
/// supported, etc.). Drives the `stable_replays` column in
|
||||
/// `tests/eval_corpus/tabulate.py` — the eval-corpus
|
||||
/// `repro_stability` budget cannot fire until this field carries a
|
||||
/// `Some(true)` for at least one Confirmed row.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub replay_stable: Option<bool>,
|
||||
/// Eval-corpus manual-triage flag. `Some(true)` when the user
|
||||
/// recorded a `wrong:<reason>` verdict via `nyx verify-feedback` or
|
||||
/// when an automated ground-truth pass marked this finding as a
|
||||
/// false confirmed. `Some(false)` when explicitly marked right;
|
||||
/// `None` when no triage has happened. Drives the
|
||||
/// `wrong_confirmed` column in `tests/eval_corpus/tabulate.py`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wrong: Option<bool>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
10
src/rank.rs
10
src/rank.rs
|
|
@ -1157,6 +1157,8 @@ mod tests {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1177,6 +1179,8 @@ mod tests {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1191,6 +1195,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1205,6 +1211,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1219,6 +1227,8 @@ mod tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,20 @@ const DRIVER_RULES: &[DriverRule] = &[
|
|||
DriverRule { leaf: "diesel::sql_query", kind: DataStoreKind::Sql, label: "Diesel" },
|
||||
DriverRule { leaf: "PgConnection::establish", kind: DataStoreKind::Sql, label: "Diesel" },
|
||||
|
||||
// Type-qualified — fires when the SSA type-fact engine resolves a
|
||||
// receiver to `TypeKind::DatabaseConnection` regardless of the bare
|
||||
// callee name (e.g. `conn = psycopg2.connect(); conn.cursor()` →
|
||||
// typed_call_receivers maps the `.cursor` ordinal to "DatabaseConnection").
|
||||
DriverRule { leaf: "DatabaseConnection.cursor", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "DatabaseConnection.execute", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "DatabaseConnection.query", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "DatabaseConnection.exec", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "DatabaseConnection.prepare", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "DatabaseConnection.commit", kind: DataStoreKind::Sql, label: "Database connection" },
|
||||
DriverRule { leaf: "FileHandle.read", kind: DataStoreKind::Filesystem, label: "Filesystem" },
|
||||
DriverRule { leaf: "FileHandle.write", kind: DataStoreKind::Filesystem, label: "Filesystem" },
|
||||
DriverRule { leaf: "FileHandle.close", kind: DataStoreKind::Filesystem, label: "Filesystem" },
|
||||
|
||||
// Filesystem (best-effort: language-agnostic open()-family)
|
||||
DriverRule { leaf: "open", kind: DataStoreKind::Filesystem, label: "Filesystem" },
|
||||
];
|
||||
|
|
@ -99,15 +113,28 @@ const DRIVER_RULES: &[DriverRule] = &[
|
|||
/// Walk every function summary's callee list and emit one
|
||||
/// [`SurfaceNode::DataStore`] per matched driver call. De-duped on
|
||||
/// `(file, line, label)`.
|
||||
///
|
||||
/// When the bare callee name does not hit a rule, the type-fact engine's
|
||||
/// per-call `typed_call_receivers` map (read off the matching
|
||||
/// [`crate::summary::SsaFuncSummary`]) is consulted: a callee whose
|
||||
/// receiver was resolved to `TypeKind::DatabaseConnection` or
|
||||
/// `TypeKind::FileHandle` is retried under the type-qualified name
|
||||
/// `"DatabaseConnection.<method>"` / `"FileHandle.<method>"`, picking up
|
||||
/// the bound-receiver call shapes (`conn.cursor()` after
|
||||
/// `conn = psycopg2.connect()`) that the name-only matcher misses.
|
||||
pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
||||
let mut out: Vec<SurfaceNode> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<(String, u32, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for (key, summary) in summaries.iter() {
|
||||
let typed = summaries.get_ssa(key).map(|s| s.typed_call_receivers.as_slice());
|
||||
for callee in &summary.callees {
|
||||
let Some(rule) = match_rule(&callee.name) else {
|
||||
continue;
|
||||
};
|
||||
let rule = match_rule(&callee.name).or_else(|| {
|
||||
typed
|
||||
.and_then(|t| container_for_ordinal(t, callee.ordinal))
|
||||
.and_then(|c| match_rule(&qualify(c, &callee.name)))
|
||||
});
|
||||
let Some(rule) = rule else { continue };
|
||||
let location = call_site_location(summary, callee);
|
||||
let dedup = (
|
||||
location.file.clone(),
|
||||
|
|
@ -117,7 +144,6 @@ pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
|||
if !seen.insert(dedup) {
|
||||
continue;
|
||||
}
|
||||
let _ = key;
|
||||
out.push(SurfaceNode::DataStore(DataStore {
|
||||
location,
|
||||
kind: rule.kind,
|
||||
|
|
@ -128,6 +154,25 @@ pub fn detect_data_stores(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Last segment of a callee text after the final `.` or `::`.
|
||||
fn leaf_segment(name: &str) -> &str {
|
||||
let after_colon = name.rsplit("::").next().unwrap_or(name);
|
||||
after_colon.rsplit('.').next().unwrap_or(after_colon)
|
||||
}
|
||||
|
||||
/// Build a type-qualified callee name (`"{container}.{method}"`) for
|
||||
/// retry-matching when the bare callee text did not hit any rule.
|
||||
fn qualify(container: &str, callee_name: &str) -> String {
|
||||
format!("{}.{}", container, leaf_segment(callee_name))
|
||||
}
|
||||
|
||||
/// Linear-scan helper since `typed_call_receivers` is a small
|
||||
/// `Vec<(ordinal, container)>` per function. Typical lengths are 0 to a
|
||||
/// few dozen; a HashMap-per-summary would be wasteful.
|
||||
fn container_for_ordinal(typed: &[(u32, String)], ordinal: u32) -> Option<&str> {
|
||||
typed.iter().find(|(o, _)| *o == ordinal).map(|(_, c)| c.as_str())
|
||||
}
|
||||
|
||||
fn match_rule(callee: &str) -> Option<&'static DriverRule> {
|
||||
let cl = callee.trim().to_ascii_lowercase();
|
||||
// Normalize `::` → `.` so segment-split treats both as separators.
|
||||
|
|
@ -290,4 +335,56 @@ mod tests {
|
|||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_receiver_database_connection_resolves_bound_cursor() {
|
||||
// `conn = psycopg2.connect(); conn.cursor()` — the bare callee
|
||||
// `conn.cursor` is not in DRIVER_RULES, but the SSA type-fact
|
||||
// engine populates `typed_call_receivers` with
|
||||
// `(ordinal, "DatabaseConnection")` for the `.cursor` ordinal.
|
||||
// The detector retries under `DatabaseConnection.cursor` and
|
||||
// emits a Sql datastore node.
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "app.py", "load", None);
|
||||
let summary = FuncSummary {
|
||||
name: "load".into(),
|
||||
file_path: "app.py".into(),
|
||||
lang: "python".into(),
|
||||
param_count: 0,
|
||||
callees: vec![
|
||||
{
|
||||
let mut c = CalleeSite::bare("conn.cursor");
|
||||
c.ordinal = 7;
|
||||
c.span = Some((4, 8));
|
||||
c
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key.clone(), summary);
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers
|
||||
.push((7, "DatabaseConnection".into()));
|
||||
gs.insert_ssa(key, ssa);
|
||||
let nodes = detect_data_stores(&gs);
|
||||
assert_eq!(nodes.len(), 1, "expected typed retry to hit; got {nodes:?}");
|
||||
let SurfaceNode::DataStore(ds) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(ds.kind, DataStoreKind::Sql);
|
||||
assert_eq!(ds.label, "Database connection");
|
||||
assert_eq!(ds.location.line, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_receiver_without_ssa_summary_falls_through() {
|
||||
// No SsaFuncSummary inserted → bare `client.cursor` does not match
|
||||
// any rule and `typed_call_receivers` is unreachable. Detector
|
||||
// emits zero nodes (no panic on missing SSA side).
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let (k, s) = summary_with_callees("load", "app.py", &["client.cursor"]);
|
||||
gs.insert(k, s);
|
||||
assert!(detect_data_stores(&gs).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,17 +76,50 @@ const CLIENT_RULES: &[ClientRule] = &[
|
|||
ClientRule { leaf: "socket.gethostbyname", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
ClientRule { leaf: "dns.lookup", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
ClientRule { leaf: "net.LookupIP", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
|
||||
// Type-qualified — fires when the SSA type-fact engine resolves a
|
||||
// receiver to `TypeKind::HttpClient` regardless of the bare callee
|
||||
// name (`session = requests.Session(); session.get(url)` →
|
||||
// typed_call_receivers maps the `.get` ordinal to "HttpClient", so
|
||||
// the bound-receiver call surfaces as an outbound HTTP node even
|
||||
// though `requests.get` is the only direct-import rule above).
|
||||
ClientRule { leaf: "HttpClient.get", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.post", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.put", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.delete", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.patch", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.request", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.head", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "HttpClient.options", kind: ExternalServiceKind::HttpApi, label: "HTTP client" },
|
||||
ClientRule { leaf: "RequestBuilder.send", kind: ExternalServiceKind::HttpApi, label: "HTTP request builder" },
|
||||
ClientRule { leaf: "URL.openConnection", kind: ExternalServiceKind::HttpApi, label: "URL connection" },
|
||||
ClientRule { leaf: "URL.openStream", kind: ExternalServiceKind::HttpApi, label: "URL connection" },
|
||||
];
|
||||
|
||||
/// Walk every function summary's callee list and emit one
|
||||
/// [`SurfaceNode::ExternalService`] per matched outbound-client call.
|
||||
///
|
||||
/// When the bare callee name does not hit a rule, the type-fact engine's
|
||||
/// per-call `typed_call_receivers` map (read off the matching
|
||||
/// [`crate::summary::SsaFuncSummary`]) is consulted: a callee whose
|
||||
/// receiver was resolved to `TypeKind::HttpClient` /
|
||||
/// `TypeKind::RequestBuilder` / `TypeKind::Url` is retried under the
|
||||
/// type-qualified name `"{container}.<method>"`, picking up the
|
||||
/// bound-receiver call shapes (`client = requests.Session();
|
||||
/// client.get(url)`) that the name-only matcher misses.
|
||||
pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
||||
let mut out: Vec<SurfaceNode> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<(String, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for (_key, summary) in summaries.iter() {
|
||||
for (key, summary) in summaries.iter() {
|
||||
let typed = summaries.get_ssa(key).map(|s| s.typed_call_receivers.as_slice());
|
||||
for callee in &summary.callees {
|
||||
let Some(rule) = match_rule(&callee.name) else {
|
||||
continue;
|
||||
};
|
||||
let rule = match_rule(&callee.name).or_else(|| {
|
||||
typed
|
||||
.and_then(|t| container_for_ordinal(t, callee.ordinal))
|
||||
.and_then(|c| match_rule(&qualify(c, &callee.name)))
|
||||
});
|
||||
let Some(rule) = rule else { continue };
|
||||
let location = call_site_location(summary, Some(callee));
|
||||
if !seen.insert((location.file.clone(), rule.label.to_string())) {
|
||||
continue;
|
||||
|
|
@ -118,6 +151,19 @@ pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode>
|
|||
out
|
||||
}
|
||||
|
||||
fn leaf_segment(name: &str) -> &str {
|
||||
let after_colon = name.rsplit("::").next().unwrap_or(name);
|
||||
after_colon.rsplit('.').next().unwrap_or(after_colon)
|
||||
}
|
||||
|
||||
fn qualify(container: &str, callee_name: &str) -> String {
|
||||
format!("{}.{}", container, leaf_segment(callee_name))
|
||||
}
|
||||
|
||||
fn container_for_ordinal(typed: &[(u32, String)], ordinal: u32) -> Option<&str> {
|
||||
typed.iter().find(|(o, _)| *o == ordinal).map(|(_, c)| c.as_str())
|
||||
}
|
||||
|
||||
fn match_rule(callee: &str) -> Option<&'static ClientRule> {
|
||||
let cl = callee.trim().to_ascii_lowercase();
|
||||
let cl_segments = cl.replace("::", ".");
|
||||
|
|
@ -195,6 +241,41 @@ mod tests {
|
|||
assert!(nodes.is_empty(), "bare rules FP-matched on {nodes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_receiver_http_client_resolves_bound_session_get() {
|
||||
// `client = requests.Session(); client.get(url)` — the bare
|
||||
// callee `client.get` is not in CLIENT_RULES, but the SSA type
|
||||
// engine resolves the receiver to `TypeKind::HttpClient`. The
|
||||
// detector retries under `HttpClient.get` and emits an HTTP
|
||||
// external-service node.
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "client.py", "fetch", None);
|
||||
let summary = FuncSummary {
|
||||
name: "fetch".into(),
|
||||
file_path: "client.py".into(),
|
||||
lang: "python".into(),
|
||||
param_count: 0,
|
||||
callees: vec![{
|
||||
let mut c = CalleeSite::bare("client.get");
|
||||
c.ordinal = 3;
|
||||
c.span = Some((9, 5));
|
||||
c
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key.clone(), summary);
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((3, "HttpClient".into()));
|
||||
gs.insert_ssa(key, ssa);
|
||||
let nodes = detect_external_services(&gs);
|
||||
assert_eq!(nodes.len(), 1, "expected typed retry to hit; got {nodes:?}");
|
||||
let SurfaceNode::ExternalService(es) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(es.label, "HTTP client");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_got_rule_matches_segmented_callee() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
|
|
|||
|
|
@ -18,16 +18,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_MIDDLEWARES: &[&str] = &[
|
||||
"AuthRequired",
|
||||
"JWT",
|
||||
"JWTAuth",
|
||||
"Auth",
|
||||
"RequireAuth",
|
||||
"RequireUser",
|
||||
"VerifyToken",
|
||||
"BasicAuth",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::GIN_MIDDLEWARES as AUTH_MIDDLEWARES;
|
||||
|
||||
const VERBS: &[&str] = &[
|
||||
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "Any",
|
||||
|
|
|
|||
|
|
@ -21,12 +21,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_ANNOTATIONS: &[&str] = &[
|
||||
"Authenticated",
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::QUARKUS_ANNOTATIONS as AUTH_ANNOTATIONS;
|
||||
|
||||
const QUARKUS_DI: &[&str] = &[
|
||||
"ApplicationScoped",
|
||||
|
|
|
|||
|
|
@ -18,12 +18,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_ANNOTATIONS: &[&str] = &[
|
||||
"RolesAllowed",
|
||||
"DenyAll",
|
||||
"RequiresAuthentication",
|
||||
"RequiresUser",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::SERVLET_ANNOTATIONS as AUTH_ANNOTATIONS;
|
||||
|
||||
const SERVLET_VERBS: &[(&str, HttpMethod)] = &[
|
||||
("doGet", HttpMethod::GET),
|
||||
|
|
|
|||
|
|
@ -16,13 +16,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_ANNOTATIONS: &[&str] = &[
|
||||
"PreAuthorize",
|
||||
"PostAuthorize",
|
||||
"Secured",
|
||||
"RolesAllowed",
|
||||
"AuthenticationPrincipal",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::SPRING_ANNOTATIONS as AUTH_ANNOTATIONS;
|
||||
|
||||
const MAPPING_ANNOTATIONS: &[(&str, Option<HttpMethod>)] = &[
|
||||
("RequestMapping", None),
|
||||
|
|
|
|||
|
|
@ -17,20 +17,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"ensureLoggedIn",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::EXPRESS_MIDDLEWARES as AUTH_MIDDLEWARES;
|
||||
|
||||
const VERBS: &[&str] = &[
|
||||
"get", "post", "put", "delete", "patch", "options", "head", "all",
|
||||
|
|
|
|||
|
|
@ -15,20 +15,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_MIDDLEWARES: &[&str] = &[
|
||||
"requireAuth",
|
||||
"requireUser",
|
||||
"isAuthenticated",
|
||||
"ensureAuthenticated",
|
||||
"authenticate",
|
||||
"authMiddleware",
|
||||
"verifyToken",
|
||||
"verifyJwt",
|
||||
"checkJwt",
|
||||
"passport",
|
||||
"jwt",
|
||||
"koaJwt",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::KOA_MIDDLEWARES as AUTH_MIDDLEWARES;
|
||||
|
||||
const VERBS: &[&str] = &[
|
||||
"get", "post", "put", "delete", "patch", "options", "head", "all",
|
||||
|
|
|
|||
|
|
@ -26,15 +26,7 @@ use std::collections::HashMap;
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"user_passes_test",
|
||||
"staff_member_required",
|
||||
"csrf_protect",
|
||||
"require_authenticated",
|
||||
"auth_required",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::DJANGO_DECORATORS as AUTH_DECORATORS;
|
||||
|
||||
const CBV_BASES: &[&str] = &[
|
||||
"View",
|
||||
|
|
|
|||
|
|
@ -21,17 +21,7 @@ use tree_sitter::{Node, Tree};
|
|||
|
||||
/// Auth markers recognised in the decorator stack. FastAPI's primary
|
||||
/// auth idiom is `Depends(...)` parameter injection, handled separately.
|
||||
pub const AUTH_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_auth",
|
||||
"require_login",
|
||||
"current_user",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::FASTAPI_DECORATORS as AUTH_DECORATORS;
|
||||
|
||||
/// Auth-callee names recognised inside a `Depends(...)` parameter.
|
||||
const AUTH_DEPENDS_CALLEES: &[&str] = &[
|
||||
|
|
|
|||
|
|
@ -28,15 +28,7 @@ use tree_sitter::{Node, Tree};
|
|||
/// last `attribute` / `identifier` segment — so `@login_required`,
|
||||
/// `@auth.login_required`, and `@flask_login.login_required` all
|
||||
/// match. Match is case-insensitive on the underscored form.
|
||||
pub const AUTH_DECORATORS: &[&str] = &[
|
||||
"login_required",
|
||||
"auth_required",
|
||||
"jwt_required",
|
||||
"token_required",
|
||||
"requires_auth",
|
||||
"authenticated",
|
||||
"require_login",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::FLASK_DECORATORS as AUTH_DECORATORS;
|
||||
|
||||
/// Detect every Flask route in a parsed Python file.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -16,14 +16,7 @@ use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode};
|
|||
use std::path::Path;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
pub const AUTH_EXTRACTORS: &[&str] = &[
|
||||
"Identity",
|
||||
"BearerAuth",
|
||||
"BasicAuth",
|
||||
"JwtClaims",
|
||||
"Authenticated",
|
||||
"User",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::ACTIX_EXTRACTORS as AUTH_EXTRACTORS;
|
||||
|
||||
const ROUTE_MACROS: &[(&str, Option<HttpMethod>)] = &[
|
||||
("get", Some(HttpMethod::GET)),
|
||||
|
|
|
|||
|
|
@ -25,13 +25,7 @@ const VERBS: &[(&str, HttpMethod)] = &[
|
|||
("options", HttpMethod::OPTIONS),
|
||||
];
|
||||
|
||||
pub const AUTH_EXTRACTORS: &[&str] = &[
|
||||
"Extension<User",
|
||||
"BearerAuth",
|
||||
"RequireAuth",
|
||||
"AuthenticatedUser",
|
||||
"JwtClaims",
|
||||
];
|
||||
pub use crate::auth_analysis::auth_markers::AXUM_EXTRACTORS as AUTH_EXTRACTORS;
|
||||
|
||||
pub fn detect_axum_routes(
|
||||
tree: &Tree,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ fn verdict(status: VerifyStatus, reason: Option<InconclusiveReason>) -> VerifyRe
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -515,6 +515,8 @@ pub fn run_shape_fixture_lang(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
Err(RunError::NoPayloadsForCap) => VerifyResult {
|
||||
|
|
@ -527,6 +529,8 @@ pub fn run_shape_fixture_lang(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
Err(e) => VerifyResult {
|
||||
finding_id: spec.finding_id.clone(),
|
||||
|
|
@ -538,6 +542,8 @@ pub fn run_shape_fixture_lang(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ fn diag_with_verdict(status: VerifyStatus) -> Diag {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
VerifyStatus::NotConfirmed => VerifyResult {
|
||||
finding_id: "abc123".into(),
|
||||
|
|
@ -89,6 +91,8 @@ fn diag_with_verdict(status: VerifyStatus) -> Diag {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
VerifyStatus::Unsupported => VerifyResult {
|
||||
finding_id: "abc123".into(),
|
||||
|
|
@ -100,6 +104,8 @@ fn diag_with_verdict(status: VerifyStatus) -> Diag {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
VerifyStatus::Inconclusive => VerifyResult {
|
||||
finding_id: "abc123".into(),
|
||||
|
|
@ -111,6 +117,8 @@ fn diag_with_verdict(status: VerifyStatus) -> Diag {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ fn set_verdict(
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -166,6 +168,8 @@ fn new_confirmed_fails_no_new_confirmed_gate() {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ mod go_fixture_tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ mod java_fixture_tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ mod js_fixture_tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ fn json_dynamic_verdict_confirmed_serialises_correctly() {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
|
@ -96,6 +98,8 @@ fn json_dynamic_verdict_not_confirmed_serialises_correctly() {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
|
@ -159,6 +163,8 @@ fn json_unsupported_verdict_has_reason() {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ mod php_fixture_tests {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ mod repro_determinism_tests {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ mod repro_hermetic_tests {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ fn sarif_confirmed_verdict_sets_partial_fingerprint() {
|
|||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
@ -107,6 +109,8 @@ fn sarif_not_confirmed_verdict_sets_partial_fingerprint() {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
@ -134,6 +138,8 @@ fn sarif_unsupported_verdict_sets_partial_fingerprint() {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
@ -166,6 +172,8 @@ fn sarif_inconclusive_verdict_sets_partial_fingerprint() {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
@ -214,6 +222,8 @@ fn sarif_confirmed_verdict_nyx_dynamic_verdict_contains_triggered_payload() {
|
|||
attempts: vec![],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
@ -245,6 +255,8 @@ fn sarif_all_four_statuses_produce_partial_fingerprint() {
|
|||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
};
|
||||
|
||||
let result = sarif_result(diag_with_verdict(verdict));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue