mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
Critical bug fixes and recall improvements (#68)
This commit is contained in:
parent
7d0e7320e2
commit
55247b7fcd
352 changed files with 60069 additions and 900 deletions
|
|
@ -971,64 +971,56 @@ fn auth_analysis_does_not_run_in_cfg_mode() {
|
|||
diags.iter().all(|diag| !diag.id.starts_with("rs.auth.")),
|
||||
"CFG mode should not emit rs.auth findings"
|
||||
);
|
||||
assert!(
|
||||
// Per-file checks: CFG mode must not produce any *.auth.* finding on
|
||||
// each fixture file. We filter by id prefix (not path-only) so that
|
||||
// genuine taint flows the engine catches in CFG mode (e.g.
|
||||
// `ctx.body = { project }` data exfil after a query) don't trip the
|
||||
// assertion. The earlier global asserts above already cover the auth
|
||||
// rule prefixes; these per-file checks pin the intent that auth
|
||||
// analysis is fully gated on AST mode.
|
||||
let auth_in_file = |needle: &str| {
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("koa_scoped_read_missing.js")),
|
||||
.any(|d| d.path.contains(needle) && d.id.contains(".auth."))
|
||||
};
|
||||
assert!(
|
||||
!auth_in_file("koa_scoped_read_missing.js"),
|
||||
"CFG mode should not emit Koa auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("fastify_scoped_write_missing.js")),
|
||||
!auth_in_file("fastify_scoped_write_missing.js"),
|
||||
"CFG mode should not emit Fastify auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("flask_scoped_write_missing.py")),
|
||||
!auth_in_file("flask_scoped_write_missing.py"),
|
||||
"CFG mode should not emit Flask auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("django_cbv_scoped_write_missing.py")),
|
||||
!auth_in_file("django_cbv_scoped_write_missing.py"),
|
||||
"CFG mode should not emit Django auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("rails_scoped_write_missing.rb")),
|
||||
!auth_in_file("rails_scoped_write_missing.rb"),
|
||||
"CFG mode should not emit Rails auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("sinatra_scoped_read_missing.rb")),
|
||||
!auth_in_file("sinatra_scoped_read_missing.rb"),
|
||||
"CFG mode should not emit Sinatra auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("gin_admin_route_missing.go")),
|
||||
!auth_in_file("gin_admin_route_missing.go"),
|
||||
"CFG mode should not emit Gin auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("echo_partial_batch.go")),
|
||||
!auth_in_file("echo_partial_batch.go"),
|
||||
"CFG mode should not emit Echo auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("spring_scoped_read_missing.java")),
|
||||
!auth_in_file("spring_scoped_read_missing.java"),
|
||||
"CFG mode should not emit Spring auth-analysis findings"
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.all(|diag| !diag.path.contains("actix_scoped_write_missing.rs")),
|
||||
!auth_in_file("actix_scoped_write_missing.rs"),
|
||||
"CFG mode should not emit Rust auth-analysis findings"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -814,7 +814,8 @@
|
|||
"py.xss.jinja_from_string"
|
||||
],
|
||||
"allowed_alternative_rule_ids": [
|
||||
"taint-unsanitised-flow"
|
||||
"taint-unsanitised-flow",
|
||||
"taint-template-injection"
|
||||
],
|
||||
"forbidden_rule_ids": [],
|
||||
"expected_severity": "HIGH",
|
||||
|
|
@ -11087,6 +11088,12 @@
|
|||
"expected_severity": "MEDIUM",
|
||||
"expected_category": "Security",
|
||||
"expected_sink_lines": [
|
||||
[
|
||||
76,
|
||||
80
|
||||
]
|
||||
],
|
||||
"expected_call_site_lines": [
|
||||
[
|
||||
58,
|
||||
58
|
||||
|
|
@ -11104,7 +11111,7 @@
|
|||
"path_traversal",
|
||||
"rack-middleware"
|
||||
],
|
||||
"notes": "CVE-2023-38337: rswag-api Rack middleware concatenated env['PATH_INFO'] into the swagger root path with no validation; GET /../config/secrets.yml served arbitrary YAML/JSON files. Fixed in 2.10.1 by File.expand_path + start_with? rooted-path check. MIT"
|
||||
"notes": "CVE-2023-38337: rswag-api Rack middleware concatenated env['PATH_INFO'] into the swagger root path with no validation; GET /../config/secrets.yml served arbitrary YAML/JSON files. Fixed in 2.10.1 by File.expand_path + start_with? rooted-path check. After multi-hop attribution lands (2026-05-10 session 0008 from_chain flag), engine reports the deeper File.read sink at line 76 (load_yaml arm) or line 80 (load_json arm); the call site for parse_file remains at line 58 and is asserted via expected_call_site_lines. MIT"
|
||||
},
|
||||
{
|
||||
"case_id": "cve-rb-2023-38337-patched",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"benchmark_version": "1.0",
|
||||
"timestamp": "2026-05-04T17:11:50Z",
|
||||
"scanner_version": "0.6.1",
|
||||
"timestamp": "2026-05-11T15:19:43Z",
|
||||
"scanner_version": "0.7.0",
|
||||
"scanner_config": {
|
||||
"analysis_mode": "Full",
|
||||
"taint_enabled": true,
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"state_analysis_enabled": true,
|
||||
"worker_threads": 1
|
||||
},
|
||||
"ground_truth_hash": "sha256:414494ab1b6881a9b78eca38e26561231f78767480399fda73a477e23a9fcbaa",
|
||||
"ground_truth_hash": "sha256:00a4629e50841ab26c7ba947adfdab43b909d72d7a0885d604e702cc56552eb4",
|
||||
"corpus_size": 565,
|
||||
"cases_run": 562,
|
||||
"cases_skipped": 3,
|
||||
|
|
@ -739,14 +739,11 @@
|
|||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 25:19)"
|
||||
],
|
||||
"unexpected_rule_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"cfg-unguarded-sink",
|
||||
"taint-unsanitised-flow (source 25:19)"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"security_finding_count": 1,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -1541,7 +1538,7 @@
|
|||
"is_vulnerable": true,
|
||||
"outcome_file_level": "TP",
|
||||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "FN",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 43:28)"
|
||||
],
|
||||
|
|
@ -1578,14 +1575,16 @@
|
|||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"js.code_exec.eval",
|
||||
"taint-unsanitised-flow (source 24:5)",
|
||||
"taint-unsanitised-flow (source 24:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"js.code_exec.eval",
|
||||
"taint-unsanitised-flow (source 24:5)",
|
||||
"taint-unsanitised-flow (source 24:5)"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"security_finding_count": 3,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -1934,14 +1933,11 @@
|
|||
"matched_rule_ids": [
|
||||
"py.code_exec.eval"
|
||||
],
|
||||
"unexpected_rule_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"cfg-unguarded-sink",
|
||||
"py.code_exec.eval"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"security_finding_count": 1,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -2477,12 +2473,12 @@
|
|||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 73:5)",
|
||||
"taint-unsanitised-flow (source 72:20)"
|
||||
"taint-unsanitised-flow (source 73:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 73:5)",
|
||||
"taint-unsanitised-flow (source 72:20)"
|
||||
"taint-unsanitised-flow (source 73:5)"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"non_security_finding_count": 0
|
||||
|
|
@ -2512,13 +2508,15 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 50:5)",
|
||||
"taint-unsanitised-flow (source 50:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 50:5)",
|
||||
"taint-unsanitised-flow (source 50:5)"
|
||||
],
|
||||
"security_finding_count": 1,
|
||||
"security_finding_count": 2,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -2687,16 +2685,14 @@
|
|||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"cfg-error-fallthrough",
|
||||
"cfg-unguarded-sink",
|
||||
"go.sqli.query_concat"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"cfg-error-fallthrough",
|
||||
"cfg-unguarded-sink",
|
||||
"go.sqli.query_concat"
|
||||
],
|
||||
"security_finding_count": 3,
|
||||
"security_finding_count": 2,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -3748,13 +3744,15 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"state-resource-leak"
|
||||
"state-resource-leak",
|
||||
"taint-unsanitised-flow (source 6:23)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"state-resource-leak"
|
||||
"state-resource-leak",
|
||||
"taint-unsanitised-flow (source 6:23)"
|
||||
],
|
||||
"security_finding_count": 1,
|
||||
"security_finding_count": 2,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -4090,17 +4088,13 @@
|
|||
"language": "java",
|
||||
"vuln_class": "sqli",
|
||||
"is_vulnerable": true,
|
||||
"outcome_file_level": "TP",
|
||||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"outcome_file_level": "FN",
|
||||
"outcome_rule_level": "FN",
|
||||
"outcome_location_level": "FN",
|
||||
"matched_rule_ids": [],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"security_finding_count": 1,
|
||||
"all_finding_ids": [],
|
||||
"security_finding_count": 0,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -4141,7 +4135,7 @@
|
|||
"is_vulnerable": true,
|
||||
"outcome_file_level": "TP",
|
||||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "FN",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 25:28)"
|
||||
],
|
||||
|
|
@ -6247,16 +6241,16 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 6:5)",
|
||||
"py.cmdi.os_system"
|
||||
"py.cmdi.os_system",
|
||||
"taint-unsanitised-flow (source 6:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 6:5)",
|
||||
"cfg-unguarded-sink",
|
||||
"py.cmdi.os_system"
|
||||
"py.cmdi.os_system",
|
||||
"taint-unsanitised-flow (source 6:5)"
|
||||
],
|
||||
"security_finding_count": 3,
|
||||
"non_security_finding_count": 0
|
||||
|
|
@ -6846,6 +6840,7 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 17:11)",
|
||||
"taint-unsanitised-flow (source 17:11)"
|
||||
],
|
||||
"unexpected_rule_ids": [
|
||||
|
|
@ -6853,11 +6848,12 @@
|
|||
"py.sqli.execute_format"
|
||||
],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 17:11)",
|
||||
"state-resource-leak",
|
||||
"py.sqli.execute_format",
|
||||
"taint-unsanitised-flow (source 17:11)"
|
||||
],
|
||||
"security_finding_count": 3,
|
||||
"security_finding_count": 4,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -6892,11 +6888,11 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 5:12)"
|
||||
"taint-template-injection (source 5:12)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 5:12)"
|
||||
"taint-template-injection (source 5:12)"
|
||||
],
|
||||
"security_finding_count": 1,
|
||||
"non_security_finding_count": 0
|
||||
|
|
@ -9187,14 +9183,16 @@
|
|||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 5:5)",
|
||||
"ts.code_exec.eval"
|
||||
"ts.code_exec.eval",
|
||||
"taint-unsanitised-flow (source 5:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 5:5)",
|
||||
"ts.code_exec.eval"
|
||||
"ts.code_exec.eval",
|
||||
"taint-unsanitised-flow (source 5:5)"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"security_finding_count": 3,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -9915,14 +9913,11 @@
|
|||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 18:5)"
|
||||
],
|
||||
"unexpected_rule_ids": [
|
||||
"cfg-unguarded-sink"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"cfg-unguarded-sink",
|
||||
"taint-unsanitised-flow (source 18:5)"
|
||||
],
|
||||
"security_finding_count": 2,
|
||||
"security_finding_count": 1,
|
||||
"non_security_finding_count": 0
|
||||
},
|
||||
{
|
||||
|
|
@ -10033,33 +10028,35 @@
|
|||
"outcome_rule_level": "TP",
|
||||
"outcome_location_level": "TP",
|
||||
"matched_rule_ids": [
|
||||
"taint-unsanitised-flow (source 7:5)"
|
||||
"taint-unsanitised-flow (source 7:5)",
|
||||
"taint-unsanitised-flow (source 6:17)"
|
||||
],
|
||||
"unexpected_rule_ids": [],
|
||||
"all_finding_ids": [
|
||||
"taint-unsanitised-flow (source 7:5)"
|
||||
"taint-unsanitised-flow (source 7:5)",
|
||||
"taint-unsanitised-flow (source 6:17)"
|
||||
],
|
||||
"security_finding_count": 1,
|
||||
"security_finding_count": 2,
|
||||
"non_security_finding_count": 0
|
||||
}
|
||||
],
|
||||
"aggregate_file_level": {
|
||||
"tp": 275,
|
||||
"tp": 274,
|
||||
"fp": 0,
|
||||
"fn_": 0,
|
||||
"fn_": 1,
|
||||
"tn": 287,
|
||||
"precision": 1.0,
|
||||
"recall": 1.0,
|
||||
"f1": 1.0
|
||||
"recall": 0.9963636363636363,
|
||||
"f1": 0.9981785063752276
|
||||
},
|
||||
"aggregate_rule_level": {
|
||||
"tp": 275,
|
||||
"tp": 274,
|
||||
"fp": 0,
|
||||
"fn_": 0,
|
||||
"fn_": 1,
|
||||
"tn": 287,
|
||||
"precision": 1.0,
|
||||
"recall": 1.0,
|
||||
"f1": 1.0
|
||||
"recall": 0.9963636363636363,
|
||||
"f1": 0.9981785063752276
|
||||
},
|
||||
"by_language": {
|
||||
"c": {
|
||||
|
|
@ -10090,13 +10087,13 @@
|
|||
"f1": 1.0
|
||||
},
|
||||
"java": {
|
||||
"tp": 23,
|
||||
"tp": 22,
|
||||
"fp": 0,
|
||||
"fn_": 0,
|
||||
"fn_": 1,
|
||||
"tn": 23,
|
||||
"precision": 1.0,
|
||||
"recall": 1.0,
|
||||
"f1": 1.0
|
||||
"recall": 0.9565217391304348,
|
||||
"f1": 0.9777777777777777
|
||||
},
|
||||
"javascript": {
|
||||
"tp": 25,
|
||||
|
|
@ -10317,13 +10314,13 @@
|
|||
"f1": 1.0
|
||||
},
|
||||
"sqli": {
|
||||
"tp": 37,
|
||||
"tp": 36,
|
||||
"fp": 0,
|
||||
"fn_": 0,
|
||||
"fn_": 1,
|
||||
"tn": 0,
|
||||
"precision": 1.0,
|
||||
"recall": 1.0,
|
||||
"f1": 1.0
|
||||
"recall": 0.972972972972973,
|
||||
"f1": 0.9863013698630138
|
||||
},
|
||||
"ssrf": {
|
||||
"tp": 32,
|
||||
|
|
@ -10355,22 +10352,22 @@
|
|||
"f1": 0.3586497890295359
|
||||
},
|
||||
">=Low": {
|
||||
"tp": 86,
|
||||
"tp": 85,
|
||||
"fp": 142,
|
||||
"fn_": 189,
|
||||
"fn_": 190,
|
||||
"tn": 145,
|
||||
"precision": 0.37719298245614036,
|
||||
"recall": 0.31272727272727274,
|
||||
"f1": 0.341948310139165
|
||||
"precision": 0.3744493392070485,
|
||||
"recall": 0.3090909090909091,
|
||||
"f1": 0.33864541832669326
|
||||
},
|
||||
">=Medium": {
|
||||
"tp": 86,
|
||||
"tp": 85,
|
||||
"fp": 133,
|
||||
"fn_": 189,
|
||||
"fn_": 190,
|
||||
"tn": 154,
|
||||
"precision": 0.3926940639269406,
|
||||
"recall": 0.31272727272727274,
|
||||
"f1": 0.3481781376518218
|
||||
"precision": 0.38990825688073394,
|
||||
"recall": 0.3090909090909091,
|
||||
"f1": 0.3448275862068966
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// Shared test helpers for integration and perf tests.
|
||||
|
||||
pub mod recall;
|
||||
|
||||
use nyx_scanner::commands::scan::Diag;
|
||||
use nyx_scanner::utils::config::{AnalysisMode, Config};
|
||||
use serde::Deserialize;
|
||||
|
|
|
|||
161
tests/common/recall.rs
Normal file
161
tests/common/recall.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Recall-gap fixture harness.
|
||||
//!
|
||||
//! Exposes `scan_fixture`, `assert_finding`, and `ExpectedFinding` for the
|
||||
//! integration test binary `tests/recall_gaps.rs`. Phases 02–11 each own one
|
||||
//! fixture under `tests/fixtures/realistic/` and one matching test.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use nyx_scanner::commands::scan::Diag as Finding;
|
||||
use nyx_scanner::utils::config::Config;
|
||||
|
||||
/// Copy `tests/fixtures/realistic/<rel_path>` into a fresh temp directory and
|
||||
/// run a two-pass filesystem scan against the copy. Isolating in tempdir
|
||||
/// prevents SQLite or `nyx.conf` artefacts from leaking between tests.
|
||||
///
|
||||
/// Accepts either a directory or a single file. When `rel_path` resolves
|
||||
/// to a regular file the harness copies just that file (preserving its
|
||||
/// basename) — useful for fixture areas where each test owns its own file
|
||||
/// and the directory-wide rescan would multiply wall time on cold caches.
|
||||
pub fn scan_fixture(rel_path: &str) -> Vec<Finding> {
|
||||
let src: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/realistic")
|
||||
.join(rel_path);
|
||||
assert!(src.exists(), "recall fixture not found: {}", src.display());
|
||||
let tmp = tempfile::tempdir().expect("tempdir for recall fixture");
|
||||
if src.is_file() {
|
||||
let name = src
|
||||
.file_name()
|
||||
.unwrap_or_else(|| panic!("fixture has no filename: {}", src.display()));
|
||||
fs::copy(&src, tmp.path().join(name)).expect("copy single fixture file into tempdir");
|
||||
} else {
|
||||
copy_dir_recursive(&src, tmp.path()).expect("copy fixture into tempdir");
|
||||
}
|
||||
|
||||
let cfg = Config::default();
|
||||
nyx_scanner::scan_no_index(tmp.path(), &cfg).expect("scan_no_index on recall fixture")
|
||||
}
|
||||
|
||||
/// Shape used by `recall_gaps.rs` tests to assert a specific finding exists.
|
||||
///
|
||||
/// - `rule_id` matches the rule prefix of `Diag.id`. Taint findings carry a
|
||||
/// trailing ` (source N:M)` suffix; this struct compares only the prefix.
|
||||
/// - `file_suffix` matches `Diag.path.ends_with(file_suffix)` so callers do
|
||||
/// not have to reproduce the tempdir prefix.
|
||||
/// - `sink_line` matches `Diag.line` exactly (1-based).
|
||||
/// - `source_line`, when `Some`, matches the `N` parsed from the trailing
|
||||
/// ` (source N:M)` suffix on `Diag.id`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExpectedFinding {
|
||||
pub rule_id: &'static str,
|
||||
pub file_suffix: &'static str,
|
||||
pub sink_line: usize,
|
||||
pub source_line: Option<usize>,
|
||||
}
|
||||
|
||||
/// Assert that at least one finding in `findings` matches `expected`.
|
||||
pub fn assert_finding(findings: &[Finding], expected: ExpectedFinding) {
|
||||
let hit = findings.iter().any(|f| {
|
||||
rule_id_prefix(&f.id) == expected.rule_id
|
||||
&& f.path.ends_with(expected.file_suffix)
|
||||
&& f.line == expected.sink_line
|
||||
&& match expected.source_line {
|
||||
None => true,
|
||||
Some(want) => parse_source_line(&f.id) == Some(want),
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
hit,
|
||||
"expected recall finding not produced: {expected:?}\nactual findings:\n{}",
|
||||
findings
|
||||
.iter()
|
||||
.map(|f| format!(
|
||||
" {} :: {}:{} [{}]",
|
||||
f.id,
|
||||
f.path,
|
||||
f.line,
|
||||
f.severity.as_db_str()
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Like [`assert_finding`] but also requires that the matched finding's
|
||||
/// resolved sink capability bits include all of `cap_bits`. Use to defend
|
||||
/// against a coincidentally co-located finding at the same `sink_line`
|
||||
/// (e.g. an XSS sink on `res.json(rows)` happening to sit on the same
|
||||
/// line as the SQL_QUERY sink the test actually wants to assert) silently
|
||||
/// satisfying the assertion. Pass `Cap::FOO.bits().into()` from the
|
||||
/// caller.
|
||||
pub fn assert_finding_with_cap(findings: &[Finding], expected: ExpectedFinding, cap_bits: u32) {
|
||||
let hit = findings.iter().any(|f| {
|
||||
rule_id_prefix(&f.id) == expected.rule_id
|
||||
&& f.path.ends_with(expected.file_suffix)
|
||||
&& f.line == expected.sink_line
|
||||
&& match expected.source_line {
|
||||
None => true,
|
||||
Some(want) => parse_source_line(&f.id) == Some(want),
|
||||
}
|
||||
&& f.evidence
|
||||
.as_ref()
|
||||
.map(|e| e.sink_caps & cap_bits == cap_bits)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(
|
||||
hit,
|
||||
"expected recall finding not produced: {expected:?} (cap_bits=0x{cap_bits:x})\nactual findings:\n{}",
|
||||
findings
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let caps = f.evidence.as_ref().map(|e| e.sink_caps).unwrap_or(0);
|
||||
format!(
|
||||
" {} :: {}:{} [{}] caps=0x{:x}",
|
||||
f.id,
|
||||
f.path,
|
||||
f.line,
|
||||
f.severity.as_db_str(),
|
||||
caps,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
fn rule_id_prefix(id: &str) -> &str {
|
||||
match id.find(" (source ") {
|
||||
Some(idx) => &id[..idx],
|
||||
None => id,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_source_line(id: &str) -> Option<usize> {
|
||||
let needle = " (source ";
|
||||
let start = id.find(needle)? + needle.len();
|
||||
let rest = &id[start..];
|
||||
let end = rest.find(':').or_else(|| rest.find(')'))?;
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let from = entry.path();
|
||||
let name = entry.file_name();
|
||||
if name == ".gitkeep" {
|
||||
continue;
|
||||
}
|
||||
let to = dst.join(&name);
|
||||
if from.is_dir() {
|
||||
copy_dir_recursive(&from, &to)?;
|
||||
} else {
|
||||
fs::copy(&from, &to)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
23
tests/fixtures/async_promise_chain_js/README.md
vendored
23
tests/fixtures/async_promise_chain_js/README.md
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# async_promise_chain_js — known gap
|
||||
# async_promise_chain_js — chained-receiver promise taint
|
||||
|
||||
## Intended flow
|
||||
A promise chain reads `process.env.PREFIX` inside the second `.then`
|
||||
|
|
@ -6,14 +6,15 @@ callback, concatenates it with fetched text, and sinks the result via
|
|||
`child_process.exec` from the third callback. The intended finding is
|
||||
`taint-unsanitised-flow` from the env source to the exec sink.
|
||||
|
||||
## Current engine behaviour
|
||||
The scanner produces **no** taint finding for this fixture. Tracking
|
||||
taint across chained promise callbacks requires reasoning about the
|
||||
promise resolution value returned from each arrow, which the engine
|
||||
does not model today.
|
||||
## Engine behaviour
|
||||
The engine now closes this gap. The chained-receiver promise shape
|
||||
(`fetch(...).then(..).then(..).then(..)`) keeps each `.then` call's
|
||||
identity at the CFG level so `try_apply_promise_callback` and the
|
||||
synthetic `source_to_callback` emission see the chain head's Source
|
||||
label and seed the callback's first parameter, propagating taint
|
||||
through the chain to the `exec` sink.
|
||||
|
||||
## Why this expectation is codified as a `forbidden_findings` entry
|
||||
The fixture asserts current behaviour so a future improvement that
|
||||
closes the gap — e.g. promise resolution modelling or coarser
|
||||
callback return propagation — must update `expectations.json` and
|
||||
delete this README.
|
||||
## Expectation
|
||||
`required_findings` pins the taint flow finding so a future
|
||||
regression that re-collapses the chain (e.g. an inner-call rewrite
|
||||
that erases the outer `.then` identity) will fail this test.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "taint-"
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [],
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
|
|
|
|||
33
tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/crypto_demo.py
vendored
Normal file
33
tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/crypto_demo.py
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Pin the Crypto carve-out for the Layer A literal-args suppression.
|
||||
|
||||
Pre-fix, ``hashlib.md5(b"hello")`` was treated as "all-literal args"
|
||||
and silently suppressed. The literal IS the weakness signal here: MD5
|
||||
is the algorithm choice. Suppressing the call erases the actual
|
||||
finding even though no taint flows through it.
|
||||
|
||||
The same shape applies to ``hashlib.sha1``. Both must keep firing.
|
||||
|
||||
CommandExec / SqlInjection patterns stay covered by the literal-args
|
||||
suppression: a literal command string or literal SQL string carries
|
||||
no attacker-controlled data, so silencing those is correct. The
|
||||
``os.system("ls -la")`` call demonstrates the contrast.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
def hash_with_literal_bytes() -> bytes:
|
||||
return hashlib.md5(b"static-string").hexdigest().encode()
|
||||
|
||||
|
||||
def hash_with_literal_sha1() -> bytes:
|
||||
return hashlib.sha1(b"another-static").hexdigest().encode()
|
||||
|
||||
|
||||
def hash_with_user_data(data: bytes) -> bytes:
|
||||
return hashlib.md5(data).hexdigest().encode()
|
||||
|
||||
|
||||
def safe_command_literal() -> int:
|
||||
return os.system("ls -la /tmp")
|
||||
19
tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/expectations.json
vendored
Normal file
19
tests/fixtures/fp_guards/ast_layer_a_crypto_carve_out_py/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{ "id_prefix": "py.crypto.md5", "min_count": 1 },
|
||||
{ "id_prefix": "py.crypto.sha1", "min_count": 1 }
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "py.cmdi.os_system" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 6,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
27
tests/fixtures/fp_guards/ast_layer_a_java_call_args/DriverLoader.java
vendored
Normal file
27
tests/fixtures/fp_guards/ast_layer_a_java_call_args/DriverLoader.java
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package guards;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
|
||||
public class DriverLoader {
|
||||
|
||||
private static final String MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver";
|
||||
private static final String POSTGRES_DRIVER = "org.postgresql.Driver";
|
||||
|
||||
public void loadKnownDriverByLiteral() throws Exception {
|
||||
Class.forName("com.mysql.cj.jdbc.Driver");
|
||||
}
|
||||
|
||||
public void loadKnownDriverByConst() throws Exception {
|
||||
Class.forName(MYSQL_DRIVER);
|
||||
Class.forName(POSTGRES_DRIVER);
|
||||
}
|
||||
|
||||
public void loadCallerSuppliedDriver(String driver) throws Exception {
|
||||
Class.forName(driver);
|
||||
}
|
||||
|
||||
public byte[] hashWithLiteralAlgo() throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
return md.digest("payload".getBytes());
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/ast_layer_a_java_call_args/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/ast_layer_a_java_call_args/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{ "id_prefix": "java.reflection.class_forname", "min_count": 1 },
|
||||
{ "id_prefix": "java.crypto.weak_digest", "min_count": 1 }
|
||||
],
|
||||
"forbidden_findings": [],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 6,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/auth_nextauth_callback/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/auth_nextauth_callback/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "js.auth.missing_ownership_check" },
|
||||
{ "id_prefix": "ts.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 1
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
95
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-custom-adapter.ts
vendored
Normal file
95
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-custom-adapter.ts
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// cal.com NextAuth Adapter factory shape. The function returns the
|
||||
// Adapter implementation directly, with no `callbacks: { ... }`
|
||||
// wrapper. Inner method bodies are object method shorthands that
|
||||
// don't become their own units, so every identity-resolution
|
||||
// operation inside them accumulates onto the OUTER `CalComAdapter`
|
||||
// unit. Without the Adapter-shape arm of `body_returns_nextauth_options`,
|
||||
// `is_nextauth_callback_unit` cannot match by name and the
|
||||
// missing-ownership rule fires on every `prismaClient.user.findUnique`
|
||||
// / `prismaClient.account.findUnique` call.
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
type AdapterUser = { id: string; email: string; emailVerified?: Date };
|
||||
type AdapterAccount = {
|
||||
userId: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
};
|
||||
|
||||
const toAdapterUser = (user: { id: number; email: string }): AdapterUser => ({
|
||||
id: user.id.toString(),
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
const getAccountWhere = (provider: string, providerAccountId: string) => ({
|
||||
provider_providerAccountId: { provider, providerAccountId },
|
||||
});
|
||||
|
||||
export default function CalComAdapter(prismaClient: typeof prisma) {
|
||||
return {
|
||||
createUser: async (data: Omit<AdapterUser, "id">) => {
|
||||
const user = await prismaClient.user.create({ data });
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
|
||||
getUser: async (id: string) => {
|
||||
const user = await prismaClient.user.findUnique({ where: { id: parseInt(id, 10) } });
|
||||
return user ? toAdapterUser(user) : null;
|
||||
},
|
||||
|
||||
getUserByEmail: async (email: string) => {
|
||||
const user = await prismaClient.user.findUnique({ where: { email } });
|
||||
return user ? toAdapterUser(user) : null;
|
||||
},
|
||||
|
||||
async getUserByAccount(providerAccountId: { provider: string; providerAccountId: string }) {
|
||||
const account = await prismaClient.account.findUnique({
|
||||
where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId),
|
||||
select: { user: true },
|
||||
});
|
||||
return account?.user ? toAdapterUser(account.user) : null;
|
||||
},
|
||||
|
||||
updateUser: async (userData: AdapterUser) => {
|
||||
const { id, ...data } = userData;
|
||||
const user = await prismaClient.user.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data,
|
||||
});
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
|
||||
deleteUser: async (userId: string) => {
|
||||
const user = await prismaClient.user.delete({ where: { id: parseInt(userId, 10) } });
|
||||
return toAdapterUser(user);
|
||||
},
|
||||
|
||||
createVerificationToken: async (data: { identifier: string; token: string; expires: Date }) => {
|
||||
const token = await prismaClient.verificationToken.create({ data });
|
||||
return token;
|
||||
},
|
||||
|
||||
useVerificationToken: async (identifier_token: { identifier: string; token: string }) => {
|
||||
const token = await prismaClient.verificationToken.delete({ where: { identifier_token } });
|
||||
return token;
|
||||
},
|
||||
|
||||
linkAccount: async (account: AdapterAccount) => {
|
||||
const created = await prismaClient.account.create({ data: account });
|
||||
return created;
|
||||
},
|
||||
|
||||
unlinkAccount: async (providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">) => {
|
||||
const deleted = await prismaClient.account.delete({
|
||||
where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId),
|
||||
});
|
||||
return deleted;
|
||||
},
|
||||
|
||||
createSession: async (session: { sessionToken: string; userId: string; expires: Date }) => session,
|
||||
getSessionAndUser: async () => null,
|
||||
updateSession: async (session: { sessionToken: string }) => ({ sessionToken: session.sessionToken }),
|
||||
deleteSession: async () => undefined,
|
||||
};
|
||||
}
|
||||
57
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options-factory.ts
vendored
Normal file
57
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options-factory.ts
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Same NextAuth options content, but exposed via a factory arrow that
|
||||
// returns the options object. Matches cal.com's `getOptions` shape:
|
||||
//
|
||||
// export const getOptions = (deps): AuthOptions => ({
|
||||
// callbacks: { async signIn(...) { ... }, async jwt(...) { ... } },
|
||||
// });
|
||||
//
|
||||
// The top-level unit-creation pass attributes every operation inside
|
||||
// the inner callback methods to the OUTER arrow's unit, because object
|
||||
// method shorthands are not enumerated as their own units. Without the
|
||||
// factory-aware suppressor the outer unit name is `getOptions`, not
|
||||
// `jwt`, so `is_nextauth_callback_unit`'s name match fails and the
|
||||
// missing-ownership-check rule fires on every identity-resolution
|
||||
// operation inside the callbacks.
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
type Token = { sub?: string };
|
||||
type Account = { provider: string };
|
||||
type Profile = { email?: string };
|
||||
type User = { id: number; email: string };
|
||||
|
||||
export const getOptions = ({
|
||||
getDubId,
|
||||
getTrackingData,
|
||||
}: {
|
||||
getDubId: () => string | undefined;
|
||||
getTrackingData: () => any;
|
||||
}) => ({
|
||||
callbacks: {
|
||||
async signIn({ user, account, profile }: { user: User; account: Account; profile: Profile }) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastSignInProvider: account.provider },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async session({ session, user, token }: { session: any; user: User; token: Token }) {
|
||||
const existingUser = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
if (!existingUser) return session;
|
||||
const profile = await prisma.profile.findUnique({ where: { userId: existingUser.id } });
|
||||
session.user = { ...session.user, profileId: profile?.id };
|
||||
return session;
|
||||
},
|
||||
|
||||
async jwt({ token, user, account }: { token: Token; user?: User; account?: Account }) {
|
||||
if (user) {
|
||||
const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
},
|
||||
});
|
||||
54
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options.ts
vendored
Normal file
54
tests/fixtures/fp_guards/auth_nextauth_callback/next-auth-options.ts
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Minimal NextAuth-style configuration. The named callbacks below are
|
||||
// the authentication boundary itself: operations on `user.id` /
|
||||
// `existingUser.id` inside them resolve the authenticated identity,
|
||||
// they are not request-driven foreign-id lookups. The auth analyser
|
||||
// must NOT flag js.auth.missing_ownership_check on these operations.
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
type Token = { sub?: string };
|
||||
type Account = { provider: string; providerAccountId: string };
|
||||
type Profile = { email?: string };
|
||||
type User = { id: number; email: string };
|
||||
|
||||
export const authOptions = {
|
||||
callbacks: {
|
||||
async signIn({ user, account, profile }: { user: User; account: Account; profile: Profile }) {
|
||||
// Authentication-time mutation: record provider linkage on the
|
||||
// authenticated user. Not a tenant-scoped resource lookup.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastSignInProvider: account.provider },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async session({ session, user, token }: { session: any; user: User; token: Token }) {
|
||||
// Identity-resolution read against `user.id` / `token.sub`.
|
||||
const existingUser = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
if (!existingUser) return session;
|
||||
const profile = await prisma.profile.findUnique({ where: { userId: existingUser.id } });
|
||||
session.user = { ...session.user, profileId: profile?.id };
|
||||
return session;
|
||||
},
|
||||
|
||||
async jwt({ token, user, account }: { token: Token; user?: User; account?: Account }) {
|
||||
if (user) {
|
||||
const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async authorize(credentials: { email: string; password: string }) {
|
||||
// Credentials-provider authorize: looks up the user by email and
|
||||
// verifies the password. Authentication boundary, not foreign-id
|
||||
// targeting.
|
||||
const user = await prisma.user.findUnique({ where: { email: credentials.email } });
|
||||
if (!user) return null;
|
||||
return { id: user.id, email: user.email };
|
||||
},
|
||||
},
|
||||
};
|
||||
17
tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "js.auth.missing_ownership_check" },
|
||||
{ "id_prefix": "ts.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
103
tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/page.tsx
vendored
Normal file
103
tests/fixtures/fp_guards/auth_post_fetch_ownership_jsts/page.tsx
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// FP guard for cal.com-shape post-fetch ownership equality checks
|
||||
// in JS/TS Next.js page handlers.
|
||||
//
|
||||
// The handler fetches a row by id, then verifies the row's owner field
|
||||
// matches the session user via strict-inequality. Failure calls the
|
||||
// framework denial helper (notFound, redirect, forbidden, unauthorized)
|
||||
// to terminate the request. This shape is canonical post-fetch
|
||||
// authorization across cal.com and other Next.js codebases.
|
||||
//
|
||||
// Pre-fix the engine missed this for three reasons:
|
||||
// 1. detect_ownership_equality_check only ran for if_expression (Rust),
|
||||
// not if_statement (JS/TS/Java/Python/Go/PHP).
|
||||
// 2. is_ne / is_eq matched "!=" / "==" but not the JS/TS strict variants
|
||||
// "!==" / "===".
|
||||
// 3. branch_has_early_exit only matched return / throw. notFound() and
|
||||
// similar Next.js denial helpers are call_expression and were
|
||||
// invisible.
|
||||
// 4. collect_row_population only read pattern/left, missing the
|
||||
// JS/TS variable_declarator name field.
|
||||
//
|
||||
// Each shape below exercises one column of the matrix.
|
||||
|
||||
import { notFound, redirect, unauthorized, forbidden } from "next/navigation";
|
||||
|
||||
declare class WebhookRepository {
|
||||
static getInstance(): WebhookRepository;
|
||||
findByWebhookId(id: string | undefined): Promise<{ userId: number }>;
|
||||
}
|
||||
|
||||
declare function getServerSession(): Promise<{ user?: { id: number } } | null>;
|
||||
|
||||
// 1. notFound() denial in if_statement with !== strict inequality.
|
||||
export async function pageNotFound({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId !== session.user.id) {
|
||||
notFound();
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 2. redirect() denial.
|
||||
export async function pageRedirect({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId !== session.user.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 3. unauthorized() denial.
|
||||
export async function pageUnauthorized({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId !== session.user.id) {
|
||||
unauthorized();
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 4. forbidden() denial.
|
||||
export async function pageForbidden({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId !== session.user.id) {
|
||||
forbidden();
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 5. throw on the failure branch.
|
||||
export async function pageThrow({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId !== session.user.id) {
|
||||
throw new Error("not authorized");
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// 6. === inverted equality with else { notFound() }.
|
||||
export async function pageEqElse({ params }: { params: { id: string } }) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) return null;
|
||||
const repo = WebhookRepository.getInstance();
|
||||
const webhook = await repo.findByWebhookId(params.id);
|
||||
if (webhook.userId === session.user.id) {
|
||||
return webhook;
|
||||
} else {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/auth_trpc_handler_options/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/auth_trpc_handler_options/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "js.auth.missing_ownership_check" },
|
||||
{ "id_prefix": "ts.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 1
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
62
tests/fixtures/fp_guards/auth_trpc_handler_options/handler.ts
vendored
Normal file
62
tests/fixtures/fp_guards/auth_trpc_handler_options/handler.ts
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Cal.com-shaped TRPC handler: parameter is a destructured options
|
||||
// alias (`{ ctx, input }: GetOptions`) where `GetOptions` is a local
|
||||
// type alias whose `ctx.user` is typed `NonNullable<TrpcSessionUser>`.
|
||||
// The session-resolved `ctx.user.id` is the authenticated actor;
|
||||
// composing it with `input.id` in a where-clause is the standard
|
||||
// owner-eq pattern, NOT a foreign-id targeting flow.
|
||||
//
|
||||
// `collect_trpc_ctx_param` (in src/auth_analysis/extract/common.rs)
|
||||
// must recognise the destructured `ctx` and add `ctx.user` to the
|
||||
// per-unit `self_scoped_session_bases`, so the auth analyser
|
||||
// suppresses `missing_ownership_check` on operations rooted at
|
||||
// `ctx.user.id`.
|
||||
//
|
||||
// Marker text in the body of `GetOptions` is what
|
||||
// `body_text_references_trpc_marker` keys on
|
||||
// (`TrpcSessionUser`/`TRPCContext`/`ProtectedTRPCContext`/`TrpcContext`).
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
type TrpcSessionUser = { id: number; email: string };
|
||||
|
||||
type GetOptions = {
|
||||
ctx: { user: NonNullable<TrpcSessionUser> };
|
||||
input: { id: number };
|
||||
};
|
||||
|
||||
type ListOptions = {
|
||||
ctx: { user: NonNullable<TrpcSessionUser> };
|
||||
input: { teamId: number };
|
||||
};
|
||||
|
||||
export const handleGet = async ({ ctx, input }: GetOptions) => {
|
||||
return prisma.booking.findFirst({
|
||||
where: { id: input.id, userId: ctx.user.id },
|
||||
});
|
||||
};
|
||||
|
||||
export const handleList = async ({ ctx, input }: ListOptions) => {
|
||||
return prisma.team.findMany({
|
||||
where: { id: input.teamId, ownerId: ctx.user.id },
|
||||
});
|
||||
};
|
||||
|
||||
// Renamed destructure form: `ctx: c` aliases the trpc context.
|
||||
type DeleteOptions = {
|
||||
ctx: { user: NonNullable<TrpcSessionUser> };
|
||||
input: { id: number };
|
||||
};
|
||||
|
||||
export const handleDelete = async ({ ctx: c, input }: DeleteOptions) => {
|
||||
return prisma.booking.delete({
|
||||
where: { id: input.id, userId: c.user.id },
|
||||
});
|
||||
};
|
||||
|
||||
// Plain identifier form: `(opts: GetOptions)` -> `opts.ctx.user`.
|
||||
export const handleUpdate = async (opts: GetOptions) => {
|
||||
return prisma.booking.update({
|
||||
where: { id: opts.input.id, userId: opts.ctx.user.id },
|
||||
data: { lastSeenAt: new Date() },
|
||||
});
|
||||
};
|
||||
23
tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/DatabaseDriverLoader.java
vendored
Normal file
23
tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/DatabaseDriverLoader.java
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package org.example.util;
|
||||
|
||||
public class DatabaseDriverLoader {
|
||||
private static final String MYSQL_DRIVER = "com.mysql.cj.jdbc.Driver";
|
||||
private static final String POSTGRESQL_DRIVER = "org.postgresql.Driver";
|
||||
private static final String H2_DRIVER = "org.h2.Driver";
|
||||
private static final int CONNECT_TIMEOUT_MS = 5000;
|
||||
|
||||
public static void loadDriver(String connectionUrl) throws ClassNotFoundException {
|
||||
if (connectionUrl.contains("jdbc:mysql")) {
|
||||
Class.forName(MYSQL_DRIVER);
|
||||
} else if (connectionUrl.contains("jdbc:postgresql")) {
|
||||
Class.forName(POSTGRESQL_DRIVER);
|
||||
} else if (connectionUrl.contains("jdbc:h2")) {
|
||||
Class.forName(H2_DRIVER);
|
||||
}
|
||||
}
|
||||
|
||||
public static int timeoutMs() {
|
||||
Thread.sleep(CONNECT_TIMEOUT_MS);
|
||||
return CONNECT_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/cfg_unguarded_class_constant_java/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 6,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
15
tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/DbSession.java
vendored
Normal file
15
tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/DbSession.java
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package org.openmrs.api.db.hibernate;
|
||||
|
||||
public class DbSession {
|
||||
public Object createQuery(String queryString) {
|
||||
return getSession().createQuery(queryString);
|
||||
}
|
||||
|
||||
public Object createSQLQuery(String queryString, Class type) {
|
||||
return getSession().createNativeQuery(queryString, type);
|
||||
}
|
||||
|
||||
public Object createCriteria(Class persistentClass) {
|
||||
return getSession().getCriteriaBuilder().createQuery(persistentClass);
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/cfg_unguarded_dao_passthrough_java/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
45
tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/ChangeSet.java
vendored
Normal file
45
tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/ChangeSet.java
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package org.openmrs.util.databasechange;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public class ChangeSet {
|
||||
private int getInt(JdbcConnection connection, String sql) {
|
||||
Statement stmt = null;
|
||||
int result = 0;
|
||||
try {
|
||||
stmt = connection.createStatement();
|
||||
ResultSet rs = stmt.executeQuery(sql);
|
||||
if (rs.next()) {
|
||||
result = rs.getInt(1);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
// ignored for fixture
|
||||
} finally {
|
||||
if (stmt != null) {
|
||||
try {
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void runUpdate(JdbcConnection connection, String sql) throws SQLException {
|
||||
Statement statement = connection.createStatement();
|
||||
statement.executeUpdate(sql);
|
||||
statement.close();
|
||||
}
|
||||
|
||||
private void runChained(JdbcConnection connection, String sql) throws SQLException {
|
||||
Statement stmt = connection.unwrap().createStatement();
|
||||
stmt.executeQuery(sql);
|
||||
}
|
||||
|
||||
interface JdbcConnection {
|
||||
Statement createStatement() throws SQLException;
|
||||
JdbcConnection unwrap() throws SQLException;
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/cfg_unguarded_liquibase_changeset_java/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 5,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
18
tests/fixtures/fp_guards/file_level_const_scalars_xlang/expectations.json
vendored
Normal file
18
tests/fixtures/fp_guards/file_level_const_scalars_xlang/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "py.cmdi" },
|
||||
{ "id_prefix": "py.sqli" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 4,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 2000,
|
||||
"max_ms_index_cold": 2500,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_go_package_const.go
vendored
Normal file
16
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_go_package_const.go
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Package-level scalar constant flows into a db.Exec sink. The argument
|
||||
// resolves to a `const DriverName = "postgres"` declaration at file scope,
|
||||
// so the SQL string is compile-time bounded and cfg-unguarded-sink must
|
||||
// not fire.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const DriverName = "postgres"
|
||||
const QueryLimit = 100
|
||||
|
||||
func setup(db *sql.DB) {
|
||||
db.Exec(DriverName)
|
||||
}
|
||||
19
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_python_module_const.py
vendored
Normal file
19
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_python_module_const.py
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Module-level scalar constant flows into a cmdi-shaped sink.
|
||||
|
||||
`os.system(DEFAULT_CMD)` looks like a shell-injection sink but the argument
|
||||
binds to a top-level string literal at file load time, so no attacker can
|
||||
influence the value. The `py.cmdi.os_system` AST pattern and the structural
|
||||
`cfg-unguarded-sink` rule both fire without file-level const recognition;
|
||||
the file-scalars suppression closes both.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
DEFAULT_CMD = "ls -la /tmp"
|
||||
RETRIES = 3
|
||||
ENABLED = True
|
||||
|
||||
|
||||
def run():
|
||||
os.system(DEFAULT_CMD)
|
||||
os.popen(DEFAULT_CMD)
|
||||
12
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_rust_module_const.rs
vendored
Normal file
12
tests/fixtures/fp_guards/file_level_const_scalars_xlang/safe_rust_module_const.rs
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Module-level `const` scalar binds the first arg of a Command::new call.
|
||||
// Without file-level scalar recognition the SSA path treats COMMAND as a
|
||||
// free identifier and the structural rule over-fires.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const COMMAND: &str = "ls";
|
||||
const ARG_COUNT: i32 = 2;
|
||||
|
||||
pub fn run() {
|
||||
let _ = Command::new(COMMAND).output();
|
||||
}
|
||||
24
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/expectations.json
vendored
Normal file
24
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"file_glob": "**/server.go"
|
||||
}
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 5,
|
||||
"max_high_findings": 2
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
15
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/response_writer.go
vendored
Normal file
15
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/response_writer.go
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// renderResponse keeps the canonical XSS sink shape: tainted user input
|
||||
// flows into `http.ResponseWriter` via `fmt.Fprintf`. This MUST still fire
|
||||
// `taint-unsanitised-flow` with HTML_ESCAPE caps, the writer-aware
|
||||
// suppression must not over-clear when the writer IS a response stream.
|
||||
func renderResponse(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
fmt.Fprintf(w, "<h1>hello %s</h1>", name)
|
||||
}
|
||||
51
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/server.go
vendored
Normal file
51
tests/fixtures/fp_guards/go_fmt_fprintf_safe_writer/server.go
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Package-level non-response writers, mirroring gin's `mode.go` declarations.
|
||||
// These are `io.Writer` aliases for `os.Stdout` / `os.Stderr` and are NOT
|
||||
// HTTP response sinks.
|
||||
var (
|
||||
DefaultWriter io.Writer = os.Stdout
|
||||
DefaultErrorWriter io.Writer = os.Stderr
|
||||
)
|
||||
|
||||
// debugPrintError is the gin-style logging helper. It writes to a
|
||||
// package-level non-response writer. When `err` returns from
|
||||
// `http.Server.ListenAndServe`, the value is stdlib state, not user input,
|
||||
// but the engine should still suppress HTML_ESCAPE on the writer-aware
|
||||
// branch even if a tainted value reached the writer.
|
||||
func debugPrintError(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(DefaultErrorWriter, "[debug] [ERROR] %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// debugPrint writes formatted debug output to stdout-aliased DefaultWriter.
|
||||
func debugPrint(format string, values ...any) {
|
||||
fmt.Fprintf(DefaultWriter, "[debug] "+format, values...)
|
||||
}
|
||||
|
||||
// runServer mirrors gin's `Engine.Run` shape: a deferred call that pipes
|
||||
// the named-return error into the gin-style debug logger.
|
||||
func runServer(addr string) (err error) {
|
||||
defer func() { debugPrintError(err) }()
|
||||
server := &http.Server{Addr: addr}
|
||||
err = server.ListenAndServe()
|
||||
return
|
||||
}
|
||||
|
||||
// stdlibLog is the equivalent shape using stdlib stderr directly.
|
||||
func stdlibLog(err error) {
|
||||
fmt.Fprintf(os.Stderr, "boot error: %v\n", err)
|
||||
}
|
||||
|
||||
// discardLog drops formatted output entirely. Always benign.
|
||||
func discardLog(payload string) {
|
||||
fmt.Fprintf(io.Discard, "ignored: %s\n", payload)
|
||||
}
|
||||
24
tests/fixtures/fp_guards/go_http_redirect_self_request/expectations.json
vendored
Normal file
24
tests/fixtures/fp_guards/go_http_redirect_self_request/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "taint-open-redirect",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{
|
||||
"id_prefix": "taint-open-redirect",
|
||||
"file_glob": "**/server.go"
|
||||
}
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 8,
|
||||
"max_high_findings": 2
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
25
tests/fixtures/fp_guards/go_http_redirect_self_request/handler.go
vendored
Normal file
25
tests/fixtures/fp_guards/go_http_redirect_self_request/handler.go
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Canonical OPEN_REDIRECT vulnerability: redirect destination is fully
|
||||
// attacker-controlled via `r.FormValue`. This MUST still fire
|
||||
// `taint-open-redirect` after the same-request self-redirect suppression,
|
||||
// otherwise the gate over-clears.
|
||||
func openRedirectVuln(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.FormValue("redirect")
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// Cross-request shape: a `*http.Request` from `http.NewRequest` (proxy
|
||||
// request) is NOT the inbound request; redirecting to its URL would land
|
||||
// off-origin if the URL was attacker-influenced. The same-request gate
|
||||
// only fires when arg 1 (the redirect's *Request) and the URL chain root
|
||||
// match by name, so this stays in scope of the OPEN_REDIRECT detector.
|
||||
func proxyRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.FormValue("upstream")
|
||||
other, _ := http.NewRequest("GET", target, nil)
|
||||
http.Redirect(w, r, other.URL.String(), http.StatusFound)
|
||||
}
|
||||
37
tests/fixtures/fp_guards/go_http_redirect_self_request/server.go
vendored
Normal file
37
tests/fixtures/fp_guards/go_http_redirect_self_request/server.go
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Same-request self-redirect via the canonical `*url.URL.String()` shape.
|
||||
// gin's `redirectTrailingSlash` / `redirectFixedPath` / `redirectRequest`
|
||||
// helpers all bottom out here: scheme/host echo the inbound request, only
|
||||
// the path can be edited. MUST suppress `taint-open-redirect`.
|
||||
func redirectTrailingSlash(r *http.Request, w http.ResponseWriter) {
|
||||
r.URL.Path = r.URL.Path + "/"
|
||||
rURL := r.URL.String()
|
||||
http.Redirect(w, r, rURL, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// Same-request self-redirect via the `*url.URL.Path` field accessor. No
|
||||
// method-call parens; SSA encodes this as a flat callee text. MUST
|
||||
// suppress.
|
||||
func redirectPath(r *http.Request, w http.ResponseWriter) {
|
||||
target := r.URL.Path
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// Same-request self-redirect via the `*url.URL.RequestURI()` accessor.
|
||||
// MUST suppress.
|
||||
func redirectRequestURI(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
// Same-request self-redirect via the `*url.URL.EscapedPath()` accessor.
|
||||
// MUST suppress.
|
||||
func redirectEscapedPath(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.URL.EscapedPath()
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
35
tests/fixtures/fp_guards/java_safe_map_field_lookup/SafeAllowlist.java
vendored
Normal file
35
tests/fixtures/fp_guards/java_safe_map_field_lookup/SafeAllowlist.java
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// FP guard fixture: a final class field initialised with `Map.of(literal,
|
||||
// literal, ...)` is an immutable allowlist whose `.get(taintedKey)` result
|
||||
// is bounded to the literal value set. Engine must NOT surface
|
||||
// `taint-header-injection` on the safe handler.
|
||||
//
|
||||
// Mirrors CVE-2017-12629 (Apache Solr) patched counterpart: a pre-defined
|
||||
// transformer name table prevents arbitrary downstream sinks from being
|
||||
// reached by user-controlled data.
|
||||
package com.example.fixtures;
|
||||
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class SafeAllowlist extends HttpServlet {
|
||||
private static final Map<String, String> TRANSFORMERS = Map.of(
|
||||
"identity", "classpath:xslt/identity.xsl",
|
||||
"summary", "classpath:xslt/summary.xsl"
|
||||
);
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse res) throws Exception {
|
||||
String requested = req.getParameter("tr");
|
||||
String resolved = TRANSFORMERS.get(requested);
|
||||
if (resolved == null) {
|
||||
res.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
// Safe: `resolved` is one of the two literal values above; neither
|
||||
// contains CR/LF, so no header-injection sink can be reached.
|
||||
res.setHeader("X-Solr-Transform", resolved);
|
||||
res.setStatus(HttpServletResponse.SC_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
19
tests/fixtures/fp_guards/java_safe_map_field_lookup/UnsafeBypass.java
vendored
Normal file
19
tests/fixtures/fp_guards/java_safe_map_field_lookup/UnsafeBypass.java
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Recall guard counterpart: when the field initializer is NOT a recognised
|
||||
// safe `Map.of(literal, literal, ...)` shape (here, the value position is
|
||||
// constructed dynamically from a separate request parameter), the engine
|
||||
// must still surface the header-injection flow.
|
||||
package com.example.fixtures;
|
||||
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
public class UnsafeBypass extends HttpServlet {
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse res) throws Exception {
|
||||
// Pure passthrough: tainted parameter flows directly to the header
|
||||
// value with no allowlist gate.
|
||||
String value = req.getParameter("v");
|
||||
res.setHeader("X-Echo", value);
|
||||
}
|
||||
}
|
||||
29
tests/fixtures/fp_guards/java_safe_map_field_lookup/expectations.json
vendored
Normal file
29
tests/fixtures/fp_guards/java_safe_map_field_lookup/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "taint-header-injection",
|
||||
"file_glob": "**/UnsafeBypass.java",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{
|
||||
"id_prefix": "taint-header-injection",
|
||||
"file_glob": "**/SafeAllowlist.java"
|
||||
},
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"file_glob": "**/SafeAllowlist.java"
|
||||
}
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 4,
|
||||
"max_high_findings": 2
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "taint-unsanitised-flow" },
|
||||
{ "id_prefix": "cfg-unguarded-sink" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 800,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
24
tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/page.tsx
vendored
Normal file
24
tests/fixtures/fp_guards/jsx_text_content_sanitizer_tsx/page.tsx
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import express, { Request, Response } from 'express';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Safe: JSX text-content `{bio}` is auto-escaped by React's renderer,
|
||||
// so HTML metacharacters in the user's bio cannot inject HTML.
|
||||
app.get('/bio', (req: Request, res: Response) => {
|
||||
const bio = req.query.bio as string;
|
||||
const page = <div>{bio}</div>;
|
||||
res.send(page);
|
||||
});
|
||||
|
||||
// Safe: nested text-content interpolation across multiple elements.
|
||||
app.get('/profile', (req: Request, res: Response) => {
|
||||
const name = req.query.name as string;
|
||||
const page = (
|
||||
<section>
|
||||
<h1>{name}</h1>
|
||||
<p>Profile for {name}</p>
|
||||
</section>
|
||||
);
|
||||
res.send(page);
|
||||
});
|
||||
38
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterMySQL.php
vendored
Normal file
38
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterMySQL.php
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
// Real nextcloud `lib/private/DB/AdapterMySQL.php` shape. The builder is
|
||||
// bound by `$this->conn->getQueryBuilder()`, and the executeStatement
|
||||
// first-arg wraps `$builder->getSQL()` with a `preg_replace` rewrite that
|
||||
// patches the leading verb (`INSERT` -> `INSERT IGNORE`) without weaving
|
||||
// any user payload. The structural cfg-unguarded-sink rule had previously
|
||||
// fired because `arg_callees[0]` is `preg_replace`, not a DBAL accessor.
|
||||
|
||||
namespace OC\DB;
|
||||
|
||||
class Connection
|
||||
{
|
||||
public function getQueryBuilder() { return new \OC\DB\QueryBuilder\QueryBuilder(); }
|
||||
public function executeStatement(string $sql, array $params = [], array $types = []): int { return 0; }
|
||||
}
|
||||
|
||||
class AdapterMySQL
|
||||
{
|
||||
/** @var Connection */
|
||||
protected $conn;
|
||||
|
||||
public function insertIgnoreConflict(string $table, array $values): int
|
||||
{
|
||||
$builder = $this->conn->getQueryBuilder();
|
||||
$builder->insert($table);
|
||||
foreach ($values as $key => $value) {
|
||||
$builder->setValue($key, $builder->createNamedParameter($value));
|
||||
}
|
||||
|
||||
$res = $this->conn->executeStatement(
|
||||
preg_replace('/^INSERT/i', 'INSERT IGNORE', $builder->getSQL()),
|
||||
$builder->getParameters(),
|
||||
$builder->getParameterTypes()
|
||||
);
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
28
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterSqlite.php
vendored
Normal file
28
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/AdapterSqlite.php
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
// Real nextcloud `lib/private/DB/AdapterSqlite.php` shape. The builder is
|
||||
// bound by `$this->conn->getQueryBuilder()`, and the executeStatement
|
||||
// first-arg appends a constant `' ON CONFLICT DO NOTHING'` to the
|
||||
// `$builder->getSQL()` accessor. No user payload, no taint.
|
||||
|
||||
namespace OC\DB;
|
||||
|
||||
class AdapterSqlite
|
||||
{
|
||||
/** @var Connection */
|
||||
protected $conn;
|
||||
|
||||
public function insertIgnoreConflict(string $table, array $values): int
|
||||
{
|
||||
$builder = $this->conn->getQueryBuilder();
|
||||
$builder->insert($table);
|
||||
foreach ($values as $key => $value) {
|
||||
$builder->setValue($key, $builder->createNamedParameter($value));
|
||||
}
|
||||
|
||||
return $this->conn->executeStatement(
|
||||
$builder->getSQL() . ' ON CONFLICT DO NOTHING',
|
||||
$builder->getParameters(),
|
||||
$builder->getParameterTypes()
|
||||
);
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_dbal_builder_compose_sql/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
46
tests/fixtures/fp_guards/php_dbal_builder_get_sql/QueryBuilder.php
vendored
Normal file
46
tests/fixtures/fp_guards/php_dbal_builder_get_sql/QueryBuilder.php
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
// Doctrine DBAL QueryBuilder pattern: the builder accumulates a
|
||||
// parameterised SQL string via select / from / where / setParameter
|
||||
// chains, then exposes the assembled SQL via `$qb->getSQL()`. Real
|
||||
// nextcloud `lib/private/DB/QueryBuilder/QueryBuilder.php` implements
|
||||
// the terminal `executeQuery` / `executeStatement` overloads by passing
|
||||
// `$this->getSQL()` plus `$this->getParameters()` to a connection. The
|
||||
// connection's `executeQuery` has a flat overload that takes a SQL
|
||||
// string, so the structural cfg-unguarded-sink rule fires on the
|
||||
// receiver-typed call. A `getSQL` first arg proves the SQL was built
|
||||
// via the parameterised builder API and the structural finding is noise.
|
||||
|
||||
namespace OC\DB\QueryBuilder;
|
||||
|
||||
class QueryBuilder
|
||||
{
|
||||
private $connection;
|
||||
|
||||
public function executeQuery(?\IDBConnection $connection = null)
|
||||
{
|
||||
if (!$connection) {
|
||||
$connection = $this->connection;
|
||||
}
|
||||
return $connection->executeQuery(
|
||||
$this->getSQL(),
|
||||
$this->getParameters(),
|
||||
$this->getParameterTypes(),
|
||||
);
|
||||
}
|
||||
|
||||
public function executeStatement(?\IDBConnection $connection = null): int
|
||||
{
|
||||
if (!$connection) {
|
||||
$connection = $this->connection;
|
||||
}
|
||||
return $connection->executeStatement(
|
||||
$this->getSQL(),
|
||||
$this->getParameters(),
|
||||
$this->getParameterTypes(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getSQL(): string { return ''; }
|
||||
public function getParameters(): array { return []; }
|
||||
public function getParameterTypes(): array { return []; }
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_dbal_builder_get_sql/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_dbal_builder_get_sql/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
58
tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/Propagator.php
vendored
Normal file
58
tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/Propagator.php
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
// Doctrine DBAL builder chain whose receiver variable is NOT one of the
|
||||
// canonical builder names (`qb`, `query`, `builder`, ...) so the
|
||||
// receiver-name allowlist for the zero-arg query-builder suppression
|
||||
// doesn't fire. The variable is bound earlier in the body via
|
||||
// `getQueryBuilder()`; the structural suppression walks back to the
|
||||
// receiver's defining call to recognise it as a builder regardless of
|
||||
// the local's name. Real-world appearance: nextcloud
|
||||
// `lib/private/Files/Cache/Propagator.php` uses
|
||||
// `$forUpdate = $this->connection->getQueryBuilder()` for the SELECT
|
||||
// FOR UPDATE row lock then chains `->select(...)->from(...)->where(...)
|
||||
// ->orderBy(...)->forUpdate()->executeQuery()`.
|
||||
|
||||
namespace OC\Files\Cache;
|
||||
|
||||
class Propagator
|
||||
{
|
||||
private $connection;
|
||||
private $storage;
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
public function propagateChange(array $parents, int $time, int $sizeDifference = 0): void
|
||||
{
|
||||
$parentHashes = array_map('md5', $parents);
|
||||
sort($parentHashes);
|
||||
|
||||
$builder = $this->connection->getQueryBuilder();
|
||||
$hashParams = array_map(static fn (string $hash) => $builder->expr()->literal($hash), $parentHashes);
|
||||
|
||||
$builder->update('filecache')
|
||||
->set('mtime', $builder->func()->greatest('mtime', $builder->createNamedParameter($time)))
|
||||
->where($builder->expr()->eq('storage', $builder->createNamedParameter('x')))
|
||||
->andWhere($builder->expr()->in('path_hash', $hashParams));
|
||||
|
||||
for ($i = 0; $i < self::MAX_RETRIES; $i++) {
|
||||
try {
|
||||
if ($this->connection->getDatabaseProvider() !== 'sqlite') {
|
||||
$this->connection->beginTransaction();
|
||||
$forUpdate = $this->connection->getQueryBuilder();
|
||||
$forUpdate->select('fileid')
|
||||
->from('filecache')
|
||||
->where($forUpdate->expr()->eq('storage', $forUpdate->createNamedParameter('x')))
|
||||
->andWhere($forUpdate->expr()->in('path_hash', $hashParams))
|
||||
->orderBy('path_hash')
|
||||
->forUpdate()
|
||||
->executeQuery();
|
||||
$builder->executeStatement();
|
||||
$this->connection->commit();
|
||||
} else {
|
||||
$builder->executeStatement();
|
||||
}
|
||||
break;
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_dbal_builder_via_factory_def/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
36
tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/Migration.php
vendored
Normal file
36
tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/Migration.php
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
// Doctrine DBAL `AbstractPlatform::get*SQL(...)` family of safe DDL
|
||||
// builders (`getTruncateTableSQL`, `getCreateTableSQL`,
|
||||
// `getDropTableSQL`, `getAlterTableSQL`, etc.). These methods receive
|
||||
// schema identifiers and emit DBMS-specific DDL with no user-supplied
|
||||
// payload bytes. Real-world appearance: nextcloud
|
||||
// `apps/user_ldap/lib/Migration/Version*.php` and core/Migrations/
|
||||
// `Version*.php` build a `$sql` local from a platform DDL builder then
|
||||
// pass it to `$this->dbc->executeStatement($sql)`. The flat
|
||||
// `executeStatement` SQL_QUERY sink rule fires structurally; the
|
||||
// suppression must walk back to the local's defining call to recognise
|
||||
// the safe accessor.
|
||||
|
||||
namespace OC\Migrations;
|
||||
|
||||
class TruncateBackupTableMigration
|
||||
{
|
||||
private $dbc;
|
||||
|
||||
public function postSchemaChange(\IOutput $output, \Closure $schemaClosure, array $options): void
|
||||
{
|
||||
$schema = $schemaClosure();
|
||||
if ($schema->hasTable('ldap_group_mapping_backup')) {
|
||||
$sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*ldap_group_mapping_backup`', false);
|
||||
$this->dbc->executeStatement($sql);
|
||||
}
|
||||
}
|
||||
|
||||
public function preInline(\IOutput $output, \Closure $schemaClosure, array $options): void
|
||||
{
|
||||
// Direct method-call arg variant.
|
||||
$this->dbc->executeStatement(
|
||||
$this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*tmp`', false)
|
||||
);
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_dbal_platform_ddl_builder/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
61
tests/fixtures/fp_guards/php_doctrine_querybuilder/App.php
vendored
Normal file
61
tests/fixtures/fp_guards/php_doctrine_querybuilder/App.php
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
// Doctrine DBAL `QueryBuilder` chain. The terminal `executeQuery()` /
|
||||
// `executeStatement()` verbs take zero positional args, the SQL was
|
||||
// bound earlier on the chain via `select` / `from` / `where` /
|
||||
// `setParameter` calls (parameterised, no concatenation). A flat
|
||||
// `executeQuery` Sink rule fires here regardless of taint because the
|
||||
// callee suffix matches; the structural cfg-unguarded-sink finding
|
||||
// must be suppressed when the call has no args. Distilled from
|
||||
// nextcloud apps/dav (CalDavBackend / CardDavBackend) and lib/private
|
||||
// /DB usage.
|
||||
|
||||
class CalendarRepository
|
||||
{
|
||||
private $db;
|
||||
|
||||
public function getDeletedCalendars(int $deletedBefore): array
|
||||
{
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select(['id', 'deleted_at'])
|
||||
->from('calendars')
|
||||
->where($qb->expr()->isNotNull('deleted_at'))
|
||||
->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore)));
|
||||
$result = $qb->executeQuery();
|
||||
$calendars = [];
|
||||
while (($row = $result->fetchAssociative()) !== false) {
|
||||
$calendars[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'deleted_at' => (int) $row['deleted_at'],
|
||||
];
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $calendars;
|
||||
}
|
||||
|
||||
public function deleteExpired(int $expiry): int
|
||||
{
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('calendars')
|
||||
->where($qb->expr()->lt('deleted_at', $qb->createNamedParameter($expiry)));
|
||||
|
||||
return $qb->executeStatement();
|
||||
}
|
||||
|
||||
public function restoreCalendar(int $id): void
|
||||
{
|
||||
// Closure-wrapped variant: the inner $update->executeStatement()
|
||||
// is reached via find_classifiable_inner_call descent so the
|
||||
// CFG node represents the outer atomic() call but the callee
|
||||
// text resolves to update.executeStatement. Receiver "update"
|
||||
// matches the verb-named builder allowlist; the structural
|
||||
// suppression must still fire.
|
||||
$this->atomic(function () use ($id): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$update = $qb->update('calendars')
|
||||
->set('deleted_at', $qb->createNamedParameter(null))
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
|
||||
$update->executeStatement();
|
||||
});
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_doctrine_querybuilder/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_doctrine_querybuilder/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
40
tests/fixtures/fp_guards/php_drupal_prepare_statement/App.php
vendored
Normal file
40
tests/fixtures/fp_guards/php_drupal_prepare_statement/App.php
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
// Drupal Database\Connection convention: `prepareStatement` returns a
|
||||
// statement object that carries the SQL template; binding happens
|
||||
// separately via `$stmt->execute($values, $opts)` with values shipped
|
||||
// out of band. The structural cfg-unguarded-sink rule must treat
|
||||
// `prepareStatement` as a SQL_QUERY sanitizer the same way it treats
|
||||
// `prepare`, otherwise every Drupal Query subclass surfaces an FP at
|
||||
// the execute call.
|
||||
|
||||
class DrupalQueryWrapper
|
||||
{
|
||||
private $connection;
|
||||
private $queryOptions;
|
||||
|
||||
public function execute()
|
||||
{
|
||||
$stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions, true);
|
||||
try {
|
||||
$stmt->execute([], $this->queryOptions);
|
||||
return $stmt->rowCount();
|
||||
} catch (\Exception $e) {
|
||||
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, [], $this->queryOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function executeUpdate($values)
|
||||
{
|
||||
$stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions, true);
|
||||
try {
|
||||
$stmt->execute($values, $this->queryOptions);
|
||||
return $stmt->rowCount();
|
||||
} catch (\Exception $e) {
|
||||
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, $values, $this->queryOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_drupal_prepare_statement/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_drupal_prepare_statement/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
39
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/MySqlTools.php
vendored
Normal file
39
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/MySqlTools.php
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
// Real nextcloud `lib/private/DB/MySqlTools.php` shape. `$variables` is
|
||||
// built from a literal-keyed array plus optional literal subscript-set
|
||||
// extensions; the foreach-key `$var` ranges over a finite metachar-free
|
||||
// set (`innodb_file_per_table`, `innodb_file_format`,
|
||||
// `innodb_large_prefix`). The interpolated SQL `SHOW VARIABLES LIKE
|
||||
// '$var'` is bounded to the literal key set.
|
||||
|
||||
namespace OC\DB;
|
||||
|
||||
class MySqlTools
|
||||
{
|
||||
public function supports4ByteCharset($connection): bool
|
||||
{
|
||||
$variables = ['innodb_file_per_table' => 'ON'];
|
||||
if (!$this->isMariaDBWithLargePrefix($connection)) {
|
||||
$variables['innodb_file_format'] = 'Barracuda';
|
||||
$variables['innodb_large_prefix'] = 'ON';
|
||||
}
|
||||
|
||||
foreach ($variables as $var => $val) {
|
||||
$result = $connection->executeQuery("SHOW VARIABLES LIKE '$var'");
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
if ($row === false) {
|
||||
return false;
|
||||
}
|
||||
if (strcasecmp($row['Value'], $val) !== 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function isMariaDBWithLargePrefix($connection): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/UnsafeBypass.php
vendored
Normal file
17
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/UnsafeBypass.php
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
// Negative case: the foreach iterates a parameter (not a literal-keyed
|
||||
// array), so the suppression must NOT fire and the structural rule must
|
||||
// still emit cfg-unguarded-sink for the SQL_QUERY sink.
|
||||
|
||||
namespace OC\DB;
|
||||
|
||||
class UnsafeBypass
|
||||
{
|
||||
public function badQuery($connection, array $userVariables): bool
|
||||
{
|
||||
foreach ($userVariables as $var => $val) {
|
||||
$connection->executeQuery("SHOW VARIABLES LIKE '$var'");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
28
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/expectations.json
vendored
Normal file
28
tests/fixtures/fp_guards/php_foreach_safe_literal_keys/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "cfg-unguarded-sink",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{
|
||||
"id_prefix": "cfg-unguarded-sink",
|
||||
"file_glob": "**/MySqlTools.php"
|
||||
},
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"file_glob": "**/MySqlTools.php"
|
||||
}
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
47
tests/fixtures/fp_guards/php_thin_method_wrapper/Connection.php
vendored
Normal file
47
tests/fixtures/fp_guards/php_thin_method_wrapper/Connection.php
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
// Thin method wrapper that forwards typed parameters to an inner sink
|
||||
// call on `$this`. Real-world equivalents: Doctrine DBAL
|
||||
// `Connection::executeUpdate` delegating to `executeStatement`,
|
||||
// nextcloud `lib/private/DB/Connection::executeUpdate`,
|
||||
// `ConnectionAdapter::executeQuery` wrapping `$this->inner->executeQuery`,
|
||||
// Drupal `Connection::query` thin overrides per driver. Because every
|
||||
// argument to the inner call is the wrapper's own parameter, the
|
||||
// `cfg-unguarded-sink` structural rule has zero signal at the wrapper
|
||||
// site; the real signal is at callers, which the taint engine handles.
|
||||
|
||||
namespace OC\DB;
|
||||
|
||||
class Connection
|
||||
{
|
||||
private $inner;
|
||||
|
||||
public function executeUpdate(string $sql, array $params = [], array $types = []): int
|
||||
{
|
||||
return $this->executeStatement($sql, $params, $types);
|
||||
}
|
||||
|
||||
public function executeStatement($sql, array $params = [], array $types = []): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectionAdapter
|
||||
{
|
||||
private $inner;
|
||||
|
||||
public function executeQuery(string $sql, array $params = [], $types = [])
|
||||
{
|
||||
return new ResultAdapter($this->inner->executeQuery($sql, $params, $types));
|
||||
}
|
||||
|
||||
public function executeStatement($sql, array $params = [], array $types = []): int
|
||||
{
|
||||
return $this->inner->executeStatement($sql, $params, $types);
|
||||
}
|
||||
}
|
||||
|
||||
class ResultAdapter
|
||||
{
|
||||
public function __construct($inner) {}
|
||||
}
|
||||
17
tests/fixtures/fp_guards/php_thin_method_wrapper/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/php_thin_method_wrapper/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
61
tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/RoundtripTest.php
vendored
Normal file
61
tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/RoundtripTest.php
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
// PHPUnit test methods that round-trip a value through serialize() and
|
||||
// then assert the unserialize() result equals a literal expected value.
|
||||
// Drupal, Joomla, and Nextcloud each carry ~30 of these in their test
|
||||
// trees. The actionable signal is zero: the test inputs are
|
||||
// developer-supplied and the assertion bounds the unserialize result
|
||||
// to the literal expected value. Suppress `php.deser.unserialize` on
|
||||
// every shape below; firing on test-only assertions is noise.
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RoundtripTest extends TestCase
|
||||
{
|
||||
public function testArrayLiteralExpected(): void
|
||||
{
|
||||
$blob = serialize(['a' => 1, 'b' => 2]);
|
||||
$this->assertSame(['a' => 1, 'b' => 2], unserialize($blob));
|
||||
}
|
||||
|
||||
public function testNestedArrayLiteralExpected(): void
|
||||
{
|
||||
$blob = serialize([['k' => 'v'], 'tail']);
|
||||
$this->assertEquals([['k' => 'v'], 'tail'], unserialize($blob));
|
||||
}
|
||||
|
||||
public function testScalarStringExpected(): void
|
||||
{
|
||||
$blob = 's:5:"hello";';
|
||||
$this->assertSame('hello', unserialize($blob));
|
||||
}
|
||||
|
||||
public function testScalarIntegerExpected(): void
|
||||
{
|
||||
$blob = 'i:42;';
|
||||
$this->assertEquals(42, unserialize($blob));
|
||||
}
|
||||
|
||||
public function testNullExpected(): void
|
||||
{
|
||||
$blob = 'N;';
|
||||
$this->assertNull(unserialize($blob));
|
||||
}
|
||||
|
||||
public function testStaticCallScopeExpected(): void
|
||||
{
|
||||
$blob = serialize(['x']);
|
||||
static::assertSame(['x'], unserialize($blob));
|
||||
}
|
||||
|
||||
public function testSelfCallScopeExpected(): void
|
||||
{
|
||||
$blob = serialize(['y']);
|
||||
self::assertEquals(['y'], unserialize($blob));
|
||||
}
|
||||
|
||||
public function testCaseInsensitiveAssertionVerb(): void
|
||||
{
|
||||
$blob = serialize([true, false]);
|
||||
$this->AssertSame([true, false], unserialize($blob));
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/php_unserialize_in_phpunit_assertion/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "php.deser.unserialize" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
18
tests/fixtures/fp_guards/python_deser_in_pytest_assert/expectations.json
vendored
Normal file
18
tests/fixtures/fp_guards/python_deser_in_pytest_assert/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "py.deser.pickle_loads" },
|
||||
{ "id_prefix": "py.deser.yaml_load" },
|
||||
{ "id_prefix": "py.deser.shelve_open" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
87
tests/fixtures/fp_guards/python_deser_in_pytest_assert/test_roundtrip.py
vendored
Normal file
87
tests/fixtures/fp_guards/python_deser_in_pytest_assert/test_roundtrip.py
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""pytest plain-`assert` round-trip patterns. Each shape bounds the
|
||||
deserialized result to a literal expected; a poisoned blob produces a
|
||||
different shape, the assertion fails loudly, no object-injection side
|
||||
effect escapes the test boundary. Layer C4's pytest path suppresses
|
||||
every finding here."""
|
||||
|
||||
import pickle
|
||||
import yaml
|
||||
|
||||
|
||||
def test_eq_literal():
|
||||
blob = pickle.dumps([1, 2, 3])
|
||||
assert pickle.loads(blob) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_neq_literal():
|
||||
blob = pickle.dumps([1])
|
||||
assert pickle.loads(blob) != [9, 9, 9]
|
||||
|
||||
|
||||
def test_is_none():
|
||||
blob = pickle.dumps(None)
|
||||
assert pickle.loads(blob) is None
|
||||
|
||||
|
||||
def test_is_not_none():
|
||||
blob = pickle.dumps([1])
|
||||
assert pickle.loads(blob) is not None
|
||||
|
||||
|
||||
def test_in_literal_tuple():
|
||||
blob = pickle.dumps(2)
|
||||
assert pickle.loads(blob) in [1, 2, 3]
|
||||
|
||||
|
||||
def test_not_in_literal():
|
||||
blob = pickle.dumps(99)
|
||||
assert pickle.loads(blob) not in [1, 2, 3]
|
||||
|
||||
|
||||
def test_truthy_assertion():
|
||||
blob = pickle.dumps([1])
|
||||
assert pickle.loads(blob)
|
||||
|
||||
|
||||
def test_not_truthy_assertion():
|
||||
blob = pickle.dumps(None)
|
||||
assert not pickle.loads(blob)
|
||||
|
||||
|
||||
def test_isinstance_dict():
|
||||
blob = pickle.dumps({"a": 1})
|
||||
assert isinstance(pickle.loads(blob), dict)
|
||||
|
||||
|
||||
def test_isinstance_tuple_of_types():
|
||||
blob = pickle.dumps([1])
|
||||
assert isinstance(pickle.loads(blob), (list, tuple))
|
||||
|
||||
|
||||
def test_paren_wrap():
|
||||
blob = pickle.dumps([1])
|
||||
assert (pickle.loads(blob) == [1])
|
||||
|
||||
|
||||
def test_assert_with_message():
|
||||
blob = pickle.dumps(1)
|
||||
assert pickle.loads(blob) == 1, "round trip failed"
|
||||
|
||||
|
||||
def test_yaml_load_truthy():
|
||||
assert yaml.load(b"key: 1")
|
||||
|
||||
|
||||
def test_bool_wrap():
|
||||
blob = pickle.dumps([1])
|
||||
assert bool(pickle.loads(blob))
|
||||
|
||||
|
||||
def test_len_wrap():
|
||||
blob = pickle.dumps([1, 2, 3])
|
||||
assert len(pickle.loads(blob)) == 3
|
||||
|
||||
|
||||
def test_unary_minus_eq_literal():
|
||||
blob = pickle.dumps(-7)
|
||||
assert -pickle.loads(blob) == 7
|
||||
18
tests/fixtures/fp_guards/python_deser_in_unittest_assertion/expectations.json
vendored
Normal file
18
tests/fixtures/fp_guards/python_deser_in_unittest_assertion/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "py.deser.pickle_loads" },
|
||||
{ "id_prefix": "py.deser.yaml_load" },
|
||||
{ "id_prefix": "py.deser.shelve_open" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
78
tests/fixtures/fp_guards/python_deser_in_unittest_assertion/roundtrip_test.py
vendored
Normal file
78
tests/fixtures/fp_guards/python_deser_in_unittest_assertion/roundtrip_test.py
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
unittest.TestCase methods that round-trip a value through pickle.dumps /
|
||||
yaml.dump and assert the loads result equals a literal expected value.
|
||||
Production Python projects ship hundreds of these in their test trees;
|
||||
every firing is noise because the assertion bounds the deser result to
|
||||
the literal expected. Suppress `py.deser.pickle_loads`,
|
||||
`py.deser.yaml_load`, `py.deser.shelve_open`, and the
|
||||
`cfg-unguarded-sink` mirror on every shape below.
|
||||
"""
|
||||
|
||||
import pickle
|
||||
import yaml
|
||||
import unittest
|
||||
|
||||
|
||||
class RoundtripTest(unittest.TestCase):
|
||||
def test_dict_literal_expected(self):
|
||||
blob = pickle.dumps({"a": 1, "b": 2})
|
||||
self.assertEqual({"a": 1, "b": 2}, pickle.loads(blob))
|
||||
|
||||
def test_list_literal_expected(self):
|
||||
blob = pickle.dumps([1, 2, 3])
|
||||
self.assertEqual([1, 2, 3], pickle.loads(blob))
|
||||
|
||||
def test_nested_literal_expected(self):
|
||||
blob = pickle.dumps([{"k": "v"}, "tail"])
|
||||
self.assertEquals([{"k": "v"}, "tail"], pickle.loads(blob))
|
||||
|
||||
def test_string_literal_expected(self):
|
||||
blob = pickle.dumps("hello")
|
||||
self.assertEqual("hello", pickle.loads(blob))
|
||||
|
||||
def test_integer_literal_expected(self):
|
||||
blob = pickle.dumps(42)
|
||||
self.assertEqual(42, pickle.loads(blob))
|
||||
|
||||
def test_unary_negative_expected(self):
|
||||
blob = pickle.dumps(-7)
|
||||
self.assertEqual(-7, pickle.loads(blob))
|
||||
|
||||
def test_none_expected(self):
|
||||
blob = pickle.dumps(None)
|
||||
self.assertIsNone(pickle.loads(blob))
|
||||
|
||||
def test_assert_true_bounds(self):
|
||||
blob = pickle.dumps(True)
|
||||
self.assertTrue(pickle.loads(blob))
|
||||
|
||||
def test_assert_is_instance_dict(self):
|
||||
blob = pickle.dumps({"a": 1})
|
||||
self.assertIsInstance(pickle.loads(blob), dict)
|
||||
|
||||
def test_assert_in_list(self):
|
||||
blob = pickle.dumps("apple")
|
||||
self.assertIn(pickle.loads(blob), ["apple", "banana"])
|
||||
|
||||
def test_yaml_round_trip(self):
|
||||
blob = yaml.dump({"port": 5432})
|
||||
self.assertEqual({"port": 5432}, yaml.load(blob))
|
||||
|
||||
def test_msg_kwarg_keeps_bound(self):
|
||||
blob = pickle.dumps([1])
|
||||
self.assertEqual([1], pickle.loads(blob), msg="round trip should preserve list")
|
||||
|
||||
def test_actual_first_position(self):
|
||||
"""pytest-style ordering: deser result first, literal second."""
|
||||
blob = pickle.dumps({"k": "v"})
|
||||
self.assertEqual(pickle.loads(blob), {"k": "v"})
|
||||
|
||||
|
||||
# Free function imports also cover the suppression: `from pickle import loads`.
|
||||
from pickle import loads as pickle_loads
|
||||
|
||||
|
||||
class FreeImportTest(unittest.TestCase):
|
||||
def test_free_function_loads(self):
|
||||
blob = pickle.dumps([1, 2])
|
||||
self.assertEqual([1, 2], pickle_loads(blob))
|
||||
17
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "rb.deser.marshal_load" },
|
||||
{ "id_prefix": "rb.deser.yaml_load" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
48
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_spec.rb
vendored
Normal file
48
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_spec.rb
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# RSpec round-trip patterns: `expect(deser).to MATCHER`. Same bounding
|
||||
# semantics as the Minitest sibling fixture.
|
||||
|
||||
RSpec.describe MarshalRoundTrip do
|
||||
it "eq literal" do
|
||||
blob = Marshal.dump([1, 2, 3])
|
||||
expect(Marshal.load(blob)).to eq([1, 2, 3])
|
||||
end
|
||||
|
||||
it "is nil" do
|
||||
blob = Marshal.dump(nil)
|
||||
expect(Marshal.load(blob)).to be_nil
|
||||
end
|
||||
|
||||
it "is_a Array" do
|
||||
blob = Marshal.dump([1])
|
||||
expect(Marshal.load(blob)).to be_a(Array)
|
||||
end
|
||||
|
||||
it "be_kind_of Array" do
|
||||
blob = Marshal.dump([1])
|
||||
expect(Marshal.load(blob)).to be_kind_of(Array)
|
||||
end
|
||||
|
||||
it "be_truthy" do
|
||||
blob = Marshal.dump([1])
|
||||
expect(Marshal.load(blob)).to be_truthy
|
||||
end
|
||||
|
||||
it "not_to be_nil" do
|
||||
blob = Marshal.dump([1])
|
||||
expect(Marshal.load(blob)).not_to be_nil
|
||||
end
|
||||
|
||||
it "to_not be_nil" do
|
||||
blob = Marshal.dump([1])
|
||||
expect(Marshal.load(blob)).to_not be_nil
|
||||
end
|
||||
|
||||
it "yaml load eq literal" do
|
||||
expect(YAML.load("- 1\n")).to eq([1])
|
||||
end
|
||||
|
||||
it "match_array" do
|
||||
blob = Marshal.dump([1, 2, 3])
|
||||
expect(Marshal.load(blob)).to match_array([3, 2, 1])
|
||||
end
|
||||
end
|
||||
57
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_test.rb
vendored
Normal file
57
tests/fixtures/fp_guards/ruby_deser_in_test_assertion/marshal_test.rb
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Minitest round-trip patterns. Each shape bounds the deserialized
|
||||
# result to a literal expected; a poisoned blob produces a different
|
||||
# shape, the assertion fails loudly, no object-injection side effect
|
||||
# escapes the test boundary. Layer C5 suppresses every finding here.
|
||||
|
||||
require "minitest/autorun"
|
||||
|
||||
class MarshalRoundTripTest < Minitest::Test
|
||||
def test_eq_array
|
||||
blob = Marshal.dump([1, 2, 3])
|
||||
assert_equal [1, 2, 3], Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_eq_hash
|
||||
blob = Marshal.dump({a: 1})
|
||||
assert_equal({a: 1}, Marshal.load(blob))
|
||||
end
|
||||
|
||||
def test_assert_nil
|
||||
blob = Marshal.dump(nil)
|
||||
assert_nil Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_assert_truthy
|
||||
blob = Marshal.dump([1])
|
||||
assert Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_kind_of
|
||||
blob = Marshal.dump([1])
|
||||
assert_kind_of Array, Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_instance_of
|
||||
blob = Marshal.dump([1])
|
||||
assert_instance_of Array, Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_refute_nil
|
||||
blob = Marshal.dump([1])
|
||||
refute_nil Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_refute_equal_literal
|
||||
blob = Marshal.dump([1])
|
||||
refute_equal [9, 9], Marshal.load(blob)
|
||||
end
|
||||
|
||||
def test_yaml_eq_literal
|
||||
assert_equal [1], YAML.load("- 1\n")
|
||||
end
|
||||
|
||||
def test_assert_includes
|
||||
blob = Marshal.dump(2)
|
||||
assert_includes [1, 2, 3], Marshal.load(blob)
|
||||
end
|
||||
end
|
||||
17
tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "state-resource-leak" },
|
||||
{ "id_prefix": "cfg-resource-leak" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 2000,
|
||||
"max_ms_index_cold": 2500,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Resources acquired inside Java try-with-resources must not propagate
|
||||
// an Acquire effect onto callers' receivers. The acquire node is
|
||||
// `managed_resource = true` after CFG lowering, so the method-summary
|
||||
// builder skips it.
|
||||
//
|
||||
// Pre-fix: methods like `void load(File f) { try (var in = new
|
||||
// FileInputStream(f)) { ... } }` were summarised as Acquire, so callers
|
||||
// `obj.load(f)` got `obj` marked OPEN.
|
||||
import java.io.FileInputStream;
|
||||
|
||||
public class SafeLoader {
|
||||
public void load(java.io.File f) throws Exception {
|
||||
try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
|
||||
in.read();
|
||||
}
|
||||
}
|
||||
|
||||
public static void useLoader(java.io.File f) throws Exception {
|
||||
SafeLoader loader = new SafeLoader();
|
||||
loader.load(f);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"""Sphinx / Flask / MQTT-style event handler `app.connect(event, cb)`
|
||||
must not be flagged as a DB connection acquire by the generic `connect`
|
||||
matcher.
|
||||
|
||||
Pre-fix: `app.connect("config-inited", _create_init_py)` matched the
|
||||
ends-with-`.connect` acquire pattern; the static `exclude_acquire`
|
||||
list only carved out `signal.connect`, `event.connect`, and `.register`,
|
||||
missing Sphinx's `app.connect` and similar event-handler dispatchers.
|
||||
|
||||
Post-fix: `is_event_handler_register_shape` (string-literal first arg
|
||||
without `scheme://` plus a single-identifier second positional arg)
|
||||
recognises the canonical handler shape and suppresses the acquire on
|
||||
`db connection` pairs only. Real `engine.connect("postgres://...")`
|
||||
shapes still fire because their first arg carries a `://`.
|
||||
"""
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.connect("config-inited", _on_config_inited)
|
||||
app.connect("html-page-context", _on_page_context)
|
||||
app.connect("build-finished", _on_build_finished)
|
||||
|
||||
|
||||
def _on_config_inited(app, config):
|
||||
pass
|
||||
|
||||
|
||||
def _on_page_context(app, pagename, templatename, context, doctree):
|
||||
pass
|
||||
|
||||
|
||||
def _on_build_finished(app, exception):
|
||||
pass
|
||||
|
||||
|
||||
class MqttListener:
|
||||
def setup(self, client):
|
||||
client.connect("device/status/+", self._on_status)
|
||||
|
||||
def _on_status(self, topic, payload):
|
||||
pass
|
||||
51
tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_with_block.py
vendored
Normal file
51
tests/fixtures/fp_guards/state_resource_method_summary_managed_xlang/safe_python_with_block.py
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Resource opens inside Python `with` blocks must not propagate an
|
||||
Acquire effect onto callers' receivers.
|
||||
|
||||
Pre-fix: `build_resource_method_summaries` summarised every method body
|
||||
containing an `open(...)` callee as Acquire, regardless of whether the
|
||||
handle was managed by a `with` block (released before return) or bound
|
||||
to a class field (genuine receiver state). Callers of `obj.method()`
|
||||
were then marked OPEN forever, producing the airflow `subject=self` FP
|
||||
cluster (58 findings).
|
||||
|
||||
Post-fix: nodes flagged `managed_resource = true` (Python `with`, Java
|
||||
try-with-resources, Ruby File.open block) are excluded from the summary
|
||||
table.
|
||||
"""
|
||||
|
||||
|
||||
class JWKS:
|
||||
"""Mimics airflow's tokens.JWKS shape."""
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
def _fetch_local_jwks(self):
|
||||
try:
|
||||
with open(self.url) as jwks_file:
|
||||
content = jwks_file.read()
|
||||
return content
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class BundleVersionLockReader:
|
||||
"""Mimics airflow's BundleUsageTrackingManager._remove_stale_bundle."""
|
||||
|
||||
@staticmethod
|
||||
def remove_stale(info):
|
||||
try:
|
||||
with open(info, "a") as f:
|
||||
f.write("x")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def use_jwks():
|
||||
j = JWKS("/tmp/x")
|
||||
j._fetch_local_jwks()
|
||||
|
||||
|
||||
def use_bundle_reader(info):
|
||||
r = BundleVersionLockReader()
|
||||
r.remove_stale(info)
|
||||
24
tests/fixtures/fp_guards/url_builder_const_base/expectations.json
vendored
Normal file
24
tests/fixtures/fp_guards/url_builder_const_base/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"min_count": 1
|
||||
}
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{
|
||||
"id_prefix": "taint-unsanitised-flow",
|
||||
"file_glob": "**/server.ts"
|
||||
}
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 8,
|
||||
"max_high_findings": 2
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 1000,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
10
tests/fixtures/fp_guards/url_builder_const_base/handler.ts
vendored
Normal file
10
tests/fixtures/fp_guards/url_builder_const_base/handler.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Negative control: an unbound base must still surface SSRF.
|
||||
// Without const-bound origin lock the abstract domain has no prefix
|
||||
// info, the SSRF arm fires.
|
||||
|
||||
export async function fetchByBase(req: {
|
||||
body: { path: string; base: string };
|
||||
}) {
|
||||
const u = new URL(req.body.path, req.body.base);
|
||||
return fetch(u);
|
||||
}
|
||||
17
tests/fixtures/fp_guards/url_builder_const_base/server.ts
vendored
Normal file
17
tests/fixtures/fp_guards/url_builder_const_base/server.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Phase 08 const-bound base: the URL constructor's second arg is a
|
||||
// `const` identifier whose value is a literal. Must surface no SSRF
|
||||
// finding because the abstract-string singleton domain proves the
|
||||
// origin is locked even though the base arg is not a syntactic
|
||||
// literal at the call site.
|
||||
|
||||
export async function fetchUserPath(req: { body: { path: string } }) {
|
||||
const apiBase = "https://api.example.com";
|
||||
const u = new URL(req.body.path, apiBase);
|
||||
return fetch(u);
|
||||
}
|
||||
|
||||
export async function fetchAltPath(req: { body: { path: string } }) {
|
||||
const altBase = "https://alt.example.com/";
|
||||
const u = new URL(req.body.path, altBase);
|
||||
return fetch(u);
|
||||
}
|
||||
5
tests/fixtures/fp_guards/vendored_assets_skip/bower_components/lib/lib.js
vendored
Normal file
5
tests/fixtures/fp_guards/vendored_assets_skip/bower_components/lib/lib.js
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Synthetic bower component. The engine must not parse this file because
|
||||
// `bower_components/` is unambiguously vendored regardless of file name.
|
||||
var entropy = Math.random();
|
||||
var compiled = eval("1+1");
|
||||
document.write(location.hash);
|
||||
20
tests/fixtures/fp_guards/vendored_assets_skip/expectations.json
vendored
Normal file
20
tests/fixtures/fp_guards/vendored_assets_skip/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"required_findings": [
|
||||
{ "id_prefix": "js.crypto.math_random", "min_count": 1 }
|
||||
],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "", "file_glob": "**/*.min.js" },
|
||||
{ "id_prefix": "", "file_glob": "**/vendor/**" },
|
||||
{ "id_prefix": "", "file_glob": "**/bower_components/**" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 5,
|
||||
"max_high_findings": 2
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
4
tests/fixtures/fp_guards/vendored_assets_skip/jquery-ui.custom.min.js
vendored
Normal file
4
tests/fixtures/fp_guards/vendored_assets_skip/jquery-ui.custom.min.js
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Synthetic minified bundle. The engine must not parse this file because
|
||||
// it carries the `.min.js` suffix. If parsed, every line below would surface
|
||||
// findings (Math.random, eval, prototype merge, document.write).
|
||||
var x=Math.random();var y=eval("1+1");function deepMerge(a,b){for(var k in b){a[k]=b[k];}return a;}deepMerge({},JSON.parse(location.hash));document.write(location.search);
|
||||
8
tests/fixtures/fp_guards/vendored_assets_skip/src/handler.js
vendored
Normal file
8
tests/fixtures/fp_guards/vendored_assets_skip/src/handler.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Negative control: hand-authored production source must still be scanned.
|
||||
// `Math.random` here MUST surface as `js.crypto.math_random` so the
|
||||
// vendored-asset skip is proven not to over-suppress.
|
||||
function makeToken() {
|
||||
return Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
module.exports = { makeToken };
|
||||
8
tests/fixtures/fp_guards/vendored_assets_skip/vendor/jquery/jquery.js
vendored
Normal file
8
tests/fixtures/fp_guards/vendored_assets_skip/vendor/jquery/jquery.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Synthetic vendored library. The engine must not parse this file because
|
||||
// it lives under a `vendor/` directory with a front-end `.js` extension.
|
||||
// Without the vendored-asset skip, every line below would surface findings.
|
||||
var token = Math.random();
|
||||
var result = eval("1+1");
|
||||
function merge(target, src) { for (var k in src) target[k] = src[k]; }
|
||||
merge({}, JSON.parse(location.hash));
|
||||
document.write(location.search);
|
||||
0
tests/fixtures/realistic/.gitkeep
vendored
Normal file
0
tests/fixtures/realistic/.gitkeep
vendored
Normal file
16
tests/fixtures/realistic/async_await/await_count.rs
vendored
Normal file
16
tests/fixtures/realistic/async_await/await_count.rs
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Phase 12 ssa-equivalence fixture (Rust): three `await_expression` nodes
|
||||
// in distinct positions (let-binding, statement-expression, implicit
|
||||
// return) used by `await_emits_at_most_one_assign_per_node` to assert
|
||||
// the SSA lowering does not double-fire Assign ops on a single
|
||||
// AwaitForward CFG node.
|
||||
async fn pass(s: String) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
pub async fn run() -> String {
|
||||
let env = std::env::var("X").unwrap_or_default();
|
||||
let fut1 = pass(env);
|
||||
let r1 = fut1.await;
|
||||
let fut2 = pass(r1);
|
||||
fut2.await
|
||||
}
|
||||
14
tests/fixtures/realistic/async_await/gather.py
vendored
Normal file
14
tests/fixtures/realistic/async_await/gather.py
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Phase 12 recall-gap fixture (Python combinator). `asyncio.gather`
|
||||
# concurrently awaits its argument futures and resolves to a list whose
|
||||
# elements carry the union of argument taints. The SQL sink on
|
||||
# `results[0]` proves the engine's `PromiseCombinator` rule fires for
|
||||
# Python via the `is_promise_combinator("python", "asyncio.gather")`
|
||||
# entry added in this phase.
|
||||
import asyncio
|
||||
|
||||
|
||||
async def main(request):
|
||||
a = request.args.get("x")
|
||||
b = request.form.get("y")
|
||||
results = await asyncio.gather(a, b)
|
||||
cursor.execute(results[0])
|
||||
9
tests/fixtures/realistic/async_await/handler.js
vendored
Normal file
9
tests/fixtures/realistic/async_await/handler.js
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Phase 02 recall-gap fixture: source flows through `await` into a SQL sink.
|
||||
// Modern handler shape — `await` is the front door of every framework that
|
||||
// exposes the request as a Promise (Next.js, Web Streams, fetch handlers).
|
||||
async function handler(req, res) {
|
||||
const data = await req.body;
|
||||
db.query(data);
|
||||
}
|
||||
|
||||
module.exports = handler;
|
||||
8
tests/fixtures/realistic/async_await/handler.py
vendored
Normal file
8
tests/fixtures/realistic/async_await/handler.py
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Phase 12 recall-gap fixture (Python). FastAPI / starlette-shape async
|
||||
# handler reads the request body via `await request.json()` and feeds it
|
||||
# to a SQL sink. Exercises the new Python `"await"` -> `Kind::AwaitForward`
|
||||
# mapping in `src/labels/python.rs`: without it, the engine never models
|
||||
# the await boundary as a 1:1 forward and `data` carries no Source taint.
|
||||
async def handler(request):
|
||||
data = await request.json()
|
||||
cursor.execute("SELECT * FROM t WHERE id = " + data)
|
||||
27
tests/fixtures/realistic/async_await/handler.rs
vendored
Normal file
27
tests/fixtures/realistic/async_await/handler.rs
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Phase 12 recall-gap fixture (Rust). axum-style async handler reads a
|
||||
// header value via `req.headers.get(...)` (a Source-tagged accessor in
|
||||
// `src/labels/rust.rs`) and awaits the result before passing it to a
|
||||
// command-injection sink. Exercises the new explicit
|
||||
// `"await_expression" => Kind::AwaitForward` mapping in
|
||||
// `src/labels/rust.rs`: the engine must see the await boundary as a
|
||||
// 1:1 forward so taint from the headers chain reaches `cmd`.
|
||||
use std::process::Command;
|
||||
|
||||
#[allow(unused)]
|
||||
struct Request;
|
||||
impl Request {
|
||||
fn headers(&self) -> Headers {
|
||||
Headers
|
||||
}
|
||||
}
|
||||
struct Headers;
|
||||
impl Headers {
|
||||
async fn get(&self, _key: &str) -> String {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handler(req: Request) {
|
||||
let cmd = req.headers().get("X-Cmd").await;
|
||||
Command::new("sh").arg("-c").arg(&cmd).status().ok();
|
||||
}
|
||||
9
tests/fixtures/realistic/async_await/handler.ts
vendored
Normal file
9
tests/fixtures/realistic/async_await/handler.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Phase 02 deferred-sweep fixture: the `.ts` counterpart to handler.js.
|
||||
// Exercises the TypeScript KINDS-map entry for `await_expression`.
|
||||
async function handler(req: { body: string }): Promise<void> {
|
||||
const data = await req.body;
|
||||
db.query(data);
|
||||
}
|
||||
|
||||
declare const db: { query(sql: string): void };
|
||||
export default handler;
|
||||
12
tests/fixtures/realistic/async_await/tokio_join.rs
vendored
Normal file
12
tests/fixtures/realistic/async_await/tokio_join.rs
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Phase 12 recall-gap fixture (Rust combinator). `tokio::join!` evaluates
|
||||
// every passed future concurrently and binds the tuple of resolved values.
|
||||
// `cfg::push_node` lifts the macro_invocation's `arg_uses` from its
|
||||
// `token_tree`, and `is_promise_combinator("rust", "tokio::join")` (added
|
||||
// in this phase) routes the SsaOp::Call through the existing combinator
|
||||
// transfer so each future's tainted inputs surface on the result tuple.
|
||||
pub async fn run() {
|
||||
let url_a = std::env::var("URL_A").unwrap_or_default();
|
||||
let url_b = std::env::var("URL_B").unwrap_or_default();
|
||||
let results = tokio::join!(url_a, url_b);
|
||||
reqwest::get(results.0).await.ok();
|
||||
}
|
||||
14
tests/fixtures/realistic/async_await/tokio_join_bare.rs
vendored
Normal file
14
tests/fixtures/realistic/async_await/tokio_join_bare.rs
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Phase 12 deferred-fix fixture (Rust combinator, bare macro form).
|
||||
// `use tokio::join;` brings the macro into scope; the call site then uses
|
||||
// the bare `join!(...)` shape. `cfg::push_node` rewrites the bare macro
|
||||
// callee text to `tokio::join` when the file imports the matching macro,
|
||||
// so `is_promise_combinator("rust", "tokio::join")` recognises the
|
||||
// resulting SSA Call op and unions argument taint into the tuple value.
|
||||
use tokio::join;
|
||||
|
||||
pub async fn run() {
|
||||
let url_a = std::env::var("URL_A").unwrap_or_default();
|
||||
let url_b = std::env::var("URL_B").unwrap_or_default();
|
||||
let results = join!(url_a, url_b);
|
||||
reqwest::get(results.0).await.ok();
|
||||
}
|
||||
5
tests/fixtures/realistic/cross_package_ipa/packages/util/package.json
vendored
Normal file
5
tests/fixtures/realistic/cross_package_ipa/packages/util/package.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@scope/util",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
7
tests/fixtures/realistic/cross_package_ipa/packages/util/src/sanitize.ts
vendored
Normal file
7
tests/fixtures/realistic/cross_package_ipa/packages/util/src/sanitize.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Cross-package fixture for Phase 09: passthrough function whose name is
|
||||
// NOT in the JS/TS intrinsic sanitizer matcher list, so the only way for
|
||||
// the engine to know it preserves taint is via the cross-package SSA
|
||||
// summary lookup that step 0.7 of `resolve_callee_full` performs.
|
||||
export function escapeHtmlNoop(s: string): string {
|
||||
return s;
|
||||
}
|
||||
9
tests/fixtures/realistic/cross_package_ipa/packages/util/src/strip.ts
vendored
Normal file
9
tests/fixtures/realistic/cross_package_ipa/packages/util/src/strip.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Cross-package fixture for Phase 09: sanitizer that wraps the JS
|
||||
// intrinsic `encodeURIComponent` (recognised by the JS sanitizer label
|
||||
// table as `Sanitizer(URL_ENCODE | HTML_ESCAPE)`). The intra-file SSA
|
||||
// summary therefore carries a real sanitize transform on `s → return`,
|
||||
// which step 0.7 of `resolve_callee_full` propagates into the caller
|
||||
// site so the cross-package safe path stays silent.
|
||||
export function stripTags(s: string): string {
|
||||
return encodeURIComponent(s);
|
||||
}
|
||||
8
tests/fixtures/realistic/cross_package_ipa/packages/web/package.json
vendored
Normal file
8
tests/fixtures/realistic/cross_package_ipa/packages/web/package.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@scope/web",
|
||||
"version": "0.0.0",
|
||||
"main": "src/handler.ts",
|
||||
"dependencies": {
|
||||
"@scope/util": "*"
|
||||
}
|
||||
}
|
||||
14
tests/fixtures/realistic/cross_package_ipa/packages/web/src/handler.ts
vendored
Normal file
14
tests/fixtures/realistic/cross_package_ipa/packages/web/src/handler.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { escapeHtmlNoop } from "@scope/util/sanitize";
|
||||
import { stripTags } from "@scope/util/strip";
|
||||
|
||||
export function unsafeHandler(req: any, res: any) {
|
||||
const x = req.query.x;
|
||||
const y = escapeHtmlNoop(x);
|
||||
res.send(y);
|
||||
}
|
||||
|
||||
export function safeHandler(req: any, res: any) {
|
||||
const x = req.query.x;
|
||||
const y = stripTags(x);
|
||||
res.send(y);
|
||||
}
|
||||
8
tests/fixtures/realistic/cross_package_ipa/tsconfig.json
vendored
Normal file
8
tests/fixtures/realistic/cross_package_ipa/tsconfig.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@scope/*": ["packages/*/src"]
|
||||
}
|
||||
}
|
||||
}
|
||||
13
tests/fixtures/realistic/entry_points_xlang/actix_handler.rs
vendored
Normal file
13
tests/fixtures/realistic/entry_points_xlang/actix_handler.rs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Phase 16 fixture: actix-web handler. The `#[get("/u/{name}")]`
|
||||
// routing macro attribute marks `u` as an `ActixHandler` entry point.
|
||||
// The `name` formal is seeded as `Source(Cap::all())` and flows into
|
||||
// `Command::new("sh").arg(&name)` (SHELL_ESCAPE sink).
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use std::process::Command;
|
||||
|
||||
#[get("/u/{name}")]
|
||||
pub async fn u(name: web::Path<String>) -> HttpResponse {
|
||||
let s: String = name.into_inner();
|
||||
Command::new("sh").arg("-c").arg(&s).status().ok();
|
||||
HttpResponse::Ok().body(s)
|
||||
}
|
||||
14
tests/fixtures/realistic/entry_points_xlang/axum_handler.rs
vendored
Normal file
14
tests/fixtures/realistic/entry_points_xlang/axum_handler.rs
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Phase 16 fixture: axum handler. The signature contains an axum
|
||||
// `Query<_>` extractor, so `list` is recognised as an `AxumHandler`
|
||||
// entry point. Every formal `Param` in the SSA entry block is seeded
|
||||
// with `Cap::all()` Source taint, which flows through the destructured
|
||||
// `q` String value into the `Command::new("sh").arg(&q)` chain. The
|
||||
// chained `.arg` call resolves to `command::arg` (SHELL_ESCAPE sink)
|
||||
// in `src/labels/rust.rs`.
|
||||
use axum::extract::Query;
|
||||
use std::process::Command;
|
||||
|
||||
pub async fn list(Query(q): Query<String>) -> String {
|
||||
Command::new("sh").arg("-c").arg(&q).status().ok();
|
||||
q
|
||||
}
|
||||
14
tests/fixtures/realistic/entry_points_xlang/django_view.py
vendored
Normal file
14
tests/fixtures/realistic/entry_points_xlang/django_view.py
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Phase 16 fixture: Django class-based view. `MyView` extends
|
||||
# `django.views.View`; `get(self, request, name)` is recognised as a
|
||||
# `DjangoView` entry point. The seeding policy paints every formal
|
||||
# (including `name`, the path-captured kwarg) as `Source(Cap::all())`,
|
||||
# so flowing `name` into `cursor.execute` fires SQL_QUERY.
|
||||
from django.db import connection
|
||||
from django.views import View
|
||||
|
||||
|
||||
class MyView(View):
|
||||
def get(self, request, name):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT * FROM users WHERE name = '" + name + "'")
|
||||
return cursor.fetchall()
|
||||
17
tests/fixtures/realistic/entry_points_xlang/express_route.js
vendored
Normal file
17
tests/fixtures/realistic/entry_points_xlang/express_route.js
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Phase 16 fixture: Express route handler. `app.post('/u', ...)`
|
||||
// registers an arrow handler whose span is detected as an
|
||||
// `ExpressRoute { method: POST }` entry point. The seeding policy
|
||||
// paints `req` and `res` as `Source(Cap::all())`; `req.body.name` is
|
||||
// already a JS-handler-param-name source via Phase 05, so flowing
|
||||
// into `db.query(...)` fires the SQL_QUERY sink. The new entry-kind
|
||||
// detection guarantees the seeding even outside Next.js.
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const db = require('./db');
|
||||
|
||||
app.post('/u', (req, res) => {
|
||||
db.query("SELECT * FROM users WHERE name = '" + req.body.name + "'");
|
||||
res.send('ok');
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
17
tests/fixtures/realistic/entry_points_xlang/fastapi_route.py
vendored
Normal file
17
tests/fixtures/realistic/entry_points_xlang/fastapi_route.py
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Phase 16 fixture: FastAPI route handler. The `@app.get("/items/{item_id}")`
|
||||
# decorator marks `read_item` as a `FastApiRoute { method: GET }` entry
|
||||
# point. Every formal is seeded as `Source(Cap::all())`; the unsanitised
|
||||
# `item_id` value is concatenated into a SQL statement and forwarded to
|
||||
# `cursor.execute` (flat SQL_QUERY sink).
|
||||
from fastapi import FastAPI
|
||||
import sqlite3
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
async def read_item(item_id: str):
|
||||
conn = sqlite3.connect("db.sqlite")
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM items WHERE id = '" + item_id + "'")
|
||||
return cursor.fetchall()
|
||||
15
tests/fixtures/realistic/entry_points_xlang/flask_route.py
vendored
Normal file
15
tests/fixtures/realistic/entry_points_xlang/flask_route.py
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Phase 16 fixture: Flask route handler. The `@app.route(...)` decorator
|
||||
# with `methods=["POST"]` marks `submit` as a `FlaskRoute { method: POST }`
|
||||
# entry point. The path-captured `name` formal is auto-seeded as
|
||||
# `Source(Cap::all())` and forwarded into `os.system`, firing
|
||||
# SHELL_ESCAPE.
|
||||
from flask import Flask
|
||||
import os
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/submit/<name>", methods=["POST"])
|
||||
def submit(name):
|
||||
os.system("echo " + name)
|
||||
return "ok"
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue