mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
1652 lines
70 KiB
Rust
1652 lines
70 KiB
Rust
mod common;
|
|
|
|
use common::{assert_no_findings, scan_fixture_dir, validate_expectations};
|
|
use nyx_scanner::utils::config::AnalysisMode;
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
|
|
fn fixture_path(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
// ── Per-fixture tests ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn rust_web_app() {
|
|
let dir = fixture_path("rust_web_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn rust_framework_rules() {
|
|
let dir = fixture_path("rust_framework_rules");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn rust_module_path_resolution() {
|
|
// Two modules define `pub fn validate(&str) -> String` with the same arity.
|
|
// `main.rs` has `use crate::auth::token::validate;` and calls `validate(&cmd)`.
|
|
// A correct use-map driven resolver must target `auth::token::validate`
|
|
// (pass-through sanitizer) and NOT `auth::session::validate` (shell sink);
|
|
// the expectations forbid any taint finding on main.rs.
|
|
let dir = fixture_path("rust_module_path_resolution");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn express_app() {
|
|
let dir = fixture_path("express_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn koa_app() {
|
|
let dir = fixture_path("koa_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn fastify_app() {
|
|
let dir = fixture_path("fastify_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_integration() {
|
|
let dir = fixture_path("auth_analysis_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_frameworks_integration() {
|
|
let dir = fixture_path("auth_analysis_frameworks_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_noise_frameworks() {
|
|
let dir = fixture_path("auth_analysis_noise_frameworks");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_python_frameworks_integration() {
|
|
let dir = fixture_path("auth_analysis_python_frameworks_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_ruby_frameworks_integration() {
|
|
let dir = fixture_path("auth_analysis_ruby_frameworks_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_go_java_frameworks_integration() {
|
|
let dir = fixture_path("auth_analysis_go_java_frameworks_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_rust_frameworks_integration() {
|
|
let dir = fixture_path("auth_analysis_rust_frameworks_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_admin_multilang_integration() {
|
|
let dir = fixture_path("auth_analysis_admin_multilang_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_analysis_ownership_multilang_integration() {
|
|
let dir = fixture_path("auth_analysis_ownership_multilang_integration");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn flask_app() {
|
|
let dir = fixture_path("flask_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn go_server() {
|
|
let dir = fixture_path("go_server");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn c_utils() {
|
|
let dir = fixture_path("c_utils");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn java_service() {
|
|
let dir = fixture_path("java_service");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_project() {
|
|
let dir = fixture_path("mixed_project");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_taint() {
|
|
let dir = fixture_path("cross_file_taint");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_ssa_propagation() {
|
|
let dir = fixture_path("cross_file_ssa_propagation");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_ssa_source() {
|
|
let dir = fixture_path("cross_file_ssa_source");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_ssa_sanitizer() {
|
|
let dir = fixture_path("cross_file_ssa_sanitizer");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── Cross-file param sink precision ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cross_file_param_sink_precision() {
|
|
let dir = fixture_path("cross_file_param_sink_precision");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_mixed_cap_sink() {
|
|
let dir = fixture_path("cross_file_mixed_cap_sink");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Two different sinks on the same line (SQL + SHELL) must produce two
|
|
/// distinct taint findings. Regression guard for the dedup fix where
|
|
/// the grouping key includes sink capability bits, so `sink_sql(x);
|
|
/// sink_shell(x);` no longer collapses into a single finding.
|
|
#[test]
|
|
fn dedup_same_line_different_sinks() {
|
|
let dir = fixture_path("dedup_same_line_different_sinks");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
|
|
// Inspect the specific line where the two sinks live. Both findings
|
|
// must exist, and must carry different resolved sink cap bits.
|
|
let taint_on_target_line: Vec<&nyx_scanner::commands::scan::Diag> = diags
|
|
.iter()
|
|
.filter(|d| d.id.starts_with("taint-unsanitised-flow") && d.line == 10)
|
|
.collect();
|
|
assert!(
|
|
taint_on_target_line.len() >= 2,
|
|
"expected at least 2 taint findings on line 10 (dedup must not collapse \
|
|
different sinks), got {}: {:#?}",
|
|
taint_on_target_line.len(),
|
|
taint_on_target_line
|
|
.iter()
|
|
.map(|d| format!(
|
|
"{}:{} [caps={}]",
|
|
d.path,
|
|
d.line,
|
|
d.evidence.as_ref().map(|e| e.sink_caps).unwrap_or(0)
|
|
))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
let caps: HashSet<u32> = taint_on_target_line
|
|
.iter()
|
|
.map(|d| d.evidence.as_ref().map(|e| e.sink_caps).unwrap_or(0))
|
|
.collect();
|
|
assert!(
|
|
caps.len() >= 2,
|
|
"expected findings on line 10 to carry distinct sink_caps, got {:?}",
|
|
caps
|
|
);
|
|
}
|
|
|
|
// ── Multi-arg validator target narrowing ────────────────────────────────
|
|
|
|
/// `validate(x, 100)` must narrow validation to `x`, so the tainted
|
|
/// `x` flowing to `os.system(x)` on the true branch is correctly silenced.
|
|
/// Regression guard for the existing target-extraction path.
|
|
#[test]
|
|
fn predicate_multi_arg_validator_tainted() {
|
|
let dir = fixture_path("predicate_multi_arg_validator_tainted");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// `validate(limit, x)` validates `limit`, not `x`. Tainted `x`
|
|
/// still flows to `os.system(x)` and the finding must fire. Regression guard
|
|
/// against upstream code marking every `condition_var` as validated when
|
|
/// target extraction narrows to a non-tainted var.
|
|
#[test]
|
|
fn predicate_multi_arg_validator_wrong() {
|
|
let dir = fixture_path("predicate_multi_arg_validator_wrong");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── Gated-sink dynamic activation conservatism ────────────────────────────
|
|
|
|
/// `setAttribute(attr, val)` with a dynamic first arg returns the
|
|
/// ALL_ARGS_PAYLOAD sentinel, so sink scanning expands to every positional
|
|
/// arg, a tainted attribute name is itself a vulnerability path. Expects
|
|
/// at least two findings (one per call where either arg is tainted).
|
|
#[test]
|
|
fn gated_sink_dynamic_activation() {
|
|
let dir = fixture_path("gated_sink_dynamic_activation");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── SCC SSA summary refinement ────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cross_file_scc_ssa() {
|
|
let dir = fixture_path("cross_file_scc_ssa");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_scc_convergence() {
|
|
let dir = fixture_path("cross_file_scc_convergence");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_symex_body() {
|
|
let dir = fixture_path("cross_file_symex_body");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn cross_file_symex_js() {
|
|
let dir = fixture_path("cross_file_symex_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── New multi-file fixtures ────────────────────────────────────────────────
|
|
|
|
// --- True positives ---------------------------------------------------------
|
|
|
|
/// Go: HTTP handler in handler.go passes r.FormValue("cmd") to runCommand()
|
|
/// defined in executor.go, which calls exec.Command, shell execution sink.
|
|
#[test]
|
|
fn cross_file_go_handler_exec() {
|
|
let dir = fixture_path("cross_file_go_handler_exec");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Java: UserController.java reads getParameter("name") and passes it to
|
|
/// UserRepository.findByName(), which concatenates it into executeQuery().
|
|
/// Cross-file taint propagates via param_to_sink in the resolved summary.
|
|
#[test]
|
|
fn cross_file_java_sqli() {
|
|
let dir = fixture_path("cross_file_java_sqli");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// TypeScript: router.ts reads req.query.url and forwards it to
|
|
/// fetchRemote() in httpClient.ts, which passes it to fetch(), SSRF.
|
|
#[test]
|
|
fn cross_file_ts_ssrf() {
|
|
let dir = fixture_path("cross_file_ts_ssrf");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JavaScript: source.js exports getInput(data); app.js destructures it under
|
|
/// the alias fetchUserCmd and passes req.query.cmd through it to execSync.
|
|
/// Import alias resolution maps fetchUserCmd → getInput for cross-file taint.
|
|
#[test]
|
|
fn cross_file_js_aliased_import() {
|
|
let dir = fixture_path("cross_file_js_aliased_import");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JavaScript: req.body.returnTo (inline source member expression in call arg)
|
|
/// flows through cross-file safeRedirect() passthrough to res.redirect() sink.
|
|
/// Exercises source node pre-emission for source member expressions nested
|
|
/// directly inside sink call arguments.
|
|
#[test]
|
|
fn cross_file_js_redirect() {
|
|
let dir = fixture_path("cross_file_js_redirect");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JavaScript: req.query.q flows through cross-file globalSearch() which
|
|
/// concatenates the param into raw SQL and passes it to db.query().
|
|
/// Tests cross-file param_to_sink propagation for SQL injection.
|
|
#[test]
|
|
fn cross_file_js_sqli() {
|
|
let dir = fixture_path("cross_file_js_sqli");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python: 3-file chain, os.environ in input_reader.py → passthrough in
|
|
/// transform.py → subprocess.call in executor.py. Taint must survive two
|
|
/// inter-file hops with no sanitisation.
|
|
#[test]
|
|
fn cross_file_py_nested_chain() {
|
|
let dir = fixture_path("cross_file_py_nested_chain");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python: object attribute carries taint across files, JobRequest.cmd is
|
|
/// populated from os.environ in models.py; handler.py reads req.cmd and
|
|
/// passes it to subprocess.call.
|
|
#[test]
|
|
fn cross_file_py_object_field() {
|
|
let dir = fixture_path("cross_file_py_object_field");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// --- True negatives ---------------------------------------------------------
|
|
|
|
/// Python: shlex.quote (SHELL_ESCAPE sanitiser) is defined in shell_utils.py
|
|
/// and called from handler.py before subprocess.call, no finding expected.
|
|
#[test]
|
|
fn cross_file_py_shlex_sanitizer() {
|
|
let dir = fixture_path("cross_file_py_shlex_sanitizer");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JavaScript: xss() HTML sanitiser defined in security.js is applied before
|
|
/// document.write in app.js, no taint-unsanitised-flow expected.
|
|
#[test]
|
|
fn cross_file_js_html_sanitized() {
|
|
let dir = fixture_path("cross_file_js_html_sanitized");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python: constants.py returns a hardcoded string literal; runner.py uses it
|
|
/// in subprocess.call, no taint source exists, so no finding expected.
|
|
#[test]
|
|
fn cross_file_py_const_passthrough() {
|
|
let dir = fixture_path("cross_file_py_const_passthrough");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Go: validation.go converts r.FormValue("id") with strconv.Atoi (Cap::all
|
|
/// sanitiser) before handler.go calls db.QueryRow, no SQL taint expected.
|
|
#[test]
|
|
fn cross_file_go_int_validated() {
|
|
let dir = fixture_path("cross_file_go_int_validated");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// --- Near-miss cases --------------------------------------------------------
|
|
|
|
/// Python near miss, TRUE POSITIVE:
|
|
/// html_guard.py applies html.escape (HTML_ESCAPE cap) before a SQL
|
|
/// concatenation in app.py. The HTML sanitiser does not cover SQL_QUERY
|
|
/// capability, so the flow is still vulnerable, Nyx should detect it.
|
|
/// Tests that the engine does not over-sanitise with the wrong cap type.
|
|
#[test]
|
|
fn cross_file_near_miss_wrong_sanitizer() {
|
|
let dir = fixture_path("cross_file_near_miss_wrong_sanitizer");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JavaScript near miss, TRUE NEGATIVE:
|
|
/// session.js stores user input in `lastUser` but getDefaultQuery() returns
|
|
/// the constant `defaultQuery`. app.js passes the result to pool.query().
|
|
/// A coarse analysis might falsely flag this; a precise one should not.
|
|
/// Tests that the engine does not conflate distinct module-level variables.
|
|
#[test]
|
|
fn cross_file_near_miss_field_isolation() {
|
|
let dir = fixture_path("cross_file_near_miss_field_isolation");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Same-file identity collision, ADVERSARIAL.
|
|
/// `runTask` is defined as a free function (shell-exec sink) AND as a
|
|
/// method on multiple classes in the same file with conflicting
|
|
/// security behaviours. A bare `runTask(tainted)` top-level call MUST
|
|
/// resolve to the free function (its summary carries a SHELL_ESCAPE
|
|
/// sink), the pre-fix resolver returned Ambiguous for this call and
|
|
/// silently dropped the finding. Regression guard for the bare-call
|
|
/// free-function preference (resolve_callee step 5.5).
|
|
#[test]
|
|
fn same_name_collisions_js() {
|
|
let dir = fixture_path("same_name_collisions_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── New sink coverage fixtures ────────────────────────────────────────────
|
|
|
|
/// JS: execAsync wraps child_process.exec; user input flows through the
|
|
/// wrapper to the inner exec call, SHELL_ESCAPE finding expected.
|
|
#[test]
|
|
fn exec_async_wrapper() {
|
|
let dir = fixture_path("exec_async_wrapper");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JS: res.download(path.join(root, req.query.path)), path traversal
|
|
/// via Express res.download FILE_IO sink.
|
|
#[test]
|
|
fn path_traversal_download() {
|
|
let dir = fixture_path("path_traversal_download");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JS: md5(password) and crypto.createHash("sha1"), weak hash patterns.
|
|
#[test]
|
|
fn weak_hash_password() {
|
|
let dir = fixture_path("weak_hash_password");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// JS: hardcoded secret/password in object literal.
|
|
#[test]
|
|
fn hardcoded_secret() {
|
|
let dir = fixture_path("hardcoded_secret");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── Cross-cutting tests ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn ast_only_mode_excludes_taint() {
|
|
let dir = fixture_path("rust_web_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Ast);
|
|
|
|
assert_no_findings(&diags, "taint-");
|
|
assert_no_findings(&diags, "cfg-");
|
|
}
|
|
|
|
#[test]
|
|
fn taint_only_mode_excludes_ast() {
|
|
let dir = fixture_path("rust_web_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Taint);
|
|
|
|
// Taint mode should not produce AST-only pattern findings
|
|
assert_no_findings(&diags, "rs.quality.unwrap");
|
|
assert_no_findings(&diags, "rs.quality.expect");
|
|
}
|
|
|
|
#[test]
|
|
fn dedup_no_double_report() {
|
|
let dir = fixture_path("rust_web_app");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
|
|
// The same (path, line, col, rule_id) tuple should never appear twice.
|
|
// Different rule IDs at the same location are fine (e.g., taint + cfg-auth-gap).
|
|
let mut seen: HashSet<(String, usize, usize, String)> = HashSet::new();
|
|
let mut exact_dupes = Vec::new();
|
|
for d in &diags {
|
|
let key = (d.path.clone(), d.line, d.col, d.id.clone());
|
|
if !seen.insert(key) {
|
|
exact_dupes.push(format!("{}:{}:{} {}", d.path, d.line, d.col, d.id));
|
|
}
|
|
}
|
|
assert!(
|
|
exact_dupes.is_empty(),
|
|
"Exact duplicate findings (same location + rule ID) found ({}):\n {}",
|
|
exact_dupes.len(),
|
|
exact_dupes.join("\n ")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_project_multi_language() {
|
|
let dir = fixture_path("mixed_project");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
|
|
// Findings should span at least 2 different file extensions
|
|
let extensions: HashSet<&str> = diags
|
|
.iter()
|
|
.filter_map(|d| {
|
|
std::path::Path::new(&d.path)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
})
|
|
.collect();
|
|
|
|
assert!(
|
|
extensions.len() >= 2,
|
|
"Expected findings from >= 2 language file extensions, got: {:?}",
|
|
extensions
|
|
);
|
|
|
|
// Total findings >= 3 across languages
|
|
assert!(
|
|
diags.len() >= 3,
|
|
"Expected >= 3 total findings in mixed project, got {}",
|
|
diags.len()
|
|
);
|
|
}
|
|
|
|
/// JS: throw in error-check branch should be recognized as a terminator,
|
|
/// suppressing cfg-error-fallthrough false positives.
|
|
#[test]
|
|
fn error_throw_terminates() {
|
|
let dir = fixture_path("error_throw_terminates");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── Binary smoke test ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn binary_json_output() {
|
|
let fixture = fixture_path("rust_web_app");
|
|
let home = tempfile::tempdir().expect("temp home");
|
|
#[allow(deprecated)]
|
|
let cmd = assert_cmd::Command::cargo_bin("nyx")
|
|
.expect("nyx binary should exist")
|
|
.env("HOME", home.path())
|
|
.env("XDG_CONFIG_HOME", home.path().join(".config"))
|
|
.env("XDG_DATA_HOME", home.path().join(".local/share"))
|
|
.env("NO_COLOR", "1")
|
|
.arg("scan")
|
|
.arg(fixture.to_str().unwrap())
|
|
.arg("--no-index")
|
|
.arg("--format")
|
|
.arg("json")
|
|
.output()
|
|
.expect("failed to execute nyx binary");
|
|
|
|
assert!(
|
|
cmd.status.success(),
|
|
"nyx scan exited with non-zero status: {:?}\nstderr: {}",
|
|
cmd.status,
|
|
String::from_utf8_lossy(&cmd.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&cmd.stdout);
|
|
// Phase 25: JSON output is `{ "findings": [...], "chains": [...] }`.
|
|
let json_start = stdout.find('{').expect("Expected JSON object in stdout");
|
|
let json_end = stdout.rfind('}').expect("Expected closing brace in JSON") + 1;
|
|
let json_str = &stdout[json_start..json_end];
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(json_str).expect("stdout should contain valid JSON object");
|
|
|
|
let findings = parsed["findings"]
|
|
.as_array()
|
|
.expect("JSON output must have a `findings` array");
|
|
assert!(
|
|
!findings.is_empty(),
|
|
"Expected at least 1 finding in JSON output"
|
|
);
|
|
// Phase 25: every scan emits a `chains` array (possibly empty).
|
|
assert!(
|
|
parsed["chains"].is_array(),
|
|
"JSON output must have a `chains` array"
|
|
);
|
|
}
|
|
|
|
// ── EJS / config / debug endpoint fixtures ──────────────────────────────────
|
|
|
|
/// EJS template: detects unescaped `<%- query %>` and `<%- resultHtml %>`
|
|
/// but not `<%- include(...) %>` or `<%= safe %>`.
|
|
#[test]
|
|
fn ejs_xss() {
|
|
let dir = fixture_path("ejs_xss");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Express session config: detects httpOnly: false, secure: false,
|
|
/// sameSite: "none", and hardcoded secret.
|
|
#[test]
|
|
fn insecure_session_config() {
|
|
let dir = fixture_path("insecure_session_config");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Debug endpoint: process.env → res.json() should be caught by taint.
|
|
#[test]
|
|
fn debug_endpoint() {
|
|
let dir = fixture_path("debug_endpoint");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Internal path-prefix redirects should be suppressed; open redirects should fire.
|
|
#[test]
|
|
fn internal_redirect_taint() {
|
|
let dir = fixture_path("internal_redirect_taint");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Route registration methods (router.get/post) and session lifecycle should
|
|
/// not propagate taint or generate findings.
|
|
#[test]
|
|
fn route_registration_noise() {
|
|
let dir = fixture_path("route_registration_noise");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
#[test]
|
|
fn route_registration_noise_frameworks() {
|
|
let dir = fixture_path("route_registration_noise_frameworks");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Dynamic HTTP module dispatch: lib = require("http"), lib.request(url)
|
|
/// should be resolved as SSRF sink via module alias tracking.
|
|
#[test]
|
|
fn dynamic_dispatch_ssrf() {
|
|
let dir = fixture_path("dynamic_dispatch_ssrf");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Cross-file info leak: service returns process.env data (source-independent
|
|
/// taint), caller passes to res.json() sink.
|
|
#[test]
|
|
fn cross_file_info_leak() {
|
|
let dir = fixture_path("cross_file_info_leak");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python `subprocess.run(cmd, shell=True)` where `cmd` is user-controlled ,
|
|
/// the multi-kwarg SHELL_ESCAPE gate activates. Validates end-to-end wiring
|
|
/// of `CallMeta.kwargs` through `classify_gated_sink`'s `dangerous_kwargs`
|
|
/// path (presence-aware shell=True → dangerous).
|
|
#[test]
|
|
fn python_subprocess_shell_true_tainted() {
|
|
let dir = fixture_path("python_subprocess_shell_true");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python `subprocess.run([cmd], shell=False)`, shell kwarg present but not
|
|
/// dangerous. The gate must not fire and no taint flow should be reported.
|
|
#[test]
|
|
fn python_subprocess_shell_false_safe() {
|
|
let dir = fixture_path("python_subprocess_shell_false_safe");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Python `subprocess.run([cmd])`, no shell kwarg (default shell=False).
|
|
/// The gate must not fire and no taint flow should be reported.
|
|
#[test]
|
|
fn python_subprocess_shell_default_safe() {
|
|
let dir = fixture_path("python_subprocess_shell_default_safe");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
// ── FP guard fixtures ─────────────────────────────────────────────────────
|
|
//
|
|
// Each fixture below is a small source file exercising a pattern where
|
|
// the analyser must NOT emit a taint-unsanitised-flow (with the single
|
|
// exception of `fp_guard_call_site_specialization_py`, which requires
|
|
// one finding only on the tainted call-site). The fixtures are grouped
|
|
// into five categories so a single regression cannot silently erase a
|
|
// whole category's coverage.
|
|
|
|
/// FP guard, sanitizer edge case: hand-rolled HTML escape covers
|
|
/// document.write sink.
|
|
#[test]
|
|
fn fp_guard_sanitizer_html_escape_js() {
|
|
let dir = fixture_path("fp_guards/sanitizer_html_escape_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, React JSX text-content auto-escape: `{expr}` interpolations
|
|
/// that are direct children of `jsx_element` / `jsx_fragment` tags carry an
|
|
/// implicit `Sanitizer(HTML_ESCAPE)` because React's renderer escapes HTML
|
|
/// metacharacters in text content. Closes ts-safe-010 (`safe_jsx_text.tsx`)
|
|
/// in `tests/benchmark`. Attribute interpolations and `dangerouslySetInnerHTML`
|
|
/// are NOT covered by this synthesis and remain in their existing sink path
|
|
/// (regression-checked by `tests/benchmark/corpus/typescript/xss/xss_dangerously_set_inner_html.tsx`).
|
|
#[test]
|
|
fn fp_guard_jsx_text_content_sanitizer_tsx() {
|
|
let dir = fixture_path("fp_guards/jsx_text_content_sanitizer_tsx");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, sanitizer edge case: shlex.quote with shell metacharacters.
|
|
#[test]
|
|
fn fp_guard_sanitizer_shlex_quote_py() {
|
|
let dir = fixture_path("fp_guards/sanitizer_shlex_quote_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, sanitizer edge case: encodeURIComponent on a URL argument.
|
|
#[test]
|
|
fn fp_guard_sanitizer_url_encode_js() {
|
|
let dir = fixture_path("fp_guards/sanitizer_url_encode_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, sanitizer edge case: multi-step chain (`.strip()` then
|
|
/// `shlex.quote`) preserves the final SHELL_ESCAPE cap.
|
|
#[test]
|
|
fn fp_guard_sanitizer_multi_step_py() {
|
|
let dir = fixture_path("fp_guards/sanitizer_multi_step_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, type-driven suppression: `int()` parse of env port
|
|
/// before `socket.bind`.
|
|
#[test]
|
|
fn fp_guard_types_int_port_py() {
|
|
let dir = fixture_path("fp_guards/types_int_port_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, type-driven suppression: `int()` parse guarantees SQL
|
|
/// concat is decimal-only.
|
|
#[test]
|
|
fn fp_guard_types_int_id_sql_py() {
|
|
let dir = fixture_path("fp_guards/types_int_id_sql_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, type-driven suppression: Go `strconv.Atoi` covers
|
|
/// Cap::all on the resulting int.
|
|
#[test]
|
|
fn fp_guard_types_parse_int_go() {
|
|
let dir = fixture_path("fp_guards/types_parse_int_go");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, type-driven suppression: bool comparison never reaches
|
|
/// a string-context sink.
|
|
#[test]
|
|
fn fp_guard_types_bool_flag_py() {
|
|
let dir = fixture_path("fp_guards/types_bool_flag_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, struct-field isolation: JS object `safeField` used at
|
|
/// sink, tainted `unsafeField` unused.
|
|
#[test]
|
|
fn fp_guard_fields_object_isolation_js() {
|
|
let dir = fixture_path("fp_guards/fields_object_isolation_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, struct-field isolation: Python class attributes, only
|
|
/// the hardcoded attribute flows to the sink.
|
|
#[test]
|
|
fn fp_guard_fields_class_attr_py() {
|
|
let dir = fixture_path("fp_guards/fields_class_attr_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, struct-field isolation: Python dict keys, only the
|
|
/// constant key flows to the sink.
|
|
#[test]
|
|
fn fp_guard_fields_dict_key_py() {
|
|
let dir = fixture_path("fp_guards/fields_dict_key_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, struct-field isolation: nested JS objects, sibling path
|
|
/// isolation at `cfg.auth.*`.
|
|
#[test]
|
|
fn fp_guard_fields_nested_object_js() {
|
|
let dir = fixture_path("fp_guards/fields_nested_object_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, cross-call-site specialization: same callee, two callers
|
|
/// (one tainted, one constant). Required finding only from the
|
|
/// tainted caller.
|
|
#[test]
|
|
fn fp_guard_call_site_specialization_py() {
|
|
let dir = fixture_path("fp_guards/call_site_specialization_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, cross-call-site specialization: JS helper called with a
|
|
/// literal SQL string must not inherit taint.
|
|
#[test]
|
|
fn fp_guard_call_site_specialization_js() {
|
|
let dir = fixture_path("fp_guards/call_site_specialization_js");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, cross-call-site specialization: helper called with a
|
|
/// shlex.quote-sanitised value, inline analysis sees SHELL_ESCAPE cap.
|
|
#[test]
|
|
fn fp_guard_call_site_sanitized_caller_py() {
|
|
let dir = fixture_path("fp_guards/call_site_sanitized_caller_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, cross-call-site specialization: polymorphic caller
|
|
/// (int branch and constant branch), neither carries a payload.
|
|
#[test]
|
|
fn fp_guard_call_site_polymorphic_py() {
|
|
let dir = fixture_path("fp_guards/call_site_polymorphic_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, framework-safe pattern: Rails `sanitize` before render.
|
|
#[test]
|
|
fn fp_guard_framework_rails_sanitize() {
|
|
let dir = fixture_path("fp_guards/framework_rails_sanitize");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, framework-safe pattern: Flask + MarkupSafe `escape`.
|
|
#[test]
|
|
fn fp_guard_framework_flask_escape() {
|
|
let dir = fixture_path("fp_guards/framework_flask_escape");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, framework-safe pattern: Express `res.json` with a
|
|
/// constant payload is not an XSS sink.
|
|
#[test]
|
|
fn fp_guard_framework_express_res_json() {
|
|
let dir = fixture_path("fp_guards/framework_express_res_json");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, broker-adapter receiver collisions: OSS-shaped handlers named
|
|
/// `handler` / `process` and a non-SQS `.send(...)` publisher must stay
|
|
/// ordinary helper code unless receiver facts prove the call is on a broker
|
|
/// runtime object.
|
|
#[test]
|
|
fn fp_guard_broker_adapter_receiver_collisions() {
|
|
let dir = fixture_path("fp_guards/broker_adapter_collisions");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Phase 21 adapter collisions: framework-marked files can contain
|
|
/// ordinary helpers, controller bootstrappers, mailer queues, and migration
|
|
/// formatting functions that must not be promoted to dynamic entry kinds.
|
|
#[test]
|
|
fn fp_guard_phase21_adapter_collisions() {
|
|
let dir = fixture_path("fp_guards/phase21_adapter_collisions");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, FastAPI `dependencies=[Depends(requires_access_*)]`
|
|
/// route-level guard short-circuits `auth_check_covers_subject` so
|
|
/// the handler body's path-param ORM calls and row-variable method
|
|
/// calls do not trip `py.auth.missing_ownership_check`. Pinned by
|
|
/// the `is_route_level` flag on `AuthCheck` plus the kind-aware
|
|
/// `function_params_route_handler` that includes id-like Python
|
|
/// typed params (`dag_id: str`) in `unit.params`.
|
|
#[test]
|
|
fn fp_guard_framework_fastapi_route_level_auth() {
|
|
let dir = fixture_path("fp_guards/framework_fastapi_route_level_auth");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, framework-safe pattern: JDBC PreparedStatement.setString
|
|
/// covers SQL_QUERY on the bound parameter.
|
|
#[test]
|
|
fn fp_guard_framework_prepared_stmt_java() {
|
|
let dir = fixture_path("fp_guards/framework_prepared_stmt_java");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, JPA parameterised execute chain
|
|
/// (`em.createQuery(LITERAL).setParameter(...).executeUpdate()`).
|
|
/// Pinned from a 150-finding cluster in keycloak's
|
|
/// `JpaEventStoreProvider.java`. The engine walks the receiver chain
|
|
/// from the zero-arg `.executeUpdate()` / `.executeQuery()` sink down
|
|
/// to the SQL-binding call (`createQuery` / `createNativeQuery`) and
|
|
/// synthesises a same-node `Sanitizer(SQL_QUERY)` when arg 0 is a
|
|
/// `string_literal`.
|
|
#[test]
|
|
fn fp_guard_framework_jpa_parameterised_execute() {
|
|
let dir = fixture_path("fp_guards/framework_jpa_parameterised_execute");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Strapi-style ORM accessor chain
|
|
/// (`<obj>.db.query(MODEL_UID).<orm_method>(...)`). Pinned from a
|
|
/// ~98-finding `cfg-unguarded-sink` + 40-finding `taint-unsanitised-flow`
|
|
/// cluster across strapi services (api-token, transfer/token, user,
|
|
/// release, …). When the chain shape `*.query(LITERAL).<orm_method>` ,
|
|
/// `findOne|findMany|findFirst|findUnique|find|create|createMany|update|
|
|
/// updateMany|upsert|delete|deleteMany|count|aggregate|distinct|save` ,
|
|
/// is detected, a same-node `Sanitizer(SQL_QUERY)` is synthesised that
|
|
/// reflexively dominates the sink. Bare `connection.query(...)` and
|
|
/// chained `.then` (Promise method) are not affected.
|
|
#[test]
|
|
fn fp_guard_framework_strapi_db_query_chain() {
|
|
let dir = fixture_path("fp_guards/framework_strapi_db_query_chain");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard: jest-style nested arrow callbacks
|
|
/// (`describe('...', () => { it('...', async () => { ... }) })`) bubble
|
|
/// inner-scope free vars (`body`, `userId`, `server.post`) up to the
|
|
/// outer arrow as synthetic Params. Before the fix, JS/TS auto-seed
|
|
/// treated every Param whose var_name matched a handler-name (e.g.
|
|
/// `userId` via the `user*` camelCase rule) as a real formal of the
|
|
/// outer arrow and seeded it as `Source(UserInput)`, producing 934
|
|
/// phantom `taint-unsanitised-flow` findings on outline alone (the
|
|
/// dominant cluster in the JS/TS slice baseline). Engine fix:
|
|
/// `lower_to_ssa_with_params` signals `with_params=true` to
|
|
/// `lower_to_ssa_inner`, which makes the synthetic-externals
|
|
/// classifier always exclude formals (even when the formal list is
|
|
/// empty, e.g. arrow `() => {…}`); bubbled-up free vars become
|
|
/// synthetic and the auto-seed pass skips them. Distilled from
|
|
/// `outline/server/routes/api/comments/comments.test.ts`.
|
|
#[test]
|
|
fn fp_guard_framework_jest_test_callback_arrow() {
|
|
let dir = fixture_path("fp_guards/framework_jest_test_callback_arrow");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, composer / PSR-4 autoloader closure includes a parameter.
|
|
/// Pinned from a 32-finding cluster in nextcloud's vendored
|
|
/// `composer/composer/ClassLoader.php` plus three further methods
|
|
/// (Router::requireRouteFile, Installer::includeAppScript,
|
|
/// Template/Base::load). The pattern rule fires syntactically on
|
|
/// `include $var`; without taint context it over-fires when `$var` is a
|
|
/// formal parameter of the immediately enclosing function/closure with
|
|
/// no intervening reassignment.
|
|
#[test]
|
|
fn fp_guard_php_include_param_passthrough() {
|
|
let dir = fixture_path("fp_guards/php_include_param_passthrough");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, `unserialize($x, ['allowed_classes' => …])` PHP 7+
|
|
/// structural mitigation against object injection. Pinned from
|
|
/// nextcloud's profiler / DAV custom-properties / queue-bus call sites
|
|
/// where `allowed_classes` is set to `false`, an array literal, or a
|
|
/// class constant referring to an explicit allow-list.
|
|
#[test]
|
|
fn fp_guard_php_unserialize_allowed_classes() {
|
|
let dir = fixture_path("fp_guards/php_unserialize_allowed_classes");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, `php.deser.unserialize` inside a PHPUnit assertion call
|
|
/// of the shape `$this->assertSame(LITERAL, unserialize($blob))` (and
|
|
/// the `assertEquals` / `assertNull` / `assertIsArray` family,
|
|
/// including `static::` / `self::` / `parent::` dispatch). Drupal,
|
|
/// Joomla, and Nextcloud each carry tens of these `Serializable` /
|
|
/// cache / session round-trip tests in their test trees; the literal
|
|
/// expected value bounds the `unserialize` result so a poisoned blob
|
|
/// would abort the test rather than escape an object-injection side
|
|
/// effect.
|
|
#[test]
|
|
fn fp_guard_php_unserialize_in_phpunit_assertion() {
|
|
let dir = fixture_path("fp_guards/php_unserialize_in_phpunit_assertion");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Python `unittest.TestCase` round-trip tests that wrap a
|
|
/// `pickle.loads` / `yaml.load` / `shelve.open` call in an assertion
|
|
/// whose other argument is a literal expected value. The same shape
|
|
/// that drives the PHP recogniser above: a poisoned blob would fail
|
|
/// the assertion rather than leak object-injection side effects out
|
|
/// of the test boundary. Suppresses both the `py.deser.*` AST-rule
|
|
/// finding AND the `cfg-unguarded-sink` mirror.
|
|
#[test]
|
|
fn fp_guard_python_deser_in_unittest_assertion() {
|
|
let dir = fixture_path("fp_guards/python_deser_in_unittest_assertion");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, pytest plain-`assert` round-trip tests. The assertion
|
|
/// reaches the deser through allowed wrappers (comparison vs literal,
|
|
/// `is None` / `is not None`, `in [LIT, ...]`, truthy bare assert,
|
|
/// `not deser`, `isinstance(deser, TYPE)`, `bool` / `len` single-arg
|
|
/// wrap). Same bounding semantics as the unittest variant: a
|
|
/// poisoned blob produces a different shape, the assertion fails, no
|
|
/// side effect escapes the test boundary.
|
|
#[test]
|
|
fn fp_guard_python_deser_in_pytest_assert() {
|
|
let dir = fixture_path("fp_guards/python_deser_in_pytest_assert");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Ruby `Marshal.load` / `YAML.load` round-trip patterns
|
|
/// inside Minitest assertion verbs (`assert_equal LIT, deser`,
|
|
/// `assert_nil deser`, `assert deser`, `assert_kind_of TYPE, deser`,
|
|
/// `refute_*` mirrors, `assert_includes LIT, deser`) and RSpec matcher
|
|
/// chains (`expect(deser).to eq(LIT)`, `be_nil`, `be_a(TYPE)`,
|
|
/// `be_truthy`, `match_array(LIT)`, `to`/`not_to`/`to_not`). Mirror
|
|
/// of the Python and PHP recognisers for Ruby test trees.
|
|
#[test]
|
|
fn fp_guard_ruby_deser_in_test_assertion() {
|
|
let dir = fixture_path("fp_guards/ruby_deser_in_test_assertion");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Hibernate / JPA DAO passthrough wrappers whose body is a
|
|
/// single chain `getSession().createQuery(formal)` (or a longer chain
|
|
/// like `getSession().getCriteriaBuilder().createQuery(formal)`). The
|
|
/// helper itself contributes no signal; whether each call site is
|
|
/// parameterised is a caller-side concern. The param-only filter must
|
|
/// recognise method-call chain segments as pseudo-uses so the wrapper
|
|
/// does not surface a structural `cfg-unguarded-sink` finding when
|
|
/// taint analysis found nothing actionable. Receiver-variable shapes
|
|
/// (`cursor.execute(name)`, `stmt.executeUpdate(name)`) keep the
|
|
/// finding because the receiver carries data the wrapper itself
|
|
/// cannot reason about without taint.
|
|
#[test]
|
|
fn fp_guard_cfg_unguarded_dao_passthrough_java() {
|
|
let dir = fixture_path("fp_guards/cfg_unguarded_dao_passthrough_java");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Liquibase changeset wrappers like
|
|
/// `Statement stmt = connection.createStatement(); stmt.executeQuery(sql);`.
|
|
/// Both `stmt` and `sql` show up in the sink's `taint.uses`. `sql` is a
|
|
/// formal parameter; `stmt` is a body-local whose every assignment is
|
|
/// derived from the `connection` parameter (`connection.createStatement()`
|
|
/// or `connection.unwrap().createStatement()`). The function is a thin
|
|
/// wrapper around its params, so `cfg-unguarded-sink` should not fire,
|
|
/// the structural backup adds no signal here. Receiver-variable shapes
|
|
/// without a parameter-derived definition (`cursor.execute(name)` where
|
|
/// `cursor` comes from module scope) still emit because their one-hop
|
|
/// trace fails.
|
|
#[test]
|
|
fn fp_guard_cfg_unguarded_liquibase_changeset_java() {
|
|
let dir = fixture_path("fp_guards/cfg_unguarded_liquibase_changeset_java");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Java `Class.forName(STATIC_FINAL_CONSTANT)` and similar
|
|
/// sink calls whose argument is a class-level `static final TYPE NAME =
|
|
/// LITERAL;` field reference. The field lives outside any function
|
|
/// body, so the per-function CFG one-hop trace and the per-function SSA
|
|
/// const-prop both treat the identifier as a runtime-dynamic value; the
|
|
/// structural rule then fires `cfg-unguarded-sink` on every call site.
|
|
/// The class-constant-scalars map collected at CFG build time exposes
|
|
/// these compile-time constants so the all-args-constant check picks
|
|
/// them up.
|
|
#[test]
|
|
fn fp_guard_cfg_unguarded_class_constant_java() {
|
|
let dir = fixture_path("fp_guards/cfg_unguarded_class_constant_java");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, file-level constant scalars across Python / Go / Rust. The
|
|
/// same gap the Java fixture closes also exists in other languages: a
|
|
/// module-level `NAME = LITERAL` (Python), package-level `const NAME =
|
|
/// LITERAL` (Go), and crate-level `const NAME: TYPE = LITERAL` (Rust)
|
|
/// resolve as free identifiers inside any function body, so neither the
|
|
/// CFG one-hop trace nor per-function SSA const-prop sees them as
|
|
/// constant. The same file-scalars map drives suppression of both the
|
|
/// structural `cfg-unguarded-sink` rule and the AST-pattern rules like
|
|
/// `py.cmdi.os_system` that gate on all-literal arguments.
|
|
#[test]
|
|
fn fp_guard_file_level_const_scalars_xlang() {
|
|
let dir = fixture_path("fp_guards/file_level_const_scalars_xlang");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Layer A literal-args suppression on Java
|
|
/// `method_invocation` and `object_creation_expression` shapes. The
|
|
/// AST gate's `find_enclosing_call` walker only matched node kinds
|
|
/// containing the substring `call`, so Java's `method_invocation`
|
|
/// (e.g. `Class.forName(MYSQL_DRIVER)`) and `object_creation_expression`
|
|
/// (e.g. `new Foo("literal")`) silently bypassed the suppression.
|
|
/// Every `Class.forName(LITERAL)` / `Class.forName(CONST)` then fired
|
|
/// `java.reflection.class_forname` regardless of whether the argument
|
|
/// was provably constant. Param-derived calls remain noisy because
|
|
/// taint cannot prove the input safe. The Crypto carve-out keeps
|
|
/// `MessageDigest.getInstance("MD5")` firing because the literal
|
|
/// algorithm name IS the weakness signal.
|
|
#[test]
|
|
fn fp_guard_ast_layer_a_java_call_args() {
|
|
let dir = fixture_path("fp_guards/ast_layer_a_java_call_args");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Crypto / Secrets / InsecureConfig / InsecureTransport
|
|
/// patterns must keep firing under the Layer A literal-args
|
|
/// suppression. Pre-fix, `hashlib.md5(b"static")` was treated as
|
|
/// "all-literal args" and silently suppressed even though MD5 is
|
|
/// weak regardless of input. The carve-out routes calls in those
|
|
/// categories around the suppression. The contrast call,
|
|
/// `os.system("ls -la /tmp")`, stays suppressed because a literal
|
|
/// command string carries no attacker-controlled data.
|
|
#[test]
|
|
fn fp_guard_ast_layer_a_crypto_carve_out_py() {
|
|
let dir = fixture_path("fp_guards/ast_layer_a_crypto_carve_out_py");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, resource-method summary builder must not propagate an
|
|
/// Acquire effect onto callers when the method's acquire is inside a
|
|
/// managed cleanup scope (Python `with`, Java try-with-resources, Ruby
|
|
/// File.open block). Pre-fix, every method body containing an `open(...)`
|
|
/// (or `FileInputStream(...)`) callee produced a method-name Acquire
|
|
/// summary regardless of whether the handle escaped receiver state;
|
|
/// callers like `obj.method()` were then marked OPEN forever, surfacing
|
|
/// `state-resource-leak subject=self` (58 findings on airflow) and the
|
|
/// caller-side `obj` leak. The fix gates the summary on
|
|
/// `info.managed_resource == false` and on `info.taint.defines.is_some()`
|
|
/// so anonymous (`return open(...)`) and managed-scope acquires no
|
|
/// longer poison receiver state.
|
|
#[test]
|
|
fn fp_guard_state_resource_method_summary_managed_xlang() {
|
|
let dir = fixture_path("fp_guards/state_resource_method_summary_managed_xlang");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Drupal Database Query subclasses use
|
|
/// `Connection::prepareStatement($sql, $opts, ...)` to obtain a
|
|
/// statement object then bind values out of band via
|
|
/// `$stmt->execute($values, $opts)`. Phase 15 added `stmt.execute`
|
|
/// as a SQL_QUERY sink, so without recognising `prepareStatement`
|
|
/// as a SQL_QUERY sanitizer (semantic twin of `prepare`) the rule
|
|
/// fires on every Truncate / Update / Delete / Insert / Upsert
|
|
/// subclass. Distilled from drupal core/lib/Drupal/Core/Database
|
|
/// /Query/{Truncate,Update,Delete,Insert,Upsert}.php.
|
|
#[test]
|
|
fn fp_guard_php_drupal_prepare_statement() {
|
|
let dir = fixture_path("fp_guards/php_drupal_prepare_statement");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Doctrine DBAL `QueryBuilder` chain (`$qb->select(...)
|
|
/// ->from(...)->where(...)->executeQuery()`). The terminal
|
|
/// `executeQuery` / `executeStatement` verbs take zero positional
|
|
/// args; the SQL is bound earlier on the chain via parameterised
|
|
/// API calls. Without a structural zero-arg suppression the flat
|
|
/// `executeQuery` SQL_QUERY sink rule fires every time, surfacing
|
|
/// ~160 cfg-unguarded-sink findings on a single nextcloud snapshot
|
|
/// (CalDavBackend, CardDavBackend, lib/private/DB). Distilled from
|
|
/// nextcloud apps/dav/lib/CalDAV/CalDavBackend.php /
|
|
/// CardDavBackend.php.
|
|
#[test]
|
|
fn fp_guard_php_doctrine_querybuilder() {
|
|
let dir = fixture_path("fp_guards/php_doctrine_querybuilder");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, thin PHP method wrappers that forward typed parameters to
|
|
/// an inner sink call on `$this`. `cfg-unguarded-sink` is a structural
|
|
/// rule with zero signal at the wrapper site (every arg is the wrapper's
|
|
/// own parameter); the real signal is at callers, which the taint engine
|
|
/// handles. The earlier `param_only && !in_entrypoint` suppression
|
|
/// missed PHP method wrappers because `taint.uses` carries pseudo-uses
|
|
/// for the chain receiver (`this`, `inner`) that aren't param names.
|
|
/// Filtering callee-fragment uses out of the param-only check before
|
|
/// comparing against the function's params closes the wrapper FP cluster
|
|
/// across nextcloud `Connection::executeUpdate`,
|
|
/// `ConnectionAdapter::executeQuery`, `ExtendedQueryBuilder::executeQuery`,
|
|
/// drupal validators / containers, and similar shapes.
|
|
#[test]
|
|
fn fp_guard_php_thin_method_wrapper() {
|
|
let dir = fixture_path("fp_guards/php_thin_method_wrapper");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Doctrine DBAL `QueryBuilder::executeQuery` /
|
|
/// `executeStatement` overloads that pass `$this->getSQL()` /
|
|
/// `$this->getParameters()` to a connection's flat `executeQuery` /
|
|
/// `executeStatement` overload. `getSQL()` is the canonical accessor
|
|
/// for the parameterised SQL string the builder constructed; the
|
|
/// receiver of the terminal verb is the connection (not a builder), so
|
|
/// the receiver-name suppression does not fire. The first-arg
|
|
/// accessor recognition closes the FP without depending on the
|
|
/// receiver shape. Distilled from nextcloud
|
|
/// `lib/private/DB/QueryBuilder/QueryBuilder.php`.
|
|
#[test]
|
|
fn fp_guard_php_dbal_builder_get_sql() {
|
|
let dir = fixture_path("fp_guards/php_dbal_builder_get_sql");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Doctrine DBAL `Platform::get*SQL(...)` family of safe DDL
|
|
/// builders. Methods like `getTruncateTableSQL`, `getCreateTableSQL`,
|
|
/// `getDropTableSQL` accept schema identifiers and emit DBMS-specific
|
|
/// DDL with no user payload. Migration code commonly binds the result
|
|
/// to a local then passes it to `$this->dbc->executeStatement($sql)`.
|
|
/// The first-arg accessor recognition walks back to the local's
|
|
/// defining Call to identify the safe accessor before deciding the
|
|
/// finding is structural noise. Distilled from nextcloud
|
|
/// `apps/user_ldap/lib/Migration/Version*.php` and `core/Migrations/
|
|
/// Version*.php`.
|
|
#[test]
|
|
fn fp_guard_php_dbal_platform_ddl_builder() {
|
|
let dir = fixture_path("fp_guards/php_dbal_platform_ddl_builder");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Doctrine DBAL builder chain whose local variable is named
|
|
/// after a verb (`forUpdate`) rather than a canonical builder name
|
|
/// (`qb` / `query` / `builder`). The receiver-name allowlist of the
|
|
/// zero-arg query-builder suppression doesn't match, but the local was
|
|
/// bound earlier in the body via `$this->connection->getQueryBuilder()`.
|
|
/// The receiver-defined-by-builder-factory back-walk recognises it via
|
|
/// the def-call's callee name (or via a source-text scan when the CFG
|
|
/// def-lookup misses a multi-line chained assignment nested inside
|
|
/// `try` / `for` blocks). Distilled from nextcloud
|
|
/// `lib/private/Files/Cache/Propagator.php`.
|
|
#[test]
|
|
fn fp_guard_php_dbal_builder_via_factory_def() {
|
|
let dir = fixture_path("fp_guards/php_dbal_builder_via_factory_def");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Doctrine DBAL `<builder>->getSQL()` accessor *composed*
|
|
/// with constant string-shaping ops:
|
|
/// AdapterMySQL: `preg_replace('/^INSERT/i', 'INSERT IGNORE',
|
|
/// $builder->getSQL())` patches the leading verb without
|
|
/// user payload.
|
|
/// AdapterSqlite: `$builder->getSQL() . ' ON CONFLICT DO NOTHING'`
|
|
/// appends a constant suffix.
|
|
/// The direct-accessor recognition (`sink_first_arg_is_builder_get_sql`)
|
|
/// only matches when arg 0 is itself the accessor or a local-var alias
|
|
/// of it; the composition recognition extends coverage to arg 0 *bytes*
|
|
/// containing a `$<builder>->getSQL(` token where every PHP variable in
|
|
/// the slice is bound by a query-builder factory. Distilled from
|
|
/// nextcloud `lib/private/DB/AdapterMySQL.php` and `AdapterSqlite.php`.
|
|
#[test]
|
|
fn fp_guard_php_dbal_builder_compose_sql() {
|
|
let dir = fixture_path("fp_guards/php_dbal_builder_compose_sql");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, PHP `foreach` over a literal-keyed array whose foreach-key
|
|
/// flows into a SQL_QUERY sink string interpolation
|
|
/// (`"SHOW VARIABLES LIKE '$var'"`). When `$variables` is built only
|
|
/// from `['LIT' => 'LIT', ...]` literal-keyed array initialisers and
|
|
/// optional `$variables['LIT'] = 'LIT';` subscript-set extensions, the
|
|
/// foreach-key ranges over a finite metachar-free literal set, so the
|
|
/// interpolated SQL is bounded. Negative case
|
|
/// (`UnsafeBypass.php`) iterates a method parameter; the suppression
|
|
/// must NOT fire and `cfg-unguarded-sink` must still emit. Distilled
|
|
/// from nextcloud `lib/private/DB/MySqlTools.php`.
|
|
#[test]
|
|
fn fp_guard_php_foreach_safe_literal_keys() {
|
|
let dir = fixture_path("fp_guards/php_foreach_safe_literal_keys");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, PHP `md5()` / `sha1()` weak-hash pattern rule firing
|
|
/// syntactically on every callsite. Real-world PHP uses these
|
|
/// functions pervasively for non-cryptographic purposes (ETag
|
|
/// generation, cache-key / array-index hashing, dedup fingerprints).
|
|
/// Layer F suppression recognises the consuming context — variable
|
|
/// LHS, member-access LHS, subscript LHS, array element key,
|
|
/// lookup-verb argument, return-from-method, hash-as-index — and
|
|
/// refuses to fire. Distilled from nextcloud apps/dav (CalDavBackend,
|
|
/// CardDavBackend, CardDav PhotoCache), apps/contactsinteraction,
|
|
/// apps/theming (Util / CommonThemeTrait), apps/encryption KeyManager,
|
|
/// apps/files Cache, and phpmyadmin Controllers/Database / Table /
|
|
/// Display / Favorites.
|
|
#[test]
|
|
fn fp_guard_php_md5_sha1_non_crypto_use() {
|
|
let dir = fixture_path("fp_guards/php_md5_sha1_non_crypto_use");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, JS / TS local-collection receivers. Pinned from the
|
|
/// excalidraw element-manipulation cluster (66 → ~9 on
|
|
/// `js.auth.missing_ownership_check` over the repo). The fix lives at
|
|
/// the deepest representable layer: SSA `TypeFacts::constructor_type`
|
|
/// recognises `new Map()` / `new Set()` / `new WeakMap()` /
|
|
/// `new WeakSet()` / `new Array()` as `TypeKind::LocalCollection`;
|
|
/// `cfg::params::ts_type_to_local_collection` extends
|
|
/// `classify_param_type_ts` so explicitly-typed params resolve to
|
|
/// `LocalCollection` independent of NestJS decorator presence;
|
|
/// `cfg::dto::collect_type_alias_local_collections` populates a
|
|
/// per-file `TYPE_ALIAS_LC` set so same-file `type X = Map<...>`
|
|
/// aliases also resolve. The auth analyser already exempts
|
|
/// `LocalCollection`-typed receivers via
|
|
/// `auth_analysis::sink_class_for_type → InMemoryLocal`.
|
|
#[test]
|
|
fn fp_guard_auth_local_collection_receiver() {
|
|
let dir = fixture_path("fp_guards/auth_local_collection_receiver");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, NextAuth callback definitions (`signIn`/`session`/`jwt`/
|
|
/// `authorize` etc.) are themselves the authentication boundary. Reads
|
|
/// and mutations against `user.id` / `existingUser.id` inside them
|
|
/// resolve the authenticated identity; they are not foreign-id lookups
|
|
/// driven by untrusted request input. `is_nextauth_callback_unit` in
|
|
/// `auth_analysis::checks` recognises these by name + canonical
|
|
/// callback-formal evidence (any of `user`/`token`/`account`/
|
|
/// `profile`/`credentials`/`session` in the destructured params) and
|
|
/// suppresses missing-ownership findings on every op kind.
|
|
#[test]
|
|
fn fp_guard_auth_nextauth_callback() {
|
|
let dir = fixture_path("fp_guards/auth_nextauth_callback");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, cal.com-shaped TRPC handlers whose parameter is a
|
|
/// destructured options alias (`{ ctx, input }: GetOptions`) where
|
|
/// `GetOptions` is a local type alias whose `ctx.user` is typed
|
|
/// `NonNullable<TrpcSessionUser>`. `collect_trpc_ctx_param` in
|
|
/// `auth_analysis::extract::common` recognises three shapes:
|
|
/// destructured shorthand, destructured rename (`ctx: c`), and plain
|
|
/// identifier (`opts: GetOptions`). All three add the appropriate
|
|
/// session-base entry to `self_scoped_session_bases` so `ctx.user.id`
|
|
/// resolves as authenticated actor context, not foreign-id targeting.
|
|
#[test]
|
|
fn fp_guard_auth_trpc_handler_options() {
|
|
let dir = fixture_path("fp_guards/auth_trpc_handler_options");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Go `fmt.Fprintf` flagged as an HTML_ESCAPE sink even when
|
|
/// the writer is a known non-response stream (`os.Stderr`, `os.Stdout`,
|
|
/// `io.Discard`, gin's package-level `DefaultErrorWriter` /
|
|
/// `DefaultWriter`). Without the writer-aware suppression in
|
|
/// `suppress_known_safe_callees`, gin's own `defer func() {
|
|
/// debugPrintError(err) }()` shape lights up because `debugPrintError`
|
|
/// summarises through the IPA path as param 0 → `fmt.Fprintf`
|
|
/// HTML_ESCAPE. The fixture also asserts the canonical
|
|
/// `fmt.Fprintf(w http.ResponseWriter, ...)` XSS path still fires so the
|
|
/// suppression does not over-clear.
|
|
#[test]
|
|
fn fp_guard_go_fmt_fprintf_safe_writer() {
|
|
let dir = fixture_path("fp_guards/go_fmt_fprintf_safe_writer");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Go `http.Redirect(w, r, urlExpr, code)` whose URL string is
|
|
/// derived from the same request's `*url.URL` (e.g. `r.URL.String()`,
|
|
/// `r.URL.Path`, `r.URL.RequestURI()`, `r.URL.EscapedPath()`). Such a
|
|
/// redirect echoes the inbound request's URL with at most path-only edits
|
|
/// — scheme/host are same-origin by construction — so OPEN_REDIRECT is
|
|
/// inapplicable. Without this gate, gin's `redirectTrailingSlash` /
|
|
/// `redirectFixedPath` / `redirectRequest` helpers record `param_to_sink`
|
|
/// for OPEN_REDIRECT through the inner `http.Redirect` and then surface
|
|
/// `taint-open-redirect` at every call site that reaches them with a
|
|
/// tainted `c.Request.URL`. The fixture also asserts that the canonical
|
|
/// attacker-controlled `r.FormValue → http.Redirect` shape still fires so
|
|
/// the gate does not over-clear.
|
|
#[test]
|
|
fn fp_guard_go_http_redirect_self_request() {
|
|
let dir = fixture_path("fp_guards/go_http_redirect_self_request");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, `new URL(req.body.path, BASE)` where `BASE` is a `const`
|
|
/// identifier bound to a literal origin must NOT fire SSRF — the
|
|
/// abstract-string singleton domain proves the origin is locked even
|
|
/// though the base arg is not a syntactic literal at the call site.
|
|
/// Negative control under `handler.ts` (base read from request body)
|
|
/// MUST still surface `taint-ssrf`.
|
|
#[test]
|
|
fn fp_guard_url_builder_const_base() {
|
|
let dir = fixture_path("fp_guards/url_builder_const_base");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, Java `final ... = Map.of(literal, literal, ...)` allowlist
|
|
/// fields suppress the free-identifier `<FIELD>.get(taintedKey)` lookup so
|
|
/// downstream sinks (here, `res.setHeader`) do NOT surface
|
|
/// `taint-header-injection`. Mirrors the CVE-2017-12629 patched
|
|
/// counterpart shape: the engine had no model for unresolved-receiver
|
|
/// container loads, so default arg-to-result propagation tainted the
|
|
/// lookup result even though every value in the map is a literal.
|
|
/// `safe_fields::collect_safe_lookup_fields` extracts the literal value
|
|
/// set during CFG construction; the SSA taint engine consults the per-
|
|
/// file view from `try_container_propagation`'s Load fallback and leaves
|
|
/// the result untainted. Recall control under `UnsafeBypass.java` MUST
|
|
/// still surface a `taint-header-injection`.
|
|
#[test]
|
|
fn fp_guard_java_safe_map_field_lookup() {
|
|
let dir = fixture_path("fp_guards/java_safe_map_field_lookup");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, third-party bundled / minified assets must be skipped before
|
|
/// parsing so vendored libraries (jQuery, htmx, Sortable, lodash) do not
|
|
/// surface findings the codebase author cannot remediate. `is_vendored_asset_path`
|
|
/// matches `*.min.js` / `*.bundle.js` / `*.umd.js` / `*.umd.min.js` / `*.iife.js`
|
|
/// suffixes plus `bower_components/` and (for front-end extensions only)
|
|
/// `vendor/` path components. Recall stays intact for genuine production
|
|
/// `.js` files; the negative control under `src/handler.js` MUST still
|
|
/// surface a `js.crypto.math_random` finding.
|
|
#[test]
|
|
fn fp_guard_vendored_assets_skip() {
|
|
let dir = fixture_path("fp_guards/vendored_assets_skip");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, C/C++ buffer-overflow pattern rules
|
|
/// (`c.memory.strcpy`, `strcat`, `sprintf`) over-fire when the source /
|
|
/// format-string argument is a literal whose contributed length is
|
|
/// statically bounded. Pinned from a 938-finding cluster across postgres
|
|
/// (`pg_prewarm/autoprewarm.c::apw_start_leader_worker`,
|
|
/// `formatting.c::DCH_a_m` ternary-of-literals, `datetime.c::EncodeDateTime`
|
|
/// `%.*s`/numeric-only sprintf). Layer D suppression in
|
|
/// `src/ast.rs::is_c_buffer_call_literal_safe`.
|
|
#[test]
|
|
fn fp_guard_c_buffer_literal_src() {
|
|
let dir = fixture_path("fp_guards/c_buffer_literal_src");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, `cpp.memory.reinterpret_cast` over-fires on every
|
|
/// `reinterpret_cast<T>(x)` syntactically — including the canonical
|
|
/// well-defined-by-aliasing-rules targets: byte-pointer family
|
|
/// (`char*`, `uint8_t*`, `std::byte*`), `void*`, the integer
|
|
/// round-trip types `uintptr_t` / `intptr_t`, and the BSD-socket
|
|
/// address family. These are exempt per [basic.lval]/11 and POSIX
|
|
/// socket-API contracts; suppressing them is a layer-2 structural fix
|
|
/// in `src/ast.rs::is_cpp_cast_target_type_safe`. Genuine
|
|
/// strict-aliasing UB casts (target is a user struct / class type)
|
|
/// keep firing. Distilled from bitcoin's leveldb / serialization /
|
|
/// IPC / netif shapes (109 → 55 findings on bitcoin in the
|
|
/// real-repo precision sweep).
|
|
#[test]
|
|
fn fp_guard_cpp_reinterpret_cast_byte_pointer() {
|
|
let dir = fixture_path("fp_guards/cpp_reinterpret_cast_byte_pointer");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, `rs.auth.missing_ownership_check` over-fires on Rust
|
|
/// helpers when (a) a parameter's TYPE annotation contains an
|
|
/// identifier whose lower-case form matches the framework-request-name
|
|
/// allow-list (`path`, `req`, `request`, `ctx`, `body`, …), e.g.
|
|
/// `dst: &std::path::Path` contributes the `Path` ident, or (b) a
|
|
/// receiver typed as an in-memory container (`RoaringBitmap`,
|
|
/// `HashMap<K, V>`, `HashSet<T>`) is treated as a `DbMutation` because
|
|
/// the verb-name dispatch (`is_mutation: insert/remove`) doesn't see
|
|
/// the type. Both clusters surfaced from meilisearch's
|
|
/// `index-scheduler` crate
|
|
/// (`scheduler/process_snapshot_creation.rs::remove_tasks` for (a),
|
|
/// `scheduler/enterprise_edition/network.rs::balance_shards` for (b)).
|
|
///
|
|
/// Engine fixes:
|
|
/// * `src/auth_analysis/extract/common.rs::collect_param_names` ,
|
|
/// added a Rust `parameter` arm that descends only into the
|
|
/// `pattern` field, never the `type` field. Type-segment idents
|
|
/// no longer pollute `unit.params` and the
|
|
/// `unit_has_user_input_evidence` gate stays closed on internal
|
|
/// helpers whose true params carry no user-input shape.
|
|
/// * `src/cfg/params.rs::rust_type_to_local_collection` (new) +
|
|
/// `classify_param_type_rust` rewire, Rust function-parameter
|
|
/// type annotations naming a known local-collection type
|
|
/// (`Vec`/`HashMap`/`HashSet`/`BTreeMap`/`BTreeSet`/`VecDeque`/
|
|
/// `BinaryHeap`/`LinkedList`/`IndexMap`/`IndexSet`/`SmallVec`/
|
|
/// `DashMap`/`DashSet`/`FxHashMap`/`FxHashSet`/`RoaringBitmap`/
|
|
/// `RoaringTreemap`, plus `[T; N]` / `[T]` array-and-slice
|
|
/// shorthand) classify the receiver as `TypeKind::LocalCollection`,
|
|
/// which `auth_analysis::sink_class_for_type` maps to
|
|
/// `SinkClass::InMemoryLocal` (non-auth-relevant).
|
|
/// * `src/ssa/type_facts.rs::is_rust_local_collection_constructor` ,
|
|
/// `RoaringBitmap` / `RoaringTreemap` added to the constructor-type
|
|
/// table so `let s = RoaringBitmap::new(); s.insert(...)` also
|
|
/// classifies correctly.
|
|
///
|
|
/// Persistent-store types like heed `Database<...>` / `sled::Db` /
|
|
/// `Mutex<HashMap<...>>` deliberately stay `None` so real IDOR
|
|
/// detection on persistent-store calls is preserved (covered by the
|
|
/// `unsafe_handler_local_collection_does_not_blanket_suppress.rs`
|
|
/// vulnerable counterpart).
|
|
#[test]
|
|
fn fp_guard_auth_rust_param_typed_local_collection() {
|
|
let dir = fixture_path("fp_guards/auth_rust_param_typed_local_collection");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// FP guard, JS/TS post-fetch ownership equality check. The cal.com
|
|
/// shape `const x = await repo.findById(id); if (x.userId !== session.
|
|
/// user.id) { notFound(); }` is the canonical post-fetch authorisation
|
|
/// idiom across Next.js codebases. Pre-fix the engine missed this
|
|
/// because `detect_ownership_equality_check` only ran on rust-style
|
|
/// `if_expression`, the strict-inequality operators `!==` / `===` were
|
|
/// not in the recognised set, framework denial calls
|
|
/// (`notFound`, `redirect`, `unauthorized`, `forbidden`) were not
|
|
/// recognised as early-exit terminators, and `collect_row_population`
|
|
/// missed JS/TS `variable_declarator` declarations because it only
|
|
/// read the `pattern` / `left` field. Each shape in the fixture
|
|
/// exercises one column of that matrix.
|
|
#[test]
|
|
fn fp_guard_auth_post_fetch_ownership_jsts() {
|
|
let dir = fixture_path("fp_guards/auth_post_fetch_ownership_jsts");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|
|
|
|
/// Panic guard, CFG condition-text truncation (and symex display
|
|
/// truncation) must round byte cuts down to the nearest UTF-8 char
|
|
/// boundary. Reproduces the gogs scan crash where
|
|
/// `public/plugins/codemirror-5.17.0/mode/gherkin/gherkin.js` ships a
|
|
/// long localised regex (Gurmukhi `ਖ`, Devanagari, CJK, Cyrillic…) inside
|
|
/// a boolean sub-condition; byte 256 landed inside `'ਖ'` (3-byte UTF-8)
|
|
/// and `t[..MAX_CONDITION_TEXT_LEN].to_string()` panicked the rayon
|
|
/// worker. Engine fix:
|
|
/// `src/utils/snippet.rs::truncate_at_char_boundary`, applied at three
|
|
/// CFG sites (`src/cfg/conditions.rs::push_condition_node`,
|
|
/// `emit_rust_match_guard_if`, `src/cfg/mod.rs::extract_condition`) and
|
|
/// two symex display sites (`src/symex/value.rs::Display`). Invariant:
|
|
/// scanning this file must terminate without panicking, regardless of
|
|
/// where byte 256 lands inside the regex literal.
|
|
#[test]
|
|
fn fp_guard_cfg_utf8_long_condition() {
|
|
let dir = fixture_path("fp_guards/cfg_utf8_long_condition");
|
|
let diags = scan_fixture_dir(&dir, AnalysisMode::Full);
|
|
validate_expectations(&diags, &dir);
|
|
}
|