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::>() ); let caps: HashSet = 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 /// (`.db.query(MODEL_UID).(...)`). 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).` , /// `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 `->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 `$->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`. `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 `.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(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`, `HashSet`) 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>` 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); }