1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8
9use crate::npm::{PackageFinding, PackageScanResult, RuleSeverity};
10
11const VENIN_BIN_ENV: &str = "JSDET_VENIN_BIN";
12const VENIN_FRAMEWORKS_ENV: &str = "JSDET_VENIN_FRAMEWORKS";
13
14pub fn scan_npm_package_with_venin(path: impl AsRef<Path>) -> Result<PackageScanResult> {
26 let package_dir = path.as_ref();
27 if !package_dir.is_dir() {
28 bail!(
29 "failed to taint-scan {}. Fix: provide a directory containing JavaScript source",
30 package_dir.display()
31 );
32 }
33
34 let (venin_bin, frameworks_dir) = resolve_venin_installation()?;
35 let manifest = read_package_manifest(package_dir)?;
36 let output = Command::new(&venin_bin)
37 .arg("scan")
38 .arg(package_dir)
39 .arg("--frameworks")
40 .arg(&frameworks_dir)
41 .arg("--language")
42 .arg("java-script")
43 .arg("--mode")
44 .arg("fast")
45 .arg("--format")
46 .arg("json")
47 .output()
48 .with_context(|| {
49 format!(
50 "failed to launch Venin at {}. Fix: verify the binary is executable or set {}",
51 venin_bin.display(),
52 VENIN_BIN_ENV
53 )
54 })?;
55
56 if !output.status.success() {
57 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
58 bail!(
59 "Venin taint scan failed for {}. Fix: verify the project parses under Venin and that framework rules exist at {}. stderr: {}",
60 package_dir.display(),
61 frameworks_dir.display(),
62 stderr
63 );
64 }
65
66 let report: VeninScanReport = serde_json::from_slice(&output.stdout).with_context(|| {
67 format!(
68 "failed to parse Venin JSON output for {}. Fix: ensure Venin emits valid --format json output",
69 package_dir.display()
70 )
71 })?;
72
73 Ok(PackageScanResult {
74 package_name: manifest.name,
75 package_version: manifest.version,
76 findings: convert_findings(package_dir, report.findings),
77 })
78}
79
80#[derive(Debug, Clone)]
81struct VeninInstallation {
82 bin: PathBuf,
83 frameworks: PathBuf,
84}
85
86fn resolve_venin_installation() -> Result<(PathBuf, PathBuf)> {
87 if let (Some(bin), Some(frameworks)) = (
88 std::env::var_os(VENIN_BIN_ENV),
89 std::env::var_os(VENIN_FRAMEWORKS_ENV),
90 ) {
91 let installation = VeninInstallation {
92 bin: PathBuf::from(bin),
93 frameworks: PathBuf::from(frameworks),
94 };
95 validate_installation(&installation)?;
96 return Ok((installation.bin, installation.frameworks));
97 }
98
99 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
100 let repo_root = manifest_dir
101 .parent()
102 .and_then(Path::parent)
103 .map(Path::to_path_buf)
104 .context("failed to resolve jsdet workspace root. Fix: run from a valid jsdet checkout")?;
105
106 let candidates = [
107 repo_root.join("software/venin"),
108 repo_root.join("../software/venin"),
109 repo_root.join("../../software/venin"),
110 ];
111
112 for candidate in candidates {
113 let installation = VeninInstallation {
114 bin: candidate.join("target/release/venin"),
115 frameworks: candidate.join("frameworks"),
116 };
117 if installation.bin.is_file() && installation.frameworks.is_dir() {
118 return Ok((installation.bin, installation.frameworks));
119 }
120
121 let installation = VeninInstallation {
122 bin: candidate.join("target/debug/venin"),
123 frameworks: candidate.join("frameworks"),
124 };
125 if installation.bin.is_file() && installation.frameworks.is_dir() {
126 return Ok((installation.bin, installation.frameworks));
127 }
128 }
129
130 bail!(
131 "failed to locate Venin. Fix: build Venin under software/venin/target/{{debug,release}}/venin or set {} and {}",
132 VENIN_BIN_ENV,
133 VENIN_FRAMEWORKS_ENV
134 );
135}
136
137fn validate_installation(installation: &VeninInstallation) -> Result<()> {
138 if !installation.bin.is_file() {
139 bail!(
140 "{} points to {}, which is not a file. Fix: set it to the Venin CLI binary",
141 VENIN_BIN_ENV,
142 installation.bin.display()
143 );
144 }
145 if !installation.frameworks.is_dir() {
146 bail!(
147 "{} points to {}, which is not a directory. Fix: set it to Venin's frameworks directory",
148 VENIN_FRAMEWORKS_ENV,
149 installation.frameworks.display()
150 );
151 }
152 Ok(())
153}
154
155fn convert_findings(package_dir: &Path, findings: Vec<VeninFinding>) -> Vec<PackageFinding> {
156 let mut deduped = BTreeMap::<String, PackageFinding>::new();
157
158 for finding in findings {
159 let Some(severity) = RuleSeverity::from_reported_str(&finding.severity) else {
160 continue;
161 };
162 if !matches!(severity, RuleSeverity::Critical | RuleSeverity::High) {
163 continue;
164 }
165
166 let sink = &finding.sink;
167 let cwe = normalize_cwe(&finding.cwe);
168 let file = relativize_path(package_dir, &sink.file);
169 let evidence = build_evidence(&finding, sink);
170 let rule_id = cwe
171 .clone()
172 .unwrap_or_else(|| sink.category.to_ascii_uppercase());
173 let key = format!(
174 "{}:{}:{}",
175 file,
176 sink.line,
177 cwe.as_deref().unwrap_or("UNKNOWN")
178 );
179 let message = build_message(&finding, sink);
180
181 let candidate = PackageFinding {
182 id: rule_id,
183 severity,
184 cwe,
185 description: format!(
186 "{} Fix: validate, sanitize, or constrain the tainted value before it reaches {}",
187 message, sink.function
188 ),
189 file,
190 line: sink.line,
191 evidence,
192 };
193
194 match deduped.get(&key) {
195 Some(existing)
196 if severity_rank(existing.severity) > severity_rank(candidate.severity) => {}
197 Some(existing)
198 if severity_rank(existing.severity) == severity_rank(candidate.severity)
199 && !existing.description.is_empty()
200 && existing.evidence.len() >= candidate.evidence.len() => {}
201 _ => {
202 deduped.insert(key, candidate);
203 }
204 }
205 }
206
207 deduped.into_values().collect()
208}
209
210fn severity_rank(severity: RuleSeverity) -> u8 {
211 match severity {
212 RuleSeverity::Critical => 4,
213 RuleSeverity::High => 3,
214 RuleSeverity::Medium => 2,
215 RuleSeverity::Low => 1,
216 _ => 0,
217 }
218}
219
220trait RuleSeverityExt {
221 fn from_reported_str(value: &str) -> Option<RuleSeverity>;
222}
223
224impl RuleSeverityExt for RuleSeverity {
225 fn from_reported_str(value: &str) -> Option<RuleSeverity> {
226 match value.trim().to_ascii_uppercase().as_str() {
227 "CRITICAL" => Some(RuleSeverity::Critical),
228 "HIGH" => Some(RuleSeverity::High),
229 "MEDIUM" => Some(RuleSeverity::Medium),
230 "LOW" => Some(RuleSeverity::Low),
231 _ => None,
232 }
233 }
234}
235
236fn build_message(finding: &VeninFinding, sink: &VeninLocation) -> String {
237 format!(
238 "{} ({} at {}:{})",
239 finding.title, sink.category, sink.file, sink.line
240 )
241}
242
243fn build_evidence(finding: &VeninFinding, sink: &VeninLocation) -> String {
244 let source = &finding.source;
245 let mut parts = Vec::with_capacity(finding.steps.len() + 2);
246 parts.push(format!(
247 "source {}:{} {}",
248 source.file,
249 source.line,
250 source.snippet.trim()
251 ));
252 for step in &finding.steps {
253 let snippet = step.snippet.trim();
254 if snippet.is_empty() {
255 parts.push(format!(
256 "step {}:{} {}",
257 step.file, step.line, step.description
258 ));
259 } else {
260 parts.push(format!(
261 "step {}:{} {} [{}]",
262 step.file, step.line, step.description, snippet
263 ));
264 }
265 }
266 parts.push(format!(
267 "sink {}:{} {}",
268 sink.file,
269 sink.line,
270 sink.snippet.trim()
271 ));
272 parts.join(" -> ")
273}
274
275fn relativize_path(package_dir: &Path, file: &str) -> String {
276 let path = Path::new(file);
277 path.strip_prefix(package_dir)
278 .unwrap_or(path)
279 .to_string_lossy()
280 .replace('\\', "/")
281}
282
283fn normalize_cwe(cwe: &str) -> Option<String> {
284 let trimmed = cwe.trim();
285 if trimmed.is_empty() {
286 None
287 } else {
288 Some(trimmed.to_ascii_uppercase())
289 }
290}
291
292#[derive(Debug, Clone, Deserialize)]
293struct PackageManifest {
294 #[serde(default)]
295 name: String,
296 #[serde(default)]
297 version: String,
298}
299
300fn read_package_manifest(package_dir: &Path) -> Result<PackageManifest> {
301 let package_json = package_dir.join("package.json");
302 if !package_json.is_file() {
303 return Ok(PackageManifest {
304 name: package_dir
305 .file_name()
306 .and_then(|name| name.to_str())
307 .map_or_else(|| "unknown-package".to_string(), ToOwned::to_owned),
308 version: "0.0.0".to_string(),
309 });
310 }
311
312 let text = fs::read_to_string(&package_json).with_context(|| {
313 format!(
314 "failed to read {}. Fix: provide a readable package.json or remove the broken manifest",
315 package_json.display()
316 )
317 })?;
318 let manifest: PackageManifest = serde_json::from_str(&text).with_context(|| {
319 format!(
320 "failed to parse {}. Fix: ensure package.json is valid JSON",
321 package_json.display()
322 )
323 })?;
324
325 Ok(PackageManifest {
326 name: if manifest.name.trim().is_empty() {
327 package_dir
328 .file_name()
329 .and_then(|name| name.to_str())
330 .map_or_else(|| "unknown-package".to_string(), ToOwned::to_owned)
331 } else {
332 manifest.name
333 },
334 version: if manifest.version.trim().is_empty() {
335 "0.0.0".to_string()
336 } else {
337 manifest.version
338 },
339 })
340}
341
342#[derive(Debug, Clone, Deserialize)]
343struct VeninScanReport {
344 #[serde(default)]
345 findings: Vec<VeninFinding>,
346}
347
348#[derive(Debug, Clone, Deserialize)]
349struct VeninFinding {
350 severity: String,
351 title: String,
352 cwe: String,
353 source: VeninLocation,
354 #[serde(default)]
355 steps: Vec<VeninStep>,
356 sink: VeninLocation,
357}
358
359#[derive(Debug, Clone, Deserialize)]
360struct VeninLocation {
361 file: String,
362 line: usize,
363 #[serde(default)]
364 snippet: String,
365 #[serde(default)]
366 category: String,
367 #[serde(default)]
368 function: String,
369}
370
371#[derive(Debug, Clone, Deserialize)]
372struct VeninStep {
373 file: String,
374 line: usize,
375 description: String,
376 #[serde(default)]
377 snippet: String,
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use std::ffi::OsString;
384
385 #[test]
386 fn converts_and_deduplicates_high_and_critical_venin_findings() {
387 let package_dir = Path::new("/tmp/pkg");
388 let findings = vec![
389 VeninFinding {
390 severity: "HIGH".to_string(),
391 title: "SQL Injection".to_string(),
392 cwe: "CWE-89".to_string(),
393 source: VeninLocation {
394 file: "/tmp/pkg/index.js".to_string(),
395 line: 3,
396 snippet: "req.query.id".to_string(),
397 category: "http-param".to_string(),
398 function: "query".to_string(),
399 },
400 steps: vec![VeninStep {
401 file: "/tmp/pkg/index.js".to_string(),
402 line: 4,
403 description: "taint propagated through call".to_string(),
404 snippet: "lookup(req.query.id)".to_string(),
405 }],
406 sink: VeninLocation {
407 file: "/tmp/pkg/index.js".to_string(),
408 line: 9,
409 snippet: "db.query(sql)".to_string(),
410 category: "sql-injection".to_string(),
411 function: "db.query".to_string(),
412 },
413 },
414 VeninFinding {
415 severity: "HIGH".to_string(),
416 title: "SQL Injection".to_string(),
417 cwe: "CWE-89".to_string(),
418 source: VeninLocation {
419 file: "/tmp/pkg/index.js".to_string(),
420 line: 3,
421 snippet: "req.query.id".to_string(),
422 category: "http-param".to_string(),
423 function: "query".to_string(),
424 },
425 steps: vec![],
426 sink: VeninLocation {
427 file: "/tmp/pkg/index.js".to_string(),
428 line: 9,
429 snippet: "db.query(sql)".to_string(),
430 category: "sql-injection".to_string(),
431 function: "db.query".to_string(),
432 },
433 },
434 VeninFinding {
435 severity: "MEDIUM".to_string(),
436 title: "Open Redirect".to_string(),
437 cwe: "CWE-601".to_string(),
438 source: VeninLocation {
439 file: "/tmp/pkg/index.js".to_string(),
440 line: 12,
441 snippet: "req.get".to_string(),
442 category: "http-header".to_string(),
443 function: "get".to_string(),
444 },
445 steps: vec![],
446 sink: VeninLocation {
447 file: "/tmp/pkg/index.js".to_string(),
448 line: 12,
449 snippet: "res.redirect(url)".to_string(),
450 category: "open-redirect".to_string(),
451 function: "redirect".to_string(),
452 },
453 },
454 ];
455
456 let converted = convert_findings(package_dir, findings);
457
458 assert_eq!(converted.len(), 1);
459 assert_eq!(converted[0].id, "CWE-89");
460 assert_eq!(converted[0].file, "index.js");
461 assert_eq!(converted[0].line, 9);
462 assert_eq!(converted[0].severity, RuleSeverity::High);
463 assert!(
464 converted[0]
465 .evidence
466 .contains("source /tmp/pkg/index.js:3 req.query.id")
467 );
468 assert!(
469 converted[0]
470 .evidence
471 .contains("sink /tmp/pkg/index.js:9 db.query(sql)")
472 );
473 }
474
475 #[test]
476 fn honors_env_override_when_present() {
477 let tempdir = tempfile::tempdir().expect("tempdir");
478 let bin = tempdir.path().join("venin");
479 let frameworks = tempdir.path().join("frameworks");
480 fs::write(&bin, "#!/bin/sh\nexit 0\n").expect("write mock bin");
481 #[cfg(unix)]
482 {
483 use std::os::unix::fs::PermissionsExt;
484 let mut perms = fs::metadata(&bin).expect("stat mock bin").permissions();
485 perms.set_mode(0o755);
486 fs::set_permissions(&bin, perms).expect("chmod mock bin");
487 }
488 fs::create_dir(&frameworks).expect("create frameworks dir");
489
490 let old_bin: Option<OsString> = std::env::var_os(VENIN_BIN_ENV);
491 let old_frameworks: Option<OsString> = std::env::var_os(VENIN_FRAMEWORKS_ENV);
492 unsafe {
493 std::env::set_var(VENIN_BIN_ENV, &bin);
494 std::env::set_var(VENIN_FRAMEWORKS_ENV, &frameworks);
495 }
496
497 let resolved = resolve_venin_installation().expect("resolve install");
498
499 match old_bin {
500 Some(value) => unsafe { std::env::set_var(VENIN_BIN_ENV, value) },
501 None => unsafe { std::env::remove_var(VENIN_BIN_ENV) },
502 }
503 match old_frameworks {
504 Some(value) => unsafe { std::env::set_var(VENIN_FRAMEWORKS_ENV, value) },
505 None => unsafe { std::env::remove_var(VENIN_FRAMEWORKS_ENV) },
506 }
507
508 assert_eq!(resolved.0, bin);
509 assert_eq!(resolved.1, frameworks);
510 }
511}