Skip to main content

fallow_cli/
security.rs

1//! `fallow security` command: opt-in local security-candidate surface.
2//!
3//! Ships the graph-structural `client-server-leak` rule plus the data-driven
4//! `tainted-sink` catalogue (one `TaintedSink` kind covering every CWE category
5//! in `security_matchers.toml`). Findings are CANDIDATES for downstream agent
6//! verification, NOT verified vulnerabilities.
7//! This command is the ONLY surface for security findings: they never appear
8//! under bare `fallow` or the `audit` gate. There is no `confidence` or
9//! `signal_strength` field; the structural trace is the only honest signal.
10
11use std::path::{Path, PathBuf};
12use std::process::ExitCode;
13
14use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
15use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHopRole};
16use serde::Serialize;
17
18use crate::error::emit_error;
19use crate::load_config_for_analysis;
20
21/// The `fallow security --format json` schema version. Independently versioned
22/// from the main contract, mirroring `ImpactReportSchemaVersion`.
23#[derive(Debug, Clone, Copy, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25pub enum SecuritySchemaVersion {
26    /// First release of the `fallow security --format json` shape.
27    #[serde(rename = "1")]
28    V1,
29}
30
31/// The `fallow security --format json` envelope. `security_findings` is the
32/// unique required field used for untagged narrowing in `FallowOutput`.
33#[derive(Debug, Clone, Serialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct SecurityOutput {
36    /// Schema version of this envelope.
37    pub schema_version: SecuritySchemaVersion,
38    /// Security candidates. Paths are project-root-relative, forward-slash.
39    pub security_findings: Vec<SecurityFinding>,
40    /// In-band blind spot: number of `"use client"` files whose transitive
41    /// import cone contains a dynamic `import()` the reachability BFS could not
42    /// follow. A leak hidden behind such an edge would not be reported, so a
43    /// zero finding count with a non-zero value here is NOT a clean bill.
44    pub unresolved_edge_files: usize,
45    /// In-band blind spot: number of sink-shaped nodes the catalogue detector
46    /// could not flatten to a static callee path (dynamic dispatch, computed
47    /// members, aliased bindings). A zero finding count with a non-zero value
48    /// here is NOT a clean bill.
49    pub unresolved_callee_sites: usize,
50}
51
52/// Options for `fallow security`, mirroring the global CLI flags it honors.
53pub struct SecurityOptions<'a> {
54    /// Project root.
55    pub root: &'a Path,
56    /// Explicit config path (global `--config`).
57    pub config_path: &'a Option<PathBuf>,
58    /// Output format.
59    pub output: OutputFormat,
60    /// Disable the extraction cache.
61    pub no_cache: bool,
62    /// Resolved thread-pool size.
63    pub threads: usize,
64    /// Suppress progress output.
65    pub quiet: bool,
66    /// Exit with code 1 when candidates are found.
67    pub fail_on_issues: bool,
68    /// Write SARIF to a sidecar file in addition to the primary output.
69    pub sarif_file: Option<&'a Path>,
70    /// Show a compact human summary instead of per-finding detail.
71    pub summary: bool,
72    /// `--changed-since <ref>`: scope findings to files changed since the ref.
73    pub changed_since: Option<&'a str>,
74    /// Apply the shared `--diff-file` / `--diff-stdin` line filter.
75    pub use_shared_diff_index: bool,
76    /// `--workspace <patterns...>`: scope findings to selected workspace roots.
77    pub workspace: Option<&'a [String]>,
78    /// `--changed-workspaces <ref>`: scope to workspaces with changed files.
79    pub changed_workspaces: Option<&'a str>,
80}
81
82/// Run `fallow security`. Always exits 0 unless the user explicitly raised the
83/// `security-client-server-leak` rule to `error` AND findings exist (the rule
84/// defaults to `off` and the command forces it to `warn`, so the common case is
85/// advisory). Unsupported output formats exit 2.
86#[expect(
87    deprecated,
88    reason = "ADR-008 deprecates fallow_core::analyze externally; the CLI uses the workspace path dependency"
89)]
90pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
91    if !matches!(
92        opts.output,
93        OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
94    ) {
95        return emit_error(
96            "fallow security supports --format human, json, or sarif only.",
97            2,
98            opts.output,
99        );
100    }
101
102    let mut config = match load_config_for_analysis(
103        opts.root,
104        opts.config_path,
105        opts.output,
106        opts.no_cache,
107        opts.threads,
108        None,
109        opts.quiet,
110        ProductionAnalysis::DeadCode,
111    ) {
112        Ok(config) => config,
113        Err(code) => return code,
114    };
115
116    // Respect an explicit user severity; force the rule on (warn) when it is the
117    // default off, so the detector runs for this dedicated command. Both the
118    // client-server-leak and the catalogue-driven tainted-sink rules are flipped.
119    let effective_severity = config.rules.security_client_server_leak;
120    if effective_severity == Severity::Off {
121        config.rules.security_client_server_leak = Severity::Warn;
122    }
123    let effective_sink_severity = config.rules.security_sink;
124    if effective_sink_severity == Severity::Off {
125        config.rules.security_sink = Severity::Warn;
126    }
127
128    let mut results = match fallow_core::analyze(&config) {
129        Ok(results) => results,
130        Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
131    };
132
133    // Workspace scope (mutually exclusive flags resolved by the shared helper).
134    let ws_roots = match crate::check::filtering::resolve_workspace_scope(
135        opts.root,
136        opts.workspace,
137        opts.changed_workspaces,
138        opts.output,
139    ) {
140        Ok(roots) => roots,
141        Err(code) => return code,
142    };
143    if let Some(ref roots) = ws_roots {
144        crate::check::filtering::filter_to_workspaces(&mut results, roots);
145    }
146
147    // Changed-since scope (canonical normalization via the core filter, which
148    // now retains security_findings too).
149    if let Some(git_ref) = opts.changed_since
150        && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
151    {
152        fallow_core::changed_files::filter_results_by_changed_files(&mut results, &changed);
153    }
154    if opts.use_shared_diff_index
155        && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
156    {
157        crate::check::filtering::filter_results_by_diff(&mut results, diff_index, opts.root);
158    }
159
160    let unresolved_edge_files = results.security_unresolved_edge_files;
161    let unresolved_callee_sites = results.security_unresolved_callee_sites;
162    let findings: Vec<SecurityFinding> = std::mem::take(&mut results.security_findings)
163        .into_iter()
164        .map(|f| relativize_finding(f, &config.root))
165        .collect();
166
167    let fail = (opts.fail_on_issues
168        || effective_severity == Severity::Error
169        || effective_sink_severity == Severity::Error)
170        && !findings.is_empty();
171
172    let output = SecurityOutput {
173        schema_version: SecuritySchemaVersion::V1,
174        security_findings: findings,
175        unresolved_edge_files,
176        unresolved_callee_sites,
177    };
178
179    if let Some(path) = opts.sarif_file
180        && let Err(message) = write_sarif_file(&output, path)
181    {
182        return emit_error(&message, 2, opts.output);
183    }
184
185    let rendered = match opts.output {
186        OutputFormat::Json => render_json(&output),
187        OutputFormat::Sarif => render_sarif(&output),
188        _ if opts.summary => render_human_summary(&output),
189        _ => render_human(&output),
190    };
191    println!("{rendered}");
192
193    if fail {
194        ExitCode::from(1)
195    } else {
196        ExitCode::SUCCESS
197    }
198}
199
200/// Rewrite a finding's anchor + every trace hop path to be project-root-relative
201/// (forward-slash normalization happens at serialize time via `serde_path`).
202fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
203    finding.path = relativize(&finding.path, root);
204    for hop in &mut finding.trace {
205        hop.path = relativize(&hop.path, root);
206    }
207    finding
208}
209
210fn relativize(path: &Path, root: &Path) -> PathBuf {
211    path.strip_prefix(root)
212        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
213}
214
215/// JSON: the `SecurityOutput` envelope, pretty-printed.
216#[must_use]
217pub fn render_json(output: &SecurityOutput) -> String {
218    let Ok(value) = crate::output_envelope::serialize_root_output(
219        crate::output_envelope::FallowOutput::Security(output.clone()),
220    ) else {
221        return "{\"error\":\"failed to serialize security output\"}".to_owned();
222    };
223    serde_json::to_string_pretty(&value)
224        .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
225}
226
227fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
228    if let Some(parent) = path.parent()
229        && !parent.as_os_str().is_empty()
230    {
231        std::fs::create_dir_all(parent).map_err(|err| {
232            format!(
233                "Failed to create directory for SARIF file {}: {err}",
234                path.display()
235            )
236        })?;
237    }
238    std::fs::write(path, render_sarif(output))
239        .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
240}
241
242#[must_use]
243fn render_human_summary(output: &SecurityOutput) -> String {
244    use crate::report::plural;
245    use std::fmt::Write as _;
246
247    let count = output.security_findings.len();
248    let mut out = format!(
249        "Security candidates: {count} candidate{} found. These are NOT verified vulnerabilities; verify each before acting.\n",
250        plural(count),
251    );
252    if output.unresolved_edge_files > 0 {
253        let n = output.unresolved_edge_files;
254        let _ = writeln!(
255            out,
256            "Unresolved dynamic import cones: {n} client file{}.",
257            plural(n)
258        );
259    }
260    if output.unresolved_callee_sites > 0 {
261        let n = output.unresolved_callee_sites;
262        let _ = writeln!(out, "Unresolved sink callees: {n} site{}.", plural(n));
263    }
264    out
265}
266
267/// Human output. Frames findings as candidates and states the next human action
268/// per finding; surfaces the unresolved-edge blind spot as a counted line.
269#[must_use]
270#[expect(
271    clippy::format_push_string,
272    reason = "small report renderer; readability over avoiding the extra allocation"
273)]
274pub fn render_human(output: &SecurityOutput) -> String {
275    use crate::report::plural;
276    use colored::Colorize;
277
278    let mut out = String::new();
279    out.push_str("Security candidates (unverified; for agent or human verification)\n\n");
280
281    if output.security_findings.is_empty() {
282        out.push_str("No security candidates found.\n");
283    } else {
284        for finding in &output.security_findings {
285            let kind = security_finding_label(finding);
286            // [I] (info/advisory) is the design-system prefix for off-by-default
287            // findings surfaced for review; it deliberately is NOT a severity glyph.
288            out.push_str(&format!(
289                "{} {kind}  {}:{}\n",
290                "[I]".blue().bold(),
291                finding.path.to_string_lossy().replace('\\', "/").bold(),
292                finding.line,
293            ));
294            out.push_str(&format!("    {}\n", finding.evidence));
295            if !finding.trace.is_empty() {
296                out.push_str("    trace:\n");
297                for hop in &finding.trace {
298                    out.push_str(&format!(
299                        "      {}:{} ({})\n",
300                        hop.path.to_string_lossy().replace('\\', "/"),
301                        hop.line,
302                        hop_role_label(hop.role),
303                    ));
304                }
305            }
306            if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
307                out.push_str(
308                    "    Next: check whether the import is type-only, server-only, or behind a \
309                     build-time guard; if the value never ships to the client bundle, this \
310                     candidate is a false positive.\n",
311                );
312            }
313            out.push('\n');
314        }
315    }
316
317    if output.unresolved_edge_files > 0 {
318        let n = output.unresolved_edge_files;
319        out.push_str(&format!(
320            "{} {n} client file{} reached a dynamic import the reachability scan could not \
321             follow; a leak behind those edges would not be reported, so an empty result is \
322             not a clean bill.\n",
323            "[I]".blue().bold(),
324            plural(n),
325        ));
326    }
327
328    if output.unresolved_callee_sites > 0 {
329        let n = output.unresolved_callee_sites;
330        out.push_str(&format!(
331            "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
332             path (dynamic dispatch, computed members, aliased bindings); an empty result is \
333             not a clean bill.\n",
334            "[I]".blue().bold(),
335            plural(n),
336        ));
337    }
338
339    let count = output.security_findings.len();
340    out.push_str(&format!(
341        "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
342         each before acting.\n",
343        plural(count),
344    ));
345    out
346}
347
348/// Render the human-facing label for a finding. `ClientServerLeak` keeps its
349/// bespoke kebab kind; `TaintedSink` uses the catalogue title plus the CWE
350/// number carried on the finding.
351fn security_finding_label(finding: &SecurityFinding) -> String {
352    match finding.kind {
353        SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
354        SecurityFindingKind::TaintedSink => {
355            let title = finding
356                .category
357                .as_deref()
358                .and_then(fallow_core::analyze::security_catalogue_title)
359                .or(finding.category.as_deref())
360                .unwrap_or("tainted-sink");
361            match finding.cwe {
362                Some(cwe) => format!("{title} (CWE-{cwe})"),
363                None => title.to_string(),
364            }
365        }
366    }
367}
368
369const fn hop_role_label(role: TraceHopRole) -> &'static str {
370    match role {
371        TraceHopRole::ClientBoundary => "client boundary",
372        TraceHopRole::Intermediate => "intermediate",
373        TraceHopRole::SecretSource => "secret source",
374        TraceHopRole::Sink => "sink site",
375    }
376}
377
378/// The SARIF ruleId for a finding. `client-server-leak` keeps its bespoke id;
379/// each `TaintedSink` category gets `security/<category>` so the GitHub Security
380/// tab groups and labels candidates per CWE class instead of collapsing every
381/// finding under the client-server-leak rule.
382fn sarif_rule_id(finding: &SecurityFinding) -> String {
383    match finding.kind {
384        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
385        SecurityFindingKind::TaintedSink => {
386            format!(
387                "security/{}",
388                finding.category.as_deref().unwrap_or("tainted-sink")
389            )
390        }
391    }
392}
393
394/// Build the SARIF rule definition for a ruleId, deriving per-category metadata
395/// (catalogue title + CWE tag) for `TaintedSink` findings so the CWE survives
396/// into GHAS via the `external/cwe/cwe-NNN` tag convention.
397fn sarif_rule_def(rule_id: &str, finding: &SecurityFinding) -> serde_json::Value {
398    match finding.kind {
399        SecurityFindingKind::ClientServerLeak => serde_json::json!({
400            "id": rule_id,
401            "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
402            "fullDescription": { "text":
403                "Unverified candidate, requires verification: a \"use client\" file \
404                 transitively imports a module that reads a non-public process.env \
405                 secret. fallow does not prove the secret reaches client-bundled code." },
406            "helpUri": "https://github.com/fallow-rs/fallow",
407            "defaultConfiguration": { "level": "note" }
408        }),
409        SecurityFindingKind::TaintedSink => {
410            let title = finding
411                .category
412                .as_deref()
413                .and_then(fallow_core::analyze::security_catalogue_title)
414                .or(finding.category.as_deref())
415                .unwrap_or("tainted-sink");
416            let mut rule = serde_json::json!({
417                "id": rule_id,
418                "shortDescription": { "text": format!("{title} candidate (unverified)") },
419                "fullDescription": { "text": format!(
420                    "Unverified candidate, requires verification: {title}. fallow flags a \
421                     syntactic sink reached by a non-literal argument; it does not prove the \
422                     value is attacker-controlled or reaches the sink unsanitized."
423                ) },
424                "helpUri": "https://github.com/fallow-rs/fallow",
425                "defaultConfiguration": { "level": "note" }
426            });
427            if let Some(cwe) = finding.cwe {
428                rule["properties"] = serde_json::json!({
429                    "tags": [format!("external/cwe/cwe-{cwe}")]
430                });
431            }
432            rule
433        }
434    }
435}
436
437/// SARIF output. Emits `level: "note"` (never error/warning) so the candidate
438/// framing survives into the GitHub Security tab. Each finding's ruleId is
439/// per-category (`security/<category>` for tainted-sink, `security/client-server-leak`
440/// for the graph rule); the `rules` array carries one definition per distinct
441/// ruleId present, with the CWE tag for tainted-sink categories. Trace hops
442/// become `relatedLocations` of the result.
443#[must_use]
444fn render_sarif(output: &SecurityOutput) -> String {
445    let results: Vec<serde_json::Value> = output
446        .security_findings
447        .iter()
448        .map(|finding| {
449            let rule_id = sarif_rule_id(finding);
450            let related: Vec<serde_json::Value> = finding
451                .trace
452                .iter()
453                .map(|hop| sarif_location(&hop.path, hop.line, hop.col))
454                .collect();
455            // Stable dedup key for GHAS: rule + anchor path + line. Without
456            // partialFingerprints, every run re-opens previously triaged alerts.
457            let fp = format!(
458                "{rule_id}:{}:{}",
459                finding.path.to_string_lossy().replace('\\', "/"),
460                finding.line,
461            );
462            serde_json::json!({
463                "ruleId": rule_id,
464                "level": "note",
465                "message": { "text": finding.evidence },
466                "locations": [sarif_location(&finding.path, finding.line, finding.col)],
467                "relatedLocations": related,
468                "partialFingerprints": { "fallowSecurity/v1": fnv_hex(&fp) },
469            })
470        })
471        .collect();
472
473    // One rule definition per distinct ruleId present in the findings.
474    let mut seen: Vec<String> = Vec::new();
475    let mut rules: Vec<serde_json::Value> = Vec::new();
476    for finding in &output.security_findings {
477        let rule_id = sarif_rule_id(finding);
478        if seen.iter().any(|s| s == &rule_id) {
479            continue;
480        }
481        seen.push(rule_id.clone());
482        rules.push(sarif_rule_def(&rule_id, finding));
483    }
484
485    let sarif = serde_json::json!({
486        "version": "2.1.0",
487        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
488        "runs": [{
489            "tool": { "driver": {
490                "name": "fallow",
491                "version": env!("CARGO_PKG_VERSION"),
492                "informationUri": "https://github.com/fallow-rs/fallow",
493                "rules": rules,
494            }},
495            "results": results,
496        }],
497    });
498    serde_json::to_string_pretty(&sarif)
499        .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
500}
501
502/// Small FNV-1a hex digest for SARIF `partialFingerprints` dedup stability.
503fn fnv_hex(input: &str) -> String {
504    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
505    for byte in input.bytes() {
506        hash ^= u64::from(byte);
507        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
508    }
509    format!("{hash:016x}")
510}
511
512fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
513    serde_json::json!({
514        "physicalLocation": {
515            "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
516            "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
517        }
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHop, TraceHopRole};
525
526    /// Build a finding anchored under `root` with a three-hop client -> secret trace.
527    fn sample_finding(root: &Path) -> SecurityFinding {
528        SecurityFinding {
529            kind: SecurityFindingKind::ClientServerLeak,
530            path: root.join("src/app.tsx"),
531            line: 12,
532            col: 3,
533            evidence: "reaches process.env.SECRET_KEY".to_owned(),
534            trace: vec![
535                TraceHop {
536                    path: root.join("src/app.tsx"),
537                    line: 12,
538                    col: 3,
539                    role: TraceHopRole::ClientBoundary,
540                },
541                TraceHop {
542                    path: root.join("src/lib/util.ts"),
543                    line: 4,
544                    col: 0,
545                    role: TraceHopRole::Intermediate,
546                },
547                TraceHop {
548                    path: root.join("src/lib/secret.ts"),
549                    line: 8,
550                    col: 2,
551                    role: TraceHopRole::SecretSource,
552                },
553            ],
554            actions: vec![],
555            category: None,
556            cwe: None,
557        }
558    }
559
560    fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
561        SecurityOutput {
562            schema_version: SecuritySchemaVersion::V1,
563            security_findings: findings,
564            unresolved_edge_files,
565            unresolved_callee_sites: 0,
566        }
567    }
568
569    #[test]
570    fn relativize_strips_root_prefix() {
571        let root = Path::new("/proj/root");
572        let abs = root.join("src/app.tsx");
573        let rel = relativize(&abs, root);
574        assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
575    }
576
577    #[test]
578    fn relativize_keeps_path_when_outside_root() {
579        let root = Path::new("/proj/root");
580        let outside = Path::new("/elsewhere/file.ts");
581        // Not under root: the original path is returned unchanged.
582        assert_eq!(relativize(outside, root), outside.to_path_buf());
583    }
584
585    #[test]
586    fn relativize_finding_relativizes_anchor_and_every_hop() {
587        let root = Path::new("/proj/root");
588        let finding = relativize_finding(sample_finding(root), root);
589        assert_eq!(
590            finding.path.to_string_lossy().replace('\\', "/"),
591            "src/app.tsx"
592        );
593        let hop_paths: Vec<String> = finding
594            .trace
595            .iter()
596            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
597            .collect();
598        assert_eq!(
599            hop_paths,
600            vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
601        );
602    }
603
604    #[test]
605    fn fnv_hex_is_deterministic_and_16_hex_digits() {
606        let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
607        let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
608        assert_eq!(a, b, "same input must hash identically");
609        assert_eq!(a.len(), 16);
610        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
611        // Distinct input yields a distinct digest (anchor line differs).
612        assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
613    }
614
615    #[test]
616    fn hop_role_labels_cover_every_role() {
617        assert_eq!(
618            hop_role_label(TraceHopRole::ClientBoundary),
619            "client boundary"
620        );
621        assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
622        assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
623        assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
624    }
625
626    #[test]
627    fn sarif_location_clamps_line_and_offsets_column() {
628        // A zero line clamps to 1; the 0-based column becomes 1-based.
629        let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
630        let region = &loc["physicalLocation"]["region"];
631        assert_eq!(region["startLine"], 1);
632        assert_eq!(region["startColumn"], 1);
633        // Backslash separators normalize to forward slashes in the URI.
634        assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
635    }
636
637    #[test]
638    fn human_summary_reports_zero_without_edge_line() {
639        let out = render_human_summary(&output_with(vec![], 0));
640        assert!(out.contains("0 candidates found"), "got: {out}");
641        assert!(!out.contains("Unresolved dynamic import cones"));
642    }
643
644    #[test]
645    fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
646        let root = Path::new("/proj/root");
647        let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
648        assert!(out.contains("1 candidate found"), "got: {out}");
649        assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
650    }
651
652    #[test]
653    fn human_render_empty_states_no_candidates() {
654        colored::control::set_override(false);
655        let out = render_human(&output_with(vec![], 0));
656        assert!(out.contains("No security candidates found."));
657        assert!(out.contains("Found 0 security candidates"));
658    }
659
660    #[test]
661    fn human_render_shows_finding_trace_and_next_action() {
662        colored::control::set_override(false);
663        let root = Path::new("/proj/root");
664        let finding = relativize_finding(sample_finding(root), root);
665        let out = render_human(&output_with(vec![finding], 0));
666        assert!(out.contains("client-server-leak"));
667        assert!(out.contains("src/app.tsx:12"));
668        assert!(out.contains("reaches process.env.SECRET_KEY"));
669        assert!(out.contains("trace:"));
670        assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
671        assert!(out.contains("src/app.tsx:12 (client boundary)"));
672        assert!(out.contains("Next:"));
673        assert!(out.contains("Found 1 security candidate."));
674    }
675
676    #[test]
677    fn human_render_surfaces_unresolved_edge_blind_spot() {
678        colored::control::set_override(false);
679        let out = render_human(&output_with(vec![], 3));
680        assert!(out.contains("3 client files reached a dynamic import"));
681        assert!(out.contains("not a clean bill"));
682    }
683
684    #[test]
685    fn json_render_carries_schema_version_and_findings() {
686        let root = Path::new("/proj/root");
687        let finding = relativize_finding(sample_finding(root), root);
688        let rendered = render_json(&output_with(vec![finding], 1));
689        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
690        assert_eq!(value["schema_version"], "1");
691        assert_eq!(value["unresolved_edge_files"], 1);
692        let findings = value["security_findings"].as_array().expect("array");
693        assert_eq!(findings.len(), 1);
694        assert_eq!(findings[0]["kind"], "client-server-leak");
695        assert_eq!(findings[0]["path"], "src/app.tsx");
696    }
697
698    #[test]
699    fn sarif_render_emits_note_level_with_fingerprint_and_related_locations() {
700        let root = Path::new("/proj/root");
701        let finding = relativize_finding(sample_finding(root), root);
702        let rendered = render_sarif(&output_with(vec![finding], 0));
703        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
704        assert_eq!(sarif["version"], "2.1.0");
705        let run = &sarif["runs"][0];
706        assert_eq!(run["tool"]["driver"]["name"], "fallow");
707        let result = &run["results"][0];
708        // Candidate framing: never error/warning, and no CWE tag.
709        assert_eq!(result["level"], "note");
710        assert_eq!(result["ruleId"], "security/client-server-leak");
711        assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
712        // Trace hops surface as relatedLocations (3 hops).
713        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
714        // Stable dedup fingerprint present for GHAS.
715        assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
716    }
717
718    #[test]
719    fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_tag() {
720        let root = Path::new("/proj/root");
721        let mut finding = sample_finding(root);
722        finding.kind = SecurityFindingKind::TaintedSink;
723        finding.category = Some("dangerous-html".to_owned());
724        finding.cwe = Some(79);
725        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
726        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
727        let run = &sarif["runs"][0];
728        // The finding is grouped under its own per-category rule, not collapsed
729        // into client-server-leak, and stays at candidate (note) level.
730        let result = &run["results"][0];
731        assert_eq!(result["level"], "note");
732        assert_eq!(result["ruleId"], "security/dangerous-html");
733        // Exactly one rule definition, carrying the CWE as a GHAS tag.
734        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
735        assert_eq!(rules.len(), 1);
736        assert_eq!(rules[0]["id"], "security/dangerous-html");
737        let tags = rules[0]["properties"]["tags"].as_array().unwrap();
738        assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
739    }
740
741    #[test]
742    fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
743        let root = Path::new("/proj/root");
744        let finding = relativize_finding(sample_finding(root), root);
745        let output = output_with(vec![finding], 0);
746        let dir = tempfile::tempdir().expect("tempdir");
747        let path = dir.path().join("nested/out.sarif");
748        write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
749        let written = std::fs::read_to_string(&path).expect("file exists");
750        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
751        assert_eq!(sarif["version"], "2.1.0");
752    }
753
754    /// No explicit `--config`; static so the `&'a Option<PathBuf>` field borrows it.
755    const NO_CONFIG: Option<PathBuf> = None;
756
757    fn leak_fixture_root() -> PathBuf {
758        Path::new(env!("CARGO_MANIFEST_DIR"))
759            .join("../../tests/fixtures/security-client-server-leak")
760    }
761
762    fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
763        SecurityOptions {
764            root,
765            config_path: &NO_CONFIG,
766            output,
767            no_cache: true,
768            threads: 1,
769            quiet: true,
770            fail_on_issues,
771            sarif_file: None,
772            summary: false,
773            changed_since: None,
774            use_shared_diff_index: false,
775            workspace: None,
776            changed_workspaces: None,
777        }
778    }
779
780    #[test]
781    fn run_is_advisory_and_exits_zero_even_with_candidates() {
782        // The rule defaults to off; the command forces it to warn, so findings on
783        // the fixture are surfaced but the exit stays 0 (advisory) by default.
784        let root = leak_fixture_root();
785        let code = run(&run_opts(&root, OutputFormat::Json, false));
786        assert_eq!(code, ExitCode::SUCCESS);
787    }
788
789    #[test]
790    fn run_with_fail_on_issues_exits_one_when_candidates_found() {
791        // The fixture has real leak candidates, so --fail-on-issues raises exit 1.
792        let root = leak_fixture_root();
793        let code = run(&run_opts(&root, OutputFormat::Human, true));
794        assert_eq!(code, ExitCode::from(1));
795    }
796
797    #[test]
798    fn run_rejects_unsupported_output_format() {
799        // Only human / json / sarif are supported; compact exits 2 before analysis.
800        let root = leak_fixture_root();
801        let code = run(&run_opts(&root, OutputFormat::Compact, false));
802        assert_eq!(code, ExitCode::from(2));
803    }
804
805    #[test]
806    fn run_summary_mode_dispatches_compact_human_renderer() {
807        let root = leak_fixture_root();
808        let opts = SecurityOptions {
809            summary: true,
810            ..run_opts(&root, OutputFormat::Human, false)
811        };
812        assert_eq!(run(&opts), ExitCode::SUCCESS);
813    }
814
815    #[test]
816    fn run_sarif_format_dispatches_sarif_renderer() {
817        let root = leak_fixture_root();
818        assert_eq!(
819            run(&run_opts(&root, OutputFormat::Sarif, false)),
820            ExitCode::SUCCESS
821        );
822    }
823
824    #[test]
825    fn run_writes_sarif_sidecar_file_when_requested() {
826        let root = leak_fixture_root();
827        let dir = tempfile::tempdir().expect("tempdir");
828        let sidecar = dir.path().join("security.sarif");
829        let opts = SecurityOptions {
830            sarif_file: Some(&sidecar),
831            ..run_opts(&root, OutputFormat::Human, false)
832        };
833        assert_eq!(run(&opts), ExitCode::SUCCESS);
834        let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
835        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
836        assert_eq!(sarif["version"], "2.1.0");
837    }
838}