Critical bug fixes and recall improvements (#68)

This commit is contained in:
Eli Peter 2026-05-11 12:42:39 -04:00 committed by GitHub
parent 7d0e7320e2
commit 55247b7fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
352 changed files with 60069 additions and 900 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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"
}
}

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

View 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"
}
}

View 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"
}
}

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

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

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

View 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"
}
}

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

View 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"
}
}

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

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

View 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"
}
}

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

View 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"
}
}

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

View 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"
}
}

View 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"
}
}

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

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

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

View 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"
}
}

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

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

View 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"
}
}

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

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

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

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

View 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"
}
}

View 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"
}
}

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

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

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

View 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"
}
}

View 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 []; }
}

View 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"
}
}

View 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) {
}
}
}
}

View 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"
}
}

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

View 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"
}
}

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

View 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"
}
}

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

View 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"
}
}

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

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

View 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"
}
}

View 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) {}
}

View 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"
}
}

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

View 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"
}
}

View 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"
}
}

View 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

View 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"
}
}

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

View 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"
}
}

View 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

View 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

View 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"
}
}

View file

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

View file

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

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

View 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"
}
}

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

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

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

View 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"
}
}

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

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

View 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
View file

View 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
}

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

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

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

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

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

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

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

View file

@ -0,0 +1,5 @@
{
"name": "@scope/util",
"version": "0.0.0",
"main": "src/index.ts"
}

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

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

View file

@ -0,0 +1,8 @@
{
"name": "@scope/web",
"version": "0.0.0",
"main": "src/handler.ts",
"dependencies": {
"@scope/util": "*"
}
}

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

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@scope/*": ["packages/*/src"]
}
}
}

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

View 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
}

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

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

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

View 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