Skip to content

Commit 61f7451

Browse files
committed
feat: implement module alias tracking for dynamic dispatch in JS/TS
1 parent 69f52bc commit 61f7451

25 files changed

Lines changed: 664 additions & 20 deletions

File tree

src/ast.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,52 @@ fn is_binary(bytes: &[u8]) -> bool {
250250
bytes.iter().filter(|b| **b == 0).count() * 100 / bytes.len().max(1) > 1
251251
}
252252

253+
/// Check if a file path indicates a test file. Matches filename-based
254+
/// conventions (`.test.js`, `.spec.ts`) and the `__tests__` directory
255+
/// convention. Directory-only checks (`test/`, `tests/`, `fixtures/`)
256+
/// are intentionally excluded because they're too broad when scanning
257+
/// absolute paths.
258+
fn is_test_file(path: &Path) -> bool {
259+
static TEST_SUFFIXES: &[&str] = &[
260+
".test.js",
261+
".test.ts",
262+
".test.jsx",
263+
".test.tsx",
264+
".spec.js",
265+
".spec.ts",
266+
".spec.jsx",
267+
".spec.tsx",
268+
];
269+
270+
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
271+
for suffix in TEST_SUFFIXES {
272+
if name.ends_with(suffix) {
273+
return true;
274+
}
275+
}
276+
}
277+
278+
// __tests__ is specific enough (React/Jest convention) to match on directory
279+
for component in path.components() {
280+
if let std::path::Component::Normal(c) = component
281+
&& c == "__tests__"
282+
{
283+
return true;
284+
}
285+
}
286+
287+
false
288+
}
289+
290+
/// Pattern IDs that are noise-prone in test files (fixture credentials,
291+
/// non-crypto randomness, plain HTTP in test harnesses).
292+
fn is_test_suppressible_pattern(id: &str) -> bool {
293+
// Suffix-match to handle both js. and ts. prefixes
294+
id.ends_with(".secrets.hardcoded_secret")
295+
|| id.ends_with(".crypto.math_random")
296+
|| id.ends_with(".transport.fetch_http")
297+
}
298+
253299
/// Check if a file path belongs to a non-production context (tests, vendor,
254300
/// benchmarks, etc.). Used to downgrade severity for findings in paths that
255301
/// are unlikely to represent attack surface.
@@ -363,11 +409,16 @@ impl<'a> ParsedSource<'a> {
363409
let compiled = query_cache::for_lang(self.lang_slug, self.ts_lang.clone());
364410
let mut cursor = QueryCursor::new();
365411
let mut out = Vec::new();
412+
let in_test_file = is_test_file(self.path);
366413

367414
for cq in compiled.iter() {
368415
if cq.meta.severity > cfg.scanner.min_severity {
369416
continue;
370417
}
418+
// Suppress noise-prone patterns in test files
419+
if in_test_file && is_test_suppressible_pattern(cq.meta.id) {
420+
continue;
421+
}
371422
let mut matches = cursor.matches(&cq.query, root, self.bytes);
372423
while let Some(m) = matches.next() {
373424
if let Some(cap) = m.captures.iter().find(|c| c.index == 0) {

src/cfg.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2448,7 +2448,18 @@ fn push_node<'a>(
24482448
fn extract_param_names<'a>(func_node: Node<'a>, lang: &str, code: &'a [u8]) -> Vec<String> {
24492449
let cfg = param_config(lang);
24502450
let mut names = Vec::new();
2451-
let Some(params) = func_node.child_by_field_name(cfg.params_field) else {
2451+
// Try the params_field directly on the function node first.
2452+
// For C/C++, the parameter list is nested inside the declarator
2453+
// (function_definition > declarator:function_declarator > parameters:parameter_list),
2454+
// so fall back to looking one level deeper via the "declarator" field.
2455+
let params = func_node
2456+
.child_by_field_name(cfg.params_field)
2457+
.or_else(|| {
2458+
func_node
2459+
.child_by_field_name("declarator")
2460+
.and_then(|d| d.child_by_field_name(cfg.params_field))
2461+
});
2462+
let Some(params) = params else {
24522463
return names;
24532464
};
24542465
let mut cursor = params.walk();
@@ -6027,4 +6038,48 @@ mod cfg_tests {
60276038
assert!(!has_sql_placeholders("SELECT * FROM t WHERE x = $0")); // $0 not valid
60286039
assert!(!has_sql_placeholders("ratio = 50%")); // %<not s>
60296040
}
6041+
6042+
#[test]
6043+
fn c_function_extracts_param_names() {
6044+
let src = b"void handle_command(int cmd, char *arg) { }";
6045+
let ts_lang = Language::from(tree_sitter_c::LANGUAGE);
6046+
let file_cfg = parse_to_file_cfg(src, "c", ts_lang);
6047+
let params: Vec<_> = file_cfg
6048+
.summaries
6049+
.values()
6050+
.flat_map(|s| s.param_names.iter().cloned())
6051+
.collect();
6052+
assert!(
6053+
params.contains(&"cmd".to_string()),
6054+
"expected 'cmd' in params, got: {:?}",
6055+
params
6056+
);
6057+
assert!(
6058+
params.contains(&"arg".to_string()),
6059+
"expected 'arg' in params, got: {:?}",
6060+
params
6061+
);
6062+
}
6063+
6064+
#[test]
6065+
fn cpp_function_extracts_param_names() {
6066+
let src = b"void process(int x, std::string name) { }";
6067+
let ts_lang = Language::from(tree_sitter_cpp::LANGUAGE);
6068+
let file_cfg = parse_to_file_cfg(src, "cpp", ts_lang);
6069+
let params: Vec<_> = file_cfg
6070+
.summaries
6071+
.values()
6072+
.flat_map(|s| s.param_names.iter().cloned())
6073+
.collect();
6074+
assert!(
6075+
params.contains(&"x".to_string()),
6076+
"expected 'x' in params, got: {:?}",
6077+
params
6078+
);
6079+
assert!(
6080+
params.contains(&"name".to_string()),
6081+
"expected 'name' in params, got: {:?}",
6082+
params
6083+
);
6084+
}
60306085
}

src/cfg_analysis/guards.rs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,28 @@ fn is_all_args_constant(ctx: &AnalysisContext, sink: NodeIndex) -> bool {
3232
.split(['.', ':'])
3333
.map(|p| p.split('(').next().unwrap_or(p))
3434
.collect();
35+
// When the callee was overridden by an inner call (e.g. `db.query` inside
36+
// `Promise.all([db.query(...)])`), the outer callee's parts (e.g. "Promise",
37+
// "all") also belong to the callee machinery, not to arguments.
38+
let outer_parts: Vec<&str> = sink_info
39+
.call
40+
.outer_callee
41+
.as_deref()
42+
.map(|oc| {
43+
oc.split(['.', ':'])
44+
.map(|p| p.split('(').next().unwrap_or(p))
45+
.collect()
46+
})
47+
.unwrap_or_default();
3548
let sink_func = sink_info.ast.enclosing_func.as_deref();
3649

37-
// Collect parameter names for the enclosing function — parameters are not
38-
// user-controlled constants but they're not externally tainted either, so
39-
// they should not block constant-arg suppression.
40-
let param_names: Vec<&str> = ctx
41-
.func_summaries
42-
.values()
43-
.filter(|s| ctx.cfg[s.entry].ast.enclosing_func.as_deref() == sink_func)
44-
.flat_map(|s| s.param_names.iter().map(|p| p.as_str()))
45-
.collect();
46-
4750
sink_info.taint.uses.iter().all(|u| {
48-
// Part of the callee name itself → constant
51+
// Part of the callee name itself → not an argument, skip
4952
// Check both individual parts and the full dotted callee path
50-
if callee_parts.contains(&u.as_str()) || u == callee_desc {
51-
return true;
52-
}
53-
// Function parameter → not externally tainted
54-
if param_names.contains(&u.as_str()) {
53+
if callee_parts.contains(&u.as_str())
54+
|| u == callee_desc
55+
|| outer_parts.contains(&u.as_str())
56+
{
5557
return true;
5658
}
5759
// One-hop trace: find the defining node in the same function
@@ -424,6 +426,29 @@ impl CfgAnalysis for UnguardedSink {
424426
if (sink_caps & high_risk).is_empty() {
425427
continue; // FILE_IO, SSRF, FMT_STRING etc. without taint → noise
426428
}
429+
// If the function containing the sink has no Source-labeled
430+
// nodes AND no parameters (through which taint could flow
431+
// from callers), taint ran and found nothing because there
432+
// is nothing to find. Suppress — the structural finding
433+
// is noise.
434+
let sink_func = sink_info.ast.enclosing_func.as_deref();
435+
let has_sources = ctx.cfg.node_indices().any(|n| {
436+
let info = &ctx.cfg[n];
437+
info.ast.enclosing_func.as_deref() == sink_func
438+
&& info
439+
.taint
440+
.labels
441+
.iter()
442+
.any(|l| matches!(l, DataLabel::Source(_)))
443+
});
444+
let has_params = ctx.func_summaries.values().any(|s| {
445+
s.entry.index() < ctx.cfg.node_count()
446+
&& ctx.cfg[s.entry].ast.enclosing_func.as_deref() == sink_func
447+
&& !s.param_names.is_empty()
448+
});
449+
if !has_sources && !has_params {
450+
continue; // No sources or params in scope → noise
451+
}
427452
(Severity::Medium, Confidence::Medium)
428453
};
429454

src/database.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,6 +1978,7 @@ fn make_test_callee_body(
19781978
},
19791979
alias_result: crate::ssa::alias::BaseAliasResult::empty(),
19801980
points_to: crate::ssa::heap::PointsToResult::empty(),
1981+
module_aliases: std::collections::HashMap::new(),
19811982
branches_pruned: 0,
19821983
copies_eliminated: 0,
19831984
dead_defs_removed: 0,

src/patterns/javascript.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,71 @@ pub const PATTERNS: &[Pattern] = &[
241241
category: PatternCategory::InsecureConfig,
242242
confidence: Confidence::High,
243243
},
244+
// ── Tier A: TLS verification disabled ─────────────────────────────
245+
Pattern {
246+
id: "js.config.reject_unauthorized",
247+
description: "TLS certificate verification disabled via rejectUnauthorized: false",
248+
query: r#"(pair
249+
key: (property_identifier) @key (#eq? @key "rejectUnauthorized")
250+
value: (false) @val)
251+
@vuln"#,
252+
severity: Severity::Medium,
253+
tier: PatternTier::A,
254+
category: PatternCategory::InsecureConfig,
255+
confidence: Confidence::High,
256+
},
257+
// ── Tier A: Hardcoded fallback secret ──────────────────────────────
258+
Pattern {
259+
id: "js.secrets.fallback_secret",
260+
description: "Environment variable with secret-like name has hardcoded fallback value",
261+
query: r#"(binary_expression
262+
left: (member_expression
263+
object: (member_expression
264+
object: (identifier) @proc (#eq? @proc "process")
265+
property: (property_identifier) @env (#eq? @env "env"))
266+
property: (property_identifier) @key
267+
(#match? @key "(?i)(secret|password|key|token)"))
268+
operator: "||"
269+
right: (string) @fallback)
270+
@vuln"#,
271+
severity: Severity::Medium,
272+
tier: PatternTier::A,
273+
category: PatternCategory::Secrets,
274+
confidence: Confidence::Medium,
275+
},
276+
// ── Tier A: Verbose error response ────────────────────────────────
277+
Pattern {
278+
id: "js.config.verbose_error_response",
279+
description: "Error object passed to response renderer — may leak stack traces to users",
280+
query: r#"(call_expression
281+
function: (member_expression
282+
property: (property_identifier) @method
283+
(#match? @method "^(render|send|json)$"))
284+
arguments: (arguments
285+
(_)
286+
(object
287+
(shorthand_property_identifier) @prop
288+
(#eq? @prop "error"))))
289+
@vuln"#,
290+
severity: Severity::Medium,
291+
tier: PatternTier::A,
292+
category: PatternCategory::InsecureConfig,
293+
confidence: Confidence::Medium,
294+
},
295+
// ── Tier B: CORS dynamic origin reflection ────────────────────────
296+
Pattern {
297+
id: "js.config.cors_dynamic_origin",
298+
description: "CORS Access-Control-Allow-Origin set to dynamic value — may reflect arbitrary origins",
299+
query: r#"(call_expression
300+
function: (member_expression
301+
property: (property_identifier) @method (#eq? @method "setHeader"))
302+
arguments: (arguments
303+
(string) @header_name (#match? @header_name "Access-Control-Allow-Origin")
304+
. (identifier) @value))
305+
@vuln"#,
306+
severity: Severity::High,
307+
tier: PatternTier::A,
308+
category: PatternCategory::InsecureConfig,
309+
confidence: Confidence::Medium,
310+
},
244311
];

src/patterns/typescript.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,71 @@ pub const PATTERNS: &[Pattern] = &[
230230
category: PatternCategory::InsecureConfig,
231231
confidence: Confidence::High,
232232
},
233+
// ── Tier A: TLS verification disabled ─────────────────────────────
234+
Pattern {
235+
id: "ts.config.reject_unauthorized",
236+
description: "TLS certificate verification disabled via rejectUnauthorized: false",
237+
query: r#"(pair
238+
key: (property_identifier) @key (#eq? @key "rejectUnauthorized")
239+
value: (false) @val)
240+
@vuln"#,
241+
severity: Severity::Medium,
242+
tier: PatternTier::A,
243+
category: PatternCategory::InsecureConfig,
244+
confidence: Confidence::High,
245+
},
246+
// ── Tier A: Hardcoded fallback secret ──────────────────────────────
247+
Pattern {
248+
id: "ts.secrets.fallback_secret",
249+
description: "Environment variable with secret-like name has hardcoded fallback value",
250+
query: r#"(binary_expression
251+
left: (member_expression
252+
object: (member_expression
253+
object: (identifier) @proc (#eq? @proc "process")
254+
property: (property_identifier) @env (#eq? @env "env"))
255+
property: (property_identifier) @key
256+
(#match? @key "(?i)(secret|password|key|token)"))
257+
operator: "||"
258+
right: (string) @fallback)
259+
@vuln"#,
260+
severity: Severity::Medium,
261+
tier: PatternTier::A,
262+
category: PatternCategory::Secrets,
263+
confidence: Confidence::Medium,
264+
},
265+
// ── Tier A: Verbose error response ────────────────────────────────
266+
Pattern {
267+
id: "ts.config.verbose_error_response",
268+
description: "Error object passed to response renderer — may leak stack traces to users",
269+
query: r#"(call_expression
270+
function: (member_expression
271+
property: (property_identifier) @method
272+
(#match? @method "^(render|send|json)$"))
273+
arguments: (arguments
274+
(_)
275+
(object
276+
(shorthand_property_identifier) @prop
277+
(#eq? @prop "error"))))
278+
@vuln"#,
279+
severity: Severity::Medium,
280+
tier: PatternTier::A,
281+
category: PatternCategory::InsecureConfig,
282+
confidence: Confidence::Medium,
283+
},
284+
// ── Tier B: CORS dynamic origin reflection ────────────────────────
285+
Pattern {
286+
id: "ts.config.cors_dynamic_origin",
287+
description: "CORS Access-Control-Allow-Origin set to dynamic value — may reflect arbitrary origins",
288+
query: r#"(call_expression
289+
function: (member_expression
290+
property: (property_identifier) @method (#eq? @method "setHeader"))
291+
arguments: (arguments
292+
(string) @header_name (#match? @header_name "Access-Control-Allow-Origin")
293+
. (identifier) @value))
294+
@vuln"#,
295+
severity: Severity::High,
296+
tier: PatternTier::A,
297+
category: PatternCategory::InsecureConfig,
298+
confidence: Confidence::Medium,
299+
},
233300
];

src/server/debug.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,11 @@ pub fn analyse_function_taint(
943943
points_to: Some(&opt.points_to),
944944
dynamic_pts: None,
945945
import_bindings: None,
946+
module_aliases: if opt.module_aliases.is_empty() {
947+
None
948+
} else {
949+
Some(&opt.module_aliases)
950+
},
946951
};
947952

948953
crate::taint::ssa_transfer::run_ssa_taint_full_with_exits(ssa, cfg, &transfer)

0 commit comments

Comments
 (0)