Skip to main content

jsdet_cli/
venin.rs

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
14/// Runs Venin taint analysis against a JavaScript package directory and converts
15/// high-confidence findings into jsdet's npm finding format.
16///
17/// # Examples
18///
19/// ```rust,no_run
20/// use jsdet_cli::venin::scan_npm_package_with_venin;
21///
22/// let result = scan_npm_package_with_venin("/tmp/real-scan/express").unwrap();
23/// println!("{}", result.findings.len());
24/// ```
25pub 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}