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