[pitboss/grind] deferred session-0003 (20260516T052512Z-20f8)

This commit is contained in:
pitboss 2026-05-16 02:26:41 -05:00
parent 282acddbbf
commit 678f0f5d48
35 changed files with 737 additions and 109 deletions

View 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());
}
}

View file

@ -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;

View file

@ -446,6 +446,8 @@ mod tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
}),
..Default::default()
});

View file

@ -108,6 +108,8 @@ mod tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
}
}

View file

@ -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,
}
}

View file

@ -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();

View file

@ -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();

View file

@ -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.

View file

@ -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>,
}
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -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,
}
}

View file

@ -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());
}
}

View file

@ -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();

View file

@ -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",

View file

@ -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",

View file

@ -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),

View file

@ -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),

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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] = &[

View file

@ -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.
///

View 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)),

View file

@ -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,

View file

@ -74,6 +74,8 @@ fn verdict(status: VerifyStatus, reason: Option<InconclusiveReason>) -> VerifyRe
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
}
}

View file

@ -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,
},
}
}

View file

@ -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,
},
};

View file

@ -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,
});
}
}

View file

@ -59,6 +59,8 @@ mod go_fixture_tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
};
}

View file

@ -67,6 +67,8 @@ mod java_fixture_tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
};
}

View file

@ -60,6 +60,8 @@ mod js_fixture_tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
};
}

View file

@ -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()
});

View file

@ -59,6 +59,8 @@ mod php_fixture_tests {
attempts: vec![],
toolchain_match: None,
differential: None,
replay_stable: None,
wrong: None,
};
}

View file

@ -68,6 +68,8 @@ mod repro_determinism_tests {
}],
toolchain_match: Some("exact".into()),
differential: None,
replay_stable: None,
wrong: None,
}
}

View file

@ -87,6 +87,8 @@ mod repro_hermetic_tests {
}],
toolchain_match: Some("exact".into()),
differential: None,
replay_stable: None,
wrong: None,
}
}

View file

@ -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));