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; structural traces and reachability context are the
10//! only honest signals.
11
12use crate::report::sink::outln;
13use std::path::{Path, PathBuf};
14use std::process::ExitCode;
15
16use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
17use fallow_core::results::{
18    AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
19    SecurityFindingKind, TraceHopRole,
20};
21use fallow_types::discover::DiscoveredFile;
22use fallow_types::extract::ModuleInfo;
23use fallow_types::results::{SecurityRuntimeContext, SecurityRuntimeState};
24use serde::Serialize;
25
26use crate::error::emit_error;
27use crate::health::{HealthOptions, SharedParseData, SortBy};
28use crate::health_types::{
29    RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
30};
31use crate::load_config_for_analysis;
32
33/// The `fallow security --format json` schema version. Independently versioned
34/// from the main contract, mirroring `ImpactReportSchemaVersion`.
35#[derive(Debug, Clone, Copy, Serialize)]
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37pub enum SecuritySchemaVersion {
38    /// First release of the `fallow security --format json` shape.
39    #[serde(rename = "1")]
40    V1,
41}
42
43/// Gate mode for `fallow security --gate <mode>` (issue #886). Tier 2 reserves
44/// the value `newly-reachable`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[serde(rename_all = "kebab-case")]
48pub enum SecurityGateMode {
49    /// Fail when the change introduces a NEW security-sink candidate on a changed
50    /// line (not merely a sink in a changed file). There is deliberately no `all`
51    /// mode: gating on the whole candidate backlog is the anti-feature this gate
52    /// exists to avoid.
53    New,
54}
55
56/// Gate verdict on the wire. `fail` is the CI-state token; human output renders
57/// it as "REVIEW REQUIRED" because these stay unverified candidates, never
58/// confirmed vulnerabilities.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub enum SecurityGateVerdict {
63    /// No new candidate in the changed lines.
64    Pass,
65    /// At least one new candidate in the changed lines; review required.
66    Fail,
67}
68
69/// The `gate` block on `SecurityOutput`, present only when `--gate <mode>` ran.
70/// Invariant: `verdict == Fail  IFF  exit code 8  IFF  new_count > 0`.
71#[derive(Debug, Clone, Copy, Serialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct SecurityGate {
74    /// Which delta the gate checked.
75    pub mode: SecurityGateMode,
76    /// `pass` or `fail`.
77    pub verdict: SecurityGateVerdict,
78    /// Number of candidates introduced in the changed lines.
79    pub new_count: usize,
80}
81
82/// The `fallow security --format json` envelope. `FallowOutput` discriminates it
83/// by the `kind: "security"` tag; the optional `gate` block is additive and is
84/// not part of that discrimination.
85#[derive(Debug, Clone, Serialize)]
86#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
87pub struct SecurityOutput {
88    /// Schema version of this envelope.
89    pub schema_version: SecuritySchemaVersion,
90    /// Gate verdict, present only when `--gate <mode>` was set (issue #886).
91    /// Emitted on pass too (`verdict: "pass"`, `new_count: 0`) so consumers
92    /// distinguish "gate ran and passed" from "gate did not run" (absent).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub gate: Option<SecurityGate>,
95    /// Security candidates. Paths are project-root-relative, forward-slash.
96    pub security_findings: Vec<SecurityFinding>,
97    /// Opt-in attack-surface inventory from untrusted entry points to reachable
98    /// sinks. Present only when `--surface` was requested.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
101    /// In-band blind spot: number of `"use client"` files whose transitive
102    /// import cone contains a dynamic `import()` the reachability BFS could not
103    /// follow. A leak hidden behind such an edge would not be reported, so a
104    /// zero finding count with a non-zero value here is NOT a clean bill.
105    pub unresolved_edge_files: usize,
106    /// In-band blind spot: number of sink-shaped nodes the catalogue detector
107    /// could not flatten to a static callee path (dynamic dispatch, computed
108    /// members, aliased bindings). A zero finding count with a non-zero value
109    /// here is NOT a clean bill.
110    pub unresolved_callee_sites: usize,
111}
112
113/// Options for `fallow security`, mirroring the global CLI flags it honors.
114pub struct SecurityOptions<'a> {
115    /// Project root.
116    pub root: &'a Path,
117    /// Explicit config path (global `--config`).
118    pub config_path: &'a Option<PathBuf>,
119    /// Output format.
120    pub output: OutputFormat,
121    /// Disable the extraction cache.
122    pub no_cache: bool,
123    /// Resolved thread-pool size.
124    pub threads: usize,
125    /// Suppress progress output.
126    pub quiet: bool,
127    /// Exit with code 1 when candidates are found.
128    pub fail_on_issues: bool,
129    /// Write SARIF to a sidecar file in addition to the primary output.
130    pub sarif_file: Option<&'a Path>,
131    /// Show a compact human summary instead of per-finding detail.
132    pub summary: bool,
133    /// `--changed-since <ref>`: scope findings to files changed since the ref.
134    pub changed_since: Option<&'a str>,
135    /// Apply the shared `--diff-file` / `--diff-stdin` line filter.
136    pub use_shared_diff_index: bool,
137    /// `--workspace <patterns...>`: scope findings to selected workspace roots.
138    pub workspace: Option<&'a [String]>,
139    /// `--changed-workspaces <ref>`: scope to workspaces with changed files.
140    pub changed_workspaces: Option<&'a str>,
141    /// `--file <PATH>`: scope findings to selected files or trace hops.
142    pub file: &'a [PathBuf],
143    /// `--surface`: include the top-level attack-surface inventory in JSON.
144    pub surface: bool,
145    /// `--gate <mode>`: opt-in regression gate (issue #886). Requires a diff
146    /// source (`--changed-since`, `--diff-file`, or `--diff-stdin`); reports only
147    /// candidates introduced in the changed lines and exits 8 if any exist.
148    pub gate: Option<SecurityGateMode>,
149    /// Paid local runtime-coverage sidecar input.
150    pub runtime_coverage: Option<&'a Path>,
151    /// Threshold for hot-path classification when `--runtime-coverage` is set.
152    pub min_invocations_hot: u64,
153}
154
155/// Run `fallow security`. Always exits 0 unless the user explicitly raised the
156/// `security-client-server-leak` rule to `error` AND findings exist (the rule
157/// defaults to `off` and the command forces it to `warn`, so the common case is
158/// advisory). Unsupported output formats exit 2.
159pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
160    if !matches!(
161        opts.output,
162        OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
163    ) {
164        return emit_error(
165            "fallow security supports --format human, json, or sarif only.",
166            2,
167            opts.output,
168        );
169    }
170
171    let mut config = match load_config_for_analysis(
172        opts.root,
173        opts.config_path,
174        opts.output,
175        opts.no_cache,
176        opts.threads,
177        None,
178        opts.quiet,
179        ProductionAnalysis::DeadCode,
180    ) {
181        Ok(config) => config,
182        Err(code) => return code,
183    };
184
185    // Respect an explicit user severity; force the rule on (warn) when it is the
186    // default off, so the detector runs for this dedicated command. Both the
187    // client-server-leak and the catalogue-driven tainted-sink rules are flipped.
188    let effective_severity = config.rules.security_client_server_leak;
189    if effective_severity == Severity::Off {
190        config.rules.security_client_server_leak = Severity::Warn;
191    }
192    let effective_sink_severity = config.rules.security_sink;
193    if effective_sink_severity == Severity::Off {
194        config.rules.security_sink = Severity::Warn;
195    }
196
197    let mut analysis = match analyze_security_candidates(opts, &config) {
198        Ok(analysis) => analysis,
199        Err(code) => return code,
200    };
201
202    // Workspace scope (mutually exclusive flags resolved by the shared helper).
203    let ws_roots = match crate::check::filtering::resolve_workspace_scope(
204        opts.root,
205        opts.workspace,
206        opts.changed_workspaces,
207        opts.output,
208    ) {
209        Ok(roots) => roots,
210        Err(code) => return code,
211    };
212    if let Some(ref roots) = ws_roots {
213        crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
214    }
215
216    // Changed-since scope (canonical normalization via the core filter, which
217    // now retains security_findings too).
218    if let Some(git_ref) = opts.changed_since
219        && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
220    {
221        fallow_core::changed_files::filter_results_by_changed_files(
222            &mut analysis.results,
223            &changed,
224        );
225    }
226    if opts.use_shared_diff_index
227        && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
228    {
229        crate::check::filtering::filter_results_by_diff(
230            &mut analysis.results,
231            diff_index,
232            opts.root,
233        );
234    }
235    filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
236
237    let gate_mode = match apply_security_gate(opts, &mut analysis.results) {
238        Ok(mode) => mode,
239        Err(code) => return code,
240    };
241
242    let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
243    let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
244    let runtime_report = match security_runtime_report(opts, &mut analysis) {
245        Ok(report) => report,
246        Err(code) => return code,
247    };
248    let mut findings: Vec<SecurityFinding> =
249        std::mem::take(&mut analysis.results.security_findings)
250            .into_iter()
251            .map(|f| relativize_finding(f, &config.root))
252            .collect();
253    if let (Some(report), Some(modules), Some(files)) = (
254        runtime_report.as_ref(),
255        analysis.modules.as_ref(),
256        analysis.files.as_ref(),
257    ) {
258        apply_runtime_context(&mut findings, modules, files, &config.root, report);
259    }
260    for finding in &mut findings {
261        // Stamp the correlation id on the project-relative path so it matches
262        // the SARIF fingerprint.
263        finding.finding_id = security_finding_id(finding);
264    }
265    let (findings, attack_surface) = prepare_findings(findings, &config.root, opts.surface);
266
267    // In gate mode the displayed set IS the strict "new" set, so its length is
268    // the new-candidate count. The gate block is emitted unconditionally when a
269    // gate ran (present on pass with verdict Pass / new_count 0) so consumers
270    // distinguish "gate ran and passed" from "gate did not run".
271    let gate = gate_mode.map(|mode| {
272        let new_count = findings.len();
273        SecurityGate {
274            mode,
275            verdict: if new_count > 0 {
276                SecurityGateVerdict::Fail
277            } else {
278                SecurityGateVerdict::Pass
279            },
280            new_count,
281        }
282    });
283
284    let advisory_fail = (opts.fail_on_issues
285        || effective_severity == Severity::Error
286        || effective_sink_severity == Severity::Error)
287        && !findings.is_empty();
288
289    let output = SecurityOutput {
290        schema_version: SecuritySchemaVersion::V1,
291        gate,
292        security_findings: findings,
293        attack_surface,
294        unresolved_edge_files,
295        unresolved_callee_sites,
296    };
297
298    if let Some(path) = opts.sarif_file
299        && let Err(message) = write_sarif_file(&output, path)
300    {
301        return emit_error(&message, 2, opts.output);
302    }
303
304    let rendered = match opts.output {
305        OutputFormat::Json => render_json(&output),
306        OutputFormat::Sarif => render_sarif(&output),
307        _ if opts.summary => render_human_summary(&output),
308        _ => render_human(&output),
309    };
310    outln!("{rendered}");
311
312    // Exit-code contract (#886): in gate mode the gate is authoritative (8 when a
313    // new candidate exists, else 0) and SUPERSEDES the advisory --fail-on-issues
314    // path, because composing the two would re-gate on the pre-existing backlog
315    // this gate exists to avoid. Code 8 is PURE: it means ONLY "new candidate
316    // found", never "the gate could not run" (those are the exit-2 paths above).
317    if let Some(gate) = &output.gate {
318        if gate.verdict == SecurityGateVerdict::Fail {
319            ExitCode::from(8)
320        } else {
321            ExitCode::SUCCESS
322        }
323    } else if advisory_fail {
324        ExitCode::from(1)
325    } else {
326        ExitCode::SUCCESS
327    }
328}
329
330fn apply_security_gate(
331    opts: &SecurityOptions<'_>,
332    results: &mut AnalysisResults,
333) -> Result<Option<SecurityGateMode>, ExitCode> {
334    let Some(mode) = opts.gate else {
335        return Ok(None);
336    };
337
338    // Security gate (issue #886): narrow to the STRICT "new in changed lines"
339    // predicate and drive a dedicated exit code. The gate requires a diff
340    // source; a diff it cannot compute is a LOUD error (exit 2), never a green
341    // gate (a silent miss defeats a security gate).
342    let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
343    let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
344        if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
345            shared
346        } else if let Some(git_ref) = opts.changed_since {
347            match fallow_core::changed_files::try_get_changed_diff(opts.root, git_ref) {
348                Ok(text) => owned_gate_diff
349                    .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
350                Err(err) => {
351                    return Err(emit_error(
352                        &format!(
353                            "fallow security --gate could not compute the diff for '{git_ref}': {}",
354                            err.describe()
355                        ),
356                        2,
357                        opts.output,
358                    ));
359                }
360            }
361        } else {
362            return Err(emit_error(
363                "fallow security --gate requires a diff source: --changed-since <ref>, \
364                     --diff-file <path>, or --diff-stdin.",
365                2,
366                opts.output,
367            ));
368        };
369    crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
370    Ok(Some(mode))
371}
372
373struct SecurityAnalysisState {
374    results: AnalysisResults,
375    modules: Option<Vec<ModuleInfo>>,
376    files: Option<Vec<DiscoveredFile>>,
377    analysis_output: Option<fallow_core::AnalysisOutput>,
378}
379
380#[expect(
381    deprecated,
382    reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
383)]
384fn analyze_security_candidates(
385    opts: &SecurityOptions<'_>,
386    config: &fallow_config::ResolvedConfig,
387) -> Result<SecurityAnalysisState, ExitCode> {
388    if opts.runtime_coverage.is_none() {
389        return fallow_core::analyze(config)
390            .map(|results| SecurityAnalysisState {
391                results,
392                modules: None,
393                files: None,
394                analysis_output: None,
395            })
396            .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
397    }
398
399    fallow_core::analyze_retaining_modules(config, true, true)
400        .map(|mut output| {
401            let modules = output.modules.take();
402            let files = output.files.take();
403            let results = output.results.clone();
404            SecurityAnalysisState {
405                results,
406                modules,
407                files,
408                analysis_output: Some(output),
409            }
410        })
411        .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
412}
413
414fn security_runtime_report(
415    opts: &SecurityOptions<'_>,
416    analysis: &mut SecurityAnalysisState,
417) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
418    let Some(path) = opts.runtime_coverage else {
419        return Ok(None);
420    };
421    let (Some(modules), Some(files), Some(analysis_output)) = (
422        analysis.modules.as_ref(),
423        analysis.files.as_ref(),
424        analysis.analysis_output.take(),
425    ) else {
426        return Ok(None);
427    };
428    analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
429}
430
431fn analyze_security_runtime(
432    opts: &SecurityOptions<'_>,
433    path: &Path,
434    modules: Vec<ModuleInfo>,
435    files: Vec<DiscoveredFile>,
436    analysis_output: fallow_core::AnalysisOutput,
437) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
438    let runtime_coverage = crate::health::coverage::prepare_options(
439        path,
440        opts.min_invocations_hot,
441        None,
442        None,
443        opts.output,
444    )?;
445    let result = crate::health::execute_health_with_shared_parse(
446        &HealthOptions {
447            root: opts.root,
448            config_path: opts.config_path,
449            output: opts.output,
450            no_cache: opts.no_cache,
451            threads: opts.threads,
452            quiet: opts.quiet,
453            max_cyclomatic: None,
454            max_cognitive: None,
455            max_crap: None,
456            top: None,
457            sort: SortBy::Cyclomatic,
458            production: true,
459            production_override: Some(true),
460            changed_since: opts.changed_since,
461            diff_index: None,
462            use_shared_diff_index: opts.use_shared_diff_index,
463            workspace: opts.workspace,
464            changed_workspaces: opts.changed_workspaces,
465            baseline: None,
466            save_baseline: None,
467            complexity: false,
468            complexity_breakdown: false,
469            file_scores: false,
470            coverage_gaps: false,
471            config_activates_coverage_gaps: false,
472            hotspots: false,
473            ownership: false,
474            ownership_emails: None,
475            targets: false,
476            force_full: false,
477            score_only_output: false,
478            enforce_coverage_gap_gate: false,
479            effort: None,
480            score: false,
481            min_score: None,
482            since: None,
483            min_commits: None,
484            explain: false,
485            summary: false,
486            save_snapshot: None,
487            trend: false,
488            group_by: None,
489            coverage: None,
490            coverage_root: None,
491            performance: false,
492            min_severity: None,
493            report_only: false,
494            runtime_coverage: Some(runtime_coverage),
495            churn_file: None,
496        },
497        SharedParseData {
498            files,
499            modules,
500            analysis_output: Some(analysis_output),
501        },
502    )?;
503    Ok(result.report.runtime_coverage)
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Hash)]
507struct RuntimeFunctionKey {
508    path: String,
509    function: String,
510    line: u32,
511}
512
513#[derive(Debug, Clone)]
514struct FunctionSpan {
515    key: RuntimeFunctionKey,
516    end_line: u32,
517}
518
519fn apply_runtime_context(
520    findings: &mut Vec<SecurityFinding>,
521    modules: &[ModuleInfo],
522    files: &[fallow_types::discover::DiscoveredFile],
523    root: &Path,
524    report: &RuntimeCoverageReport,
525) {
526    let spans = function_spans(modules, files, root);
527    let runtime = SecurityRuntimeIndex::new(report);
528    let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
529    for (_, finding) in &mut indexed {
530        if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
531            continue;
532        }
533        finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
534    }
535    indexed.sort_by(|(left_index, left), (right_index, right)| {
536        runtime_rank(left)
537            .cmp(&runtime_rank(right))
538            .then_with(|| left_index.cmp(right_index))
539    });
540    findings.extend(indexed.into_iter().map(|(_, finding)| finding));
541}
542
543fn function_spans(
544    modules: &[ModuleInfo],
545    files: &[fallow_types::discover::DiscoveredFile],
546    root: &Path,
547) -> Vec<FunctionSpan> {
548    let paths_by_id = files
549        .iter()
550        .map(|file| (file.id, &file.path))
551        .collect::<rustc_hash::FxHashMap<_, _>>();
552    let mut spans = Vec::new();
553    for module in modules {
554        let Some(path) = paths_by_id.get(&module.file_id) else {
555            continue;
556        };
557        let path = relative_key(path, root);
558        for function in &module.complexity {
559            spans.push(FunctionSpan {
560                key: RuntimeFunctionKey {
561                    path: path.clone(),
562                    function: function.name.clone(),
563                    line: function.line,
564                },
565                end_line: function.line.saturating_add(function.line_count),
566            });
567        }
568    }
569    spans
570}
571
572struct SecurityRuntimeIndex {
573    hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
574    findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
575}
576
577impl SecurityRuntimeIndex {
578    fn new(report: &RuntimeCoverageReport) -> Self {
579        let hot_paths = report
580            .hot_paths
581            .iter()
582            .map(|hot| {
583                (
584                    runtime_hot_key(hot),
585                    hot.end_line.max(hot.line),
586                    SecurityRuntimeContext {
587                        state: SecurityRuntimeState::RuntimeHot,
588                        function: hot.function.clone(),
589                        line: hot.line,
590                        invocations: Some(hot.invocations),
591                        stable_id: hot.stable_id.clone(),
592                        evidence: Some(format!(
593                            "production hot path observed with {} invocation{}",
594                            hot.invocations,
595                            crate::report::plural(hot.invocations as usize)
596                        )),
597                    },
598                )
599            })
600            .collect();
601        let findings = report
602            .findings
603            .iter()
604            .map(runtime_finding_context)
605            .collect();
606        Self {
607            hot_paths,
608            findings,
609        }
610    }
611}
612
613fn runtime_context_for_finding(
614    finding: &SecurityFinding,
615    spans: &[FunctionSpan],
616    runtime: &SecurityRuntimeIndex,
617) -> Option<SecurityRuntimeContext> {
618    let path = path_key(&finding.path);
619    let span = spans
620        .iter()
621        .filter(|span| {
622            span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
623        })
624        .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
625    if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
626        key == &span.key && key.line <= finding.line && finding.line <= *end_line
627    }) {
628        return Some(context.clone());
629    }
630    runtime.findings.get(&span.key).cloned().or_else(|| {
631        Some(SecurityRuntimeContext {
632            state: SecurityRuntimeState::RuntimeUnknown,
633            function: span.key.function.clone(),
634            line: span.key.line,
635            invocations: None,
636            stable_id: None,
637            evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
638        })
639    })
640}
641
642fn runtime_rank(finding: &SecurityFinding) -> u8 {
643    match finding.runtime.as_ref().map(|runtime| runtime.state) {
644        Some(SecurityRuntimeState::RuntimeHot) => 0,
645        Some(SecurityRuntimeState::LowTraffic) => 1,
646        None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
647        Some(SecurityRuntimeState::CoverageUnavailable) => 3,
648        Some(SecurityRuntimeState::RuntimeCold) => 4,
649        Some(SecurityRuntimeState::NeverExecuted) => 5,
650    }
651}
652
653fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
654    RuntimeFunctionKey {
655        path: path_key(&hot.path),
656        function: hot.function.clone(),
657        line: hot.line,
658    }
659}
660
661fn runtime_finding_context(
662    finding: &RuntimeCoverageFinding,
663) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
664    let state = match finding.verdict {
665        RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
666        RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
667            SecurityRuntimeState::RuntimeCold
668        }
669        RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
670        RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
671            SecurityRuntimeState::CoverageUnavailable
672        }
673        RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
674            SecurityRuntimeState::RuntimeUnknown
675        }
676    };
677    (
678        RuntimeFunctionKey {
679            path: path_key(&finding.path),
680            function: finding.function.clone(),
681            line: finding.line,
682        },
683        SecurityRuntimeContext {
684            state,
685            function: finding.function.clone(),
686            line: finding.line,
687            invocations: finding.invocations,
688            stable_id: finding.stable_id.clone(),
689            evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
690        },
691    )
692}
693
694fn relative_key(path: &Path, root: &Path) -> String {
695    path_key(path.strip_prefix(root).unwrap_or(path))
696}
697
698fn path_key(path: &Path) -> String {
699    path.to_string_lossy().replace('\\', "/")
700}
701
702fn filter_to_files(
703    results: &mut fallow_core::results::AnalysisResults,
704    root: &Path,
705    files: &[PathBuf],
706    quiet: bool,
707) {
708    if files.is_empty() {
709        return;
710    }
711
712    let resolved_files: Vec<PathBuf> = files
713        .iter()
714        .map(|path| {
715            if crate::path_util::is_absolute_path_any_platform(path) {
716                path.clone()
717            } else {
718                root.join(path)
719            }
720        })
721        .collect();
722
723    if !quiet {
724        for (original, resolved) in files.iter().zip(&resolved_files) {
725            if !resolved.exists() {
726                eprintln!(
727                    "Warning: --file '{}' (resolved to '{}') was not found in the project",
728                    original.display(),
729                    resolved.display()
730                );
731            }
732        }
733    }
734
735    let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
736    fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
737}
738
739fn prepare_findings(
740    findings: Vec<SecurityFinding>,
741    root: &Path,
742    include_surface: bool,
743) -> (
744    Vec<SecurityFinding>,
745    Option<Vec<SecurityAttackSurfaceEntry>>,
746) {
747    let mut findings: Vec<SecurityFinding> = findings
748        .into_iter()
749        .map(|f| {
750            let mut f = relativize_finding(f, root);
751            f.finding_id = security_finding_id(&f);
752            f
753        })
754        .collect();
755    let attack_surface = include_surface.then(|| {
756        findings
757            .iter()
758            .filter_map(|finding| finding.attack_surface.clone())
759            .collect()
760    });
761    for finding in &mut findings {
762        finding.attack_surface = None;
763    }
764    (findings, attack_surface)
765}
766
767/// Rewrite a finding's anchor + every trace hop path to be project-root-relative
768/// (forward-slash normalization happens at serialize time via `serde_path`).
769fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
770    finding.path = relativize(&finding.path, root);
771    for hop in &mut finding.trace {
772        hop.path = relativize(&hop.path, root);
773    }
774    if let Some(reachability) = &mut finding.reachability {
775        for hop in &mut reachability.untrusted_source_trace {
776            hop.path = relativize(&hop.path, root);
777        }
778    }
779    finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
780    if let Some(flow) = &mut finding.taint_flow {
781        flow.source.path = relativize(&flow.source.path, root);
782        flow.sink.path = relativize(&flow.sink.path, root);
783    }
784    if let Some(surface) = &mut finding.attack_surface {
785        surface.source.path = relativize(&surface.source.path, root);
786        surface.sink.path = relativize(&surface.sink.path, root);
787        for hop in &mut surface.path {
788            hop.path = relativize(&hop.path, root);
789        }
790        for control in &mut surface.defensive_boundary.controls {
791            control.path = relativize(&control.path, root);
792        }
793    }
794    finding
795}
796
797fn relativize(path: &Path, root: &Path) -> PathBuf {
798    path.strip_prefix(root)
799        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
800}
801
802/// JSON: the `SecurityOutput` envelope, pretty-printed.
803#[must_use]
804pub fn render_json(output: &SecurityOutput) -> String {
805    let Ok(value) = crate::output_envelope::serialize_root_output(
806        crate::output_envelope::FallowOutput::Security(output.clone()),
807    ) else {
808        return "{\"error\":\"failed to serialize security output\"}".to_owned();
809    };
810    serde_json::to_string_pretty(&value)
811        .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
812}
813
814fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
815    if let Some(parent) = path.parent()
816        && !parent.as_os_str().is_empty()
817    {
818        std::fs::create_dir_all(parent).map_err(|err| {
819            format!(
820                "Failed to create directory for SARIF file {}: {err}",
821                path.display()
822            )
823        })?;
824    }
825    std::fs::write(path, render_sarif(output))
826        .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
827}
828
829/// One-line gate verdict header. Leads with the ACTION ("REVIEW REQUIRED") and
830/// immediately qualifies with the candidate framing, so a human never reads the
831/// gate as "fallow confirmed a vulnerability". The wire `verdict` token stays
832/// `fail`; only this human prose says "REVIEW REQUIRED".
833fn gate_human_header(gate: &SecurityGate) -> String {
834    use crate::report::plural;
835    match gate.verdict {
836        SecurityGateVerdict::Fail => format!(
837            "Gate: REVIEW REQUIRED, {} new security candidate{} in changed lines (unverified; not confirmed vulnerabilities).",
838            gate.new_count,
839            plural(gate.new_count),
840        ),
841        SecurityGateVerdict::Pass => {
842            "Gate: PASS, no new security candidates in changed lines.".to_owned()
843        }
844    }
845}
846
847#[must_use]
848fn render_human_summary(output: &SecurityOutput) -> String {
849    use crate::report::plural;
850    use std::fmt::Write as _;
851
852    let mut out = String::new();
853    if let Some(gate) = &output.gate {
854        out.push_str(&gate_human_header(gate));
855        out.push('\n');
856    }
857    let count = output.security_findings.len();
858    let _ = writeln!(
859        out,
860        "Security candidates: {count} candidate{} found. These are NOT verified vulnerabilities; verify each before acting.",
861        plural(count),
862    );
863    if output.unresolved_edge_files > 0 {
864        let n = output.unresolved_edge_files;
865        let _ = writeln!(
866            out,
867            "Unresolved dynamic import cones: {n} client file{}.",
868            plural(n)
869        );
870    }
871    if output.unresolved_callee_sites > 0 {
872        let n = output.unresolved_callee_sites;
873        let _ = writeln!(out, "Unresolved sink callees: {n} site{}.", plural(n));
874    }
875    out
876}
877
878/// Human output. Frames findings as candidates and states the next human action
879/// per finding; surfaces the unresolved-edge blind spot as a counted line.
880#[must_use]
881#[expect(
882    clippy::format_push_string,
883    reason = "small report renderer; readability over avoiding the extra allocation"
884)]
885pub fn render_human(output: &SecurityOutput) -> String {
886    use crate::report::plural;
887    use colored::Colorize;
888
889    let mut out = String::new();
890    if let Some(gate) = &output.gate {
891        out.push_str(&gate_human_header(gate));
892        out.push_str("\n\n");
893    }
894    out.push_str("Security candidates (unverified; for agent or human verification)\n\n");
895
896    if output.security_findings.is_empty() {
897        out.push_str("No security candidates found.\n");
898    } else {
899        for finding in &output.security_findings {
900            let kind = security_finding_label(finding);
901            // [I] (info/advisory) is the design-system prefix for off-by-default
902            // findings surfaced for review; it deliberately is NOT a severity glyph.
903            out.push_str(&format!(
904                "{} {kind}  {}:{}\n",
905                "[I]".blue().bold(),
906                finding.path.to_string_lossy().replace('\\', "/").bold(),
907                finding.line,
908            ));
909            out.push_str(&format!("    {}\n", finding.evidence));
910            if let Some(hint) = dead_code_hint(finding) {
911                out.push_str(&format!("    dead-code: {hint}\n"));
912            }
913            if let Some(runtime) = finding.runtime.as_ref() {
914                out.push_str(&format!("    runtime: {}\n", runtime_hint_text(runtime)));
915            }
916            if let Some(reach) = finding.reachability.as_ref() {
917                let entry = if reach.reachable_from_entry {
918                    "reachable from a runtime entry point"
919                } else {
920                    "not reached from any runtime entry point"
921                };
922                let boundary = if reach.crosses_boundary {
923                    "; crosses an architecture boundary"
924                } else {
925                    ""
926                };
927                out.push_str(&format!(
928                    "    reach: {entry} (blast radius {}){boundary}\n",
929                    reach.blast_radius,
930                ));
931                if reach.reachable_from_untrusted_source {
932                    let hops = reach.untrusted_source_hop_count.unwrap_or(0);
933                    out.push_str(&format!(
934                        "    untrusted-source path: module reachable from an untrusted-source \
935                         module via {hops} import hop{}\n",
936                        crate::report::plural(hops as usize),
937                    ));
938                    if !reach.untrusted_source_trace.is_empty() {
939                        out.push_str("    untrusted-source trace:\n");
940                        for hop in &reach.untrusted_source_trace {
941                            out.push_str(&format!(
942                                "      {}:{} ({})\n",
943                                hop.path.to_string_lossy().replace('\\', "/"),
944                                hop.line,
945                                hop_role_label(hop.role),
946                            ));
947                        }
948                    }
949                }
950            }
951            if !finding.trace.is_empty() {
952                out.push_str("    trace:\n");
953                for hop in &finding.trace {
954                    out.push_str(&format!(
955                        "      {}:{} ({})\n",
956                        hop.path.to_string_lossy().replace('\\', "/"),
957                        hop.line,
958                        hop_role_label(hop.role),
959                    ));
960                }
961            }
962            if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
963                out.push_str(
964                    "    Next: check whether the import is type-only, server-only, or behind a \
965                     build-time guard; if the value never ships to the client bundle, this \
966                     candidate is a false positive.\n",
967                );
968            } else if finding.dead_code.is_some() {
969                out.push_str(
970                    "    Next: verify the dead-code finding and delete the code if safe; \
971                     otherwise verify and harden the sink.\n",
972                );
973            }
974            out.push('\n');
975        }
976    }
977
978    if output.unresolved_edge_files > 0 {
979        let n = output.unresolved_edge_files;
980        out.push_str(&format!(
981            "{} {n} client file{} reached a dynamic import the reachability scan could not \
982             follow; a leak behind those edges would not be reported, so an empty result is \
983             not a clean bill.\n",
984            "[I]".blue().bold(),
985            plural(n),
986        ));
987    }
988
989    if output.unresolved_callee_sites > 0 {
990        let n = output.unresolved_callee_sites;
991        out.push_str(&format!(
992            "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
993             path (dynamic dispatch, computed members, aliased bindings); an empty result is \
994             not a clean bill.\n",
995            "[I]".blue().bold(),
996            plural(n),
997        ));
998    }
999
1000    let count = output.security_findings.len();
1001    out.push_str(&format!(
1002        "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
1003         each before acting.\n",
1004        plural(count),
1005    ));
1006    out
1007}
1008
1009/// Render the human-facing label for a finding. `ClientServerLeak` keeps its
1010/// bespoke kebab kind; `TaintedSink` uses the catalogue title plus the CWE
1011/// number carried on the finding.
1012fn security_finding_label(finding: &SecurityFinding) -> String {
1013    match finding.kind {
1014        SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
1015        SecurityFindingKind::TaintedSink => {
1016            let title = finding
1017                .category
1018                .as_deref()
1019                .and_then(fallow_core::analyze::security_catalogue_title)
1020                .or(finding.category.as_deref())
1021                .unwrap_or("tainted-sink");
1022            match finding.cwe {
1023                Some(cwe) => format!("{title} (CWE-{cwe})"),
1024                None => title.to_string(),
1025            }
1026        }
1027    }
1028}
1029
1030fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
1031    let context = finding.dead_code.as_ref()?;
1032    match context.kind {
1033        SecurityDeadCodeKind::UnusedFile => Some(
1034            "also reported as unused-file; delete this file instead of hardening the sink"
1035                .to_string(),
1036        ),
1037        SecurityDeadCodeKind::UnusedExport => Some(format!(
1038            "also reported as unused-export{}; remove the export instead of hardening the sink",
1039            context
1040                .export_name
1041                .as_ref()
1042                .map_or(String::new(), |name| format!(" `{name}`"))
1043        )),
1044    }
1045}
1046
1047const fn hop_role_label(role: TraceHopRole) -> &'static str {
1048    match role {
1049        TraceHopRole::ClientBoundary => "client boundary",
1050        TraceHopRole::UntrustedSource => "untrusted source module",
1051        TraceHopRole::Intermediate => "intermediate",
1052        TraceHopRole::SecretSource => "secret source",
1053        TraceHopRole::Sink => "sink site",
1054    }
1055}
1056
1057fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
1058    finding
1059        .reachability
1060        .as_ref()
1061        .filter(|reach| reach.reachable_from_untrusted_source)
1062        .map(|_| {
1063            "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
1064        })
1065}
1066
1067fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
1068    use std::fmt::Write as _;
1069
1070    let mut text = format!(
1071        "{} in {}:{}",
1072        runtime_state_label(runtime.state),
1073        runtime.function,
1074        runtime.line
1075    );
1076    if let Some(invocations) = runtime.invocations {
1077        let _ = write!(
1078            text,
1079            " ({} invocation{})",
1080            invocations,
1081            crate::report::plural(invocations as usize)
1082        );
1083    }
1084    if let Some(evidence) = runtime.evidence.as_deref() {
1085        text.push_str("; ");
1086        text.push_str(evidence);
1087    }
1088    text
1089}
1090
1091const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
1092    match state {
1093        SecurityRuntimeState::RuntimeHot => "runtime-hot",
1094        SecurityRuntimeState::RuntimeCold => "runtime-cold",
1095        SecurityRuntimeState::NeverExecuted => "never-executed",
1096        SecurityRuntimeState::LowTraffic => "low-traffic",
1097        SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
1098        SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
1099    }
1100}
1101
1102/// The SARIF ruleId for a finding. `client-server-leak` keeps its bespoke id;
1103/// each `TaintedSink` category gets `security/<category>` so the GitHub Security
1104/// tab groups and labels candidates per CWE class instead of collapsing every
1105/// finding under the client-server-leak rule.
1106fn sarif_rule_id(finding: &SecurityFinding) -> String {
1107    match finding.kind {
1108        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
1109        SecurityFindingKind::TaintedSink => {
1110            format!(
1111                "security/{}",
1112                finding.category.as_deref().unwrap_or("tainted-sink")
1113            )
1114        }
1115    }
1116}
1117
1118/// Build the SARIF rule definition for a ruleId, deriving per-category metadata
1119/// (catalogue title + CWE tag) for `TaintedSink` findings so the CWE survives
1120/// into GHAS via the `external/cwe/cwe-NNN` tag convention.
1121fn sarif_rule_def(rule_id: &str, finding: &SecurityFinding) -> serde_json::Value {
1122    match finding.kind {
1123        SecurityFindingKind::ClientServerLeak => serde_json::json!({
1124            "id": rule_id,
1125            "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
1126            "fullDescription": { "text":
1127                "Unverified candidate, requires verification: a \"use client\" file \
1128                 transitively imports a module that reads a non-public process.env \
1129                 secret. fallow does not prove the secret reaches client-bundled code." },
1130            "helpUri": "https://github.com/fallow-rs/fallow",
1131            "defaultConfiguration": { "level": "note" }
1132        }),
1133        SecurityFindingKind::TaintedSink => {
1134            let title = finding
1135                .category
1136                .as_deref()
1137                .and_then(fallow_core::analyze::security_catalogue_title)
1138                .or(finding.category.as_deref())
1139                .unwrap_or("tainted-sink");
1140            let mut rule = serde_json::json!({
1141                "id": rule_id,
1142                "shortDescription": { "text": format!("{title} candidate (unverified)") },
1143                "fullDescription": { "text": format!(
1144                    "Unverified candidate, requires verification: {title}. fallow flags a \
1145                     syntactic sink reached by a non-literal argument; it does not prove the \
1146                     value is attacker-controlled or reaches the sink unsanitized."
1147                ) },
1148                "helpUri": "https://github.com/fallow-rs/fallow",
1149                "defaultConfiguration": { "level": "note" }
1150            });
1151            if let Some(cwe) = finding.cwe {
1152                rule["properties"] = serde_json::json!({
1153                    "tags": [format!("external/cwe/cwe-{cwe}")]
1154                });
1155            }
1156            rule
1157        }
1158    }
1159}
1160
1161/// SARIF output. Emits `level: "note"` (never error/warning) so the candidate
1162/// framing survives into the GitHub Security tab. Each finding's ruleId is
1163/// per-category (`security/<category>` for tainted-sink, `security/client-server-leak`
1164/// for the graph rule); the `rules` array carries one definition per distinct
1165/// ruleId present, with the CWE tag for tainted-sink categories. Detector trace
1166/// hops and source-reachability hops become `relatedLocations` of the result.
1167#[must_use]
1168fn render_sarif(output: &SecurityOutput) -> String {
1169    let results: Vec<serde_json::Value> = output
1170        .security_findings
1171        .iter()
1172        .map(|finding| {
1173            let rule_id = sarif_rule_id(finding);
1174            let mut message = dead_code_hint(finding).map_or_else(
1175                || finding.evidence.clone(),
1176                |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
1177            );
1178            if let Some(hint) = source_reachability_hint(finding) {
1179                message.push(' ');
1180                message.push_str(hint);
1181            }
1182            if let Some(runtime) = finding.runtime.as_ref() {
1183                message.push_str(" Runtime context: ");
1184                message.push_str(&runtime_hint_text(runtime));
1185                message.push('.');
1186            }
1187            let mut related: Vec<serde_json::Value> = finding
1188                .trace
1189                .iter()
1190                .map(|hop| sarif_location(&hop.path, hop.line, hop.col))
1191                .collect();
1192            if let Some(reach) = finding.reachability.as_ref() {
1193                related.extend(
1194                    reach
1195                        .untrusted_source_trace
1196                        .iter()
1197                        .map(|hop| sarif_location(&hop.path, hop.line, hop.col)),
1198                );
1199            }
1200            // Stable dedup key for GHAS: rule + anchor path + line. Without
1201            // partialFingerprints, every run re-opens previously triaged alerts.
1202            // Same helper as the JSON `finding_id` field so the two never drift
1203            // (issue #900).
1204            serde_json::json!({
1205                "ruleId": rule_id,
1206                "level": "note",
1207                "message": { "text": message },
1208                "locations": [sarif_location(&finding.path, finding.line, finding.col)],
1209                "relatedLocations": related,
1210                "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
1211            })
1212        })
1213        .collect();
1214
1215    // One rule definition per distinct ruleId present in the findings.
1216    let mut seen: Vec<String> = Vec::new();
1217    let mut rules: Vec<serde_json::Value> = Vec::new();
1218    for finding in &output.security_findings {
1219        let rule_id = sarif_rule_id(finding);
1220        if seen.iter().any(|s| s == &rule_id) {
1221            continue;
1222        }
1223        seen.push(rule_id.clone());
1224        rules.push(sarif_rule_def(&rule_id, finding));
1225    }
1226
1227    let mut run = serde_json::json!({
1228        "tool": { "driver": {
1229            "name": "fallow",
1230            "version": env!("CARGO_PKG_VERSION"),
1231            "informationUri": "https://github.com/fallow-rs/fallow",
1232            "rules": rules,
1233        }},
1234        "results": results,
1235    });
1236    // Gate verdict rides as a RUN-level property, never on result severity:
1237    // every result stays `level: note` so the candidate framing survives into
1238    // GHAS (an `error`-level result reads as a confirmed problem).
1239    if let Some(gate) = &output.gate
1240        && let Ok(gate_value) = serde_json::to_value(gate)
1241    {
1242        run["properties"] = serde_json::json!({ "fallowGate": gate_value });
1243    }
1244
1245    let sarif = serde_json::json!({
1246        "version": "2.1.0",
1247        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1248        "runs": [run],
1249    });
1250    serde_json::to_string_pretty(&sarif)
1251        .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
1252}
1253
1254/// Small FNV-1a hex digest for SARIF `partialFingerprints` dedup stability.
1255fn fnv_hex(input: &str) -> String {
1256    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1257    for byte in input.bytes() {
1258        hash ^= u64::from(byte);
1259        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1260    }
1261    format!("{hash:016x}")
1262}
1263
1264/// Stable per-finding correlation id: FNV-1a hex of `rule:path:line`. The single
1265/// source of truth for BOTH the JSON `finding_id` field and the SARIF
1266/// `partialFingerprints` value, so an agent can join the two and they never
1267/// drift. Computed on the project-relative path, so it must run after the
1268/// finding is relativized (issue #900).
1269fn security_finding_id(finding: &SecurityFinding) -> String {
1270    let fp = format!(
1271        "{}:{}:{}",
1272        sarif_rule_id(finding),
1273        finding.path.to_string_lossy().replace('\\', "/"),
1274        finding.line,
1275    );
1276    fnv_hex(&fp)
1277}
1278
1279fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
1280    serde_json::json!({
1281        "physicalLocation": {
1282            "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
1283            "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
1284        }
1285    })
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use super::*;
1291    use fallow_core::results::{
1292        SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
1293        SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
1294        TraceHop, TraceHopRole,
1295    };
1296    use fallow_types::results::SecurityReachability;
1297
1298    /// Build a finding anchored under `root` with a three-hop client -> secret trace.
1299    fn sample_finding(root: &Path) -> SecurityFinding {
1300        SecurityFinding {
1301            kind: SecurityFindingKind::ClientServerLeak,
1302            path: root.join("src/app.tsx"),
1303            line: 12,
1304            col: 3,
1305            evidence: "reaches process.env.SECRET_KEY".to_owned(),
1306            source_backed: false,
1307            trace: vec![
1308                TraceHop {
1309                    path: root.join("src/app.tsx"),
1310                    line: 12,
1311                    col: 3,
1312                    role: TraceHopRole::ClientBoundary,
1313                },
1314                TraceHop {
1315                    path: root.join("src/lib/util.ts"),
1316                    line: 4,
1317                    col: 0,
1318                    role: TraceHopRole::Intermediate,
1319                },
1320                TraceHop {
1321                    path: root.join("src/lib/secret.ts"),
1322                    line: 8,
1323                    col: 2,
1324                    role: TraceHopRole::SecretSource,
1325                },
1326            ],
1327            actions: vec![],
1328            category: None,
1329            cwe: None,
1330            dead_code: None,
1331            reachability: None,
1332            finding_id: String::new(),
1333            candidate: SecurityCandidate {
1334                source_kind: None,
1335                sink: SecurityCandidateSink {
1336                    path: root.join("src/app.tsx"),
1337                    line: 12,
1338                    col: 3,
1339                    category: None,
1340                    cwe: None,
1341                    callee: None,
1342                },
1343                boundary: SecurityCandidateBoundary {
1344                    client_server: true,
1345                    cross_module: false,
1346                    architecture_zone: None,
1347                },
1348                network: None,
1349            },
1350            taint_flow: None,
1351            runtime: None,
1352            attack_surface: None,
1353        }
1354    }
1355
1356    fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
1357        SecurityOutput {
1358            schema_version: SecuritySchemaVersion::V1,
1359            gate: None,
1360            security_findings: findings,
1361            attack_surface: None,
1362            unresolved_edge_files,
1363            unresolved_callee_sites: 0,
1364        }
1365    }
1366
1367    fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
1368        SecurityOutput {
1369            schema_version: SecuritySchemaVersion::V1,
1370            gate: Some(SecurityGate {
1371                mode: SecurityGateMode::New,
1372                verdict,
1373                new_count,
1374            }),
1375            security_findings: vec![],
1376            attack_surface: None,
1377            unresolved_edge_files: 0,
1378            unresolved_callee_sites: 0,
1379        }
1380    }
1381
1382    fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
1383        let mut finding = sample_finding(root);
1384        finding.kind = SecurityFindingKind::TaintedSink;
1385        finding.category = Some("dangerous-html".to_owned());
1386        finding.cwe = Some(79);
1387        finding.runtime = state.map(|state| SecurityRuntimeContext {
1388            state,
1389            function: "render".to_owned(),
1390            line: 10,
1391            invocations: Some(123),
1392            stable_id: Some("fallow:fn:test".to_owned()),
1393            evidence: Some("production runtime evidence".to_owned()),
1394        });
1395        finding
1396    }
1397
1398    #[test]
1399    fn runtime_rank_promotes_hot_and_demotes_never_executed() {
1400        let root = Path::new("/proj/root");
1401        let mut findings = [
1402            tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
1403            tainted_with_runtime(root, None),
1404            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1405            tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
1406        ];
1407
1408        findings.sort_by_key(runtime_rank);
1409
1410        assert_eq!(
1411            findings
1412                .iter()
1413                .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
1414                .collect::<Vec<_>>(),
1415            vec![
1416                Some(SecurityRuntimeState::RuntimeHot),
1417                None,
1418                Some(SecurityRuntimeState::CoverageUnavailable),
1419                Some(SecurityRuntimeState::NeverExecuted),
1420            ]
1421        );
1422    }
1423
1424    #[test]
1425    fn human_render_includes_runtime_context_line() {
1426        let root = Path::new("/proj/root");
1427        let finding = relativize_finding(
1428            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1429            root,
1430        );
1431        let out = render_human(&output_with(vec![finding], 0));
1432
1433        assert!(
1434            out.contains("runtime: runtime-hot in render:10"),
1435            "got: {out}"
1436        );
1437        assert!(out.contains("production runtime evidence"), "got: {out}");
1438    }
1439
1440    #[test]
1441    fn sarif_render_includes_runtime_context_in_message() {
1442        let root = Path::new("/proj/root");
1443        let finding = relativize_finding(
1444            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1445            root,
1446        );
1447        let rendered = render_sarif(&output_with(vec![finding], 0));
1448        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1449        let message = sarif["runs"][0]["results"][0]["message"]["text"]
1450            .as_str()
1451            .expect("message text");
1452
1453        assert!(message.contains("Runtime context"), "got: {message}");
1454        assert!(
1455            message.contains("runtime-hot in render:10"),
1456            "got: {message}"
1457        );
1458    }
1459
1460    #[test]
1461    fn gate_human_header_fail_says_review_required_not_fail() {
1462        let gate = SecurityGate {
1463            mode: SecurityGateMode::New,
1464            verdict: SecurityGateVerdict::Fail,
1465            new_count: 2,
1466        };
1467        let header = gate_human_header(&gate);
1468        assert!(header.contains("REVIEW REQUIRED"));
1469        assert!(header.contains("2 new security candidate"));
1470        assert!(header.contains("not confirmed vulnerabilities"));
1471        assert!(!header.to_uppercase().contains("GATE: FAIL"));
1472    }
1473
1474    #[test]
1475    fn gate_human_header_fail_singular_for_one_candidate() {
1476        // The gate makes new_count == 1 the common case (one PR adds one sink).
1477        let gate = SecurityGate {
1478            mode: SecurityGateMode::New,
1479            verdict: SecurityGateVerdict::Fail,
1480            new_count: 1,
1481        };
1482        let header = gate_human_header(&gate);
1483        assert!(header.contains("1 new security candidate in changed lines"));
1484        assert!(!header.contains("1 new security candidates"));
1485    }
1486
1487    #[test]
1488    fn gate_human_header_pass() {
1489        let gate = SecurityGate {
1490            mode: SecurityGateMode::New,
1491            verdict: SecurityGateVerdict::Pass,
1492            new_count: 0,
1493        };
1494        assert!(gate_human_header(&gate).contains("Gate: PASS"));
1495    }
1496
1497    #[test]
1498    fn gate_json_block_is_snake_case_and_present_on_pass() {
1499        let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
1500        assert!(json.contains("\"gate\""));
1501        assert!(json.contains("\"mode\": \"new\""));
1502        assert!(json.contains("\"verdict\": \"pass\""));
1503        assert!(json.contains("\"new_count\": 0"));
1504    }
1505
1506    #[test]
1507    fn gate_absent_from_json_when_no_gate_ran() {
1508        let json = render_json(&output_with(vec![], 0));
1509        assert!(!json.contains("\"gate\""));
1510    }
1511
1512    #[test]
1513    fn gate_sarif_is_a_run_property_not_result_severity() {
1514        let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
1515        assert!(sarif.contains("fallowGate"));
1516        // The gate verdict never bumps a result above `note`.
1517        assert!(!sarif.contains("\"level\": \"error\""));
1518        assert!(!sarif.contains("\"level\": \"warning\""));
1519    }
1520
1521    fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
1522        finding.reachability = Some(SecurityReachability {
1523            reachable_from_entry: true,
1524            reachable_from_untrusted_source: true,
1525            untrusted_source_hop_count: Some(1),
1526            untrusted_source_trace: vec![
1527                TraceHop {
1528                    path: root.join("src/routes/api.ts"),
1529                    line: 3,
1530                    col: 0,
1531                    role: TraceHopRole::UntrustedSource,
1532                },
1533                TraceHop {
1534                    path: root.join("src/lib/sink.ts"),
1535                    line: 9,
1536                    col: 2,
1537                    role: TraceHopRole::Sink,
1538                },
1539            ],
1540            blast_radius: 2,
1541            crosses_boundary: false,
1542        });
1543    }
1544
1545    #[test]
1546    fn relativize_strips_root_prefix() {
1547        let root = Path::new("/proj/root");
1548        let abs = root.join("src/app.tsx");
1549        let rel = relativize(&abs, root);
1550        assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
1551    }
1552
1553    #[test]
1554    fn relativize_keeps_path_when_outside_root() {
1555        let root = Path::new("/proj/root");
1556        let outside = Path::new("/elsewhere/file.ts");
1557        // Not under root: the original path is returned unchanged.
1558        assert_eq!(relativize(outside, root), outside.to_path_buf());
1559    }
1560
1561    #[test]
1562    fn relativize_finding_relativizes_anchor_and_every_hop() {
1563        let root = Path::new("/proj/root");
1564        let finding = relativize_finding(sample_finding(root), root);
1565        assert_eq!(
1566            finding.path.to_string_lossy().replace('\\', "/"),
1567            "src/app.tsx"
1568        );
1569        let hop_paths: Vec<String> = finding
1570            .trace
1571            .iter()
1572            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
1573            .collect();
1574        assert_eq!(
1575            hop_paths,
1576            vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
1577        );
1578    }
1579
1580    #[test]
1581    fn relativize_finding_relativizes_untrusted_source_trace() {
1582        let root = Path::new("/proj/root");
1583        let mut finding = sample_finding(root);
1584        add_untrusted_source_reachability(&mut finding, root);
1585        let finding = relativize_finding(finding, root);
1586        let reach = finding.reachability.as_ref().expect("reachability");
1587        let hop_paths: Vec<String> = reach
1588            .untrusted_source_trace
1589            .iter()
1590            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
1591            .collect();
1592        assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
1593    }
1594
1595    #[test]
1596    fn fnv_hex_is_deterministic_and_16_hex_digits() {
1597        let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
1598        let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
1599        assert_eq!(a, b, "same input must hash identically");
1600        assert_eq!(a.len(), 16);
1601        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1602        // Distinct input yields a distinct digest (anchor line differs).
1603        assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
1604    }
1605
1606    #[test]
1607    fn hop_role_labels_cover_every_role() {
1608        assert_eq!(
1609            hop_role_label(TraceHopRole::ClientBoundary),
1610            "client boundary"
1611        );
1612        assert_eq!(
1613            hop_role_label(TraceHopRole::UntrustedSource),
1614            "untrusted source module"
1615        );
1616        assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
1617        assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
1618        assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
1619    }
1620
1621    #[test]
1622    fn sarif_location_clamps_line_and_offsets_column() {
1623        // A zero line clamps to 1; the 0-based column becomes 1-based.
1624        let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
1625        let region = &loc["physicalLocation"]["region"];
1626        assert_eq!(region["startLine"], 1);
1627        assert_eq!(region["startColumn"], 1);
1628        // Backslash separators normalize to forward slashes in the URI.
1629        assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
1630    }
1631
1632    #[test]
1633    fn human_summary_reports_zero_without_edge_line() {
1634        let out = render_human_summary(&output_with(vec![], 0));
1635        assert!(out.contains("0 candidates found"), "got: {out}");
1636        assert!(!out.contains("Unresolved dynamic import cones"));
1637    }
1638
1639    #[test]
1640    fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
1641        let root = Path::new("/proj/root");
1642        let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
1643        assert!(out.contains("1 candidate found"), "got: {out}");
1644        assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
1645    }
1646
1647    #[test]
1648    fn human_render_empty_states_no_candidates() {
1649        colored::control::set_override(false);
1650        let out = render_human(&output_with(vec![], 0));
1651        assert!(out.contains("No security candidates found."));
1652        assert!(out.contains("Found 0 security candidates"));
1653    }
1654
1655    #[test]
1656    fn human_render_shows_finding_trace_and_next_action() {
1657        colored::control::set_override(false);
1658        let root = Path::new("/proj/root");
1659        let finding = relativize_finding(sample_finding(root), root);
1660        let out = render_human(&output_with(vec![finding], 0));
1661        assert!(out.contains("client-server-leak"));
1662        assert!(out.contains("src/app.tsx:12"));
1663        assert!(out.contains("reaches process.env.SECRET_KEY"));
1664        assert!(out.contains("trace:"));
1665        assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
1666        assert!(out.contains("src/app.tsx:12 (client boundary)"));
1667        assert!(out.contains("Next:"));
1668        assert!(out.contains("Found 1 security candidate."));
1669    }
1670
1671    #[test]
1672    fn human_render_shows_dead_code_hint_and_delete_next_step() {
1673        colored::control::set_override(false);
1674        let root = Path::new("/proj/root");
1675        let mut finding = relativize_finding(sample_finding(root), root);
1676        finding.kind = SecurityFindingKind::TaintedSink;
1677        finding.dead_code = Some(SecurityDeadCodeContext {
1678            kind: SecurityDeadCodeKind::UnusedFile,
1679            export_name: None,
1680            line: None,
1681            guidance: "delete instead of harden".to_string(),
1682        });
1683        let out = render_human(&output_with(vec![finding], 0));
1684        assert!(
1685            out.contains("dead-code: also reported as unused-file"),
1686            "got: {out}"
1687        );
1688        assert!(out.contains("delete the code if safe"), "got: {out}");
1689    }
1690
1691    #[test]
1692    fn human_render_shows_untrusted_source_path_as_module_context() {
1693        colored::control::set_override(false);
1694        let root = Path::new("/proj/root");
1695        let mut finding = sample_finding(root);
1696        finding.kind = SecurityFindingKind::TaintedSink;
1697        finding.category = Some("command-injection".to_string());
1698        add_untrusted_source_reachability(&mut finding, root);
1699        let finding = relativize_finding(finding, root);
1700
1701        let out = render_human(&output_with(vec![finding], 0));
1702
1703        assert!(
1704            out.contains("module reachable from an untrusted-source module via 1 import hop"),
1705            "got: {out}"
1706        );
1707        assert!(out.contains("untrusted-source trace:"), "got: {out}");
1708        assert!(
1709            out.contains("src/routes/api.ts:3 (untrusted source module)"),
1710            "got: {out}"
1711        );
1712    }
1713
1714    #[test]
1715    fn human_render_surfaces_unresolved_edge_blind_spot() {
1716        colored::control::set_override(false);
1717        let out = render_human(&output_with(vec![], 3));
1718        assert!(out.contains("3 client files reached a dynamic import"));
1719        assert!(out.contains("not a clean bill"));
1720    }
1721
1722    #[test]
1723    fn json_render_carries_schema_version_and_findings() {
1724        let root = Path::new("/proj/root");
1725        let finding = relativize_finding(sample_finding(root), root);
1726        let rendered = render_json(&output_with(vec![finding], 1));
1727        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
1728        assert_eq!(value["schema_version"], "1");
1729        assert_eq!(value["unresolved_edge_files"], 1);
1730        let findings = value["security_findings"].as_array().expect("array");
1731        assert_eq!(findings.len(), 1);
1732        assert_eq!(findings[0]["kind"], "client-server-leak");
1733        assert_eq!(findings[0]["path"], "src/app.tsx");
1734    }
1735
1736    #[test]
1737    fn json_render_carries_candidate_record_and_omits_impact() {
1738        // Issue #900: every finding carries a 3-slot candidate record; there is
1739        // NO `impact` key on the wire (agent-owned, documented in the schema). A
1740        // client-server-leak has no source kind and no taint flow.
1741        let root = Path::new("/proj/root");
1742        let finding = relativize_finding(sample_finding(root), root);
1743        let rendered = render_json(&output_with(vec![finding], 0));
1744        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
1745        let finding = &value["security_findings"][0];
1746
1747        let candidate = &finding["candidate"];
1748        assert!(candidate.is_object(), "candidate record present");
1749        assert!(candidate["sink"].is_object(), "sink slot present");
1750        assert_eq!(candidate["boundary"]["client_server"], true);
1751        assert!(
1752            candidate.get("impact").is_none(),
1753            "impact must NOT be a wire field"
1754        );
1755        assert!(
1756            candidate.get("source_kind").is_none(),
1757            "client-server-leak has no source kind"
1758        );
1759        assert!(
1760            finding.get("taint_flow").is_none(),
1761            "no untrusted-source flow on a client-server-leak"
1762        );
1763        assert!(
1764            finding.get("finding_id").is_some(),
1765            "finding_id is on the wire"
1766        );
1767    }
1768
1769    #[test]
1770    fn finding_id_is_stable_and_matches_sarif_fingerprint() {
1771        // Issue #900: one helper computes both the JSON finding_id and the SARIF
1772        // partialFingerprint, so an agent can join the two and they never drift.
1773        let root = Path::new("/proj/root");
1774        let finding = relativize_finding(sample_finding(root), root);
1775        let id = security_finding_id(&finding);
1776        assert!(!id.is_empty());
1777        assert_eq!(
1778            id,
1779            security_finding_id(&finding),
1780            "deterministic across calls"
1781        );
1782
1783        let sarif: serde_json::Value =
1784            serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
1785                .expect("valid SARIF");
1786        assert_eq!(
1787            sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
1788            serde_json::Value::String(id)
1789        );
1790    }
1791
1792    #[test]
1793    fn json_render_carries_dead_code_context() {
1794        let root = Path::new("/proj/root");
1795        let mut finding = relativize_finding(sample_finding(root), root);
1796        finding.kind = SecurityFindingKind::TaintedSink;
1797        finding.dead_code = Some(SecurityDeadCodeContext {
1798            kind: SecurityDeadCodeKind::UnusedExport,
1799            export_name: Some("handler".to_string()),
1800            line: Some(12),
1801            guidance: "remove export instead of harden".to_string(),
1802        });
1803        let rendered = render_json(&output_with(vec![finding], 0));
1804        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
1805        let context = &value["security_findings"][0]["dead_code"];
1806        assert_eq!(context["kind"], "unused-export");
1807        assert_eq!(context["export_name"], "handler");
1808        assert_eq!(context["line"], 12);
1809    }
1810
1811    #[test]
1812    fn sarif_render_emits_note_level_with_fingerprint_and_related_locations() {
1813        let root = Path::new("/proj/root");
1814        let finding = relativize_finding(sample_finding(root), root);
1815        let rendered = render_sarif(&output_with(vec![finding], 0));
1816        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1817        assert_eq!(sarif["version"], "2.1.0");
1818        let run = &sarif["runs"][0];
1819        assert_eq!(run["tool"]["driver"]["name"], "fallow");
1820        let result = &run["results"][0];
1821        // Candidate framing: never error/warning, and no CWE tag.
1822        assert_eq!(result["level"], "note");
1823        assert_eq!(result["ruleId"], "security/client-server-leak");
1824        assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
1825        // Trace hops surface as relatedLocations (3 hops).
1826        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
1827        // Stable dedup fingerprint present for GHAS.
1828        assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
1829    }
1830
1831    #[test]
1832    fn sarif_render_includes_dead_code_hint_in_message() {
1833        let root = Path::new("/proj/root");
1834        let mut finding = relativize_finding(sample_finding(root), root);
1835        finding.kind = SecurityFindingKind::TaintedSink;
1836        finding.dead_code = Some(SecurityDeadCodeContext {
1837            kind: SecurityDeadCodeKind::UnusedFile,
1838            export_name: None,
1839            line: None,
1840            guidance: "delete instead of harden".to_string(),
1841        });
1842        let rendered = render_sarif(&output_with(vec![finding], 0));
1843        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1844        let message = sarif["runs"][0]["results"][0]["message"]["text"]
1845            .as_str()
1846            .expect("message text");
1847        assert!(message.contains("Dead-code cross-link"), "got: {message}");
1848        assert!(
1849            message.contains("delete this file instead of hardening"),
1850            "got: {message}"
1851        );
1852    }
1853
1854    #[test]
1855    fn sarif_render_includes_untrusted_source_context_and_related_locations() {
1856        let root = Path::new("/proj/root");
1857        let mut finding = sample_finding(root);
1858        finding.kind = SecurityFindingKind::TaintedSink;
1859        finding.category = Some("command-injection".to_string());
1860        add_untrusted_source_reachability(&mut finding, root);
1861        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
1862        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1863        let result = &sarif["runs"][0]["results"][0];
1864        let message = result["message"]["text"].as_str().expect("message text");
1865        assert!(message.contains("Module-level context"), "got: {message}");
1866        assert!(
1867            message.contains("does not prove value flow"),
1868            "got: {message}"
1869        );
1870        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
1871    }
1872
1873    #[test]
1874    fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_tag() {
1875        let root = Path::new("/proj/root");
1876        let mut finding = sample_finding(root);
1877        finding.kind = SecurityFindingKind::TaintedSink;
1878        finding.category = Some("dangerous-html".to_owned());
1879        finding.cwe = Some(79);
1880        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
1881        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1882        let run = &sarif["runs"][0];
1883        // The finding is grouped under its own per-category rule, not collapsed
1884        // into client-server-leak, and stays at candidate (note) level.
1885        let result = &run["results"][0];
1886        assert_eq!(result["level"], "note");
1887        assert_eq!(result["ruleId"], "security/dangerous-html");
1888        // Exactly one rule definition, carrying the CWE as a GHAS tag.
1889        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
1890        assert_eq!(rules.len(), 1);
1891        assert_eq!(rules[0]["id"], "security/dangerous-html");
1892        let tags = rules[0]["properties"]["tags"].as_array().unwrap();
1893        assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
1894    }
1895
1896    #[test]
1897    fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
1898        let root = Path::new("/proj/root");
1899        let finding = relativize_finding(sample_finding(root), root);
1900        let output = output_with(vec![finding], 0);
1901        let dir = tempfile::tempdir().expect("tempdir");
1902        let path = dir.path().join("nested/out.sarif");
1903        write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
1904        let written = std::fs::read_to_string(&path).expect("file exists");
1905        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
1906        assert_eq!(sarif["version"], "2.1.0");
1907    }
1908
1909    /// No explicit `--config`; static so the `&'a Option<PathBuf>` field borrows it.
1910    const NO_CONFIG: Option<PathBuf> = None;
1911
1912    fn leak_fixture_root() -> PathBuf {
1913        Path::new(env!("CARGO_MANIFEST_DIR"))
1914            .join("../../tests/fixtures/security-client-server-leak")
1915    }
1916
1917    fn source_reachability_fixture_root() -> PathBuf {
1918        Path::new(env!("CARGO_MANIFEST_DIR"))
1919            .join("../../tests/fixtures/security-source-reachability-885")
1920    }
1921
1922    fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
1923        SecurityOptions {
1924            root,
1925            config_path: &NO_CONFIG,
1926            output,
1927            no_cache: true,
1928            threads: 1,
1929            quiet: true,
1930            fail_on_issues,
1931            sarif_file: None,
1932            summary: false,
1933            changed_since: None,
1934            use_shared_diff_index: false,
1935            workspace: None,
1936            changed_workspaces: None,
1937            file: &[],
1938            surface: false,
1939            gate: None,
1940            runtime_coverage: None,
1941            min_invocations_hot: 100,
1942        }
1943    }
1944
1945    #[test]
1946    #[expect(
1947        deprecated,
1948        reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
1949    )]
1950    fn source_reachability_fixture_marks_cross_module_sink() {
1951        let root = source_reachability_fixture_root();
1952        let mut config = load_config_for_analysis(
1953            &root,
1954            &NO_CONFIG,
1955            OutputFormat::Json,
1956            true,
1957            1,
1958            None,
1959            true,
1960            ProductionAnalysis::DeadCode,
1961        )
1962        .expect("fixture config loads");
1963        config.rules.security_sink = Severity::Warn;
1964
1965        let results = fallow_core::analyze(&config).expect("fixture analyzes");
1966        let finding = results
1967            .security_findings
1968            .iter()
1969            .find(|finding| finding.path.ends_with("src/runner.ts"))
1970            .expect("runner sink finding");
1971        let reach = finding.reachability.as_ref().expect("reachability");
1972
1973        assert!(reach.reachable_from_untrusted_source);
1974        assert_eq!(reach.untrusted_source_hop_count, Some(1));
1975        assert_eq!(
1976            reach
1977                .untrusted_source_trace
1978                .iter()
1979                .map(|hop| hop.role)
1980                .collect::<Vec<_>>(),
1981            vec![TraceHopRole::UntrustedSource, TraceHopRole::Sink]
1982        );
1983        assert!(
1984            reach.untrusted_source_trace[0]
1985                .path
1986                .ends_with("src/route.ts")
1987        );
1988
1989        // Issue #900: the candidate boundary slot records the cross-module hop,
1990        // and the taint-flow triple re-projects the reachability endpoints + a
1991        // compact path (not a duplicate hop array).
1992        assert!(
1993            finding.candidate.boundary.cross_module,
1994            "a sink reached across a module hop crosses a module boundary"
1995        );
1996        let flow = finding.taint_flow.as_ref().expect("taint_flow present");
1997        assert!(!flow.path.intra_module);
1998        assert_eq!(flow.path.cross_module_hops, 1);
1999        assert!(flow.source.path.ends_with("src/route.ts"));
2000        assert!(flow.sink.path.ends_with("src/runner.ts"));
2001    }
2002
2003    #[test]
2004    fn file_scope_keeps_security_finding_when_anchor_matches() {
2005        let root = Path::new("/proj/root");
2006        let mut results = fallow_core::results::AnalysisResults::default();
2007        results.security_findings.push(sample_finding(root));
2008
2009        filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
2010
2011        assert_eq!(results.security_findings.len(), 1);
2012    }
2013
2014    #[test]
2015    fn file_scope_keeps_security_finding_when_trace_hop_matches() {
2016        let root = Path::new("/proj/root");
2017        let mut results = fallow_core::results::AnalysisResults::default();
2018        results.security_findings.push(sample_finding(root));
2019
2020        filter_to_files(
2021            &mut results,
2022            root,
2023            &[PathBuf::from("src/lib/secret.ts")],
2024            true,
2025        );
2026
2027        assert_eq!(results.security_findings.len(), 1);
2028    }
2029
2030    #[test]
2031    fn file_scope_drops_unrelated_security_finding() {
2032        let root = Path::new("/proj/root");
2033        let mut results = fallow_core::results::AnalysisResults::default();
2034        results.security_findings.push(sample_finding(root));
2035
2036        filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
2037
2038        assert!(results.security_findings.is_empty());
2039    }
2040
2041    #[test]
2042    fn run_is_advisory_and_exits_zero_even_with_candidates() {
2043        // The rule defaults to off; the command forces it to warn, so findings on
2044        // the fixture are surfaced but the exit stays 0 (advisory) by default.
2045        let root = leak_fixture_root();
2046        let code = run(&run_opts(&root, OutputFormat::Json, false));
2047        assert_eq!(code, ExitCode::SUCCESS);
2048    }
2049
2050    #[test]
2051    fn run_with_fail_on_issues_exits_one_when_candidates_found() {
2052        // The fixture has real leak candidates, so --fail-on-issues raises exit 1.
2053        let root = leak_fixture_root();
2054        let code = run(&run_opts(&root, OutputFormat::Human, true));
2055        assert_eq!(code, ExitCode::from(1));
2056    }
2057
2058    #[test]
2059    fn run_rejects_unsupported_output_format() {
2060        // Only human / json / sarif are supported; compact exits 2 before analysis.
2061        let root = leak_fixture_root();
2062        let code = run(&run_opts(&root, OutputFormat::Compact, false));
2063        assert_eq!(code, ExitCode::from(2));
2064    }
2065
2066    #[test]
2067    fn run_summary_mode_dispatches_compact_human_renderer() {
2068        let root = leak_fixture_root();
2069        let opts = SecurityOptions {
2070            summary: true,
2071            ..run_opts(&root, OutputFormat::Human, false)
2072        };
2073        assert_eq!(run(&opts), ExitCode::SUCCESS);
2074    }
2075
2076    #[test]
2077    fn run_sarif_format_dispatches_sarif_renderer() {
2078        let root = leak_fixture_root();
2079        assert_eq!(
2080            run(&run_opts(&root, OutputFormat::Sarif, false)),
2081            ExitCode::SUCCESS
2082        );
2083    }
2084
2085    #[test]
2086    fn run_writes_sarif_sidecar_file_when_requested() {
2087        let root = leak_fixture_root();
2088        let dir = tempfile::tempdir().expect("tempdir");
2089        let sidecar = dir.path().join("security.sarif");
2090        let opts = SecurityOptions {
2091            sarif_file: Some(&sidecar),
2092            ..run_opts(&root, OutputFormat::Human, false)
2093        };
2094        assert_eq!(run(&opts), ExitCode::SUCCESS);
2095        let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
2096        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
2097        assert_eq!(sarif["version"], "2.1.0");
2098    }
2099}