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