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