use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{collect_files, read_file_limited, RuleInfo, Scanner};
use regex::{Regex, RegexSet};
use std::path::Path;
use std::sync::LazyLock;
use std::time::Instant;
struct TsPattern {
id: &'static str,
severity: Severity,
message: &'static str,
remediation: &'static str,
}
static PATTERNS: &[TsPattern] = &[
TsPattern {
id: "typescript/CAT-A1",
severity: Severity::Error,
message: "eval() call — arbitrary code execution risk",
remediation:
"Avoid eval(); use explicit function calls, JSON.parse(), or switch statements",
},
TsPattern {
id: "typescript/CAT-A2",
severity: Severity::Error,
message: "new Function() — dynamic code construction, arbitrary code execution risk",
remediation: "Avoid new Function(); use explicit functions or dispatch tables",
},
TsPattern {
id: "typescript/CAT-B1",
severity: Severity::Warning,
message: "child_process module imported — enables shell command execution",
remediation:
"Avoid importing child_process in skills; use typed API clients instead of shell commands",
},
TsPattern {
id: "typescript/CAT-B2",
severity: Severity::Warning,
message: "execSync/spawnSync call — executes shell commands synchronously",
remediation:
"Replace synchronous shell calls with typed API clients or Node.js built-in equivalents",
},
TsPattern {
id: "typescript/CAT-B3",
severity: Severity::Info,
message: "exec/spawn/execFile call — possible async shell execution; verify child_process context",
remediation:
"If imported from child_process, replace with typed API clients; add // audit:ignore if unrelated to child_process",
},
TsPattern {
id: "typescript/CAT-C1",
severity: Severity::Error,
message: "SSH private key path detected — credential access risk",
remediation: "Skills must not read SSH keys; use agent-provided auth tokens instead",
},
TsPattern {
id: "typescript/CAT-C2",
severity: Severity::Error,
message: "AWS credentials path detected — credential exfiltration risk",
remediation:
"Skills must not read ~/.aws/credentials; pass credentials via environment variables",
},
TsPattern {
id: "typescript/CAT-C3",
severity: Severity::Error,
message: "Kubernetes kubeconfig path detected — credential access risk",
remediation: "Skills must not read ~/.kube/config; use in-cluster service accounts",
},
TsPattern {
id: "typescript/CAT-D1",
severity: Severity::Error,
message: "Node.js net module raw socket — potential reverse shell or backdoor",
remediation:
"Skills must not open raw TCP sockets; use HTTPS APIs for all network communication",
},
TsPattern {
id: "typescript/CAT-H1",
severity: Severity::Info,
message: "Outbound HTTP call detected — verify domain is in allowed list",
remediation: "Add the domain to oxidized-agentic-audit.toml [allowlist.domains]",
},
];
static RE_URL_HOST: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)https?://(?:[^@/?#\s]+@)?([^/?#:\s]+)").unwrap());
static RE_TS_SUPPRESS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)//\s*(audit|oxidized-agentic-audit):ignore\s*$").unwrap());
static PATTERN_SET: LazyLock<RegexSet> = LazyLock::new(|| {
RegexSet::new([
r"(?:^|[^.a-zA-Z_$])eval\s*\(",
r"\bnew\s+Function\s*\(",
r#"(?i)(?:require\s*\(\s*['"]child_process['"]|from\s+['"]child_process['"])"#,
r"\b(?:execSync|spawnSync|execFileSync)\s*\(",
r"(?:^|[^.a-zA-Z_$])(?:exec|execFile|spawn)\s*\(",
r"(?i)\.ssh[/\\](?:id_rsa|id_ed25519|id_ecdsa|id_dsa|id_xmss|authorized_keys|known_hosts)",
r"(?i)\.aws[/\\]credentials",
r"(?i)\.kube[/\\]config",
r"(?i)\bnet\.(?:createConnection|createServer|connect|Socket)\s*\(",
r#"(?i)(?:fetch|axios\.(?:get|post|put|delete|patch|head|request)|got\.(?:get|post|stream)|ky\.(?:get|post|put|delete|patch))\s*\(\s*['"`]https?://"#,
])
.unwrap()
});
pub fn is_suppressed_ts(line: &str) -> bool {
RE_TS_SUPPRESS.is_match(line)
}
fn domain_is_allowed(line: &str, allowed: &std::collections::HashSet<&str>) -> bool {
let mut found_any = false;
for cap in RE_URL_HOST.captures_iter(line) {
let host = match cap.get(1) {
Some(m) => m.as_str().to_lowercase(),
None => continue,
};
if host.is_empty() {
continue;
}
found_any = true;
if allowed.contains(host.as_str()) {
continue;
}
let host_is_allowed = allowed.iter().any(|entry| {
host.strip_suffix(*entry)
.is_some_and(|prefix| prefix.ends_with('.'))
});
if !host_is_allowed {
return false;
}
}
found_any
}
pub struct TypeScriptScanner;
impl Scanner for TypeScriptScanner {
fn name(&self) -> &'static str {
"typescript_patterns"
}
fn description(&self) -> &'static str {
"Dangerous TypeScript/JavaScript pattern scanner (Categories A-H) — pure Rust regex"
}
fn is_available(&self) -> bool {
true
}
fn scan(&self, path: &Path, config: &Config) -> ScanResult {
let start = Instant::now();
let files = collect_files(path, &["ts", "tsx", "mts", "js", "mjs"]);
let mut findings = Vec::new();
assert_eq!(
PATTERNS.len(),
PATTERN_SET.len(),
"typescript PATTERNS and PATTERN_SET are out of sync — add/remove from both arrays"
);
let allowed_domains: std::collections::HashSet<&str> = config
.allowlist
.domains
.iter()
.filter(|e| !e.is_empty())
.map(String::as_str)
.collect();
for file in &files {
let content = match read_file_limited(file) {
Ok(c) => c,
Err(e) => {
findings.push(Finding {
rule_id: "typescript/read-error".to_string(),
message: format!("Could not read file: {}", e),
severity: Severity::Info,
file: Some(file.clone()),
line: None,
column: None,
scanner: self.name().to_string(),
snippet: None,
suppressed: false,
suppression_reason: None,
remediation: Some(
"Check file permissions and ensure the file is valid UTF-8".to_string(),
),
});
continue;
}
};
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1;
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if is_suppressed_ts(line) {
continue;
}
let matches = PATTERN_SET.matches(line);
if !matches.matched_any() {
continue;
}
for idx in matches.iter() {
let pattern = &PATTERNS[idx];
if pattern.id == "typescript/CAT-H1"
&& domain_is_allowed(line, &allowed_domains)
{
continue;
}
let snippet = if line.len() > 120 {
let cut = line
.char_indices()
.nth(117)
.map(|(i, _)| i)
.unwrap_or(line.len());
format!("{}...", &line[..cut])
} else {
line.to_string()
};
findings.push(Finding {
rule_id: pattern.id.to_string(),
message: pattern.message.to_string(),
severity: pattern.severity,
file: Some(file.clone()),
line: Some(line_num),
column: None,
scanner: self.name().to_string(),
snippet: Some(snippet.trim().to_string()),
suppressed: false,
suppression_reason: None,
remediation: Some(pattern.remediation.to_string()),
});
}
}
}
ScanResult {
scanner_name: self.name().to_string(),
findings,
files_scanned: files.len(),
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
PATTERNS
.iter()
.map(|p| RuleInfo {
id: p.id,
severity: match p.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
},
scanner: "typescript_patterns",
message: p.message,
remediation: p.remediation,
})
.collect()
}