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