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::cmp::Ordering;
14use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18use std::time::Instant;
19
20use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
21use fallow_core::analyze::derive_security_severity;
22use fallow_core::results::{
23    AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
24    SecurityFindingKind, TraceHop, TraceHopRole,
25};
26use fallow_types::discover::DiscoveredFile;
27use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
28use fallow_types::extract::ModuleInfo;
29use fallow_types::results::{
30    SecurityRuntimeContext, SecurityRuntimeState, SecuritySeverity,
31    SecurityUnresolvedCalleeDiagnostic, TaintConfidence,
32};
33use rustc_hash::FxHashSet;
34use serde::Serialize;
35use xxhash_rust::xxh3::xxh3_64;
36
37use crate::base_worktree::{BaseWorktree, git_rev_parse};
38use crate::error::emit_error;
39use crate::health::{HealthOptions, SharedParseData, SortBy};
40use crate::health_types::{
41    RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
42};
43use crate::load_config_for_analysis;
44
45const UNRESOLVED_CALLEE_SAMPLE_LIMIT: usize = 25;
46const UNRESOLVED_CALLEE_TOP_FILES_LIMIT: usize = 10;
47
48/// The `fallow security --format json` schema version. Independently versioned
49/// from the main contract, mirroring `ImpactReportSchemaVersion`.
50#[derive(Debug, Clone, Copy, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52pub enum SecuritySchemaVersion {
53    /// First release of the `fallow security --format json` shape.
54    #[allow(
55        dead_code,
56        reason = "kept so the generated schema documents historical v1"
57    )]
58    #[serde(rename = "1")]
59    V1,
60    /// Adds per-finding `severity` for verification-priority tiering.
61    #[allow(
62        dead_code,
63        reason = "kept so the generated schema documents historical v2"
64    )]
65    #[serde(rename = "2")]
66    V2,
67    /// Adds version, elapsed time, explain metadata, and safe config metadata.
68    #[allow(
69        dead_code,
70        reason = "kept so the generated schema documents historical v3"
71    )]
72    #[serde(rename = "3")]
73    V3,
74    /// Adds bounded diagnostics for unresolved callee blind spots.
75    #[allow(
76        dead_code,
77        reason = "kept so the generated schema documents historical v4"
78    )]
79    #[serde(rename = "4")]
80    V4,
81    /// Adds summary metadata to security summary JSON.
82    #[allow(
83        dead_code,
84        reason = "kept so the generated schema documents historical v5"
85    )]
86    #[serde(rename = "5")]
87    V5,
88    /// Adds `candidate.sink.url_shape` for URL-shaped security candidates.
89    #[allow(
90        dead_code,
91        reason = "kept so the generated schema documents historical v6"
92    )]
93    #[serde(rename = "6")]
94    V6,
95    /// Adds the server-only-import category on client-server-leak findings when a
96    /// use-client cone reaches a server-only module.
97    #[serde(rename = "7")]
98    V7,
99}
100
101/// Gate mode for `fallow security --gate <mode>`.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "kebab-case")]
105pub enum SecurityGateMode {
106    /// Fail when the change introduces a NEW security-sink candidate on a changed
107    /// line (not merely a sink in a changed file). There is deliberately no `all`
108    /// mode: gating on the whole candidate backlog is the anti-feature this gate
109    /// exists to avoid.
110    New,
111    /// Fail when a candidate becomes runtime-reachable from an entry point in
112    /// head but the matching candidate was not runtime-reachable in base.
113    NewlyReachable,
114}
115
116/// Gate verdict on the wire. `fail` is the CI-state token; human output renders
117/// it as "REVIEW REQUIRED" because these stay unverified candidates, never
118/// confirmed vulnerabilities.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[serde(rename_all = "kebab-case")]
122pub enum SecurityGateVerdict {
123    /// No new candidate in the changed lines.
124    Pass,
125    /// At least one new candidate in the changed lines; review required.
126    Fail,
127}
128
129/// The `gate` block on `SecurityOutput`, present only when `--gate <mode>` ran.
130/// Invariant: `verdict == Fail  IFF  exit code 8  IFF  new_count > 0`.
131#[derive(Debug, Clone, Copy, Serialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133pub struct SecurityGate {
134    /// Which delta the gate checked.
135    pub mode: SecurityGateMode,
136    /// `pass` or `fail`.
137    pub verdict: SecurityGateVerdict,
138    /// Number of candidates matching the selected gate mode.
139    pub new_count: usize,
140}
141
142/// Allowlisted config context for `fallow security --format json`.
143#[derive(Debug, Clone, Serialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145#[cfg_attr(
146    feature = "schema",
147    schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
148)]
149pub struct SecurityOutputConfig {
150    /// Relevant rule severities before and after this command applies its
151    /// default-on behavior for security-only rules.
152    pub rules: SecurityOutputRulesConfig,
153    /// `security.categories.include` from config. `null` means unset, `[]`
154    /// means explicitly empty.
155    pub categories_include: Option<Vec<String>>,
156    /// `security.categories.exclude` from config. `null` means unset, `[]`
157    /// means explicitly empty.
158    pub categories_exclude: Option<Vec<String>>,
159}
160
161#[derive(Debug, Clone, Copy, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct SecurityOutputRulesConfig {
164    pub security_client_server_leak: SecurityRuleSeverityConfig,
165    pub security_sink: SecurityRuleSeverityConfig,
166}
167
168#[derive(Debug, Clone, Copy, Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct SecurityRuleSeverityConfig {
171    /// Severity read from resolved config before the security command applies
172    /// its default-on behavior.
173    pub configured: Severity,
174    /// Severity used for this command run.
175    pub effective: Severity,
176}
177
178/// The `fallow security --format json` envelope. `FallowOutput` discriminates it
179/// by the `kind: "security"` tag; the optional `gate` block is additive and is
180/// not part of that discrimination.
181#[derive(Debug, Clone, Serialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct SecurityOutput {
184    /// Schema version of this envelope.
185    pub schema_version: SecuritySchemaVersion,
186    /// Fallow CLI version that produced this output.
187    pub version: ToolVersion,
188    /// Wall-clock milliseconds spent producing the report.
189    pub elapsed_ms: ElapsedMs,
190    /// Privacy-safe config context relevant to security candidate generation.
191    pub config: SecurityOutputConfig,
192    /// Security-specific rule and field metadata, emitted with `--explain`.
193    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
194    pub meta: Option<Meta>,
195    /// Gate verdict, present only when `--gate <mode>` was set (issue #886).
196    /// Emitted on pass too (`verdict: "pass"`, `new_count: 0`) so consumers
197    /// distinguish "gate ran and passed" from "gate did not run" (absent).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub gate: Option<SecurityGate>,
200    /// Security candidates. Paths are project-root-relative, forward-slash.
201    pub security_findings: Vec<SecurityFinding>,
202    /// Opt-in attack-surface inventory from untrusted entry points to reachable
203    /// sinks. Present only when `--surface` was requested.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
206    /// In-band blind spot: number of `"use client"` files whose transitive
207    /// import cone contains a dynamic `import()` the reachability BFS could not
208    /// follow. A leak hidden behind such an edge would not be reported, so a
209    /// zero finding count with a non-zero value here is NOT a clean bill.
210    pub unresolved_edge_files: usize,
211    /// In-band blind spot: number of sink-shaped nodes the catalogue detector
212    /// could not flatten to a static callee path (dynamic dispatch, computed
213    /// members, aliased bindings). A zero finding count with a non-zero value
214    /// here is NOT a clean bill.
215    pub unresolved_callee_sites: usize,
216    /// Bounded diagnostics for unresolved callee blind spots.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
219}
220
221/// Bounded unresolved-callee diagnostics for `fallow security --format json`.
222#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct SecurityUnresolvedCalleeDiagnostics {
225    /// Deterministic sample rows, capped by `sample_limit`.
226    pub sampled: Vec<SecurityUnresolvedCalleeSample>,
227    /// Files with the most unresolved callees, capped by `top_files_limit`.
228    pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
229    /// Full count by unresolved-callee reason, sorted by count then reason.
230    pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
231    /// Maximum number of sample rows emitted.
232    pub sample_limit: usize,
233    /// Maximum number of top-file rows emitted.
234    pub top_files_limit: usize,
235}
236
237/// One sampled unresolved-callee row.
238#[derive(Debug, Clone, Serialize)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240pub struct SecurityUnresolvedCalleeSample {
241    /// Project-relative source path.
242    pub path: String,
243    /// 1-based source line.
244    pub line: u32,
245    /// 0-based byte column.
246    pub col: u32,
247    /// Why the callee was skipped.
248    pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
249    /// Compact syntax shape of the skipped callee.
250    pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
251}
252
253/// Count of unresolved callees in one file.
254#[derive(Debug, Clone, Serialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256pub struct SecurityUnresolvedCalleeTopFile {
257    /// Project-relative source path.
258    pub path: String,
259    /// Number of unresolved callees in this file.
260    pub count: usize,
261}
262
263/// Count of unresolved callees for one reason.
264#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct SecurityUnresolvedCalleeReasonCount {
267    /// Why the callees were skipped.
268    pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
269    /// Number of unresolved callees with this reason.
270    pub count: usize,
271}
272
273/// Compact `fallow security --summary --format json` payload. Uses the same
274/// `kind: "security"` discriminator as the full payload, but omits candidate
275/// arrays and exposes only aggregate counts.
276#[derive(Debug, Clone, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub struct SecuritySummaryOutput {
279    /// Schema version of this envelope.
280    pub schema_version: SecuritySchemaVersion,
281    /// Fallow CLI version that produced this output.
282    pub version: ToolVersion,
283    /// Wall-clock milliseconds spent producing the report.
284    pub elapsed_ms: ElapsedMs,
285    /// Privacy-safe config context relevant to security candidate generation.
286    pub config: SecurityOutputConfig,
287    /// Security-specific rule and field metadata, emitted with `--explain`.
288    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
289    pub meta: Option<Meta>,
290    /// Gate verdict, present only when `--gate <mode>` was set.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub gate: Option<SecurityGate>,
293    /// Aggregate security counts after all filters, gates, and scopes.
294    pub summary: SecuritySummary,
295}
296
297/// Aggregate counts for `fallow security --summary --format json`.
298#[derive(Debug, Clone, Serialize)]
299#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
300pub struct SecuritySummary {
301    /// Number of security candidates after all filters, gates, and scopes.
302    pub security_findings: usize,
303    /// Fixed severity counts for the closed security severity enum.
304    pub by_severity: SecuritySeverityCounts,
305    /// Finding counts by catalogue category, or by kind for findings without a
306    /// catalogue category.
307    pub by_category: BTreeMap<String, usize>,
308    /// Fixed reachability counts for ranking and triage signals.
309    pub by_reachability: SecurityReachabilityCounts,
310    /// Fixed runtime coverage counts for runtime-state triage signals.
311    pub by_runtime_state: SecurityRuntimeStateCounts,
312    /// Number of client files whose dynamic imports could not be followed.
313    pub unresolved_edge_files: usize,
314    /// Number of sink-shaped callees that could not be statically flattened.
315    pub unresolved_callee_sites: usize,
316    /// Number of attack-surface entries included in the prepared full output.
317    pub attack_surface_entries: usize,
318}
319
320/// Fixed severity counters for summary JSON.
321#[derive(Debug, Clone, Copy, Default, Serialize)]
322#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
323pub struct SecuritySeverityCounts {
324    pub high: usize,
325    pub medium: usize,
326    pub low: usize,
327}
328
329/// Fixed reachability counters for summary JSON.
330#[derive(Debug, Clone, Copy, Default, Serialize)]
331#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
332pub struct SecurityReachabilityCounts {
333    pub entry_reachable: usize,
334    pub untrusted_source_reachable: usize,
335    pub arg_level: usize,
336    pub module_level: usize,
337    pub crosses_boundary: usize,
338    pub source_backed: usize,
339}
340
341/// Fixed runtime coverage counters for summary JSON.
342#[derive(Debug, Clone, Copy, Default, Serialize)]
343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
344pub struct SecurityRuntimeStateCounts {
345    pub runtime_hot: usize,
346    pub runtime_cold: usize,
347    pub never_executed: usize,
348    pub low_traffic: usize,
349    pub coverage_unavailable: usize,
350    pub runtime_unknown: usize,
351    pub not_collected: usize,
352}
353
354/// Options for `fallow security`, mirroring the global CLI flags it honors.
355pub struct SecurityOptions<'a> {
356    /// Project root.
357    pub root: &'a Path,
358    /// Explicit config path (global `--config`).
359    pub config_path: &'a Option<PathBuf>,
360    /// Output format.
361    pub output: OutputFormat,
362    /// Disable the extraction cache.
363    pub no_cache: bool,
364    /// Resolved thread-pool size.
365    pub threads: usize,
366    /// Suppress progress output.
367    pub quiet: bool,
368    /// Exit with code 1 when candidates are found.
369    pub fail_on_issues: bool,
370    /// Write SARIF to a sidecar file in addition to the primary output.
371    pub sarif_file: Option<&'a Path>,
372    /// Show a compact human summary instead of per-finding detail.
373    pub summary: bool,
374    /// `--changed-since <ref>`: scope findings to files changed since the ref.
375    pub changed_since: Option<&'a str>,
376    /// Apply the shared `--diff-file` / `--diff-stdin` line filter.
377    pub use_shared_diff_index: bool,
378    /// `--workspace <patterns...>`: scope findings to selected workspace roots.
379    pub workspace: Option<&'a [String]>,
380    /// `--changed-workspaces <ref>`: scope to workspaces with changed files.
381    pub changed_workspaces: Option<&'a str>,
382    /// `--file <PATH>`: scope findings to selected files or trace hops.
383    pub file: &'a [PathBuf],
384    /// `--surface`: include the top-level attack-surface inventory in JSON.
385    pub surface: bool,
386    /// `--gate <mode>`: opt-in regression gate. `new` requires a diff source and
387    /// reports candidates introduced in changed lines. `newly-reachable`
388    /// requires `--changed-since <ref>` and reports candidates newly reachable
389    /// from runtime entry points.
390    pub gate: Option<SecurityGateMode>,
391    /// Paid local runtime-coverage sidecar input.
392    pub runtime_coverage: Option<&'a Path>,
393    /// Threshold for hot-path classification when `--runtime-coverage` is set.
394    pub min_invocations_hot: u64,
395    /// Include security-specific `_meta` in JSON output.
396    pub explain: bool,
397}
398
399/// Run `fallow security`. Always exits 0 unless the user explicitly raised the
400/// `security-client-server-leak` rule to `error` AND findings exist (the rule
401/// defaults to `off` and the command forces it to `warn`, so the common case is
402/// advisory). Unsupported output formats exit 2.
403pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
404    let started = Instant::now();
405    if let Err(code) = validate_security_output(opts.output) {
406        return code;
407    }
408
409    let mut config = match load_config_for_analysis(
410        opts.root,
411        opts.config_path,
412        crate::ConfigLoadOptions {
413            output: opts.output,
414            no_cache: opts.no_cache,
415            threads: opts.threads,
416            production_override: None,
417            quiet: opts.quiet,
418        },
419        ProductionAnalysis::DeadCode,
420    ) {
421        Ok(config) => config,
422        Err(code) => return code,
423    };
424
425    let configured_severities = security_rule_severities(&config);
426    force_security_rules(&mut config);
427    let effective_severities = security_rule_severities(&config);
428
429    let mut analysis = match analyze_security_candidates(opts, &config) {
430        Ok(analysis) => analysis,
431        Err(code) => return code,
432    };
433
434    if let Err(code) = apply_security_scopes(opts, &mut analysis) {
435        return code;
436    }
437
438    let gate_mode = match apply_security_gate(opts, &config, &mut analysis.results) {
439        Ok(mode) => mode,
440        Err(code) => return code,
441    };
442
443    let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
444    let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
445    let unresolved_callee_diagnostics = unresolved_callee_diagnostics(
446        &analysis.results.security_unresolved_callee_diagnostics,
447        &config.root,
448    );
449    let runtime_report = match security_runtime_report(opts, &mut analysis) {
450        Ok(report) => report,
451        Err(code) => return code,
452    };
453    let PreparedSecurityFindings {
454        findings,
455        attack_surface,
456    } = prepare_security_findings(
457        &mut analysis,
458        runtime_report.as_ref(),
459        &config.root,
460        opts.surface,
461    );
462
463    let output = build_security_output(SecurityOutputInput {
464        opts,
465        started,
466        config: &config,
467        configured_severities,
468        effective_severities,
469        gate_mode,
470        findings,
471        attack_surface,
472        unresolved_edge_files,
473        unresolved_callee_sites,
474        unresolved_callee_diagnostics,
475    });
476    crate::telemetry::note_result_count(output.security_findings.len());
477
478    if let Err(code) = maybe_write_security_sarif(opts, &output) {
479        return code;
480    }
481
482    outln!("{}", render_security_output(opts, &output));
483    security_exit_code(opts, &output, effective_severities)
484}
485
486#[derive(Clone, Copy)]
487struct SecurityRuleSeverities {
488    leak: Severity,
489    sink: Severity,
490}
491
492struct SecurityOutputInput<'a, 'b> {
493    opts: &'a SecurityOptions<'b>,
494    started: Instant,
495    config: &'a fallow_config::ResolvedConfig,
496    configured_severities: SecurityRuleSeverities,
497    effective_severities: SecurityRuleSeverities,
498    gate_mode: Option<SecurityGateMode>,
499    findings: Vec<SecurityFinding>,
500    attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
501    unresolved_edge_files: usize,
502    unresolved_callee_sites: usize,
503    unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
504}
505
506fn validate_security_output(output: OutputFormat) -> Result<(), ExitCode> {
507    if matches!(
508        output,
509        OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
510    ) {
511        Ok(())
512    } else {
513        Err(emit_error(
514            "fallow security supports --format human, json, or sarif only.",
515            2,
516            output,
517        ))
518    }
519}
520
521fn security_rule_severities(config: &fallow_config::ResolvedConfig) -> SecurityRuleSeverities {
522    SecurityRuleSeverities {
523        leak: config.rules.security_client_server_leak,
524        sink: config.rules.security_sink,
525    }
526}
527
528fn build_security_output(input: SecurityOutputInput<'_, '_>) -> SecurityOutput {
529    SecurityOutput {
530        schema_version: SecuritySchemaVersion::V7,
531        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
532        elapsed_ms: ElapsedMs(input.started.elapsed().as_millis() as u64),
533        config: security_output_config(
534            input.config,
535            input.configured_severities.leak,
536            input.effective_severities.leak,
537            input.configured_severities.sink,
538            input.effective_severities.sink,
539        ),
540        meta: input.opts.explain.then(crate::explain::security_meta),
541        gate: input
542            .gate_mode
543            .map(|mode| security_gate_output(mode, input.findings.len())),
544        security_findings: input.findings,
545        attack_surface: input.attack_surface,
546        unresolved_edge_files: input.unresolved_edge_files,
547        unresolved_callee_sites: input.unresolved_callee_sites,
548        unresolved_callee_diagnostics: input.unresolved_callee_diagnostics,
549    }
550}
551
552fn security_gate_output(mode: SecurityGateMode, finding_count: usize) -> SecurityGate {
553    // In gate mode the displayed set is the strict "new" set, so its length is
554    // the new-candidate count. The gate block is emitted unconditionally when a
555    // gate ran so consumers can distinguish pass from "gate did not run".
556    SecurityGate {
557        mode,
558        verdict: if finding_count > 0 {
559            SecurityGateVerdict::Fail
560        } else {
561            SecurityGateVerdict::Pass
562        },
563        new_count: finding_count,
564    }
565}
566
567fn maybe_write_security_sarif(
568    opts: &SecurityOptions<'_>,
569    output: &SecurityOutput,
570) -> Result<(), ExitCode> {
571    if let Some(path) = opts.sarif_file
572        && let Err(message) = write_sarif_file(output, path)
573    {
574        return Err(emit_error(&message, 2, opts.output));
575    }
576    Ok(())
577}
578
579fn render_security_output(opts: &SecurityOptions<'_>, output: &SecurityOutput) -> String {
580    match opts.output {
581        OutputFormat::Json if opts.summary => render_json_summary(output),
582        OutputFormat::Json => render_json(output),
583        OutputFormat::Sarif => render_sarif(output),
584        _ if opts.summary => render_human_summary(output),
585        _ => render_human(output),
586    }
587}
588
589fn security_exit_code(
590    opts: &SecurityOptions<'_>,
591    output: &SecurityOutput,
592    effective_severities: SecurityRuleSeverities,
593) -> ExitCode {
594    if let Some(gate) = &output.gate {
595        if gate.verdict == SecurityGateVerdict::Fail {
596            ExitCode::from(8)
597        } else {
598            ExitCode::SUCCESS
599        }
600    } else if security_advisory_failed(opts, output, effective_severities) {
601        ExitCode::from(1)
602    } else {
603        ExitCode::SUCCESS
604    }
605}
606
607fn security_advisory_failed(
608    opts: &SecurityOptions<'_>,
609    output: &SecurityOutput,
610    effective_severities: SecurityRuleSeverities,
611) -> bool {
612    (opts.fail_on_issues
613        || effective_severities.leak == Severity::Error
614        || effective_severities.sink == Severity::Error)
615        && !output.security_findings.is_empty()
616}
617
618struct PreparedSecurityFindings {
619    findings: Vec<SecurityFinding>,
620    attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
621}
622
623fn prepare_security_findings(
624    analysis: &mut SecurityAnalysisState,
625    runtime_report: Option<&RuntimeCoverageReport>,
626    root: &Path,
627    include_surface: bool,
628) -> PreparedSecurityFindings {
629    let mut findings: Vec<SecurityFinding> =
630        std::mem::take(&mut analysis.results.security_findings)
631            .into_iter()
632            .map(|f| relativize_finding(f, root))
633            .collect();
634    if let (Some(report), Some(modules), Some(files)) = (
635        runtime_report,
636        analysis.modules.as_ref(),
637        analysis.files.as_ref(),
638    ) {
639        apply_runtime_context(&mut findings, modules, files, root, report);
640    }
641    apply_security_severity(&mut findings);
642    sort_by_security_severity(&mut findings);
643    for finding in &mut findings {
644        finding.finding_id = security_finding_id(finding);
645    }
646    let (findings, attack_surface) = prepare_findings(findings, root, include_surface);
647    PreparedSecurityFindings {
648        findings,
649        attack_surface,
650    }
651}
652
653fn force_security_rules(config: &mut fallow_config::ResolvedConfig) {
654    // Respect explicit user severities; force the rules on when they are the
655    // default off so this dedicated command actually surfaces candidates.
656    if config.rules.security_client_server_leak == Severity::Off {
657        config.rules.security_client_server_leak = Severity::Warn;
658    }
659    if config.rules.security_sink == Severity::Off {
660        config.rules.security_sink = Severity::Warn;
661    }
662}
663
664fn security_output_config(
665    config: &fallow_config::ResolvedConfig,
666    configured_severity: Severity,
667    effective_severity: Severity,
668    configured_sink_severity: Severity,
669    effective_sink_severity: Severity,
670) -> SecurityOutputConfig {
671    let categories = config.security.categories.as_ref();
672    SecurityOutputConfig {
673        rules: SecurityOutputRulesConfig {
674            security_client_server_leak: SecurityRuleSeverityConfig {
675                configured: configured_severity,
676                effective: effective_severity,
677            },
678            security_sink: SecurityRuleSeverityConfig {
679                configured: configured_sink_severity,
680                effective: effective_sink_severity,
681            },
682        },
683        categories_include: categories.and_then(|categories| categories.include.clone()),
684        categories_exclude: categories.and_then(|categories| categories.exclude.clone()),
685    }
686}
687
688fn apply_changed_scope(opts: &SecurityOptions<'_>, results: &mut AnalysisResults) {
689    if let Some(git_ref) = opts.changed_since
690        && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
691    {
692        fallow_core::changed_files::filter_results_by_changed_files(results, &changed);
693    }
694    if opts.use_shared_diff_index
695        && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
696    {
697        crate::check::filtering::filter_results_by_diff(results, diff_index, opts.root);
698    }
699}
700
701fn apply_security_scopes(
702    opts: &SecurityOptions<'_>,
703    analysis: &mut SecurityAnalysisState,
704) -> Result<(), ExitCode> {
705    let ws_roots = crate::check::filtering::resolve_workspace_scope(
706        opts.root,
707        opts.workspace,
708        opts.changed_workspaces,
709        opts.output,
710    )?;
711    if let Some(ref roots) = ws_roots {
712        crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
713    }
714
715    if !matches!(opts.gate, Some(SecurityGateMode::NewlyReachable)) {
716        apply_changed_scope(opts, &mut analysis.results);
717    }
718    filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
719
720    Ok(())
721}
722
723fn apply_security_gate(
724    opts: &SecurityOptions<'_>,
725    config: &fallow_config::ResolvedConfig,
726    results: &mut AnalysisResults,
727) -> Result<Option<SecurityGateMode>, ExitCode> {
728    let Some(mode) = opts.gate else {
729        return Ok(None);
730    };
731
732    if matches!(mode, SecurityGateMode::NewlyReachable) {
733        retain_gate_newly_reachable(opts, config, results)?;
734        return Ok(Some(mode));
735    }
736
737    // Security gate (issue #886): narrow to the STRICT "new in changed lines"
738    // predicate and drive a dedicated exit code. The gate requires a diff
739    // source; a diff it cannot compute is a LOUD error (exit 2), never a green
740    // gate (a silent miss defeats a security gate).
741    let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
742    let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
743        if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
744            shared
745        } else if let Some(git_ref) = opts.changed_since {
746            match fallow_core::changed_files::try_get_changed_diff(opts.root, git_ref) {
747                Ok(text) => owned_gate_diff
748                    .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
749                Err(err) => {
750                    return Err(emit_error(
751                        &format!(
752                            "fallow security --gate could not compute the diff for '{git_ref}': {}",
753                            err.describe()
754                        ),
755                        2,
756                        opts.output,
757                    ));
758                }
759            }
760        } else {
761            return Err(emit_error(
762                "fallow security --gate requires a diff source: --changed-since <ref>, \
763                     --diff-file <path>, or --diff-stdin.",
764                2,
765                opts.output,
766            ));
767        };
768    crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
769    Ok(Some(mode))
770}
771
772const SECURITY_BASE_SNAPSHOT_CACHE_VERSION: u8 = 1;
773const MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE: usize = 8 * 1024 * 1024;
774
775#[derive(Debug, Clone)]
776struct SecurityKeySnapshot {
777    reachable: FxHashSet<String>,
778}
779
780struct SecurityBaseSnapshotCacheKey {
781    hash: u64,
782    base_sha: String,
783}
784
785#[derive(bitcode::Encode, bitcode::Decode)]
786struct CachedSecurityKeySnapshot {
787    version: u8,
788    cli_version: String,
789    key_hash: u64,
790    base_sha: String,
791    reachable: Vec<String>,
792}
793
794fn retain_gate_newly_reachable(
795    opts: &SecurityOptions<'_>,
796    config: &fallow_config::ResolvedConfig,
797    results: &mut AnalysisResults,
798) -> Result<(), ExitCode> {
799    let Some(base_ref) = opts.changed_since else {
800        return Err(emit_error(
801            "fallow security --gate newly-reachable requires --changed-since <ref>; \
802             --diff-file and --diff-stdin do not identify a base tree.",
803            2,
804            opts.output,
805        ));
806    };
807    let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
808        return Err(emit_error(
809            &format!(
810                "fallow security --gate newly-reachable could not resolve base ref '{base_ref}'."
811            ),
812            2,
813            opts.output,
814        ));
815    };
816    let cache_key = security_base_snapshot_cache_key(opts, config, &base_sha)?;
817    let base = if let Some(snapshot) = load_cached_security_base_snapshot(config, &cache_key) {
818        snapshot
819    } else {
820        let snapshot = compute_base_security_snapshot(opts, config, base_ref, &base_sha)?;
821        save_cached_security_base_snapshot(config, &cache_key, &snapshot);
822        snapshot
823    };
824    results.security_findings.retain(|finding| {
825        security_reachability_key(finding, opts.root)
826            .is_some_and(|key| !base.reachable.contains(&key))
827    });
828    Ok(())
829}
830
831fn compute_base_security_snapshot(
832    opts: &SecurityOptions<'_>,
833    config: &fallow_config::ResolvedConfig,
834    base_ref: &str,
835    base_sha: &str,
836) -> Result<SecurityKeySnapshot, ExitCode> {
837    let Some(worktree) = BaseWorktree::create(opts.root, base_ref, Some(base_sha)) else {
838        return Err(emit_error(
839            &format!("could not create a temporary worktree for base ref '{base_ref}'"),
840            2,
841            opts.output,
842        ));
843    };
844    let base_root = base_analysis_root(opts.root, worktree.path());
845    let current_config_path = opts
846        .config_path
847        .clone()
848        .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
849    let mut base_config = load_config_for_analysis(
850        &base_root,
851        &current_config_path,
852        crate::ConfigLoadOptions {
853            output: opts.output,
854            no_cache: opts.no_cache,
855            threads: opts.threads,
856            production_override: None,
857            quiet: true,
858        },
859        ProductionAnalysis::DeadCode,
860    )?;
861    base_config.cache_dir =
862        remap_cache_dir_for_base_worktree(opts.root, &base_root, &config.cache_dir);
863    force_security_rules(&mut base_config);
864    let mut base_analysis = analyze_security_candidates(
865        &SecurityOptions {
866            root: &base_root,
867            config_path: &current_config_path,
868            output: opts.output,
869            no_cache: opts.no_cache,
870            threads: opts.threads,
871            quiet: true,
872            fail_on_issues: false,
873            sarif_file: None,
874            summary: false,
875            changed_since: None,
876            use_shared_diff_index: false,
877            workspace: opts.workspace,
878            changed_workspaces: None,
879            file: &[],
880            surface: false,
881            gate: None,
882            runtime_coverage: None,
883            min_invocations_hot: opts.min_invocations_hot,
884            explain: false,
885        },
886        &base_config,
887    )?;
888    if let Some(ref roots) = crate::check::filtering::resolve_workspace_scope(
889        &base_root,
890        opts.workspace,
891        None,
892        opts.output,
893    )? {
894        crate::check::filtering::filter_to_workspaces(&mut base_analysis.results, roots);
895    }
896    Ok(SecurityKeySnapshot {
897        reachable: security_reachable_keys(&base_analysis.results.security_findings, &base_root),
898    })
899}
900
901fn security_reachable_keys(findings: &[SecurityFinding], root: &Path) -> FxHashSet<String> {
902    findings
903        .iter()
904        .filter_map(|finding| security_reachability_key(finding, root))
905        .collect()
906}
907
908fn security_reachability_key(finding: &SecurityFinding, root: &Path) -> Option<String> {
909    if !finding
910        .reachability
911        .as_ref()
912        .is_some_and(|reachability| reachability.reachable_from_entry)
913    {
914        return None;
915    }
916    let category = finding.category.as_deref().unwrap_or("none");
917    Some(format!(
918        "security-reach:{}:{}:{}",
919        relative_key(&finding.path, root),
920        security_kind_key(finding.kind),
921        category,
922    ))
923}
924
925fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
926    match kind {
927        SecurityFindingKind::ClientServerLeak => "client-server-leak",
928        SecurityFindingKind::TaintedSink => "tainted-sink",
929    }
930}
931
932fn security_base_snapshot_cache_key(
933    opts: &SecurityOptions<'_>,
934    config: &fallow_config::ResolvedConfig,
935    base_sha: &str,
936) -> Result<SecurityBaseSnapshotCacheKey, ExitCode> {
937    let payload = serde_json::json!({
938        "cache_version": SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
939        "cli_version": env!("CARGO_PKG_VERSION"),
940        "base_sha": base_sha,
941        "config_hash": format!("{:016x}", config.cache_config_hash),
942        "security_client_server_leak": format!("{:?}", config.rules.security_client_server_leak),
943        "security_sink": format!("{:?}", config.rules.security_sink),
944        "workspace": opts.workspace,
945        "changed_workspaces": opts.changed_workspaces,
946    });
947    let bytes = serde_json::to_vec(&payload).map_err(|err| {
948        emit_error(
949            &format!("failed to build security gate cache key: {err}"),
950            2,
951            opts.output,
952        )
953    })?;
954    Ok(SecurityBaseSnapshotCacheKey {
955        hash: xxh3_64(&bytes),
956        base_sha: base_sha.to_owned(),
957    })
958}
959
960fn security_base_snapshot_cache_dir(config: &fallow_config::ResolvedConfig) -> PathBuf {
961    config.cache_dir.join("cache").join(format!(
962        "security-base-v{SECURITY_BASE_SNAPSHOT_CACHE_VERSION}"
963    ))
964}
965
966fn security_base_snapshot_cache_file(
967    config: &fallow_config::ResolvedConfig,
968    key: &SecurityBaseSnapshotCacheKey,
969) -> PathBuf {
970    security_base_snapshot_cache_dir(config).join(format!("{:016x}.bin", key.hash))
971}
972
973fn ensure_security_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
974    std::fs::create_dir_all(dir)?;
975    let gitignore = dir.join(".gitignore");
976    if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
977        std::fs::write(gitignore, "*\n")?;
978    }
979    Ok(())
980}
981
982fn load_cached_security_base_snapshot(
983    config: &fallow_config::ResolvedConfig,
984    key: &SecurityBaseSnapshotCacheKey,
985) -> Option<SecurityKeySnapshot> {
986    if config.no_cache {
987        return None;
988    }
989    let path = security_base_snapshot_cache_file(config, key);
990    let data = std::fs::read(path).ok()?;
991    if data.len() > MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE {
992        return None;
993    }
994    let cached: CachedSecurityKeySnapshot = bitcode::decode(&data).ok()?;
995    if cached.version != SECURITY_BASE_SNAPSHOT_CACHE_VERSION
996        || cached.cli_version != env!("CARGO_PKG_VERSION")
997        || cached.key_hash != key.hash
998        || cached.base_sha != key.base_sha
999    {
1000        return None;
1001    }
1002    Some(SecurityKeySnapshot {
1003        reachable: cached.reachable.into_iter().collect(),
1004    })
1005}
1006
1007fn save_cached_security_base_snapshot(
1008    config: &fallow_config::ResolvedConfig,
1009    key: &SecurityBaseSnapshotCacheKey,
1010    snapshot: &SecurityKeySnapshot,
1011) {
1012    if config.no_cache {
1013        return;
1014    }
1015    let dir = security_base_snapshot_cache_dir(config);
1016    if ensure_security_base_snapshot_cache_dir(&dir).is_err() {
1017        return;
1018    }
1019    let mut reachable = snapshot.reachable.iter().cloned().collect::<Vec<_>>();
1020    reachable.sort_unstable();
1021    let data = bitcode::encode(&CachedSecurityKeySnapshot {
1022        version: SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
1023        cli_version: env!("CARGO_PKG_VERSION").to_owned(),
1024        key_hash: key.hash,
1025        base_sha: key.base_sha.clone(),
1026        reachable,
1027    });
1028    let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
1029        return;
1030    };
1031    if tmp.write_all(&data).is_err() {
1032        return;
1033    }
1034    let _ = tmp.persist(security_base_snapshot_cache_file(config, key));
1035}
1036
1037fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1038    if current_root.is_absolute()
1039        && let Some(git_root) = crate::base_worktree::git_toplevel(current_root)
1040        && let Ok(relative) = current_root.strip_prefix(git_root)
1041    {
1042        return base_worktree_root.join(relative);
1043    }
1044    base_worktree_root.to_path_buf()
1045}
1046
1047fn remap_cache_dir_for_base_worktree(
1048    current_root: &Path,
1049    base_worktree_root: &Path,
1050    cache_dir: &Path,
1051) -> PathBuf {
1052    if cache_dir.is_absolute()
1053        && let Ok(relative) = cache_dir.strip_prefix(current_root)
1054    {
1055        return base_worktree_root.join(relative);
1056    }
1057    cache_dir.to_path_buf()
1058}
1059
1060struct SecurityAnalysisState {
1061    results: AnalysisResults,
1062    modules: Option<Vec<ModuleInfo>>,
1063    files: Option<Vec<DiscoveredFile>>,
1064    analysis_output: Option<fallow_core::AnalysisOutput>,
1065}
1066
1067#[expect(
1068    deprecated,
1069    reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
1070)]
1071fn analyze_security_candidates(
1072    opts: &SecurityOptions<'_>,
1073    config: &fallow_config::ResolvedConfig,
1074) -> Result<SecurityAnalysisState, ExitCode> {
1075    if opts.runtime_coverage.is_none() {
1076        return fallow_core::analyze(config)
1077            .map(|results| SecurityAnalysisState {
1078                results,
1079                modules: None,
1080                files: None,
1081                analysis_output: None,
1082            })
1083            .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
1084    }
1085
1086    fallow_core::analyze_retaining_modules(config, true, true)
1087        .map(|mut output| {
1088            let modules = output.modules.take();
1089            let files = output.files.take();
1090            let results = output.results.clone();
1091            SecurityAnalysisState {
1092                results,
1093                modules,
1094                files,
1095                analysis_output: Some(output),
1096            }
1097        })
1098        .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
1099}
1100
1101fn security_runtime_report(
1102    opts: &SecurityOptions<'_>,
1103    analysis: &mut SecurityAnalysisState,
1104) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1105    let Some(path) = opts.runtime_coverage else {
1106        return Ok(None);
1107    };
1108    let (Some(modules), Some(files), Some(analysis_output)) = (
1109        analysis.modules.as_ref(),
1110        analysis.files.as_ref(),
1111        analysis.analysis_output.take(),
1112    ) else {
1113        return Ok(None);
1114    };
1115    analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
1116}
1117
1118fn analyze_security_runtime(
1119    opts: &SecurityOptions<'_>,
1120    path: &Path,
1121    modules: Vec<ModuleInfo>,
1122    files: Vec<DiscoveredFile>,
1123    analysis_output: fallow_core::AnalysisOutput,
1124) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1125    let runtime_coverage = crate::health::coverage::prepare_options(
1126        path,
1127        opts.min_invocations_hot,
1128        None,
1129        None,
1130        opts.output,
1131    )?;
1132    let result = crate::health::execute_health_with_shared_parse(
1133        &HealthOptions {
1134            root: opts.root,
1135            config_path: opts.config_path,
1136            output: opts.output,
1137            no_cache: opts.no_cache,
1138            threads: opts.threads,
1139            quiet: opts.quiet,
1140            max_cyclomatic: None,
1141            max_cognitive: None,
1142            max_crap: None,
1143            top: None,
1144            sort: SortBy::Cyclomatic,
1145            production: true,
1146            production_override: Some(true),
1147            changed_since: opts.changed_since,
1148            diff_index: None,
1149            use_shared_diff_index: opts.use_shared_diff_index,
1150            workspace: opts.workspace,
1151            changed_workspaces: opts.changed_workspaces,
1152            baseline: None,
1153            save_baseline: None,
1154            complexity: false,
1155            complexity_breakdown: false,
1156            file_scores: false,
1157            coverage_gaps: false,
1158            config_activates_coverage_gaps: false,
1159            hotspots: false,
1160            ownership: false,
1161            ownership_emails: None,
1162            targets: false,
1163            css: false,
1164            force_full: false,
1165            score_only_output: false,
1166            enforce_coverage_gap_gate: false,
1167            effort: None,
1168            score: false,
1169            min_score: None,
1170            since: None,
1171            min_commits: None,
1172            explain: false,
1173            summary: false,
1174            save_snapshot: None,
1175            trend: false,
1176            group_by: None,
1177            coverage: None,
1178            coverage_root: None,
1179            performance: false,
1180            min_severity: None,
1181            report_only: false,
1182            runtime_coverage: Some(runtime_coverage),
1183            churn_file: None,
1184        },
1185        SharedParseData {
1186            files,
1187            modules,
1188            analysis_output: Some(analysis_output),
1189        },
1190    )?;
1191    Ok(result.report.runtime_coverage)
1192}
1193
1194#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1195struct RuntimeFunctionKey {
1196    path: String,
1197    function: String,
1198    line: u32,
1199}
1200
1201#[derive(Debug, Clone)]
1202struct FunctionSpan {
1203    key: RuntimeFunctionKey,
1204    end_line: u32,
1205}
1206
1207fn apply_runtime_context(
1208    findings: &mut Vec<SecurityFinding>,
1209    modules: &[ModuleInfo],
1210    files: &[fallow_types::discover::DiscoveredFile],
1211    root: &Path,
1212    report: &RuntimeCoverageReport,
1213) {
1214    let spans = function_spans(modules, files, root);
1215    let runtime = SecurityRuntimeIndex::new(report);
1216    let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
1217    for (_, finding) in &mut indexed {
1218        if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
1219            continue;
1220        }
1221        finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
1222    }
1223    indexed.sort_by(|(left_index, left), (right_index, right)| {
1224        runtime_rank(left)
1225            .cmp(&runtime_rank(right))
1226            .then_with(|| left_index.cmp(right_index))
1227    });
1228    findings.extend(indexed.into_iter().map(|(_, finding)| finding));
1229}
1230
1231fn function_spans(
1232    modules: &[ModuleInfo],
1233    files: &[fallow_types::discover::DiscoveredFile],
1234    root: &Path,
1235) -> Vec<FunctionSpan> {
1236    let paths_by_id = files
1237        .iter()
1238        .map(|file| (file.id, &file.path))
1239        .collect::<rustc_hash::FxHashMap<_, _>>();
1240    let mut spans = Vec::new();
1241    for module in modules {
1242        let Some(path) = paths_by_id.get(&module.file_id) else {
1243            continue;
1244        };
1245        let path = relative_key(path, root);
1246        for function in &module.complexity {
1247            spans.push(FunctionSpan {
1248                key: RuntimeFunctionKey {
1249                    path: path.clone(),
1250                    function: function.name.clone(),
1251                    line: function.line,
1252                },
1253                end_line: function.line.saturating_add(function.line_count),
1254            });
1255        }
1256    }
1257    spans
1258}
1259
1260struct SecurityRuntimeIndex {
1261    hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
1262    findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
1263}
1264
1265impl SecurityRuntimeIndex {
1266    fn new(report: &RuntimeCoverageReport) -> Self {
1267        let hot_paths = report
1268            .hot_paths
1269            .iter()
1270            .map(|hot| {
1271                (
1272                    runtime_hot_key(hot),
1273                    hot.end_line.max(hot.line),
1274                    SecurityRuntimeContext {
1275                        state: SecurityRuntimeState::RuntimeHot,
1276                        function: hot.function.clone(),
1277                        line: hot.line,
1278                        invocations: Some(hot.invocations),
1279                        stable_id: hot.stable_id.clone(),
1280                        evidence: Some(format!(
1281                            "production hot path observed with {} invocation{}",
1282                            hot.invocations,
1283                            crate::report::plural(hot.invocations as usize)
1284                        )),
1285                    },
1286                )
1287            })
1288            .collect();
1289        let findings = report
1290            .findings
1291            .iter()
1292            .map(runtime_finding_context)
1293            .collect();
1294        Self {
1295            hot_paths,
1296            findings,
1297        }
1298    }
1299}
1300
1301fn runtime_context_for_finding(
1302    finding: &SecurityFinding,
1303    spans: &[FunctionSpan],
1304    runtime: &SecurityRuntimeIndex,
1305) -> Option<SecurityRuntimeContext> {
1306    let path = path_key(&finding.path);
1307    let span = spans
1308        .iter()
1309        .filter(|span| {
1310            span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
1311        })
1312        .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
1313    if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
1314        key == &span.key && key.line <= finding.line && finding.line <= *end_line
1315    }) {
1316        return Some(context.clone());
1317    }
1318    runtime.findings.get(&span.key).cloned().or_else(|| {
1319        Some(SecurityRuntimeContext {
1320            state: SecurityRuntimeState::RuntimeUnknown,
1321            function: span.key.function.clone(),
1322            line: span.key.line,
1323            invocations: None,
1324            stable_id: None,
1325            evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
1326        })
1327    })
1328}
1329
1330fn runtime_rank(finding: &SecurityFinding) -> u8 {
1331    match finding.runtime.as_ref().map(|runtime| runtime.state) {
1332        Some(SecurityRuntimeState::RuntimeHot) => 0,
1333        Some(SecurityRuntimeState::LowTraffic) => 1,
1334        None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
1335        Some(SecurityRuntimeState::CoverageUnavailable) => 3,
1336        Some(SecurityRuntimeState::RuntimeCold) => 4,
1337        Some(SecurityRuntimeState::NeverExecuted) => 5,
1338    }
1339}
1340
1341fn apply_security_severity(findings: &mut [SecurityFinding]) {
1342    for finding in findings {
1343        finding.severity = derive_security_severity(finding);
1344    }
1345}
1346
1347fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
1348    findings.sort_by(compare_security_priority);
1349}
1350
1351fn compare_security_priority(left: &SecurityFinding, right: &SecurityFinding) -> Ordering {
1352    security_severity_rank(left.severity)
1353        .cmp(&security_severity_rank(right.severity))
1354        .then_with(|| runtime_rank(left).cmp(&runtime_rank(right)))
1355        .then_with(|| {
1356            right
1357                .reachability
1358                .as_ref()
1359                .is_some_and(|reach| reach.reachable_from_entry)
1360                .cmp(
1361                    &left
1362                        .reachability
1363                        .as_ref()
1364                        .is_some_and(|reach| reach.reachable_from_entry),
1365                )
1366        })
1367        .then_with(|| taint_rank(left).cmp(&taint_rank(right)))
1368        .then_with(|| security_blast_radius(right).cmp(&security_blast_radius(left)))
1369        .then_with(|| security_crosses_boundary(right).cmp(&security_crosses_boundary(left)))
1370        .then_with(|| left.dead_code.is_some().cmp(&right.dead_code.is_some()))
1371        .then_with(|| left.path.cmp(&right.path))
1372        .then_with(|| left.line.cmp(&right.line))
1373        .then_with(|| left.col.cmp(&right.col))
1374        .then_with(|| left.category.cmp(&right.category))
1375}
1376
1377fn taint_rank(finding: &SecurityFinding) -> u8 {
1378    match finding
1379        .reachability
1380        .as_ref()
1381        .and_then(|reach| reach.taint_confidence)
1382    {
1383        Some(TaintConfidence::ArgLevel) => 0,
1384        Some(TaintConfidence::ModuleLevel) => 1,
1385        None if finding.source_backed => 0,
1386        None if finding
1387            .reachability
1388            .as_ref()
1389            .is_some_and(|reach| reach.reachable_from_untrusted_source) =>
1390        {
1391            1
1392        }
1393        None => 2,
1394    }
1395}
1396
1397fn security_blast_radius(finding: &SecurityFinding) -> u32 {
1398    finding
1399        .reachability
1400        .as_ref()
1401        .map_or(0, |reach| reach.blast_radius)
1402}
1403
1404fn security_crosses_boundary(finding: &SecurityFinding) -> bool {
1405    finding
1406        .reachability
1407        .as_ref()
1408        .is_some_and(|reach| reach.crosses_boundary)
1409}
1410
1411const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
1412    match severity {
1413        SecuritySeverity::High => 0,
1414        SecuritySeverity::Medium => 1,
1415        SecuritySeverity::Low => 2,
1416    }
1417}
1418
1419fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
1420    RuntimeFunctionKey {
1421        path: path_key(&hot.path),
1422        function: hot.function.clone(),
1423        line: hot.line,
1424    }
1425}
1426
1427fn runtime_finding_context(
1428    finding: &RuntimeCoverageFinding,
1429) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
1430    let state = match finding.verdict {
1431        RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
1432        RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
1433            SecurityRuntimeState::RuntimeCold
1434        }
1435        RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
1436        RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
1437            SecurityRuntimeState::CoverageUnavailable
1438        }
1439        RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
1440            SecurityRuntimeState::RuntimeUnknown
1441        }
1442    };
1443    (
1444        RuntimeFunctionKey {
1445            path: path_key(&finding.path),
1446            function: finding.function.clone(),
1447            line: finding.line,
1448        },
1449        SecurityRuntimeContext {
1450            state,
1451            function: finding.function.clone(),
1452            line: finding.line,
1453            invocations: finding.invocations,
1454            stable_id: finding.stable_id.clone(),
1455            evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
1456        },
1457    )
1458}
1459
1460fn relative_key(path: &Path, root: &Path) -> String {
1461    path_key(path.strip_prefix(root).unwrap_or(path))
1462}
1463
1464fn path_key(path: &Path) -> String {
1465    path.to_string_lossy().replace('\\', "/")
1466}
1467
1468fn unresolved_callee_diagnostics(
1469    diagnostics: &[SecurityUnresolvedCalleeDiagnostic],
1470    root: &Path,
1471) -> Option<SecurityUnresolvedCalleeDiagnostics> {
1472    if diagnostics.is_empty() {
1473        return None;
1474    }
1475
1476    let mut sorted = diagnostics.to_vec();
1477    sorted.sort_by(|a, b| {
1478        a.path
1479            .cmp(&b.path)
1480            .then(a.line.cmp(&b.line))
1481            .then(a.col.cmp(&b.col))
1482            .then(a.reason.cmp(&b.reason))
1483            .then(a.expression_kind.cmp(&b.expression_kind))
1484    });
1485
1486    let sampled = sorted
1487        .iter()
1488        .take(UNRESOLVED_CALLEE_SAMPLE_LIMIT)
1489        .map(|diagnostic| SecurityUnresolvedCalleeSample {
1490            path: relative_key(&diagnostic.path, root),
1491            line: diagnostic.line,
1492            col: diagnostic.col,
1493            reason: diagnostic.reason,
1494            expression_kind: diagnostic.expression_kind,
1495        })
1496        .collect();
1497
1498    let mut by_file: BTreeMap<String, usize> = BTreeMap::new();
1499    let mut by_reason: BTreeMap<fallow_types::extract::SkippedSecurityCalleeReason, usize> =
1500        BTreeMap::new();
1501    for diagnostic in &sorted {
1502        *by_file
1503            .entry(relative_key(&diagnostic.path, root))
1504            .or_insert(0) += 1;
1505        *by_reason.entry(diagnostic.reason).or_insert(0) += 1;
1506    }
1507
1508    let mut top_files: Vec<_> = by_file
1509        .into_iter()
1510        .map(|(path, count)| SecurityUnresolvedCalleeTopFile { path, count })
1511        .collect();
1512    top_files.sort_by(|a, b| b.count.cmp(&a.count).then(a.path.cmp(&b.path)));
1513    top_files.truncate(UNRESOLVED_CALLEE_TOP_FILES_LIMIT);
1514
1515    let mut by_reason: Vec<_> = by_reason
1516        .into_iter()
1517        .map(|(reason, count)| SecurityUnresolvedCalleeReasonCount { reason, count })
1518        .collect();
1519    by_reason.sort_by(|a, b| b.count.cmp(&a.count).then(a.reason.cmp(&b.reason)));
1520
1521    Some(SecurityUnresolvedCalleeDiagnostics {
1522        sampled,
1523        top_files,
1524        by_reason,
1525        sample_limit: UNRESOLVED_CALLEE_SAMPLE_LIMIT,
1526        top_files_limit: UNRESOLVED_CALLEE_TOP_FILES_LIMIT,
1527    })
1528}
1529
1530fn filter_to_files(
1531    results: &mut fallow_core::results::AnalysisResults,
1532    root: &Path,
1533    files: &[PathBuf],
1534    quiet: bool,
1535) {
1536    if files.is_empty() {
1537        return;
1538    }
1539
1540    let resolved_files: Vec<PathBuf> = files
1541        .iter()
1542        .map(|path| {
1543            if crate::path_util::is_absolute_path_any_platform(path) {
1544                path.clone()
1545            } else {
1546                root.join(path)
1547            }
1548        })
1549        .collect();
1550
1551    if !quiet {
1552        for (original, resolved) in files.iter().zip(&resolved_files) {
1553            if !resolved.exists() {
1554                eprintln!(
1555                    "Warning: --file '{}' (resolved to '{}') was not found in the project",
1556                    original.display(),
1557                    resolved.display()
1558                );
1559            }
1560        }
1561    }
1562
1563    let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
1564    fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
1565}
1566
1567fn prepare_findings(
1568    findings: Vec<SecurityFinding>,
1569    root: &Path,
1570    include_surface: bool,
1571) -> (
1572    Vec<SecurityFinding>,
1573    Option<Vec<SecurityAttackSurfaceEntry>>,
1574) {
1575    let mut findings: Vec<SecurityFinding> = findings
1576        .into_iter()
1577        .map(|f| {
1578            let mut f = relativize_finding(f, root);
1579            f.finding_id = security_finding_id(&f);
1580            f
1581        })
1582        .collect();
1583    let attack_surface = include_surface.then(|| {
1584        findings
1585            .iter()
1586            .filter_map(|finding| finding.attack_surface.clone())
1587            .collect()
1588    });
1589    for finding in &mut findings {
1590        finding.attack_surface = None;
1591    }
1592    (findings, attack_surface)
1593}
1594
1595/// Rewrite a finding's anchor + every trace hop path to be project-root-relative
1596/// (forward-slash normalization happens at serialize time via `serde_path`).
1597fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
1598    finding.path = relativize(&finding.path, root);
1599    for hop in &mut finding.trace {
1600        hop.path = relativize(&hop.path, root);
1601    }
1602    if let Some(reachability) = &mut finding.reachability {
1603        for hop in &mut reachability.untrusted_source_trace {
1604            hop.path = relativize(&hop.path, root);
1605        }
1606    }
1607    finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
1608    if let Some(flow) = &mut finding.taint_flow {
1609        flow.source.path = relativize(&flow.source.path, root);
1610        flow.sink.path = relativize(&flow.sink.path, root);
1611    }
1612    if let Some(surface) = &mut finding.attack_surface {
1613        surface.source.path = relativize(&surface.source.path, root);
1614        surface.sink.path = relativize(&surface.sink.path, root);
1615        for hop in &mut surface.path {
1616            hop.path = relativize(&hop.path, root);
1617        }
1618        for control in &mut surface.defensive_boundary.controls {
1619            control.path = relativize(&control.path, root);
1620        }
1621    }
1622    finding
1623}
1624
1625fn relativize(path: &Path, root: &Path) -> PathBuf {
1626    path.strip_prefix(root)
1627        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
1628}
1629
1630/// JSON: the `SecurityOutput` envelope, pretty-printed.
1631#[must_use]
1632pub fn render_json(output: &SecurityOutput) -> String {
1633    let Ok(value) = crate::output_envelope::serialize_root_output(
1634        crate::output_envelope::FallowOutput::Security(output.clone()),
1635    ) else {
1636        return "{\"error\":\"failed to serialize security output\"}".to_owned();
1637    };
1638    serde_json::to_string_pretty(&value)
1639        .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
1640}
1641
1642/// JSON summary: compact aggregate payload without per-finding arrays.
1643#[must_use]
1644pub fn render_json_summary(output: &SecurityOutput) -> String {
1645    let summary = SecuritySummaryOutput {
1646        schema_version: output.schema_version,
1647        version: output.version.clone(),
1648        elapsed_ms: output.elapsed_ms,
1649        config: output.config.clone(),
1650        meta: output.meta.clone(),
1651        gate: output.gate,
1652        summary: security_summary(output),
1653    };
1654    let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
1655        crate::output_envelope::FallowOutput::SecuritySummary(summary),
1656    ) else {
1657        return "{\"error\":\"failed to serialize security summary output\"}".to_owned();
1658    };
1659    serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1660        "{\"error\":\"failed to serialize security summary output\"}".to_owned()
1661    })
1662}
1663
1664fn security_summary(output: &SecurityOutput) -> SecuritySummary {
1665    let mut by_severity = SecuritySeverityCounts::default();
1666    let mut by_reachability = SecurityReachabilityCounts::default();
1667    let mut by_runtime_state = SecurityRuntimeStateCounts::default();
1668    let mut by_category = BTreeMap::new();
1669
1670    for finding in &output.security_findings {
1671        match finding.severity {
1672            SecuritySeverity::High => by_severity.high += 1,
1673            SecuritySeverity::Medium => by_severity.medium += 1,
1674            SecuritySeverity::Low => by_severity.low += 1,
1675        }
1676        let category = finding
1677            .category
1678            .clone()
1679            .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
1680        *by_category.entry(category).or_insert(0) += 1;
1681
1682        if finding.source_backed {
1683            by_reachability.source_backed += 1;
1684        }
1685        if let Some(reachability) = &finding.reachability {
1686            if reachability.reachable_from_entry {
1687                by_reachability.entry_reachable += 1;
1688            }
1689            if reachability.reachable_from_untrusted_source {
1690                by_reachability.untrusted_source_reachable += 1;
1691            }
1692            if reachability.crosses_boundary {
1693                by_reachability.crosses_boundary += 1;
1694            }
1695            match reachability.taint_confidence {
1696                Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
1697                Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
1698                None => {}
1699            }
1700        }
1701
1702        match finding.runtime.as_ref().map(|runtime| runtime.state) {
1703            Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
1704            Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
1705            Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
1706            Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
1707            Some(SecurityRuntimeState::CoverageUnavailable) => {
1708                by_runtime_state.coverage_unavailable += 1;
1709            }
1710            Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
1711            None => by_runtime_state.not_collected += 1,
1712        }
1713    }
1714
1715    SecuritySummary {
1716        security_findings: output.security_findings.len(),
1717        by_severity,
1718        by_category,
1719        by_reachability,
1720        by_runtime_state,
1721        unresolved_edge_files: output.unresolved_edge_files,
1722        unresolved_callee_sites: output.unresolved_callee_sites,
1723        attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
1724    }
1725}
1726
1727fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
1728    if let Some(parent) = path.parent()
1729        && !parent.as_os_str().is_empty()
1730    {
1731        std::fs::create_dir_all(parent).map_err(|err| {
1732            format!(
1733                "Failed to create directory for SARIF file {}: {err}",
1734                path.display()
1735            )
1736        })?;
1737    }
1738    std::fs::write(path, render_sarif(output))
1739        .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
1740}
1741
1742/// One-line gate verdict header. Leads with the ACTION ("REVIEW REQUIRED") and
1743/// immediately qualifies with the candidate framing, so a human never reads the
1744/// gate as "fallow confirmed a vulnerability". The wire `verdict` token stays
1745/// `fail`; only this human prose says "REVIEW REQUIRED".
1746fn gate_human_header(gate: &SecurityGate) -> String {
1747    use crate::report::plural;
1748    let checked = match gate.mode {
1749        SecurityGateMode::New => "in changed lines",
1750        SecurityGateMode::NewlyReachable => "newly reachable from entry points",
1751    };
1752    match gate.verdict {
1753        SecurityGateVerdict::Fail => format!(
1754            "Gate: REVIEW REQUIRED, {} new security item{} {checked}. fallow has not confirmed a vulnerability.",
1755            gate.new_count,
1756            plural(gate.new_count),
1757        ),
1758        SecurityGateVerdict::Pass => {
1759            format!("Gate: PASS, no new security items {checked}.")
1760        }
1761    }
1762}
1763
1764fn unresolved_callee_human_hint(output: &SecurityOutput) -> Option<String> {
1765    let diagnostics = output.unresolved_callee_diagnostics.as_ref()?;
1766    let top_reason = diagnostics.by_reason.first()?;
1767    let top_file = diagnostics.top_files.first()?;
1768    Some(format!(
1769        "Most unresolved callees: {} in {}.",
1770        unresolved_callee_reason_label(top_reason.reason),
1771        top_file.path
1772    ))
1773}
1774
1775fn unresolved_callee_reason_label(
1776    reason: fallow_types::extract::SkippedSecurityCalleeReason,
1777) -> &'static str {
1778    match reason {
1779        fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => "computed-member",
1780        fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => "dynamic-dispatch",
1781        fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
1782            "unsupported-assignment-object"
1783        }
1784    }
1785}
1786
1787#[must_use]
1788fn render_human_summary(output: &SecurityOutput) -> String {
1789    use crate::report::plural;
1790    use std::fmt::Write as _;
1791
1792    let mut out = String::new();
1793    if let Some(gate) = &output.gate {
1794        out.push_str(&gate_human_header(gate));
1795        out.push('\n');
1796    }
1797    let count = output.security_findings.len();
1798    if count == 0 {
1799        out.push_str("Security review: no items to check in the scanned code.\n");
1800    } else {
1801        let _ = writeln!(
1802            out,
1803            "Security review: {count} item{} to check. These are unverified security candidates, not confirmed vulnerabilities.",
1804            plural(count),
1805        );
1806        out.push_str(
1807            "Next: check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
1808        );
1809    }
1810    if output.unresolved_edge_files > 0 {
1811        let n = output.unresolved_edge_files;
1812        let verb = if n == 1 { "uses" } else { "use" };
1813        let _ = writeln!(
1814            out,
1815            "Blind spot: {n} client file{} {verb} dynamic imports that fallow could not follow.",
1816            plural(n)
1817        );
1818    }
1819    if output.unresolved_callee_sites > 0 {
1820        let n = output.unresolved_callee_sites;
1821        let verb = if n == 1 { "uses" } else { "use" };
1822        let _ = writeln!(
1823            out,
1824            "Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve.",
1825            plural(n)
1826        );
1827        if let Some(hint) = unresolved_callee_human_hint(output) {
1828            let _ = writeln!(out, "{hint}");
1829        }
1830    }
1831    out
1832}
1833
1834/// Human output. Frames findings as candidates and states the next human action
1835/// per finding; surfaces the unresolved-edge blind spot as a counted line.
1836#[must_use]
1837#[expect(
1838    clippy::format_push_string,
1839    reason = "small report renderer; readability over avoiding the extra allocation"
1840)]
1841pub fn render_human(output: &SecurityOutput) -> String {
1842    use crate::report::plural;
1843
1844    let mut out = String::new();
1845    push_human_gate(&mut out, output);
1846    let count = output.security_findings.len();
1847    out.push_str(&format!("Security review: {count} item{}", plural(count)));
1848    if count == 0 {
1849        out.push_str(" to check in the scanned code.\n");
1850    } else {
1851        out.push_str(" to check.\n");
1852        out.push_str(
1853            "These are unverified security candidates, not confirmed vulnerabilities. Check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
1854        );
1855    }
1856    out.push('\n');
1857
1858    if output.security_findings.is_empty() {
1859        out.push_str("No security details to show.\n");
1860    } else {
1861        push_human_findings(&mut out, &output.security_findings);
1862    }
1863
1864    push_human_blind_spots(&mut out, output);
1865
1866    out.push_str(&format!(
1867        "\nResult: {count} security item{} to check.",
1868        plural(count),
1869    ));
1870    if count > 0 {
1871        out.push_str(" Review the listed evidence and trace before changing code.");
1872    }
1873    out.push('\n');
1874    out
1875}
1876
1877fn push_human_gate(out: &mut String, output: &SecurityOutput) {
1878    if let Some(gate) = &output.gate {
1879        out.push_str(&gate_human_header(gate));
1880        out.push_str("\n\n");
1881    }
1882}
1883
1884fn push_human_findings(out: &mut String, findings: &[SecurityFinding]) {
1885    for finding in findings {
1886        push_human_finding(out, finding);
1887    }
1888}
1889
1890fn push_human_finding(out: &mut String, finding: &SecurityFinding) {
1891    use std::fmt::Write as _;
1892
1893    push_human_finding_header(out, finding);
1894    let _ = writeln!(out, "    evidence: {}", finding.evidence);
1895    if let Some(hint) = dead_code_hint(finding) {
1896        let _ = writeln!(out, "    dead-code: {hint}");
1897    }
1898    if let Some(runtime) = finding.runtime.as_ref() {
1899        let _ = writeln!(out, "    runtime: {}", runtime_hint_text(runtime));
1900    }
1901    push_human_reachability(out, finding);
1902    push_human_import_trace(out, finding);
1903    push_human_next_step(out, finding);
1904    out.push('\n');
1905}
1906
1907fn push_human_finding_header(out: &mut String, finding: &SecurityFinding) {
1908    use colored::Colorize;
1909    use std::fmt::Write as _;
1910
1911    let kind = security_finding_label(finding);
1912    let (glyph, label) = human_severity_marker(finding.severity);
1913    let _ = writeln!(
1914        out,
1915        "{} {label} {kind}  {}:{}",
1916        glyph,
1917        finding.path.to_string_lossy().replace('\\', "/").bold(),
1918        finding.line,
1919    );
1920}
1921
1922fn push_human_reachability(out: &mut String, finding: &SecurityFinding) {
1923    use std::fmt::Write as _;
1924
1925    let Some(reach) = finding.reachability.as_ref() else {
1926        return;
1927    };
1928    let entry = if reach.reachable_from_entry {
1929        "reachable from a runtime entry point"
1930    } else {
1931        "not reached from any runtime entry point"
1932    };
1933    let boundary = if reach.crosses_boundary {
1934        "; crosses an architecture boundary"
1935    } else {
1936        ""
1937    };
1938    let _ = writeln!(
1939        out,
1940        "    code path: {entry} (blast radius {}){boundary}",
1941        reach.blast_radius,
1942    );
1943    if reach.reachable_from_untrusted_source {
1944        push_human_untrusted_trace(out, finding);
1945    }
1946}
1947
1948fn push_human_untrusted_trace(out: &mut String, finding: &SecurityFinding) {
1949    use std::fmt::Write as _;
1950
1951    let Some(reach) = finding.reachability.as_ref() else {
1952        return;
1953    };
1954    let hops = reach.untrusted_source_hop_count.unwrap_or(0);
1955    let _ = writeln!(
1956        out,
1957        "    input path: this module is reachable from a module that receives \
1958         untrusted input via {hops} import hop{}",
1959        crate::report::plural(hops as usize),
1960    );
1961    if !reach.untrusted_source_trace.is_empty() {
1962        out.push_str("    input import trace:\n");
1963        for hop in &reach.untrusted_source_trace {
1964            let _ = writeln!(
1965                out,
1966                "      {}:{} ({})",
1967                hop.path.to_string_lossy().replace('\\', "/"),
1968                hop.line,
1969                hop_role_label(hop.role),
1970            );
1971        }
1972    }
1973}
1974
1975fn push_human_import_trace(out: &mut String, finding: &SecurityFinding) {
1976    use std::fmt::Write as _;
1977
1978    if finding.trace.is_empty() {
1979        return;
1980    }
1981    out.push_str("    import trace:\n");
1982    for hop in &finding.trace {
1983        let _ = writeln!(
1984            out,
1985            "      {}:{} ({})",
1986            hop.path.to_string_lossy().replace('\\', "/"),
1987            hop.line,
1988            hop_role_label(hop.role),
1989        );
1990    }
1991}
1992
1993fn push_human_next_step(out: &mut String, finding: &SecurityFinding) {
1994    if is_server_only_leak(finding) {
1995        out.push_str(
1996            "    Next: check whether this server-only code is meant to run on the client. \
1997             If it is pulled in only through next/dynamic(..., { ssr: false }), type-only, \
1998             or removed at build time, mark it as a false positive.\n",
1999        );
2000    } else if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
2001        out.push_str(
2002            "    Next: check whether this import can ship a secret to the browser. If \
2003             it is type-only, server-only, or removed at build time, mark it as a false \
2004             positive.\n",
2005        );
2006    } else if finding.dead_code.is_some() {
2007        out.push_str(
2008            "    Next: first verify the dead-code finding. If the code is safe to \
2009             remove, delete it. Otherwise check and harden the risky call.\n",
2010        );
2011    } else {
2012        out.push_str(
2013            "    Next: check whether unsafe input, secrets, or settings can reach this \
2014             risky call without a safe guard. If not, mark it as a false positive.\n",
2015        );
2016    }
2017}
2018
2019fn push_human_blind_spots(out: &mut String, output: &SecurityOutput) {
2020    use crate::report::plural;
2021    use colored::Colorize;
2022    use std::fmt::Write as _;
2023
2024    if output.unresolved_edge_files > 0 {
2025        let n = output.unresolved_edge_files;
2026        let verb = if n == 1 { "uses" } else { "use" };
2027        let _ = writeln!(
2028            out,
2029            "{} Blind spot: {n} client file{} {verb} dynamic imports that fallow could not \
2030             follow. Code behind those imports may be missing from this report.",
2031            "[I]".blue().bold(),
2032            plural(n),
2033        );
2034    }
2035
2036    if output.unresolved_callee_sites > 0 {
2037        let n = output.unresolved_callee_sites;
2038        let verb = if n == 1 { "uses" } else { "use" };
2039        let _ = writeln!(
2040            out,
2041            "{} Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve, \
2042             such as dynamic dispatch, computed members, or aliased bindings.",
2043            "[I]".blue().bold(),
2044            plural(n),
2045        );
2046        if let Some(hint) = unresolved_callee_human_hint(output) {
2047            let _ = writeln!(out, "    {hint}");
2048        }
2049    }
2050}
2051
2052/// Render the human-facing label for a finding. The secret-leak
2053/// `ClientServerLeak` keeps its bespoke kebab kind; the server-only variant uses
2054/// its own kebab label so a reader tells the two apart; `TaintedSink` uses the
2055/// catalogue title plus the CWE number carried on the finding.
2056fn security_finding_label(finding: &SecurityFinding) -> String {
2057    match finding.kind {
2058        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2059            "server-only-import".to_string()
2060        }
2061        SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
2062        SecurityFindingKind::TaintedSink => {
2063            let title = finding
2064                .category
2065                .as_deref()
2066                .and_then(fallow_core::analyze::security_catalogue_title)
2067                .or(finding.category.as_deref())
2068                .unwrap_or("tainted-sink");
2069            match finding.cwe {
2070                Some(cwe) => format!("{title} (CWE-{cwe})"),
2071                None => title.to_string(),
2072            }
2073        }
2074    }
2075}
2076
2077fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
2078    use colored::Colorize;
2079    match severity {
2080        SecuritySeverity::High => ("[H]".red().bold(), "high"),
2081        SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
2082        SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
2083    }
2084}
2085
2086fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
2087    let context = finding.dead_code.as_ref()?;
2088    match context.kind {
2089        SecurityDeadCodeKind::UnusedFile => Some(
2090            "also reported as unused-file; delete this file instead of hardening the sink"
2091                .to_string(),
2092        ),
2093        SecurityDeadCodeKind::UnusedExport => Some(format!(
2094            "also reported as unused-export{}; remove the export instead of hardening the sink",
2095            context
2096                .export_name
2097                .as_ref()
2098                .map_or(String::new(), |name| format!(" `{name}`"))
2099        )),
2100    }
2101}
2102
2103const fn hop_role_label(role: TraceHopRole) -> &'static str {
2104    match role {
2105        TraceHopRole::ClientBoundary => "client boundary",
2106        TraceHopRole::UntrustedSource => "untrusted source",
2107        TraceHopRole::ModuleSource => "source module",
2108        TraceHopRole::Intermediate => "intermediate",
2109        TraceHopRole::SecretSource => "secret source",
2110        TraceHopRole::Sink => "sink site",
2111    }
2112}
2113
2114fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
2115    finding
2116        .reachability
2117        .as_ref()
2118        .filter(|reach| reach.reachable_from_untrusted_source)
2119        .map(|_| {
2120            "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
2121        })
2122}
2123
2124fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
2125    use std::fmt::Write as _;
2126
2127    let mut text = format!(
2128        "{} in {}:{}",
2129        runtime_state_label(runtime.state),
2130        runtime.function,
2131        runtime.line
2132    );
2133    if let Some(invocations) = runtime.invocations {
2134        let _ = write!(
2135            text,
2136            " ({} invocation{})",
2137            invocations,
2138            crate::report::plural(invocations as usize)
2139        );
2140    }
2141    if let Some(evidence) = runtime.evidence.as_deref() {
2142        text.push_str("; ");
2143        text.push_str(evidence);
2144    }
2145    text
2146}
2147
2148const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
2149    match state {
2150        SecurityRuntimeState::RuntimeHot => "runtime-hot",
2151        SecurityRuntimeState::RuntimeCold => "runtime-cold",
2152        SecurityRuntimeState::NeverExecuted => "never-executed",
2153        SecurityRuntimeState::LowTraffic => "low-traffic",
2154        SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
2155        SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
2156    }
2157}
2158
2159/// The `category` string distinguishing the server-only-import sink from the
2160/// secret-leak sink (both `ClientServerLeak` kind). Matches the constant in
2161/// `crates/core/src/analyze/security/mod.rs`.
2162const SERVER_ONLY_CATEGORY: &str = "server-only-import";
2163
2164/// Whether a `ClientServerLeak` finding is the server-only-import variant rather
2165/// than the original secret-leak variant. Keys on `category` because both share
2166/// the `ClientServerLeak` kind and the same rule.
2167fn is_server_only_leak(finding: &SecurityFinding) -> bool {
2168    matches!(finding.kind, SecurityFindingKind::ClientServerLeak)
2169        && finding.category.as_deref() == Some(SERVER_ONLY_CATEGORY)
2170}
2171
2172/// The SARIF ruleId for a finding. The secret-leak `client-server-leak` keeps its
2173/// bespoke id; the server-only variant gets `security/server-only-import` so the
2174/// GitHub Security tab tells "reaches server-only code" apart from "reads a
2175/// secret"; each `TaintedSink` category gets `security/<category>` so candidates
2176/// group and label per CWE class.
2177fn sarif_rule_id(finding: &SecurityFinding) -> String {
2178    match finding.kind {
2179        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2180            "security/server-only-import".to_owned()
2181        }
2182        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
2183        SecurityFindingKind::TaintedSink => {
2184            format!(
2185                "security/{}",
2186                finding.category.as_deref().unwrap_or("tainted-sink")
2187            )
2188        }
2189    }
2190}
2191
2192fn security_help_text(title: &str) -> String {
2193    format!(
2194        "Verify this unverified {title} candidate before acting. Review the source, sink, \
2195         SARIF code flow, and any runtime or dead-code context. fallow does not prove \
2196         exploitability, attacker control, or missing sanitization."
2197    )
2198}
2199
2200fn security_help_markdown(title: &str) -> String {
2201    format!(
2202        "Verify this unverified **{title}** candidate before acting.\n\n\
2203         1. Review the source and sink in the SARIF code flow.\n\
2204         2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
2205         3. Use runtime and dead-code context only as triage signals."
2206    )
2207}
2208
2209fn cwe_taxon_id(cwe: u32) -> String {
2210    format!("CWE-{cwe}")
2211}
2212
2213fn cwe_taxon(cwe: u32) -> serde_json::Value {
2214    let id = cwe_taxon_id(cwe);
2215    serde_json::json!({
2216        "id": id,
2217        "name": id,
2218        "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
2219        "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
2220        "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
2221    })
2222}
2223
2224fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
2225    serde_json::json!({
2226        "target": {
2227            "id": cwe_taxon_id(cwe),
2228            "index": taxon_index,
2229            "toolComponent": {
2230                "name": "CWE",
2231                "index": 0
2232            }
2233        },
2234        "kinds": ["superset"]
2235    })
2236}
2237
2238fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
2239    let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
2240    cwes.sort_unstable();
2241    cwes.dedup();
2242    cwes
2243}
2244
2245fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
2246    cwes.iter().position(|existing| *existing == cwe)
2247}
2248
2249fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
2250    if cwes.is_empty() {
2251        return None;
2252    }
2253    let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
2254    Some(serde_json::json!({
2255        "name": "CWE",
2256        "fullName": "Common Weakness Enumeration",
2257        "organization": "MITRE",
2258        "informationUri": "https://cwe.mitre.org/",
2259        "taxa": taxa
2260    }))
2261}
2262
2263/// Build the SARIF rule definition for a ruleId, deriving per-category metadata
2264/// (catalogue title + CWE tag and relationship) for `TaintedSink` findings so
2265/// CWE grouping survives in SARIF-aware consumers.
2266fn sarif_rule_def(
2267    rule_id: &str,
2268    finding: &SecurityFinding,
2269    cwe_taxon_index: Option<usize>,
2270) -> serde_json::Value {
2271    match finding.kind {
2272        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2273            let title = "Client imports server-only code";
2274            serde_json::json!({
2275                "id": rule_id,
2276                "name": title,
2277                "shortDescription": { "text": "Client imports server-only code candidate (unverified)" },
2278                "fullDescription": { "text":
2279                    "Unverified candidate, requires verification: a \"use client\" file \
2280                     transitively imports a server-only module (one carrying a \"use server\" \
2281                     directive or importing server-only code such as server-only, next/headers, \
2282                     next/server, or node:fs / node:child_process). fallow does not prove this \
2283                     code runs on the client; a module pulled in only through \
2284                     next/dynamic(..., { ssr: false }) is a false positive." },
2285                "help": {
2286                    "text": security_help_text(title),
2287                    "markdown": security_help_markdown(title)
2288                },
2289                "helpUri": "https://github.com/fallow-rs/fallow",
2290                "defaultConfiguration": { "level": "note" }
2291            })
2292        }
2293        SecurityFindingKind::ClientServerLeak => {
2294            let title = "Client-server secret leak";
2295            serde_json::json!({
2296                "id": rule_id,
2297                "name": title,
2298                "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
2299                "fullDescription": { "text":
2300                    "Unverified candidate, requires verification: a \"use client\" file \
2301                     transitively imports a module that reads a non-public process.env \
2302                     secret. fallow does not prove the secret reaches client-bundled code." },
2303                "help": {
2304                    "text": security_help_text(title),
2305                    "markdown": security_help_markdown(title)
2306                },
2307                "helpUri": "https://github.com/fallow-rs/fallow",
2308                "defaultConfiguration": { "level": "note" }
2309            })
2310        }
2311        SecurityFindingKind::TaintedSink => {
2312            let title = finding
2313                .category
2314                .as_deref()
2315                .and_then(fallow_core::analyze::security_catalogue_title)
2316                .or(finding.category.as_deref())
2317                .unwrap_or("tainted-sink");
2318            let mut rule = serde_json::json!({
2319                "id": rule_id,
2320                "name": title,
2321                "shortDescription": { "text": format!("{title} candidate (unverified)") },
2322                "fullDescription": { "text": format!(
2323                    "Unverified candidate, requires verification: {title}. fallow flags a \
2324                     syntactic sink reached by a non-literal argument; it does not prove the \
2325                     value is attacker-controlled or reaches the sink unsanitized."
2326                ) },
2327                "help": {
2328                    "text": security_help_text(title),
2329                    "markdown": security_help_markdown(title)
2330                },
2331                "helpUri": "https://github.com/fallow-rs/fallow",
2332                "defaultConfiguration": { "level": "note" }
2333            });
2334            if let Some(cwe) = finding.cwe {
2335                rule["properties"] = serde_json::json!({
2336                    "tags": [format!("external/cwe/cwe-{cwe}")]
2337                });
2338                if let Some(taxon_index) = cwe_taxon_index {
2339                    rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
2340                }
2341            }
2342            rule
2343        }
2344    }
2345}
2346
2347fn hop_role_token(role: TraceHopRole) -> &'static str {
2348    match role {
2349        TraceHopRole::ClientBoundary => "client-boundary",
2350        TraceHopRole::UntrustedSource => "untrusted-source",
2351        TraceHopRole::ModuleSource => "module-source",
2352        TraceHopRole::Intermediate => "intermediate",
2353        TraceHopRole::SecretSource => "secret-source",
2354        TraceHopRole::Sink => "sink",
2355    }
2356}
2357
2358fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
2359    let role = hop_role_token(hop.role);
2360    serde_json::json!({
2361        "location": sarif_location(&hop.path, hop.line, hop.col),
2362        "kinds": [role],
2363        "properties": { "fallowTraceRole": role }
2364    })
2365}
2366
2367fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
2368    if let Some(reachability) = finding.reachability.as_ref()
2369        && !reachability.untrusted_source_trace.is_empty()
2370    {
2371        return &reachability.untrusted_source_trace;
2372    }
2373    &finding.trace
2374}
2375
2376fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
2377    let hops = primary_code_flow_hops(finding);
2378    if hops.is_empty() {
2379        return None;
2380    }
2381    let locations = hops
2382        .iter()
2383        .map(sarif_thread_flow_location)
2384        .collect::<Vec<_>>();
2385    Some(serde_json::json!([
2386        {
2387            "threadFlows": [
2388                { "locations": locations }
2389            ]
2390        }
2391    ]))
2392}
2393
2394fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
2395    let location = sarif_location(&hop.path, hop.line, hop.col);
2396    if !related.iter().any(|existing| existing == &location) {
2397        related.push(location);
2398    }
2399}
2400
2401fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
2402    let mut related = Vec::new();
2403    for hop in &finding.trace {
2404        push_related_location(&mut related, hop);
2405    }
2406    if let Some(reachability) = finding.reachability.as_ref() {
2407        for hop in &reachability.untrusted_source_trace {
2408            push_related_location(&mut related, hop);
2409        }
2410    }
2411    related
2412}
2413
2414const fn sarif_level(severity: SecuritySeverity) -> &'static str {
2415    match severity {
2416        SecuritySeverity::High | SecuritySeverity::Medium => "warning",
2417        SecuritySeverity::Low => "note",
2418    }
2419}
2420
2421/// SARIF output. Maps the candidate's verification-priority tier to SARIF
2422/// `level` while keeping the message text candidate-framed. Each finding's ruleId is
2423/// per-category (`security/<category>` for tainted-sink, `security/client-server-leak`
2424/// for the graph rule); the `rules` array carries one definition per distinct
2425/// ruleId present, with the CWE tag for tainted-sink categories. Detector trace
2426/// hops and source-reachability hops become `relatedLocations` of the result.
2427#[must_use]
2428fn render_sarif(output: &SecurityOutput) -> String {
2429    let cwes = collect_cwes(&output.security_findings);
2430    let results: Vec<serde_json::Value> = output
2431        .security_findings
2432        .iter()
2433        .map(|finding| {
2434            let rule_id = sarif_rule_id(finding);
2435            let mut message = dead_code_hint(finding).map_or_else(
2436                || finding.evidence.clone(),
2437                |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
2438            );
2439            if let Some(hint) = source_reachability_hint(finding) {
2440                message.push(' ');
2441                message.push_str(hint);
2442            }
2443            if let Some(runtime) = finding.runtime.as_ref() {
2444                message.push_str(" Runtime context: ");
2445                message.push_str(&runtime_hint_text(runtime));
2446                message.push('.');
2447            }
2448            let related = sarif_related_locations(finding);
2449            // Stable dedup key for GHAS: rule + anchor path + line. Without
2450            // partialFingerprints, every run re-opens previously triaged alerts.
2451            // Same helper as the JSON `finding_id` field so the two never drift
2452            // (issue #900).
2453            let mut result = serde_json::json!({
2454                "ruleId": rule_id,
2455                "level": sarif_level(finding.severity),
2456                "message": { "text": message },
2457                "locations": [sarif_location(&finding.path, finding.line, finding.col)],
2458                "relatedLocations": related,
2459                "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
2460            });
2461            if let Some(code_flows) = sarif_code_flows(finding) {
2462                result["codeFlows"] = code_flows;
2463            }
2464            result
2465        })
2466        .collect();
2467
2468    // One rule definition per distinct ruleId present in the findings.
2469    let mut seen: Vec<String> = Vec::new();
2470    let mut rules: Vec<serde_json::Value> = Vec::new();
2471    for finding in &output.security_findings {
2472        let rule_id = sarif_rule_id(finding);
2473        if seen.iter().any(|s| s == &rule_id) {
2474            continue;
2475        }
2476        seen.push(rule_id.clone());
2477        let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(&cwes, cwe));
2478        rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
2479    }
2480
2481    let mut run = serde_json::json!({
2482        "tool": { "driver": {
2483            "name": "fallow",
2484            "version": env!("CARGO_PKG_VERSION"),
2485            "informationUri": "https://github.com/fallow-rs/fallow",
2486            "rules": rules,
2487        }},
2488        "results": results,
2489    });
2490    if let Some(taxonomy) = cwe_taxonomy(&cwes) {
2491        run["taxonomies"] = serde_json::json!([taxonomy]);
2492        run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
2493            { "name": "CWE", "index": 0 }
2494        ]);
2495    }
2496    // Gate verdict rides as a RUN-level property, never on result severity.
2497    // Result levels come from candidate review-priority severity and deliberately
2498    // avoid `error`, so GHAS does not frame candidates as confirmed problems.
2499    if let Some(gate) = &output.gate
2500        && let Ok(gate_value) = serde_json::to_value(gate)
2501    {
2502        run["properties"] = serde_json::json!({ "fallowGate": gate_value });
2503    }
2504
2505    let sarif = serde_json::json!({
2506        "version": "2.1.0",
2507        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2508        "runs": [run],
2509    });
2510    serde_json::to_string_pretty(&sarif)
2511        .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
2512}
2513
2514/// Small FNV-1a hex digest for SARIF `partialFingerprints` dedup stability.
2515fn fnv_hex(input: &str) -> String {
2516    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2517    for byte in input.bytes() {
2518        hash ^= u64::from(byte);
2519        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2520    }
2521    format!("{hash:016x}")
2522}
2523
2524/// Stable per-finding correlation id: FNV-1a hex of `rule:path:line`. The single
2525/// source of truth for BOTH the JSON `finding_id` field and the SARIF
2526/// `partialFingerprints` value, so an agent can join the two and they never
2527/// drift. Computed on the project-relative path, so it must run after the
2528/// finding is relativized (issue #900).
2529fn security_finding_id(finding: &SecurityFinding) -> String {
2530    let fp = format!(
2531        "{}:{}:{}",
2532        sarif_rule_id(finding),
2533        finding.path.to_string_lossy().replace('\\', "/"),
2534        finding.line,
2535    );
2536    fnv_hex(&fp)
2537}
2538
2539fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
2540    serde_json::json!({
2541        "physicalLocation": {
2542            "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
2543            "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
2544        }
2545    })
2546}
2547
2548#[cfg(test)]
2549mod tests {
2550    use super::*;
2551    use fallow_core::results::{
2552        SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
2553        SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
2554        TraceHop, TraceHopRole,
2555    };
2556    use fallow_types::results::{
2557        SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
2558    };
2559
2560    /// Build a finding anchored under `root` with a three-hop client -> secret trace.
2561    fn sample_finding(root: &Path) -> SecurityFinding {
2562        SecurityFinding {
2563            kind: SecurityFindingKind::ClientServerLeak,
2564            path: root.join("src/app.tsx"),
2565            line: 12,
2566            col: 3,
2567            evidence: "reaches process.env.SECRET_KEY".to_owned(),
2568            source_backed: false,
2569            source_read: None,
2570            severity: SecuritySeverity::High,
2571            trace: vec![
2572                TraceHop {
2573                    path: root.join("src/app.tsx"),
2574                    line: 12,
2575                    col: 3,
2576                    role: TraceHopRole::ClientBoundary,
2577                },
2578                TraceHop {
2579                    path: root.join("src/lib/util.ts"),
2580                    line: 4,
2581                    col: 0,
2582                    role: TraceHopRole::Intermediate,
2583                },
2584                TraceHop {
2585                    path: root.join("src/lib/secret.ts"),
2586                    line: 8,
2587                    col: 2,
2588                    role: TraceHopRole::SecretSource,
2589                },
2590            ],
2591            actions: vec![],
2592            category: None,
2593            cwe: None,
2594            dead_code: None,
2595            reachability: None,
2596            finding_id: String::new(),
2597            candidate: SecurityCandidate {
2598                source_kind: None,
2599                sink: SecurityCandidateSink {
2600                    path: root.join("src/app.tsx"),
2601                    line: 12,
2602                    col: 3,
2603                    category: None,
2604                    cwe: None,
2605                    callee: None,
2606                    url_shape: None,
2607                },
2608                boundary: SecurityCandidateBoundary {
2609                    client_server: true,
2610                    cross_module: false,
2611                    architecture_zone: None,
2612                },
2613                network: None,
2614            },
2615            taint_flow: None,
2616            runtime: None,
2617            attack_surface: None,
2618        }
2619    }
2620
2621    fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
2622        SecurityOutput {
2623            schema_version: SecuritySchemaVersion::V7,
2624            version: ToolVersion("test".to_string()),
2625            elapsed_ms: ElapsedMs(0),
2626            config: test_output_config(),
2627            meta: None,
2628            gate: None,
2629            security_findings: findings,
2630            attack_surface: None,
2631            unresolved_edge_files,
2632            unresolved_callee_sites: 0,
2633            unresolved_callee_diagnostics: None,
2634        }
2635    }
2636
2637    fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
2638        SecurityOutput {
2639            schema_version: SecuritySchemaVersion::V7,
2640            version: ToolVersion("test".to_string()),
2641            elapsed_ms: ElapsedMs(0),
2642            config: test_output_config(),
2643            meta: None,
2644            gate: Some(SecurityGate {
2645                mode: SecurityGateMode::New,
2646                verdict,
2647                new_count,
2648            }),
2649            security_findings: vec![],
2650            attack_surface: None,
2651            unresolved_edge_files: 0,
2652            unresolved_callee_sites: 0,
2653            unresolved_callee_diagnostics: None,
2654        }
2655    }
2656
2657    fn sample_unresolved_callee_diagnostics(root: &Path) -> SecurityUnresolvedCalleeDiagnostics {
2658        unresolved_callee_diagnostics(
2659            &[
2660                SecurityUnresolvedCalleeDiagnostic {
2661                    path: root.join("src/z.ts"),
2662                    line: 9,
2663                    col: 4,
2664                    reason: fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember,
2665                    expression_kind:
2666                        fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
2667                },
2668                SecurityUnresolvedCalleeDiagnostic {
2669                    path: root.join("src/a.ts"),
2670                    line: 3,
2671                    col: 2,
2672                    reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2673                    expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2674                },
2675                SecurityUnresolvedCalleeDiagnostic {
2676                    path: root.join("src/a.ts"),
2677                    line: 4,
2678                    col: 2,
2679                    reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2680                    expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2681                },
2682            ],
2683            root,
2684        )
2685        .expect("diagnostics summarized")
2686    }
2687
2688    fn test_output_config() -> SecurityOutputConfig {
2689        SecurityOutputConfig {
2690            rules: SecurityOutputRulesConfig {
2691                security_client_server_leak: SecurityRuleSeverityConfig {
2692                    configured: Severity::Off,
2693                    effective: Severity::Warn,
2694                },
2695                security_sink: SecurityRuleSeverityConfig {
2696                    configured: Severity::Off,
2697                    effective: Severity::Warn,
2698                },
2699            },
2700            categories_include: None,
2701            categories_exclude: None,
2702        }
2703    }
2704
2705    fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
2706        let mut finding = sample_finding(root);
2707        finding.kind = SecurityFindingKind::TaintedSink;
2708        finding.category = Some("dangerous-html".to_owned());
2709        finding.cwe = Some(79);
2710        finding.runtime = state.map(|state| SecurityRuntimeContext {
2711            state,
2712            function: "render".to_owned(),
2713            line: 10,
2714            invocations: Some(123),
2715            stable_id: Some("fallow:fn:test".to_owned()),
2716            evidence: Some("production runtime evidence".to_owned()),
2717        });
2718        finding
2719    }
2720
2721    #[test]
2722    fn runtime_rank_promotes_hot_and_demotes_never_executed() {
2723        let root = Path::new("/proj/root");
2724        let mut findings = [
2725            tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
2726            tainted_with_runtime(root, None),
2727            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2728            tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
2729        ];
2730
2731        findings.sort_by_key(runtime_rank);
2732
2733        assert_eq!(
2734            findings
2735                .iter()
2736                .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
2737                .collect::<Vec<_>>(),
2738            vec![
2739                Some(SecurityRuntimeState::RuntimeHot),
2740                None,
2741                Some(SecurityRuntimeState::CoverageUnavailable),
2742                Some(SecurityRuntimeState::NeverExecuted),
2743            ]
2744        );
2745    }
2746
2747    #[test]
2748    fn severity_sort_orders_tiers_then_location() {
2749        let root = Path::new("/proj/root");
2750        let mut high = sample_finding(root);
2751        high.path = root.join("z.ts");
2752        high.severity = SecuritySeverity::High;
2753        let mut low = sample_finding(root);
2754        low.path = root.join("a.ts");
2755        low.severity = SecuritySeverity::Low;
2756        let mut medium_a = sample_finding(root);
2757        medium_a.path = root.join("a.ts");
2758        medium_a.severity = SecuritySeverity::Medium;
2759        medium_a.reachability = Some(fallow_types::results::SecurityReachability {
2760            reachable_from_entry: false,
2761            reachable_from_untrusted_source: true,
2762            taint_confidence: Some(TaintConfidence::ModuleLevel),
2763            untrusted_source_hop_count: Some(1),
2764            untrusted_source_trace: vec![],
2765            blast_radius: 10,
2766            crosses_boundary: false,
2767        });
2768        let mut medium_b = sample_finding(root);
2769        medium_b.path = root.join("b.ts");
2770        medium_b.severity = SecuritySeverity::Medium;
2771        medium_b.source_backed = true;
2772        medium_b.reachability = Some(fallow_types::results::SecurityReachability {
2773            reachable_from_entry: false,
2774            reachable_from_untrusted_source: true,
2775            taint_confidence: Some(TaintConfidence::ArgLevel),
2776            untrusted_source_hop_count: Some(0),
2777            untrusted_source_trace: vec![],
2778            blast_radius: 1,
2779            crosses_boundary: false,
2780        });
2781        let mut findings = vec![low, medium_b, high, medium_a];
2782
2783        sort_by_security_severity(&mut findings);
2784
2785        assert_eq!(
2786            findings
2787                .iter()
2788                .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
2789                .collect::<Vec<_>>(),
2790            vec![
2791                (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
2792                (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
2793                (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
2794                (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
2795            ]
2796        );
2797    }
2798
2799    #[test]
2800    fn human_render_includes_runtime_context_line() {
2801        let root = Path::new("/proj/root");
2802        let finding = relativize_finding(
2803            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2804            root,
2805        );
2806        let out = render_human(&output_with(vec![finding], 0));
2807
2808        assert!(
2809            out.contains("runtime: runtime-hot in render:10"),
2810            "got: {out}"
2811        );
2812        assert!(out.contains("production runtime evidence"), "got: {out}");
2813    }
2814
2815    #[test]
2816    fn sarif_render_includes_runtime_context_in_message() {
2817        let root = Path::new("/proj/root");
2818        let finding = relativize_finding(
2819            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2820            root,
2821        );
2822        let rendered = render_sarif(&output_with(vec![finding], 0));
2823        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2824        let message = sarif["runs"][0]["results"][0]["message"]["text"]
2825            .as_str()
2826            .expect("message text");
2827
2828        assert!(message.contains("Runtime context"), "got: {message}");
2829        assert!(
2830            message.contains("runtime-hot in render:10"),
2831            "got: {message}"
2832        );
2833    }
2834
2835    #[test]
2836    fn gate_human_header_fail_says_review_required_not_fail() {
2837        let gate = SecurityGate {
2838            mode: SecurityGateMode::New,
2839            verdict: SecurityGateVerdict::Fail,
2840            new_count: 2,
2841        };
2842        let header = gate_human_header(&gate);
2843        assert!(header.contains("REVIEW REQUIRED"));
2844        assert!(header.contains("2 new security items"));
2845        assert!(header.contains("not confirmed a vulnerability"));
2846        assert!(!header.to_uppercase().contains("GATE: FAIL"));
2847    }
2848
2849    #[test]
2850    fn gate_human_header_fail_singular_for_one_candidate() {
2851        // The gate makes new_count == 1 the common case (one PR adds one sink).
2852        let gate = SecurityGate {
2853            mode: SecurityGateMode::New,
2854            verdict: SecurityGateVerdict::Fail,
2855            new_count: 1,
2856        };
2857        let header = gate_human_header(&gate);
2858        assert!(header.contains("1 new security item in changed lines"));
2859        assert!(!header.contains("1 new security candidates"));
2860    }
2861
2862    #[test]
2863    fn gate_human_header_pass() {
2864        let gate = SecurityGate {
2865            mode: SecurityGateMode::New,
2866            verdict: SecurityGateVerdict::Pass,
2867            new_count: 0,
2868        };
2869        assert!(gate_human_header(&gate).contains("Gate: PASS"));
2870    }
2871
2872    #[test]
2873    fn gate_json_block_is_snake_case_and_present_on_pass() {
2874        let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
2875        assert!(json.contains("\"gate\""));
2876        assert!(json.contains("\"mode\": \"new\""));
2877        assert!(json.contains("\"verdict\": \"pass\""));
2878        assert!(json.contains("\"new_count\": 0"));
2879    }
2880
2881    #[test]
2882    fn reachability_key_includes_path_kind_and_category() {
2883        let root = Path::new("/proj/root");
2884        let mut leak = sample_finding(root);
2885        leak.reachability = Some(SecurityReachability {
2886            reachable_from_entry: true,
2887            reachable_from_untrusted_source: false,
2888            taint_confidence: None,
2889            untrusted_source_hop_count: None,
2890            untrusted_source_trace: vec![],
2891            blast_radius: 0,
2892            crosses_boundary: false,
2893        });
2894        let mut sink = leak.clone();
2895        sink.kind = SecurityFindingKind::TaintedSink;
2896        sink.category = Some("dangerous-html".to_owned());
2897
2898        assert_eq!(
2899            security_reachability_key(&leak, root).as_deref(),
2900            Some("security-reach:src/app.tsx:client-server-leak:none")
2901        );
2902        assert_eq!(
2903            security_reachability_key(&sink, root).as_deref(),
2904            Some("security-reach:src/app.tsx:tainted-sink:dangerous-html")
2905        );
2906    }
2907
2908    #[test]
2909    fn reachability_key_ignores_unreachable_findings() {
2910        let root = Path::new("/proj/root");
2911        let finding = sample_finding(root);
2912
2913        assert!(security_reachability_key(&finding, root).is_none());
2914    }
2915
2916    #[test]
2917    fn gate_absent_from_json_when_no_gate_ran() {
2918        let json = render_json(&output_with(vec![], 0));
2919        assert!(!json.contains("\"gate\""));
2920    }
2921
2922    #[test]
2923    fn gate_sarif_is_a_run_property_not_result_severity() {
2924        let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
2925        assert!(sarif.contains("fallowGate"));
2926        // The gate verdict is a run property and creates no result severity.
2927        assert!(!sarif.contains("\"level\": \"error\""));
2928        assert!(!sarif.contains("\"level\": \"warning\""));
2929    }
2930
2931    fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
2932        finding.reachability = Some(SecurityReachability {
2933            reachable_from_entry: true,
2934            reachable_from_untrusted_source: true,
2935            // Cross-module reachability is module-level (issue #1093).
2936            taint_confidence: Some(fallow_core::results::TaintConfidence::ModuleLevel),
2937            untrusted_source_hop_count: Some(1),
2938            untrusted_source_trace: vec![
2939                TraceHop {
2940                    path: root.join("src/routes/api.ts"),
2941                    line: 3,
2942                    col: 0,
2943                    role: TraceHopRole::ModuleSource,
2944                },
2945                TraceHop {
2946                    path: root.join("src/lib/sink.ts"),
2947                    line: 9,
2948                    col: 2,
2949                    role: TraceHopRole::Sink,
2950                },
2951            ],
2952            blast_radius: 2,
2953            crosses_boundary: false,
2954        });
2955    }
2956
2957    fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
2958        finding.taint_flow = Some(SecurityTaintFlow {
2959            source: TaintEndpoint {
2960                path: root.join("src/routes/api.ts"),
2961                line: 3,
2962                col: 0,
2963            },
2964            sink: TaintEndpoint {
2965                path: root.join("src/lib/sink.ts"),
2966                line: 9,
2967                col: 2,
2968            },
2969            path: TaintPath {
2970                intra_module: false,
2971                cross_module_hops: 1,
2972            },
2973        });
2974    }
2975
2976    #[test]
2977    fn relativize_strips_root_prefix() {
2978        let root = Path::new("/proj/root");
2979        let abs = root.join("src/app.tsx");
2980        let rel = relativize(&abs, root);
2981        assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
2982    }
2983
2984    #[test]
2985    fn relativize_keeps_path_when_outside_root() {
2986        let root = Path::new("/proj/root");
2987        let outside = Path::new("/elsewhere/file.ts");
2988        // Not under root: the original path is returned unchanged.
2989        assert_eq!(relativize(outside, root), outside.to_path_buf());
2990    }
2991
2992    #[test]
2993    fn relativize_finding_relativizes_anchor_and_every_hop() {
2994        let root = Path::new("/proj/root");
2995        let finding = relativize_finding(sample_finding(root), root);
2996        assert_eq!(
2997            finding.path.to_string_lossy().replace('\\', "/"),
2998            "src/app.tsx"
2999        );
3000        let hop_paths: Vec<String> = finding
3001            .trace
3002            .iter()
3003            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
3004            .collect();
3005        assert_eq!(
3006            hop_paths,
3007            vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
3008        );
3009    }
3010
3011    #[test]
3012    fn relativize_finding_relativizes_untrusted_source_trace() {
3013        let root = Path::new("/proj/root");
3014        let mut finding = sample_finding(root);
3015        add_untrusted_source_reachability(&mut finding, root);
3016        let finding = relativize_finding(finding, root);
3017        let reach = finding.reachability.as_ref().expect("reachability");
3018        let hop_paths: Vec<String> = reach
3019            .untrusted_source_trace
3020            .iter()
3021            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
3022            .collect();
3023        assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
3024    }
3025
3026    #[test]
3027    fn fnv_hex_is_deterministic_and_16_hex_digits() {
3028        let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
3029        let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
3030        assert_eq!(a, b, "same input must hash identically");
3031        assert_eq!(a.len(), 16);
3032        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
3033        // Distinct input yields a distinct digest (anchor line differs).
3034        assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
3035    }
3036
3037    #[test]
3038    fn hop_role_labels_cover_every_role() {
3039        assert_eq!(
3040            hop_role_label(TraceHopRole::ClientBoundary),
3041            "client boundary"
3042        );
3043        assert_eq!(
3044            hop_role_label(TraceHopRole::UntrustedSource),
3045            "untrusted source"
3046        );
3047        assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
3048        assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
3049        assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
3050        assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
3051    }
3052
3053    #[test]
3054    fn sarif_location_clamps_line_and_offsets_column() {
3055        // A zero line clamps to 1; the 0-based column becomes 1-based.
3056        let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
3057        let region = &loc["physicalLocation"]["region"];
3058        assert_eq!(region["startLine"], 1);
3059        assert_eq!(region["startColumn"], 1);
3060        // Backslash separators normalize to forward slashes in the URI.
3061        assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
3062    }
3063
3064    #[test]
3065    fn human_summary_reports_zero_without_edge_line() {
3066        let out = render_human_summary(&output_with(vec![], 0));
3067        assert!(
3068            out.contains("Security review: no items to check in the scanned code."),
3069            "got: {out}"
3070        );
3071        assert!(!out.contains("Blind spot"));
3072    }
3073
3074    #[test]
3075    fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
3076        let root = Path::new("/proj/root");
3077        let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
3078        assert!(
3079            out.contains("Security review: 1 item to check."),
3080            "got: {out}"
3081        );
3082        assert!(out.contains("not confirmed vulnerabilities"));
3083        assert!(out.contains("unsafe input, secrets, or settings"));
3084        assert!(out.contains("Blind spot: 2 client files use dynamic imports"));
3085    }
3086
3087    #[test]
3088    fn human_render_empty_states_no_candidates() {
3089        colored::control::set_override(false);
3090        let out = render_human(&output_with(vec![], 0));
3091        assert!(out.contains("Security review: 0 items to check"));
3092        assert!(out.contains("No security details to show."));
3093        assert!(out.contains("Result: 0 security items to check."));
3094    }
3095
3096    #[test]
3097    fn human_render_shows_finding_trace_and_next_action() {
3098        colored::control::set_override(false);
3099        let root = Path::new("/proj/root");
3100        let finding = relativize_finding(sample_finding(root), root);
3101        let out = render_human(&output_with(vec![finding], 0));
3102        assert!(out.contains("[H] high client-server-leak"));
3103        assert!(out.contains("client-server-leak"));
3104        assert!(out.contains("src/app.tsx:12"));
3105        assert!(out.contains("evidence: reaches process.env.SECRET_KEY"));
3106        assert!(out.contains("import trace:"));
3107        assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
3108        assert!(out.contains("src/app.tsx:12 (client boundary)"));
3109        assert!(out.contains("Next: check whether this import can ship a secret to the browser"));
3110        assert!(out.contains("Result: 1 security item to check."));
3111    }
3112
3113    #[test]
3114    fn human_render_shows_dead_code_hint_and_delete_next_step() {
3115        colored::control::set_override(false);
3116        let root = Path::new("/proj/root");
3117        let mut finding = relativize_finding(sample_finding(root), root);
3118        finding.kind = SecurityFindingKind::TaintedSink;
3119        finding.dead_code = Some(SecurityDeadCodeContext {
3120            kind: SecurityDeadCodeKind::UnusedFile,
3121            export_name: None,
3122            line: None,
3123            guidance: "delete instead of harden".to_string(),
3124        });
3125        let out = render_human(&output_with(vec![finding], 0));
3126        assert!(
3127            out.contains("dead-code: also reported as unused-file"),
3128            "got: {out}"
3129        );
3130        assert!(
3131            out.contains("If the code is safe to remove, delete it"),
3132            "got: {out}"
3133        );
3134    }
3135
3136    #[test]
3137    fn human_render_shows_untrusted_source_path_as_module_context() {
3138        colored::control::set_override(false);
3139        let root = Path::new("/proj/root");
3140        let mut finding = sample_finding(root);
3141        finding.kind = SecurityFindingKind::TaintedSink;
3142        finding.category = Some("command-injection".to_string());
3143        add_untrusted_source_reachability(&mut finding, root);
3144        let finding = relativize_finding(finding, root);
3145
3146        let out = render_human(&output_with(vec![finding], 0));
3147
3148        assert!(
3149            out.contains("reachable from a module that receives untrusted input via 1 import hop"),
3150            "got: {out}"
3151        );
3152        assert!(out.contains("input import trace:"), "got: {out}");
3153        assert!(
3154            out.contains("src/routes/api.ts:3 (source module)"),
3155            "got: {out}"
3156        );
3157    }
3158
3159    #[test]
3160    fn human_render_surfaces_unresolved_edge_blind_spot() {
3161        colored::control::set_override(false);
3162        let out = render_human(&output_with(vec![], 3));
3163        assert!(out.contains("Blind spot: 3 client files use dynamic imports"));
3164        assert!(out.contains("Code behind those imports may be missing from this report."));
3165    }
3166
3167    #[test]
3168    fn human_render_blind_spots_use_singular_verbs() {
3169        colored::control::set_override(false);
3170        let mut output = output_with(vec![], 1);
3171        output.unresolved_callee_sites = 1;
3172
3173        let out = render_human(&output);
3174
3175        assert!(out.contains("Blind spot: 1 client file uses dynamic imports"));
3176        assert!(out.contains("Blind spot: 1 call site uses code patterns"));
3177    }
3178
3179    #[test]
3180    fn human_render_mentions_top_unresolved_callee_reason_and_file() {
3181        colored::control::set_override(false);
3182        let root = Path::new("/proj/root");
3183        let mut output = output_with(vec![], 0);
3184        output.unresolved_callee_sites = 3;
3185        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3186
3187        let out = render_human(&output);
3188
3189        assert!(
3190            out.contains("Most unresolved callees: dynamic-dispatch in src/a.ts."),
3191            "got: {out}"
3192        );
3193    }
3194
3195    #[test]
3196    fn json_render_carries_schema_version_and_findings() {
3197        let root = Path::new("/proj/root");
3198        let finding = relativize_finding(sample_finding(root), root);
3199        let rendered = render_json(&output_with(vec![finding], 1));
3200        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3201        assert_eq!(value["schema_version"], "7");
3202        assert_eq!(value["version"], "test");
3203        assert_eq!(value["elapsed_ms"], 0);
3204        assert_eq!(
3205            value["config"]["rules"]["security_client_server_leak"]["configured"],
3206            "off"
3207        );
3208        assert_eq!(
3209            value["config"]["rules"]["security_client_server_leak"]["effective"],
3210            "warn"
3211        );
3212        assert!(value["config"]["categories_include"].is_null());
3213        assert!(value["config"]["categories_exclude"].is_null());
3214        assert_eq!(value["unresolved_edge_files"], 1);
3215        let findings = value["security_findings"].as_array().expect("array");
3216        assert_eq!(findings.len(), 1);
3217        assert_eq!(findings[0]["kind"], "client-server-leak");
3218        assert_eq!(findings[0]["path"], "src/app.tsx");
3219        assert_eq!(findings[0]["severity"], "high");
3220    }
3221
3222    #[test]
3223    fn json_render_carries_bounded_unresolved_callee_diagnostics() {
3224        let root = Path::new("/proj/root");
3225        let mut output = output_with(vec![], 0);
3226        output.unresolved_callee_sites = 3;
3227        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3228
3229        let rendered = render_json(&output);
3230        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3231        let diagnostics = &value["unresolved_callee_diagnostics"];
3232
3233        assert_eq!(diagnostics["sample_limit"], 25);
3234        assert_eq!(diagnostics["top_files_limit"], 10);
3235        assert_eq!(diagnostics["sampled"][0]["path"], "src/a.ts");
3236        assert_eq!(diagnostics["sampled"][0]["reason"], "dynamic-dispatch");
3237        assert_eq!(diagnostics["sampled"][0]["expression_kind"], "other");
3238        assert_eq!(diagnostics["top_files"][0]["path"], "src/a.ts");
3239        assert_eq!(diagnostics["top_files"][0]["count"], 2);
3240        assert_eq!(diagnostics["by_reason"][0]["reason"], "dynamic-dispatch");
3241        assert_eq!(diagnostics["by_reason"][0]["count"], 2);
3242    }
3243
3244    #[test]
3245    fn json_summary_omits_finding_arrays_and_counts_security_findings() {
3246        let root = Path::new("/proj/root");
3247        let mut leak = relativize_finding(sample_finding(root), root);
3248        leak.severity = SecuritySeverity::High;
3249
3250        let mut sink = relativize_finding(sample_finding(root), root);
3251        sink.kind = SecurityFindingKind::TaintedSink;
3252        sink.category = Some("dangerous-html".to_string());
3253        sink.severity = SecuritySeverity::Medium;
3254        sink.source_backed = true;
3255        sink.reachability = Some(SecurityReachability {
3256            reachable_from_entry: true,
3257            reachable_from_untrusted_source: true,
3258            taint_confidence: Some(TaintConfidence::ArgLevel),
3259            untrusted_source_hop_count: Some(0),
3260            untrusted_source_trace: vec![],
3261            blast_radius: 3,
3262            crosses_boundary: true,
3263        });
3264        sink.runtime = Some(SecurityRuntimeContext {
3265            state: SecurityRuntimeState::RuntimeHot,
3266            function: "render".to_owned(),
3267            line: 10,
3268            invocations: Some(120),
3269            stable_id: Some("src/app.tsx::render:10".to_owned()),
3270            evidence: Some("production hot path observed".to_owned()),
3271        });
3272
3273        let mut output = output_with(vec![leak, sink], 2);
3274        output.elapsed_ms = ElapsedMs(17);
3275        output.unresolved_callee_sites = 3;
3276
3277        let rendered = render_json_summary(&output);
3278        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3279
3280        assert_eq!(value["kind"], "security");
3281        assert_eq!(value["schema_version"], "7");
3282        assert_eq!(value["version"], "test");
3283        assert_eq!(value["elapsed_ms"], 17);
3284        assert!(value.get("config").is_some());
3285        assert!(value.get("security_findings").is_none());
3286        assert!(value.get("attack_surface").is_none());
3287        assert!(value.get("_meta").is_none());
3288        assert_eq!(value["summary"]["security_findings"], 2);
3289        assert_eq!(value["summary"]["by_severity"]["high"], 1);
3290        assert_eq!(value["summary"]["by_severity"]["medium"], 1);
3291        assert_eq!(value["summary"]["by_severity"]["low"], 0);
3292        assert_eq!(value["summary"]["by_category"]["client-server-leak"], 1);
3293        assert_eq!(value["summary"]["by_category"]["dangerous-html"], 1);
3294        assert_eq!(value["summary"]["by_reachability"]["entry_reachable"], 1);
3295        assert_eq!(
3296            value["summary"]["by_reachability"]["untrusted_source_reachable"],
3297            1
3298        );
3299        assert_eq!(value["summary"]["by_reachability"]["arg_level"], 1);
3300        assert_eq!(value["summary"]["by_reachability"]["module_level"], 0);
3301        assert_eq!(value["summary"]["by_reachability"]["crosses_boundary"], 1);
3302        assert_eq!(value["summary"]["by_reachability"]["source_backed"], 1);
3303        assert_eq!(value["summary"]["by_runtime_state"]["runtime_hot"], 1);
3304        assert_eq!(value["summary"]["by_runtime_state"]["runtime_cold"], 0);
3305        assert_eq!(value["summary"]["by_runtime_state"]["never_executed"], 0);
3306        assert_eq!(value["summary"]["by_runtime_state"]["low_traffic"], 0);
3307        assert_eq!(
3308            value["summary"]["by_runtime_state"]["coverage_unavailable"],
3309            0
3310        );
3311        assert_eq!(value["summary"]["by_runtime_state"]["runtime_unknown"], 0);
3312        assert_eq!(value["summary"]["by_runtime_state"]["not_collected"], 1);
3313        assert_eq!(value["summary"]["unresolved_edge_files"], 2);
3314        assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
3315        assert_eq!(value["summary"]["attack_surface_entries"], 0);
3316    }
3317
3318    #[test]
3319    fn json_summary_carries_security_meta_when_explain_requested() {
3320        let root = Path::new("/proj/root");
3321        let mut output = output_with(vec![relativize_finding(sample_finding(root), root)], 0);
3322        output.meta = Some(crate::explain::security_meta());
3323
3324        let rendered = render_json_summary(&output);
3325        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3326
3327        assert!(value.get("security_findings").is_none());
3328        assert!(value["_meta"]["field_definitions"]["security_findings[]"].is_string());
3329        assert!(value["_meta"]["field_definitions"]["summary.by_reachability"].is_string());
3330        assert!(value["_meta"]["field_definitions"]["summary.by_runtime_state"].is_string());
3331        assert!(value["_meta"]["field_definitions"]["unresolved_callee_sites"].is_string());
3332    }
3333
3334    #[test]
3335    fn json_summary_preserves_gate_block() {
3336        let output = output_with_gate(SecurityGateVerdict::Fail, 1);
3337        let rendered = render_json_summary(&output);
3338        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3339
3340        assert_eq!(value["kind"], "security");
3341        assert_eq!(value["gate"]["mode"], "new");
3342        assert_eq!(value["gate"]["verdict"], "fail");
3343        assert_eq!(value["gate"]["new_count"], 1);
3344        assert_eq!(value["summary"]["security_findings"], 0);
3345    }
3346
3347    #[test]
3348    fn json_render_carries_security_meta_when_explain_requested() {
3349        let mut output = output_with(vec![], 0);
3350        output.meta = Some(crate::explain::security_meta());
3351
3352        let rendered = render_json(&output);
3353        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3354
3355        assert_eq!(
3356            value["_meta"]["field_definitions"]["security_findings[]"],
3357            "Unverified security candidates for downstream human or agent verification."
3358        );
3359        assert!(value["_meta"]["rules"]["security/tainted-sink"].is_object());
3360    }
3361
3362    #[test]
3363    fn json_render_carries_candidate_record_and_omits_impact() {
3364        // Issue #900: every finding carries a 3-slot candidate record; there is
3365        // NO `impact` key on the wire (agent-owned, documented in the schema). A
3366        // client-server-leak has no source kind and no taint flow.
3367        let root = Path::new("/proj/root");
3368        let finding = relativize_finding(sample_finding(root), root);
3369        let rendered = render_json(&output_with(vec![finding], 0));
3370        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3371        let finding = &value["security_findings"][0];
3372
3373        let candidate = &finding["candidate"];
3374        assert!(candidate.is_object(), "candidate record present");
3375        assert!(candidate["sink"].is_object(), "sink slot present");
3376        assert_eq!(candidate["boundary"]["client_server"], true);
3377        assert!(
3378            candidate.get("impact").is_none(),
3379            "impact must NOT be a wire field"
3380        );
3381        assert!(
3382            candidate.get("source_kind").is_none(),
3383            "client-server-leak has no source kind"
3384        );
3385        assert!(
3386            finding.get("taint_flow").is_none(),
3387            "no untrusted-source flow on a client-server-leak"
3388        );
3389        assert!(
3390            finding.get("finding_id").is_some(),
3391            "finding_id is on the wire"
3392        );
3393    }
3394
3395    #[test]
3396    fn finding_id_is_stable_and_matches_sarif_fingerprint() {
3397        // Issue #900: one helper computes both the JSON finding_id and the SARIF
3398        // partialFingerprint, so an agent can join the two and they never drift.
3399        let root = Path::new("/proj/root");
3400        let finding = relativize_finding(sample_finding(root), root);
3401        let id = security_finding_id(&finding);
3402        assert!(!id.is_empty());
3403        assert_eq!(
3404            id,
3405            security_finding_id(&finding),
3406            "deterministic across calls"
3407        );
3408
3409        let sarif: serde_json::Value =
3410            serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
3411                .expect("valid SARIF");
3412        assert_eq!(
3413            sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
3414            serde_json::Value::String(id)
3415        );
3416    }
3417
3418    #[test]
3419    fn json_render_carries_dead_code_context() {
3420        let root = Path::new("/proj/root");
3421        let mut finding = relativize_finding(sample_finding(root), root);
3422        finding.kind = SecurityFindingKind::TaintedSink;
3423        finding.dead_code = Some(SecurityDeadCodeContext {
3424            kind: SecurityDeadCodeKind::UnusedExport,
3425            export_name: Some("handler".to_string()),
3426            line: Some(12),
3427            guidance: "remove export instead of harden".to_string(),
3428        });
3429        let rendered = render_json(&output_with(vec![finding], 0));
3430        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3431        let context = &value["security_findings"][0]["dead_code"];
3432        assert_eq!(context["kind"], "unused-export");
3433        assert_eq!(context["export_name"], "handler");
3434        assert_eq!(context["line"], 12);
3435    }
3436
3437    #[test]
3438    fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
3439        let root = Path::new("/proj/root");
3440        let finding = relativize_finding(sample_finding(root), root);
3441        let rendered = render_sarif(&output_with(vec![finding], 0));
3442        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3443        assert_eq!(sarif["version"], "2.1.0");
3444        let run = &sarif["runs"][0];
3445        assert_eq!(run["tool"]["driver"]["name"], "fallow");
3446        let result = &run["results"][0];
3447        // Candidate framing: a high-priority finding is warning, never error.
3448        assert_eq!(result["level"], "warning");
3449        assert_eq!(result["ruleId"], "security/client-server-leak");
3450        assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
3451        // Trace hops surface as relatedLocations and codeFlows.
3452        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
3453        let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3454            .as_array()
3455            .expect("thread flow locations");
3456        assert_eq!(flow_locations.len(), 3);
3457        assert_eq!(
3458            flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3459            "src/app.tsx"
3460        );
3461        assert_eq!(
3462            flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3463            "src/lib/secret.ts"
3464        );
3465        assert_eq!(
3466            flow_locations[2]["kinds"][0],
3467            serde_json::json!("secret-source")
3468        );
3469        // Stable dedup fingerprint present for GHAS.
3470        assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
3471
3472        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3473        assert_eq!(rules[0]["name"], "Client-server secret leak");
3474        assert!(rules[0]["help"]["text"].is_string());
3475        assert!(rules[0].get("relationships").is_none());
3476        assert!(run.get("taxonomies").is_none());
3477    }
3478
3479    #[test]
3480    fn sarif_render_keeps_low_severity_as_note() {
3481        let root = Path::new("/proj/root");
3482        let mut finding = sample_finding(root);
3483        finding.severity = SecuritySeverity::Low;
3484        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3485        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3486
3487        assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
3488    }
3489
3490    #[test]
3491    fn sarif_render_includes_dead_code_hint_in_message() {
3492        let root = Path::new("/proj/root");
3493        let mut finding = relativize_finding(sample_finding(root), root);
3494        finding.kind = SecurityFindingKind::TaintedSink;
3495        finding.dead_code = Some(SecurityDeadCodeContext {
3496            kind: SecurityDeadCodeKind::UnusedFile,
3497            export_name: None,
3498            line: None,
3499            guidance: "delete instead of harden".to_string(),
3500        });
3501        let rendered = render_sarif(&output_with(vec![finding], 0));
3502        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3503        let message = sarif["runs"][0]["results"][0]["message"]["text"]
3504            .as_str()
3505            .expect("message text");
3506        assert!(message.contains("Dead-code cross-link"), "got: {message}");
3507        assert!(
3508            message.contains("delete this file instead of hardening"),
3509            "got: {message}"
3510        );
3511    }
3512
3513    #[test]
3514    fn sarif_render_includes_untrusted_source_context_and_related_locations() {
3515        let root = Path::new("/proj/root");
3516        let mut finding = sample_finding(root);
3517        finding.kind = SecurityFindingKind::TaintedSink;
3518        finding.category = Some("command-injection".to_string());
3519        add_untrusted_source_reachability(&mut finding, root);
3520        add_taint_flow(&mut finding, root);
3521        finding.trace.push(TraceHop {
3522            path: root.join("src/lib/sink.ts"),
3523            line: 9,
3524            col: 2,
3525            role: TraceHopRole::Sink,
3526        });
3527        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3528        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3529        let result = &sarif["runs"][0]["results"][0];
3530        let message = result["message"]["text"].as_str().expect("message text");
3531        assert!(message.contains("Module-level context"), "got: {message}");
3532        assert!(
3533            message.contains("does not prove value flow"),
3534            "got: {message}"
3535        );
3536        // The sink appears in both trace families, but SARIF relatedLocations requires unique items.
3537        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
3538        let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3539            .as_array()
3540            .expect("thread flow locations");
3541        assert_eq!(flow_locations.len(), 2);
3542        assert_eq!(
3543            flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3544            "src/routes/api.ts"
3545        );
3546        assert_eq!(
3547            flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3548            "src/lib/sink.ts"
3549        );
3550    }
3551
3552    #[test]
3553    fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
3554        let root = Path::new("/proj/root");
3555        let mut finding = sample_finding(root);
3556        finding.kind = SecurityFindingKind::TaintedSink;
3557        finding.category = Some("dangerous-html".to_owned());
3558        finding.cwe = Some(79);
3559        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3560        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3561        let run = &sarif["runs"][0];
3562        // The finding is grouped under its own per-category rule, not collapsed
3563        // into client-server-leak, and stays candidate-framed.
3564        let result = &run["results"][0];
3565        assert_eq!(result["level"], "warning");
3566        assert_eq!(result["ruleId"], "security/dangerous-html");
3567        // Exactly one rule definition, carrying compatible tags plus SARIF-native CWE taxonomy.
3568        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3569        assert_eq!(rules.len(), 1);
3570        assert_eq!(rules[0]["id"], "security/dangerous-html");
3571        assert_eq!(rules[0]["name"], "Dangerous HTML sink");
3572        assert!(
3573            rules[0]["help"]["text"]
3574                .as_str()
3575                .expect("help text")
3576                .contains("Verify this unverified")
3577        );
3578        assert!(
3579            rules[0]["help"]["markdown"]
3580                .as_str()
3581                .expect("help markdown")
3582                .contains("**Dangerous HTML sink**")
3583        );
3584        let tags = rules[0]["properties"]["tags"].as_array().unwrap();
3585        assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
3586        let relationship = &rules[0]["relationships"][0];
3587        assert_eq!(relationship["target"]["id"], "CWE-79");
3588        assert_eq!(relationship["target"]["index"], 0);
3589        assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
3590        assert_eq!(relationship["kinds"][0], "superset");
3591
3592        let taxonomy = &run["taxonomies"][0];
3593        assert_eq!(taxonomy["name"], "CWE");
3594        assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
3595        assert_eq!(
3596            run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
3597            "CWE"
3598        );
3599    }
3600
3601    #[test]
3602    fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
3603        let root = Path::new("/proj/root");
3604        let finding = relativize_finding(sample_finding(root), root);
3605        let output = output_with(vec![finding], 0);
3606        let dir = tempfile::tempdir().expect("tempdir");
3607        let path = dir.path().join("nested/out.sarif");
3608        write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
3609        let written = std::fs::read_to_string(&path).expect("file exists");
3610        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3611        assert_eq!(sarif["version"], "2.1.0");
3612    }
3613
3614    /// No explicit `--config`; static so the `&'a Option<PathBuf>` field borrows it.
3615    const NO_CONFIG: Option<PathBuf> = None;
3616
3617    fn leak_fixture_root() -> PathBuf {
3618        Path::new(env!("CARGO_MANIFEST_DIR"))
3619            .join("../../tests/fixtures/security-client-server-leak")
3620    }
3621
3622    fn source_reachability_fixture_root() -> PathBuf {
3623        Path::new(env!("CARGO_MANIFEST_DIR"))
3624            .join("../../tests/fixtures/security-source-reachability-885")
3625    }
3626
3627    fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
3628        SecurityOptions {
3629            root,
3630            config_path: &NO_CONFIG,
3631            output,
3632            no_cache: true,
3633            threads: 1,
3634            quiet: true,
3635            fail_on_issues,
3636            sarif_file: None,
3637            summary: false,
3638            changed_since: None,
3639            use_shared_diff_index: false,
3640            workspace: None,
3641            changed_workspaces: None,
3642            file: &[],
3643            surface: false,
3644            gate: None,
3645            runtime_coverage: None,
3646            min_invocations_hot: 100,
3647            explain: false,
3648        }
3649    }
3650
3651    #[test]
3652    #[expect(
3653        deprecated,
3654        reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
3655    )]
3656    fn source_reachability_fixture_marks_cross_module_sink() {
3657        let root = source_reachability_fixture_root();
3658        let mut config = load_config_for_analysis(
3659            &root,
3660            &NO_CONFIG,
3661            crate::ConfigLoadOptions {
3662                output: OutputFormat::Json,
3663                no_cache: true,
3664                threads: 1,
3665                production_override: None,
3666                quiet: true,
3667            },
3668            ProductionAnalysis::DeadCode,
3669        )
3670        .expect("fixture config loads");
3671        config.rules.security_sink = Severity::Warn;
3672
3673        let results = fallow_core::analyze(&config).expect("fixture analyzes");
3674        let finding = results
3675            .security_findings
3676            .iter()
3677            .find(|finding| finding.path.ends_with("src/runner.ts"))
3678            .expect("runner sink finding");
3679        let reach = finding.reachability.as_ref().expect("reachability");
3680
3681        assert!(reach.reachable_from_untrusted_source);
3682        assert_eq!(reach.untrusted_source_hop_count, Some(1));
3683        // Cross-module reachability is module-level: the structured discriminator
3684        // says so, and the source node is honestly labeled `ModuleSource`, never
3685        // `UntrustedSource` (which is reserved for an arg-level same-module read).
3686        assert_eq!(
3687            reach.taint_confidence,
3688            Some(fallow_core::results::TaintConfidence::ModuleLevel)
3689        );
3690        assert_eq!(
3691            reach
3692                .untrusted_source_trace
3693                .iter()
3694                .map(|hop| hop.role)
3695                .collect::<Vec<_>>(),
3696            vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
3697        );
3698        assert!(
3699            reach.untrusted_source_trace[0]
3700                .path
3701                .ends_with("src/route.ts")
3702        );
3703
3704        // Issue #900: the candidate boundary slot records the cross-module hop,
3705        // and the taint-flow triple re-projects the reachability endpoints + a
3706        // compact path (not a duplicate hop array).
3707        assert!(
3708            finding.candidate.boundary.cross_module,
3709            "a sink reached across a module hop crosses a module boundary"
3710        );
3711        let flow = finding.taint_flow.as_ref().expect("taint_flow present");
3712        assert!(!flow.path.intra_module);
3713        assert_eq!(flow.path.cross_module_hops, 1);
3714        assert!(flow.source.path.ends_with("src/route.ts"));
3715        assert!(flow.sink.path.ends_with("src/runner.ts"));
3716    }
3717
3718    #[test]
3719    fn file_scope_keeps_security_finding_when_anchor_matches() {
3720        let root = Path::new("/proj/root");
3721        let mut results = fallow_core::results::AnalysisResults::default();
3722        results.security_findings.push(sample_finding(root));
3723
3724        filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
3725
3726        assert_eq!(results.security_findings.len(), 1);
3727    }
3728
3729    #[test]
3730    fn file_scope_keeps_security_finding_when_trace_hop_matches() {
3731        let root = Path::new("/proj/root");
3732        let mut results = fallow_core::results::AnalysisResults::default();
3733        results.security_findings.push(sample_finding(root));
3734
3735        filter_to_files(
3736            &mut results,
3737            root,
3738            &[PathBuf::from("src/lib/secret.ts")],
3739            true,
3740        );
3741
3742        assert_eq!(results.security_findings.len(), 1);
3743    }
3744
3745    #[test]
3746    fn file_scope_drops_unrelated_security_finding() {
3747        let root = Path::new("/proj/root");
3748        let mut results = fallow_core::results::AnalysisResults::default();
3749        results.security_findings.push(sample_finding(root));
3750
3751        filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
3752
3753        assert!(results.security_findings.is_empty());
3754    }
3755
3756    #[test]
3757    fn run_is_advisory_and_exits_zero_even_with_candidates() {
3758        // The rule defaults to off; the command forces it to warn, so findings on
3759        // the fixture are surfaced but the exit stays 0 (advisory) by default.
3760        let root = leak_fixture_root();
3761        let code = run(&run_opts(&root, OutputFormat::Json, false));
3762        assert_eq!(code, ExitCode::SUCCESS);
3763    }
3764
3765    #[test]
3766    fn run_with_fail_on_issues_exits_one_when_candidates_found() {
3767        // The fixture has real leak candidates, so --fail-on-issues raises exit 1.
3768        let root = leak_fixture_root();
3769        let code = run(&run_opts(&root, OutputFormat::Human, true));
3770        assert_eq!(code, ExitCode::from(1));
3771    }
3772
3773    #[test]
3774    fn run_rejects_unsupported_output_format() {
3775        // Only human / json / sarif are supported; compact exits 2 before analysis.
3776        let root = leak_fixture_root();
3777        let code = run(&run_opts(&root, OutputFormat::Compact, false));
3778        assert_eq!(code, ExitCode::from(2));
3779    }
3780
3781    #[test]
3782    fn run_summary_mode_dispatches_compact_human_renderer() {
3783        let root = leak_fixture_root();
3784        let opts = SecurityOptions {
3785            summary: true,
3786            ..run_opts(&root, OutputFormat::Human, false)
3787        };
3788        assert_eq!(run(&opts), ExitCode::SUCCESS);
3789    }
3790
3791    #[test]
3792    fn run_sarif_format_dispatches_sarif_renderer() {
3793        let root = leak_fixture_root();
3794        assert_eq!(
3795            run(&run_opts(&root, OutputFormat::Sarif, false)),
3796            ExitCode::SUCCESS
3797        );
3798    }
3799
3800    #[test]
3801    fn run_writes_sarif_sidecar_file_when_requested() {
3802        let root = leak_fixture_root();
3803        let dir = tempfile::tempdir().expect("tempdir");
3804        let sidecar = dir.path().join("security.sarif");
3805        let opts = SecurityOptions {
3806            sarif_file: Some(&sidecar),
3807            ..run_opts(&root, OutputFormat::Human, false)
3808        };
3809        assert_eq!(run(&opts), ExitCode::SUCCESS);
3810        let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
3811        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3812        assert_eq!(sarif["version"], "2.1.0");
3813    }
3814}