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 let Some(reach) = finding.reachability {
296                let entry = if reach.reachable_from_entry {
297                    "reachable from a runtime entry point"
298                } else {
299                    "not reached from any runtime entry point"
300                };
301                let boundary = if reach.crosses_boundary {
302                    "; crosses an architecture boundary"
303                } else {
304                    ""
305                };
306                out.push_str(&format!(
307                    "    reach: {entry} (blast radius {}){boundary}\n",
308                    reach.blast_radius,
309                ));
310            }
311            if !finding.trace.is_empty() {
312                out.push_str("    trace:\n");
313                for hop in &finding.trace {
314                    out.push_str(&format!(
315                        "      {}:{} ({})\n",
316                        hop.path.to_string_lossy().replace('\\', "/"),
317                        hop.line,
318                        hop_role_label(hop.role),
319                    ));
320                }
321            }
322            if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
323                out.push_str(
324                    "    Next: check whether the import is type-only, server-only, or behind a \
325                     build-time guard; if the value never ships to the client bundle, this \
326                     candidate is a false positive.\n",
327                );
328            }
329            out.push('\n');
330        }
331    }
332
333    if output.unresolved_edge_files > 0 {
334        let n = output.unresolved_edge_files;
335        out.push_str(&format!(
336            "{} {n} client file{} reached a dynamic import the reachability scan could not \
337             follow; a leak behind those edges would not be reported, so an empty result is \
338             not a clean bill.\n",
339            "[I]".blue().bold(),
340            plural(n),
341        ));
342    }
343
344    if output.unresolved_callee_sites > 0 {
345        let n = output.unresolved_callee_sites;
346        out.push_str(&format!(
347            "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
348             path (dynamic dispatch, computed members, aliased bindings); an empty result is \
349             not a clean bill.\n",
350            "[I]".blue().bold(),
351            plural(n),
352        ));
353    }
354
355    let count = output.security_findings.len();
356    out.push_str(&format!(
357        "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
358         each before acting.\n",
359        plural(count),
360    ));
361    out
362}
363
364/// Render the human-facing label for a finding. `ClientServerLeak` keeps its
365/// bespoke kebab kind; `TaintedSink` uses the catalogue title plus the CWE
366/// number carried on the finding.
367fn security_finding_label(finding: &SecurityFinding) -> String {
368    match finding.kind {
369        SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
370        SecurityFindingKind::TaintedSink => {
371            let title = finding
372                .category
373                .as_deref()
374                .and_then(fallow_core::analyze::security_catalogue_title)
375                .or(finding.category.as_deref())
376                .unwrap_or("tainted-sink");
377            match finding.cwe {
378                Some(cwe) => format!("{title} (CWE-{cwe})"),
379                None => title.to_string(),
380            }
381        }
382    }
383}
384
385const fn hop_role_label(role: TraceHopRole) -> &'static str {
386    match role {
387        TraceHopRole::ClientBoundary => "client boundary",
388        TraceHopRole::Intermediate => "intermediate",
389        TraceHopRole::SecretSource => "secret source",
390        TraceHopRole::Sink => "sink site",
391    }
392}
393
394/// The SARIF ruleId for a finding. `client-server-leak` keeps its bespoke id;
395/// each `TaintedSink` category gets `security/<category>` so the GitHub Security
396/// tab groups and labels candidates per CWE class instead of collapsing every
397/// finding under the client-server-leak rule.
398fn sarif_rule_id(finding: &SecurityFinding) -> String {
399    match finding.kind {
400        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
401        SecurityFindingKind::TaintedSink => {
402            format!(
403                "security/{}",
404                finding.category.as_deref().unwrap_or("tainted-sink")
405            )
406        }
407    }
408}
409
410/// Build the SARIF rule definition for a ruleId, deriving per-category metadata
411/// (catalogue title + CWE tag) for `TaintedSink` findings so the CWE survives
412/// into GHAS via the `external/cwe/cwe-NNN` tag convention.
413fn sarif_rule_def(rule_id: &str, finding: &SecurityFinding) -> serde_json::Value {
414    match finding.kind {
415        SecurityFindingKind::ClientServerLeak => serde_json::json!({
416            "id": rule_id,
417            "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
418            "fullDescription": { "text":
419                "Unverified candidate, requires verification: a \"use client\" file \
420                 transitively imports a module that reads a non-public process.env \
421                 secret. fallow does not prove the secret reaches client-bundled code." },
422            "helpUri": "https://github.com/fallow-rs/fallow",
423            "defaultConfiguration": { "level": "note" }
424        }),
425        SecurityFindingKind::TaintedSink => {
426            let title = finding
427                .category
428                .as_deref()
429                .and_then(fallow_core::analyze::security_catalogue_title)
430                .or(finding.category.as_deref())
431                .unwrap_or("tainted-sink");
432            let mut rule = serde_json::json!({
433                "id": rule_id,
434                "shortDescription": { "text": format!("{title} candidate (unverified)") },
435                "fullDescription": { "text": format!(
436                    "Unverified candidate, requires verification: {title}. fallow flags a \
437                     syntactic sink reached by a non-literal argument; it does not prove the \
438                     value is attacker-controlled or reaches the sink unsanitized."
439                ) },
440                "helpUri": "https://github.com/fallow-rs/fallow",
441                "defaultConfiguration": { "level": "note" }
442            });
443            if let Some(cwe) = finding.cwe {
444                rule["properties"] = serde_json::json!({
445                    "tags": [format!("external/cwe/cwe-{cwe}")]
446                });
447            }
448            rule
449        }
450    }
451}
452
453/// SARIF output. Emits `level: "note"` (never error/warning) so the candidate
454/// framing survives into the GitHub Security tab. Each finding's ruleId is
455/// per-category (`security/<category>` for tainted-sink, `security/client-server-leak`
456/// for the graph rule); the `rules` array carries one definition per distinct
457/// ruleId present, with the CWE tag for tainted-sink categories. Trace hops
458/// become `relatedLocations` of the result.
459#[must_use]
460fn render_sarif(output: &SecurityOutput) -> String {
461    let results: Vec<serde_json::Value> = output
462        .security_findings
463        .iter()
464        .map(|finding| {
465            let rule_id = sarif_rule_id(finding);
466            let related: Vec<serde_json::Value> = finding
467                .trace
468                .iter()
469                .map(|hop| sarif_location(&hop.path, hop.line, hop.col))
470                .collect();
471            // Stable dedup key for GHAS: rule + anchor path + line. Without
472            // partialFingerprints, every run re-opens previously triaged alerts.
473            let fp = format!(
474                "{rule_id}:{}:{}",
475                finding.path.to_string_lossy().replace('\\', "/"),
476                finding.line,
477            );
478            serde_json::json!({
479                "ruleId": rule_id,
480                "level": "note",
481                "message": { "text": finding.evidence },
482                "locations": [sarif_location(&finding.path, finding.line, finding.col)],
483                "relatedLocations": related,
484                "partialFingerprints": { "fallowSecurity/v1": fnv_hex(&fp) },
485            })
486        })
487        .collect();
488
489    // One rule definition per distinct ruleId present in the findings.
490    let mut seen: Vec<String> = Vec::new();
491    let mut rules: Vec<serde_json::Value> = Vec::new();
492    for finding in &output.security_findings {
493        let rule_id = sarif_rule_id(finding);
494        if seen.iter().any(|s| s == &rule_id) {
495            continue;
496        }
497        seen.push(rule_id.clone());
498        rules.push(sarif_rule_def(&rule_id, finding));
499    }
500
501    let sarif = serde_json::json!({
502        "version": "2.1.0",
503        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
504        "runs": [{
505            "tool": { "driver": {
506                "name": "fallow",
507                "version": env!("CARGO_PKG_VERSION"),
508                "informationUri": "https://github.com/fallow-rs/fallow",
509                "rules": rules,
510            }},
511            "results": results,
512        }],
513    });
514    serde_json::to_string_pretty(&sarif)
515        .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
516}
517
518/// Small FNV-1a hex digest for SARIF `partialFingerprints` dedup stability.
519fn fnv_hex(input: &str) -> String {
520    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
521    for byte in input.bytes() {
522        hash ^= u64::from(byte);
523        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
524    }
525    format!("{hash:016x}")
526}
527
528fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
529    serde_json::json!({
530        "physicalLocation": {
531            "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
532            "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
533        }
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHop, TraceHopRole};
541
542    /// Build a finding anchored under `root` with a three-hop client -> secret trace.
543    fn sample_finding(root: &Path) -> SecurityFinding {
544        SecurityFinding {
545            kind: SecurityFindingKind::ClientServerLeak,
546            path: root.join("src/app.tsx"),
547            line: 12,
548            col: 3,
549            evidence: "reaches process.env.SECRET_KEY".to_owned(),
550            source_backed: false,
551            trace: vec![
552                TraceHop {
553                    path: root.join("src/app.tsx"),
554                    line: 12,
555                    col: 3,
556                    role: TraceHopRole::ClientBoundary,
557                },
558                TraceHop {
559                    path: root.join("src/lib/util.ts"),
560                    line: 4,
561                    col: 0,
562                    role: TraceHopRole::Intermediate,
563                },
564                TraceHop {
565                    path: root.join("src/lib/secret.ts"),
566                    line: 8,
567                    col: 2,
568                    role: TraceHopRole::SecretSource,
569                },
570            ],
571            actions: vec![],
572            category: None,
573            cwe: None,
574            reachability: None,
575        }
576    }
577
578    fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
579        SecurityOutput {
580            schema_version: SecuritySchemaVersion::V1,
581            security_findings: findings,
582            unresolved_edge_files,
583            unresolved_callee_sites: 0,
584        }
585    }
586
587    #[test]
588    fn relativize_strips_root_prefix() {
589        let root = Path::new("/proj/root");
590        let abs = root.join("src/app.tsx");
591        let rel = relativize(&abs, root);
592        assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
593    }
594
595    #[test]
596    fn relativize_keeps_path_when_outside_root() {
597        let root = Path::new("/proj/root");
598        let outside = Path::new("/elsewhere/file.ts");
599        // Not under root: the original path is returned unchanged.
600        assert_eq!(relativize(outside, root), outside.to_path_buf());
601    }
602
603    #[test]
604    fn relativize_finding_relativizes_anchor_and_every_hop() {
605        let root = Path::new("/proj/root");
606        let finding = relativize_finding(sample_finding(root), root);
607        assert_eq!(
608            finding.path.to_string_lossy().replace('\\', "/"),
609            "src/app.tsx"
610        );
611        let hop_paths: Vec<String> = finding
612            .trace
613            .iter()
614            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
615            .collect();
616        assert_eq!(
617            hop_paths,
618            vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
619        );
620    }
621
622    #[test]
623    fn fnv_hex_is_deterministic_and_16_hex_digits() {
624        let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
625        let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
626        assert_eq!(a, b, "same input must hash identically");
627        assert_eq!(a.len(), 16);
628        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
629        // Distinct input yields a distinct digest (anchor line differs).
630        assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
631    }
632
633    #[test]
634    fn hop_role_labels_cover_every_role() {
635        assert_eq!(
636            hop_role_label(TraceHopRole::ClientBoundary),
637            "client boundary"
638        );
639        assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
640        assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
641        assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
642    }
643
644    #[test]
645    fn sarif_location_clamps_line_and_offsets_column() {
646        // A zero line clamps to 1; the 0-based column becomes 1-based.
647        let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
648        let region = &loc["physicalLocation"]["region"];
649        assert_eq!(region["startLine"], 1);
650        assert_eq!(region["startColumn"], 1);
651        // Backslash separators normalize to forward slashes in the URI.
652        assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
653    }
654
655    #[test]
656    fn human_summary_reports_zero_without_edge_line() {
657        let out = render_human_summary(&output_with(vec![], 0));
658        assert!(out.contains("0 candidates found"), "got: {out}");
659        assert!(!out.contains("Unresolved dynamic import cones"));
660    }
661
662    #[test]
663    fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
664        let root = Path::new("/proj/root");
665        let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
666        assert!(out.contains("1 candidate found"), "got: {out}");
667        assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
668    }
669
670    #[test]
671    fn human_render_empty_states_no_candidates() {
672        colored::control::set_override(false);
673        let out = render_human(&output_with(vec![], 0));
674        assert!(out.contains("No security candidates found."));
675        assert!(out.contains("Found 0 security candidates"));
676    }
677
678    #[test]
679    fn human_render_shows_finding_trace_and_next_action() {
680        colored::control::set_override(false);
681        let root = Path::new("/proj/root");
682        let finding = relativize_finding(sample_finding(root), root);
683        let out = render_human(&output_with(vec![finding], 0));
684        assert!(out.contains("client-server-leak"));
685        assert!(out.contains("src/app.tsx:12"));
686        assert!(out.contains("reaches process.env.SECRET_KEY"));
687        assert!(out.contains("trace:"));
688        assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
689        assert!(out.contains("src/app.tsx:12 (client boundary)"));
690        assert!(out.contains("Next:"));
691        assert!(out.contains("Found 1 security candidate."));
692    }
693
694    #[test]
695    fn human_render_surfaces_unresolved_edge_blind_spot() {
696        colored::control::set_override(false);
697        let out = render_human(&output_with(vec![], 3));
698        assert!(out.contains("3 client files reached a dynamic import"));
699        assert!(out.contains("not a clean bill"));
700    }
701
702    #[test]
703    fn json_render_carries_schema_version_and_findings() {
704        let root = Path::new("/proj/root");
705        let finding = relativize_finding(sample_finding(root), root);
706        let rendered = render_json(&output_with(vec![finding], 1));
707        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
708        assert_eq!(value["schema_version"], "1");
709        assert_eq!(value["unresolved_edge_files"], 1);
710        let findings = value["security_findings"].as_array().expect("array");
711        assert_eq!(findings.len(), 1);
712        assert_eq!(findings[0]["kind"], "client-server-leak");
713        assert_eq!(findings[0]["path"], "src/app.tsx");
714    }
715
716    #[test]
717    fn sarif_render_emits_note_level_with_fingerprint_and_related_locations() {
718        let root = Path::new("/proj/root");
719        let finding = relativize_finding(sample_finding(root), root);
720        let rendered = render_sarif(&output_with(vec![finding], 0));
721        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
722        assert_eq!(sarif["version"], "2.1.0");
723        let run = &sarif["runs"][0];
724        assert_eq!(run["tool"]["driver"]["name"], "fallow");
725        let result = &run["results"][0];
726        // Candidate framing: never error/warning, and no CWE tag.
727        assert_eq!(result["level"], "note");
728        assert_eq!(result["ruleId"], "security/client-server-leak");
729        assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
730        // Trace hops surface as relatedLocations (3 hops).
731        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
732        // Stable dedup fingerprint present for GHAS.
733        assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
734    }
735
736    #[test]
737    fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_tag() {
738        let root = Path::new("/proj/root");
739        let mut finding = sample_finding(root);
740        finding.kind = SecurityFindingKind::TaintedSink;
741        finding.category = Some("dangerous-html".to_owned());
742        finding.cwe = Some(79);
743        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
744        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
745        let run = &sarif["runs"][0];
746        // The finding is grouped under its own per-category rule, not collapsed
747        // into client-server-leak, and stays at candidate (note) level.
748        let result = &run["results"][0];
749        assert_eq!(result["level"], "note");
750        assert_eq!(result["ruleId"], "security/dangerous-html");
751        // Exactly one rule definition, carrying the CWE as a GHAS tag.
752        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
753        assert_eq!(rules.len(), 1);
754        assert_eq!(rules[0]["id"], "security/dangerous-html");
755        let tags = rules[0]["properties"]["tags"].as_array().unwrap();
756        assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
757    }
758
759    #[test]
760    fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
761        let root = Path::new("/proj/root");
762        let finding = relativize_finding(sample_finding(root), root);
763        let output = output_with(vec![finding], 0);
764        let dir = tempfile::tempdir().expect("tempdir");
765        let path = dir.path().join("nested/out.sarif");
766        write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
767        let written = std::fs::read_to_string(&path).expect("file exists");
768        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
769        assert_eq!(sarif["version"], "2.1.0");
770    }
771
772    /// No explicit `--config`; static so the `&'a Option<PathBuf>` field borrows it.
773    const NO_CONFIG: Option<PathBuf> = None;
774
775    fn leak_fixture_root() -> PathBuf {
776        Path::new(env!("CARGO_MANIFEST_DIR"))
777            .join("../../tests/fixtures/security-client-server-leak")
778    }
779
780    fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
781        SecurityOptions {
782            root,
783            config_path: &NO_CONFIG,
784            output,
785            no_cache: true,
786            threads: 1,
787            quiet: true,
788            fail_on_issues,
789            sarif_file: None,
790            summary: false,
791            changed_since: None,
792            use_shared_diff_index: false,
793            workspace: None,
794            changed_workspaces: None,
795        }
796    }
797
798    #[test]
799    fn run_is_advisory_and_exits_zero_even_with_candidates() {
800        // The rule defaults to off; the command forces it to warn, so findings on
801        // the fixture are surfaced but the exit stays 0 (advisory) by default.
802        let root = leak_fixture_root();
803        let code = run(&run_opts(&root, OutputFormat::Json, false));
804        assert_eq!(code, ExitCode::SUCCESS);
805    }
806
807    #[test]
808    fn run_with_fail_on_issues_exits_one_when_candidates_found() {
809        // The fixture has real leak candidates, so --fail-on-issues raises exit 1.
810        let root = leak_fixture_root();
811        let code = run(&run_opts(&root, OutputFormat::Human, true));
812        assert_eq!(code, ExitCode::from(1));
813    }
814
815    #[test]
816    fn run_rejects_unsupported_output_format() {
817        // Only human / json / sarif are supported; compact exits 2 before analysis.
818        let root = leak_fixture_root();
819        let code = run(&run_opts(&root, OutputFormat::Compact, false));
820        assert_eq!(code, ExitCode::from(2));
821    }
822
823    #[test]
824    fn run_summary_mode_dispatches_compact_human_renderer() {
825        let root = leak_fixture_root();
826        let opts = SecurityOptions {
827            summary: true,
828            ..run_opts(&root, OutputFormat::Human, false)
829        };
830        assert_eq!(run(&opts), ExitCode::SUCCESS);
831    }
832
833    #[test]
834    fn run_sarif_format_dispatches_sarif_renderer() {
835        let root = leak_fixture_root();
836        assert_eq!(
837            run(&run_opts(&root, OutputFormat::Sarif, false)),
838            ExitCode::SUCCESS
839        );
840    }
841
842    #[test]
843    fn run_writes_sarif_sidecar_file_when_requested() {
844        let root = leak_fixture_root();
845        let dir = tempfile::tempdir().expect("tempdir");
846        let sidecar = dir.path().join("security.sarif");
847        let opts = SecurityOptions {
848            sarif_file: Some(&sidecar),
849            ..run_opts(&root, OutputFormat::Human, false)
850        };
851        assert_eq!(run(&opts), ExitCode::SUCCESS);
852        let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
853        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
854        assert_eq!(sarif["version"], "2.1.0");
855    }
856}