use std::path::PathBuf;
fn repo_root() -> PathBuf {
let mut p = std::env::current_dir().expect("cwd");
p.push("../..");
p.canonicalize().expect("repo root")
}
fn fixture_root(lang: &str) -> Option<PathBuf> {
let p = repo_root().join("examples").join(lang).join("callback_flow");
if !p.is_dir() {
return None;
}
let has_file = std::fs::read_dir(&p)
.ok()?
.any(|e| e.ok().map(|e| e.path().is_file()).unwrap_or(false));
has_file.then_some(p)
}
fn rules_root() -> PathBuf {
repo_root().join("security-patterns")
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Expected {
Pass,
#[allow(dead_code)]
Pending,
}
const LANG_TABLE: &[(&str, Expected)] = &[
("c", Expected::Pass),
("cpp", Expected::Pass),
("csharp", Expected::Pass),
("dart", Expected::Pass),
("elixir", Expected::Pass),
("erlang", Expected::Pass),
("go", Expected::Pass),
("java", Expected::Pass),
("javascript", Expected::Pass),
("kotlin", Expected::Pass),
("lua", Expected::Pass),
("objc", Expected::Pass),
("perl", Expected::Pass),
("php", Expected::Pass),
("python", Expected::Pass),
("ruby", Expected::Pass),
("rust", Expected::Pass),
("scala", Expected::Pass),
("swift", Expected::Pass),
("typescript", Expected::Pass),
];
fn run_taint_for(lang: &str) -> Result<bonsai_security::TaintAnalysisReport, String> {
let ws_root = fixture_root(lang).ok_or_else(|| "missing fixture".to_string())?;
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).map_err(|e| format!("rulepack load: {e}"))?;
let ws = bonsai_workspace::Workspace::index(&ws_root, registry)
.map_err(|e| format!("index workspace: {e}"))?;
bonsai_security::run_taint_analysis(&ws, &pack, Default::default())
.map_err(|e| format!("taint_analysis: {e}"))
}
#[derive(Debug)]
struct Result_ {
lang: &'static str,
skipped: Option<String>,
findings: usize,
}
fn audit_one(lang: &'static str) -> Result_ {
let report = match run_taint_for(lang) {
Ok(r) => r,
Err(reason) => {
return Result_ {
lang,
skipped: Some(reason),
findings: 0,
}
}
};
Result_ {
lang,
skipped: None,
findings: report.findings.len(),
}
}
#[test]
fn callback_flow_audit_per_language() {
let results: Vec<_> = LANG_TABLE
.iter()
.copied()
.map(|(lang, expected)| (audit_one(lang), expected))
.collect();
eprintln!("\n=== callback-flow audit ===");
eprintln!("{:<11} {:>9} {:<22} status", "lang", "findings", "expected");
for (r, expected) in &results {
let exp = match expected {
Expected::Pass => "Pass",
Expected::Pending => "Pending",
};
let status = if matches!(expected, Expected::Pending) {
"skipped (pending)"
} else if r.skipped.is_some() {
"fixture missing"
} else {
match (expected, r.findings) {
(Expected::Pass, n) if n >= 1 => "ok",
(Expected::Pass, _) => "REGRESSION: callback flow not connected",
(Expected::Pending, _) => unreachable!(),
}
};
eprintln!("{:<11} {:>9} {:<22} {status}", r.lang, r.findings, exp);
}
let regressions: Vec<String> = results
.iter()
.filter_map(|(r, expected)| match expected {
Expected::Pending => None,
Expected::Pass => {
if r.skipped.is_some() {
Some(format!("{}: fixture missing", r.lang))
} else if r.findings == 0 {
Some(format!("{}: callback flow not connected", r.lang))
} else {
None
}
}
})
.collect();
if !regressions.is_empty() {
let mut msg = String::from("callback-flow audit drift:\n");
for r in ®ressions {
msg.push_str(" ");
msg.push_str(r);
msg.push('\n');
}
panic!("{msg}");
}
}
#[test]
fn object_config_route_handler_body_is_analyzed() {
let dir = std::env::temp_dir().join("bonsai_objcfg_handler_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("m.js"),
"const cp = require('child_process');\n\
const Hapi = require('@hapi/hapi');\n\
server.route({ method: 'GET', path: '/x', handler: (request, h) => { cp.exec(request.query.q); } });\n",
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let n = report.findings.len();
let _ = std::fs::remove_dir_all(&dir);
assert!(
n >= 1,
"Hapi object-config route handler body must be analyzed (request.query -> cp.exec), got {n} findings"
);
}
#[test]
fn fastify_object_route_handler_body_is_analyzed() {
let dir = std::env::temp_dir().join("bonsai_fastify_objcfg_handler_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("m.js"),
"const cp = require('child_process');\n\
const fastify = require('fastify')();\n\
fastify.route({ method: 'GET', url: '/x', handler: async (request, reply) => {\n\
cp.exec(request.query.q);\n\
} });\n",
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let n = report.findings.len();
let _ = std::fs::remove_dir_all(&dir);
assert!(
n >= 1,
"Fastify object-config route handler body must be analyzed (request.query -> cp.exec), got {n} findings"
);
}
#[test]
fn node_source_callback_named_function_reaches_sink() {
let dir = std::env::temp_dir().join("bonsai_node_source_callback_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("m.js"),
"const fs = require('fs');\n\
function named(path) {\n\
fs.readFile(path, 'utf8', onRead);\n\
}\n\
function onRead(err, data) {\n\
eval(data);\n\
}\n",
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let matching = report.findings.iter().find(|finding| {
finding.finding.source.rule_id == "javascript.source.fs_readfile_options_callback"
&& finding.finding.sink.rule_id == "javascript.eval.builtin_eval"
&& finding
.finding
.chain_display
.iter()
.map(String::as_str)
.eq(["named", "onRead"])
&& finding.finding.taint_path.iter().any(|step| {
step.caller == "named"
&& step.callee == "onRead"
&& step
.tainted_args
.iter()
.any(|arg| arg.value_text == "data" && arg.param_name == "data")
})
});
let _ = std::fs::remove_dir_all(&dir);
assert!(
matching.is_some(),
"fs.readFile named callback data parameter must reach eval with a named->onRead path; findings={:#?}",
report.findings
);
}
#[test]
fn node_source_callback_inline_function_reaches_sink() {
let dir = std::env::temp_dir().join("bonsai_node_inline_source_callback_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("m.js"),
"const fs = require('fs');\n\
function load(path) {\n\
fs.readFile(path, (err, data) => {\n\
eval(data);\n\
});\n\
}\n",
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let matching = report.findings.iter().any(|finding| {
finding.finding.source.rule_id == "javascript.source.fs_readfile_callback"
&& finding.finding.sink.rule_id == "javascript.eval.builtin_eval"
});
let _ = std::fs::remove_dir_all(&dir);
assert!(
matching,
"fs.readFile must deliver its rule-declared data parameter into an inline callback: {:#?}",
report.findings
);
}
#[test]
fn typescript_trpc_inline_destructured_input_reaches_prisma_sink() {
let dir = std::env::temp_dir().join("bonsai_typescript_trpc_inline_callback_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("router.ts"),
r#"
import { initTRPC } from "@trpc/server";
import { PrismaClient } from "@prisma/client";
const t = initTRPC.create();
const prisma = new PrismaClient();
const schema = {};
export const appRouter = t.router({
list: t.procedure.input(schema).query(async ({ input }) => {
return prisma.$queryRawUnsafe("SELECT * FROM reports ORDER BY " + input.column);
}),
});
"#,
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let matching = report.findings.iter().find(|finding| {
finding.finding.source.rule_id == "typescript.source.trpc_callback_input"
&& finding.finding.sink.rule_id == "typescript.sqli.prisma_query_raw_unsafe"
});
let _ = std::fs::remove_dir_all(&dir);
assert!(
matching.is_some(),
"tRPC inline callback input must reach the Prisma raw-query sink: {:#?}",
report.findings
);
}
#[test]
fn typescript_callback_without_a_declared_source_shape_stays_clean() {
let dir = std::env::temp_dir().join("bonsai_typescript_non_source_inline_callback_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("router.ts"),
r#"
import { initTRPC } from "@trpc/server";
import { PrismaClient } from "@prisma/client";
const t = initTRPC.create();
const prisma = new PrismaClient();
export const appRouter = t.router({
list: t.procedure.query(async ({ input }) => {
return prisma.$queryRawUnsafe("SELECT * FROM reports ORDER BY " + input.column);
}),
});
"#,
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(&ws, &pack, Default::default()).expect("taint");
let unexpected = report.findings.iter().any(|finding| {
finding.finding.source.rule_id == "typescript.source.trpc_callback_input"
&& finding.finding.sink.rule_id == "typescript.sqli.prisma_query_raw_unsafe"
});
let _ = std::fs::remove_dir_all(&dir);
assert!(
!unexpected,
"an inline callback compiler fact is not itself a source: {:#?}",
report.findings
);
}
#[test]
fn go_string_conversion_preserves_taint() {
let dir = std::env::temp_dir().join("bonsai_go_string_conv_audit");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("m.go"),
"package main\n\
import \"os/exec\"\n\
func h(input string) {\n\
\tx := string([]byte(input))\n\
\texec.Command(x)\n\
}\n",
)
.expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(
&ws,
&pack,
bonsai_security::TaintAnalysisOptions {
include_inferred_sources: true,
..Default::default()
},
)
.expect("taint");
let n = report.findings.len();
let _ = std::fs::remove_dir_all(&dir);
assert!(
n >= 1,
"Go `string([]byte(input))` must preserve taint into exec.Command, got {n} findings"
);
}
#[test]
fn lua_tostring_preserves_taint() {
assert!(
coercion_findings(
"lua",
"lua",
"function h(input) os.execute(tostring(input)) end\n"
) >= 1,
"Lua tostring(input) must preserve taint into os.execute"
);
}
#[test]
fn elixir_to_string_preserves_taint() {
let src = "defmodule App do\n def h(input), do: System.cmd(to_string(input), [])\nend\n";
assert!(
coercion_findings("elixir", "ex", src) >= 1,
"Elixir to_string(input) must preserve taint into System.cmd"
);
}
#[test]
fn elixir_inline_keyword_conditional_preserves_taint() {
let src = "defmodule App do\n def h(input, flag) do\n cmd = if flag, do: input, else: \"\"\n System.shell(cmd)\n end\nend\n";
assert!(
coercion_findings("elixir_inline_if", "ex", src) >= 1,
"Elixir inline `if flag, do: input` reassignment must preserve taint into System.shell"
);
let safe = "defmodule App do\n def h(flag) do\n cmd = if flag, do: \"safe\", else: \"ok\"\n System.shell(cmd)\n end\nend\n";
assert_eq!(
coercion_findings("elixir_inline_if_safe", "ex", safe),
0,
"Elixir inline if with only literal branches must not create a finding"
);
}
#[test]
fn free_function_string_coercion_preserves_taint() {
let scala = "object App {\n def h(input: String): Unit = {\n val v = String.valueOf(input)\n Runtime.getRuntime().exec(v)\n }\n}\n";
assert!(
coercion_findings("scala_valueof", "scala", scala) >= 1,
"scala String.valueOf(input) must preserve taint"
);
let rust = "use std::process::Command;\nfn run(p: &str) {\n Command::new(String::from(p));\n}\n";
assert!(
coercion_findings("rust_string_from", "rs", rust) >= 1,
"rust String::from(p) must preserve taint"
);
let erlang = "-module(example).\n-export([test/1]).\ntest(Input) ->\n S = lists:flatten(io_lib:format(\"~s\", [Input])),\n os:cmd(S).\n";
assert!(
coercion_findings("erlang_flatten", "erl", erlang) >= 1,
"erlang lists:flatten(io_lib:format(...)) must preserve taint"
);
let ruby = "def example(input)\n system(String(input))\nend\n";
assert!(
coercion_findings("ruby_string", "rb", ruby) >= 1,
"ruby system(String(input)) must preserve taint"
);
let js = "const child_process = require(\"child_process\");\nfunction demo(input) {\n return child_process.exec(String(input));\n}\n";
assert!(
coercion_findings("js_string", "js", js) >= 1,
"js exec(String(input)) inline must preserve taint"
);
let ts = "const child_process = require(\"child_process\");\nfunction demo(input: string) {\n return child_process.exec(String(input));\n}\n";
assert!(
coercion_findings("ts_string", "ts", ts) >= 1,
"ts exec(String(input)) inline must preserve taint"
);
let cpp = "#include <string>\n#include <cstdlib>\nvoid run(char *p) {\n std::string s = std::string(p);\n system(s.c_str());\n}\n";
assert!(
coercion_findings("cpp_stdstring", "cpp", cpp) >= 1,
"cpp `std::string s = std::string(p)` must preserve taint into system"
);
}
#[test]
fn factory_return_cursor_typing_fires() {
let src = "import sqlite3\ndef example(user_input):\n c = sqlite3.connect(\"db\").cursor()\n c.execute(user_input)\n";
assert!(
coercion_findings("py_factory_cursor", "py", src) >= 1,
"factory-returned cursor (c = connect().cursor()) must type c and fire cursor.execute via python.typing.dbapi_cursor"
);
let safe = "import sqlite3\ndef example():\n c = sqlite3.connect(\"db\").cursor()\n c.execute(\"SELECT 1\")\n";
assert_eq!(
coercion_findings("py_factory_cursor_safe", "py", safe),
0,
"factory-typed cursor with a literal query must not create a finding"
);
}
fn coercion_findings(tag: &str, ext: &str, src: &str) -> usize {
let dir = std::env::temp_dir().join(format!("bonsai_coercion_{tag}_audit"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join(format!("m.{ext}")), src).expect("write");
let registry = bonsai_adapters::all_languages_registry();
let pack = bonsai_security::load_rulepack(&rules_root()).expect("rulepack");
let ws = bonsai_workspace::Workspace::index(&dir, registry).expect("index");
let report = bonsai_security::run_taint_analysis(
&ws,
&pack,
bonsai_security::TaintAnalysisOptions {
include_inferred_sources: true,
..Default::default()
},
)
.expect("taint");
let n = report.findings.len();
let _ = std::fs::remove_dir_all(&dir);
n
}
#[test]
fn erlang_binary_to_list_preserves_taint() {
assert!(
coercion_findings(
"erlang_b2l",
"erl",
"h(Input) -> os:cmd(binary_to_list(Input)).\n"
) >= 1,
"Erlang binary_to_list(Input) must preserve taint into os:cmd"
);
assert!(
coercion_findings(
"erlang_c2l",
"erl",
"h(Input) -> os:cmd(unicode:characters_to_list(Input)).\n"
) >= 1,
"Erlang unicode:characters_to_list(Input) must preserve taint into os:cmd"
);
}
#[test]
fn base64_decode_preserves_taint_across_langs() {
assert!(
coercion_findings(
"php_b64",
"php",
"<?php\nfunction h($input){ system(base64_decode($input)); }\n"
) >= 1,
"PHP base64_decode must preserve taint"
);
assert!(
coercion_findings(
"ruby_b64",
"rb",
"require 'base64'\ndef h(input)\n system(Base64.decode64(input))\nend\n"
) >= 1,
"Ruby Base64.decode64 must preserve taint"
);
assert!(
coercion_findings(
"js_atob",
"js",
"const cp=require('child_process')\nfunction h(input){ cp.exec(atob(input)) }\n"
) >= 1,
"JS atob must preserve taint"
);
assert!(
coercion_findings(
"ts_atob",
"ts",
"const cp=require('child_process')\nfunction h(input: string){ cp.exec(atob(input)) }\n"
) >= 1,
"TS atob must preserve taint"
);
}
#[test]
fn json_parse_preserves_taint_across_langs() {
assert!(
coercion_findings(
"php_json",
"php",
"<?php\nfunction h($input){ system(json_decode($input)); }\n"
) >= 1,
"PHP json_decode must preserve taint"
);
assert!(
coercion_findings(
"ruby_json",
"rb",
"require 'json'\ndef h(input)\n system(JSON.parse(input))\nend\n"
) >= 1,
"Ruby JSON.parse must preserve taint"
);
assert!(
coercion_findings(
"js_json",
"js",
"const cp=require('child_process')\nfunction h(input){ cp.exec(JSON.parse(input)) }\n"
) >= 1,
"JS JSON.parse must preserve taint"
);
assert!(
coercion_findings(
"ts_json",
"ts",
"const cp=require('child_process')\nfunction h(input: string){ cp.exec(JSON.parse(input)) }\n"
) >= 1,
"TS JSON.parse must preserve taint"
);
}
#[test]
fn comprehension_and_generator_sinks_are_analyzed() {
assert!(
coercion_findings(
"py_genexpr_call",
"py",
"import os\ndef h(items):\n any(os.system(t) for t in items)\n"
) >= 1,
"generator expr as call arg must taint the sink"
);
assert!(
coercion_findings(
"py_genexpr_list",
"py",
"import os\ndef h(items):\n list(os.system(t) for t in items)\n"
) >= 1,
"generator expr in list() must taint the sink"
);
assert!(
coercion_findings(
"py_nested_comp",
"py",
"import os\ndef h(rows):\n [os.system(t) for row in rows for t in row]\n"
) >= 1,
"nested comprehension must chain rows -> row -> t into the sink"
);
assert!(
coercion_findings(
"py_list_comp",
"py",
"import os\ndef h(items):\n [os.system(t) for t in items]\n"
) >= 1,
"single-clause list comprehension must still taint the sink"
);
}
#[test]
fn hex_decode_preserves_taint_across_langs() {
assert!(
coercion_findings(
"py_unhex",
"py",
"import os, binascii\ndef h(input):\n os.system(binascii.unhexlify(input))\n"
) >= 1,
"python binascii.unhexlify must preserve taint"
);
assert!(
coercion_findings(
"py_fromhex",
"py",
"import os\ndef h(input):\n os.system(bytes.fromhex(input))\n"
) >= 1,
"python bytes.fromhex must preserve taint"
);
assert!(
coercion_findings(
"php_hex2bin",
"php",
"<?php\nfunction h($input){ system(hex2bin($input)); }\n"
) >= 1,
"php hex2bin must preserve taint"
);
}
#[test]
fn yaml_safe_load_preserves_taint() {
assert!(
coercion_findings(
"py_yaml",
"py",
"import os, yaml\ndef h(input):\n os.system(yaml.safe_load(input))\n"
) >= 1,
"python yaml.safe_load must preserve taint"
);
assert!(
coercion_findings(
"ruby_yaml",
"rb",
"require 'yaml'\ndef h(input)\n system(YAML.safe_load(input))\nend\n"
) >= 1,
"ruby YAML.safe_load must preserve taint"
);
}
#[test]
fn go_base64_decodestring_preserves_taint() {
for enc in ["StdEncoding", "URLEncoding", "RawStdEncoding", "RawURLEncoding"] {
let src = format!(
"package main\nimport (\"os/exec\"; \"encoding/base64\")\nfunc h(input string){{ b,_:=base64.{enc}.DecodeString(input); exec.Command(string(b)) }}\n"
);
assert!(
coercion_findings(&format!("go_b64_{enc}"), "go", &src) >= 1,
"go base64.{enc}.DecodeString must preserve taint into exec.Command"
);
}
}