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        &base_snapshot_security_options(opts, &base_root, &current_config_path),
1263        &base_config,
1264    )?;
1265    scope_base_snapshot_to_workspaces(opts, &base_root, &mut base_analysis.results)?;
1266    Ok(SecurityKeySnapshot {
1267        reachable: security_reachable_keys(&base_analysis.results.security_findings, &base_root),
1268    })
1269}
1270
1271/// Build the quiet, non-gating `SecurityOptions` used to re-analyze the base
1272/// worktree for the `--gate newly-reachable` snapshot.
1273#[expect(
1274    clippy::ref_option,
1275    reason = "config_path mirrors the SecurityOptions.config_path field which is &Option<PathBuf>"
1276)]
1277fn base_snapshot_security_options<'a>(
1278    opts: &SecurityOptions<'a>,
1279    base_root: &'a Path,
1280    config_path: &'a Option<PathBuf>,
1281) -> SecurityOptions<'a> {
1282    SecurityOptions {
1283        root: base_root,
1284        config_path,
1285        output: opts.output,
1286        no_cache: opts.no_cache,
1287        threads: opts.threads,
1288        quiet: true,
1289        fail_on_issues: false,
1290        sarif_file: None,
1291        summary: false,
1292        changed_since: None,
1293        use_shared_diff_index: false,
1294        workspace: opts.workspace,
1295        changed_workspaces: None,
1296        file: &[],
1297        surface: false,
1298        gate: None,
1299        runtime_coverage: None,
1300        min_invocations_hot: opts.min_invocations_hot,
1301        explain: false,
1302    }
1303}
1304
1305/// Apply the run's `--workspace` scope to the base-snapshot results so the
1306/// reachable-key set matches the head scope it is diffed against.
1307fn scope_base_snapshot_to_workspaces(
1308    opts: &SecurityOptions<'_>,
1309    base_root: &Path,
1310    results: &mut AnalysisResults,
1311) -> Result<(), ExitCode> {
1312    if let Some(ref roots) = crate::check::filtering::resolve_workspace_scope(
1313        base_root,
1314        opts.workspace,
1315        None,
1316        opts.output,
1317    )? {
1318        crate::check::filtering::filter_to_workspaces(results, roots);
1319    }
1320    Ok(())
1321}
1322
1323fn security_reachable_keys(findings: &[SecurityFinding], root: &Path) -> FxHashSet<String> {
1324    findings
1325        .iter()
1326        .filter_map(|finding| security_reachability_key(finding, root))
1327        .collect()
1328}
1329
1330fn security_reachability_key(finding: &SecurityFinding, root: &Path) -> Option<String> {
1331    if !finding
1332        .reachability
1333        .as_ref()
1334        .is_some_and(|reachability| reachability.reachable_from_entry)
1335    {
1336        return None;
1337    }
1338    let category = finding.category.as_deref().unwrap_or("none");
1339    Some(format!(
1340        "security-reach:{}:{}:{}",
1341        relative_key(&finding.path, root),
1342        security_kind_key(finding.kind),
1343        category,
1344    ))
1345}
1346
1347fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
1348    match kind {
1349        SecurityFindingKind::ClientServerLeak => "client-server-leak",
1350        SecurityFindingKind::TaintedSink => "tainted-sink",
1351    }
1352}
1353
1354fn security_base_snapshot_cache_key(
1355    opts: &SecurityOptions<'_>,
1356    config: &fallow_config::ResolvedConfig,
1357    base_sha: &str,
1358) -> Result<SecurityBaseSnapshotCacheKey, ExitCode> {
1359    let payload = serde_json::json!({
1360        "cache_version": SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
1361        "cli_version": env!("CARGO_PKG_VERSION"),
1362        "base_sha": base_sha,
1363        "config_hash": format!("{:016x}", config.cache_config_hash),
1364        "security_client_server_leak": format!("{:?}", config.rules.security_client_server_leak),
1365        "security_sink": format!("{:?}", config.rules.security_sink),
1366        "workspace": opts.workspace,
1367        "changed_workspaces": opts.changed_workspaces,
1368    });
1369    let bytes = serde_json::to_vec(&payload).map_err(|err| {
1370        emit_error(
1371            &format!("failed to build security gate cache key: {err}"),
1372            2,
1373            opts.output,
1374        )
1375    })?;
1376    Ok(SecurityBaseSnapshotCacheKey {
1377        hash: xxh3_64(&bytes),
1378        base_sha: base_sha.to_owned(),
1379    })
1380}
1381
1382fn security_base_snapshot_cache_dir(config: &fallow_config::ResolvedConfig) -> PathBuf {
1383    config.cache_dir.join("cache").join(format!(
1384        "security-base-v{SECURITY_BASE_SNAPSHOT_CACHE_VERSION}"
1385    ))
1386}
1387
1388fn security_base_snapshot_cache_file(
1389    config: &fallow_config::ResolvedConfig,
1390    key: &SecurityBaseSnapshotCacheKey,
1391) -> PathBuf {
1392    security_base_snapshot_cache_dir(config).join(format!("{:016x}.bin", key.hash))
1393}
1394
1395fn ensure_security_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
1396    std::fs::create_dir_all(dir)?;
1397    let gitignore = dir.join(".gitignore");
1398    if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
1399        std::fs::write(gitignore, "*\n")?;
1400    }
1401    Ok(())
1402}
1403
1404fn load_cached_security_base_snapshot(
1405    config: &fallow_config::ResolvedConfig,
1406    key: &SecurityBaseSnapshotCacheKey,
1407) -> Option<SecurityKeySnapshot> {
1408    if config.no_cache {
1409        return None;
1410    }
1411    let path = security_base_snapshot_cache_file(config, key);
1412    let data = std::fs::read(path).ok()?;
1413    if data.len() > MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE {
1414        return None;
1415    }
1416    let cached: CachedSecurityKeySnapshot = bitcode::decode(&data).ok()?;
1417    if cached.version != SECURITY_BASE_SNAPSHOT_CACHE_VERSION
1418        || cached.cli_version != env!("CARGO_PKG_VERSION")
1419        || cached.key_hash != key.hash
1420        || cached.base_sha != key.base_sha
1421    {
1422        return None;
1423    }
1424    Some(SecurityKeySnapshot {
1425        reachable: cached.reachable.into_iter().collect(),
1426    })
1427}
1428
1429fn save_cached_security_base_snapshot(
1430    config: &fallow_config::ResolvedConfig,
1431    key: &SecurityBaseSnapshotCacheKey,
1432    snapshot: &SecurityKeySnapshot,
1433) {
1434    if config.no_cache {
1435        return;
1436    }
1437    let dir = security_base_snapshot_cache_dir(config);
1438    if ensure_security_base_snapshot_cache_dir(&dir).is_err() {
1439        return;
1440    }
1441    let mut reachable = snapshot.reachable.iter().cloned().collect::<Vec<_>>();
1442    reachable.sort_unstable();
1443    let data = bitcode::encode(&CachedSecurityKeySnapshot {
1444        version: SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
1445        cli_version: env!("CARGO_PKG_VERSION").to_owned(),
1446        key_hash: key.hash,
1447        base_sha: key.base_sha.clone(),
1448        reachable,
1449    });
1450    let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
1451        return;
1452    };
1453    if tmp.write_all(&data).is_err() {
1454        return;
1455    }
1456    let _ = tmp.persist(security_base_snapshot_cache_file(config, key));
1457}
1458
1459fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1460    if current_root.is_absolute()
1461        && let Some(git_root) = crate::base_worktree::git_toplevel(current_root)
1462        && let Ok(relative) = current_root.strip_prefix(git_root)
1463    {
1464        return base_worktree_root.join(relative);
1465    }
1466    base_worktree_root.to_path_buf()
1467}
1468
1469fn remap_cache_dir_for_base_worktree(
1470    current_root: &Path,
1471    base_worktree_root: &Path,
1472    cache_dir: &Path,
1473) -> PathBuf {
1474    if cache_dir.is_absolute()
1475        && let Ok(relative) = cache_dir.strip_prefix(current_root)
1476    {
1477        return base_worktree_root.join(relative);
1478    }
1479    cache_dir.to_path_buf()
1480}
1481
1482struct SecurityAnalysisState {
1483    results: AnalysisResults,
1484    modules: Option<Vec<ModuleInfo>>,
1485    files: Option<Vec<DiscoveredFile>>,
1486    analysis_output: Option<fallow_core::AnalysisOutput>,
1487}
1488
1489#[expect(
1490    deprecated,
1491    reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
1492)]
1493fn analyze_security_candidates(
1494    opts: &SecurityOptions<'_>,
1495    config: &fallow_config::ResolvedConfig,
1496) -> Result<SecurityAnalysisState, ExitCode> {
1497    if opts.runtime_coverage.is_none() {
1498        return fallow_core::analyze(config)
1499            .map(|results| SecurityAnalysisState {
1500                results,
1501                modules: None,
1502                files: None,
1503                analysis_output: None,
1504            })
1505            .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
1506    }
1507
1508    fallow_core::analyze_retaining_modules(config, true, true)
1509        .map(|mut output| {
1510            let modules = output.modules.take();
1511            let files = output.files.take();
1512            let results = output.results.clone();
1513            SecurityAnalysisState {
1514                results,
1515                modules,
1516                files,
1517                analysis_output: Some(output),
1518            }
1519        })
1520        .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
1521}
1522
1523fn security_runtime_report(
1524    opts: &SecurityOptions<'_>,
1525    analysis: &mut SecurityAnalysisState,
1526) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1527    let Some(path) = opts.runtime_coverage else {
1528        return Ok(None);
1529    };
1530    let (Some(modules), Some(files), Some(analysis_output)) = (
1531        analysis.modules.as_ref(),
1532        analysis.files.as_ref(),
1533        analysis.analysis_output.take(),
1534    ) else {
1535        return Ok(None);
1536    };
1537    analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
1538}
1539
1540fn analyze_security_runtime(
1541    opts: &SecurityOptions<'_>,
1542    path: &Path,
1543    modules: Vec<ModuleInfo>,
1544    files: Vec<DiscoveredFile>,
1545    analysis_output: fallow_core::AnalysisOutput,
1546) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1547    let runtime_coverage = crate::health::coverage::prepare_options(
1548        path,
1549        opts.min_invocations_hot,
1550        None,
1551        None,
1552        opts.output,
1553    )?;
1554    let result = crate::health::execute_health_with_shared_parse(
1555        &security_runtime_health_options(opts, runtime_coverage),
1556        SharedParseData {
1557            files,
1558            modules,
1559            analysis_output: Some(analysis_output),
1560        },
1561    )?;
1562    Ok(result.report.runtime_coverage)
1563}
1564
1565/// Build the production-forced `HealthOptions` used to compute runtime coverage
1566/// context for security findings (complexity/hotspot/ownership all disabled).
1567fn security_runtime_health_options<'a>(
1568    opts: &SecurityOptions<'a>,
1569    runtime_coverage: crate::health::RuntimeCoverageOptions,
1570) -> HealthOptions<'a> {
1571    HealthOptions {
1572        root: opts.root,
1573        config_path: opts.config_path,
1574        output: opts.output,
1575        no_cache: opts.no_cache,
1576        threads: opts.threads,
1577        quiet: opts.quiet,
1578        max_cyclomatic: None,
1579        max_cognitive: None,
1580        max_crap: None,
1581        top: None,
1582        sort: SortBy::Cyclomatic,
1583        production: true,
1584        production_override: Some(true),
1585        changed_since: opts.changed_since,
1586        diff_index: None,
1587        use_shared_diff_index: opts.use_shared_diff_index,
1588        workspace: opts.workspace,
1589        changed_workspaces: opts.changed_workspaces,
1590        baseline: None,
1591        save_baseline: None,
1592        complexity: false,
1593        complexity_breakdown: false,
1594        file_scores: false,
1595        coverage_gaps: false,
1596        config_activates_coverage_gaps: false,
1597        hotspots: false,
1598        ownership: false,
1599        ownership_emails: None,
1600        targets: false,
1601        css: false,
1602        force_full: false,
1603        score_only_output: false,
1604        enforce_coverage_gap_gate: false,
1605        effort: None,
1606        score: false,
1607        min_score: None,
1608        since: None,
1609        min_commits: None,
1610        explain: false,
1611        summary: false,
1612        save_snapshot: None,
1613        trend: false,
1614        group_by: None,
1615        coverage: None,
1616        coverage_root: None,
1617        performance: false,
1618        min_severity: None,
1619        report_only: false,
1620        runtime_coverage: Some(runtime_coverage),
1621        churn_file: None,
1622    }
1623}
1624
1625#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1626struct RuntimeFunctionKey {
1627    path: String,
1628    function: String,
1629    line: u32,
1630}
1631
1632#[derive(Debug, Clone)]
1633struct FunctionSpan {
1634    key: RuntimeFunctionKey,
1635    end_line: u32,
1636}
1637
1638fn apply_runtime_context(
1639    findings: &mut Vec<SecurityFinding>,
1640    modules: &[ModuleInfo],
1641    files: &[fallow_types::discover::DiscoveredFile],
1642    root: &Path,
1643    report: &RuntimeCoverageReport,
1644) {
1645    let spans = function_spans(modules, files, root);
1646    let runtime = SecurityRuntimeIndex::new(report);
1647    let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
1648    for (_, finding) in &mut indexed {
1649        if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
1650            continue;
1651        }
1652        finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
1653    }
1654    indexed.sort_by(|(left_index, left), (right_index, right)| {
1655        runtime_rank(left)
1656            .cmp(&runtime_rank(right))
1657            .then_with(|| left_index.cmp(right_index))
1658    });
1659    findings.extend(indexed.into_iter().map(|(_, finding)| finding));
1660}
1661
1662fn function_spans(
1663    modules: &[ModuleInfo],
1664    files: &[fallow_types::discover::DiscoveredFile],
1665    root: &Path,
1666) -> Vec<FunctionSpan> {
1667    let paths_by_id = files
1668        .iter()
1669        .map(|file| (file.id, &file.path))
1670        .collect::<rustc_hash::FxHashMap<_, _>>();
1671    let mut spans = Vec::new();
1672    for module in modules {
1673        let Some(path) = paths_by_id.get(&module.file_id) else {
1674            continue;
1675        };
1676        let path = relative_key(path, root);
1677        for function in &module.complexity {
1678            spans.push(FunctionSpan {
1679                key: RuntimeFunctionKey {
1680                    path: path.clone(),
1681                    function: function.name.clone(),
1682                    line: function.line,
1683                },
1684                end_line: function.line.saturating_add(function.line_count),
1685            });
1686        }
1687    }
1688    spans
1689}
1690
1691struct SecurityRuntimeIndex {
1692    hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
1693    findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
1694}
1695
1696impl SecurityRuntimeIndex {
1697    fn new(report: &RuntimeCoverageReport) -> Self {
1698        let hot_paths = report
1699            .hot_paths
1700            .iter()
1701            .map(|hot| {
1702                (
1703                    runtime_hot_key(hot),
1704                    hot.end_line.max(hot.line),
1705                    SecurityRuntimeContext {
1706                        state: SecurityRuntimeState::RuntimeHot,
1707                        function: hot.function.clone(),
1708                        line: hot.line,
1709                        invocations: Some(hot.invocations),
1710                        stable_id: hot.stable_id.clone(),
1711                        evidence: Some(format!(
1712                            "production hot path observed with {} invocation{}",
1713                            hot.invocations,
1714                            crate::report::plural(hot.invocations as usize)
1715                        )),
1716                    },
1717                )
1718            })
1719            .collect();
1720        let findings = report
1721            .findings
1722            .iter()
1723            .map(runtime_finding_context)
1724            .collect();
1725        Self {
1726            hot_paths,
1727            findings,
1728        }
1729    }
1730}
1731
1732fn runtime_context_for_finding(
1733    finding: &SecurityFinding,
1734    spans: &[FunctionSpan],
1735    runtime: &SecurityRuntimeIndex,
1736) -> Option<SecurityRuntimeContext> {
1737    let path = path_key(&finding.path);
1738    let span = spans
1739        .iter()
1740        .filter(|span| {
1741            span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
1742        })
1743        .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
1744    if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
1745        key == &span.key && key.line <= finding.line && finding.line <= *end_line
1746    }) {
1747        return Some(context.clone());
1748    }
1749    runtime.findings.get(&span.key).cloned().or_else(|| {
1750        Some(SecurityRuntimeContext {
1751            state: SecurityRuntimeState::RuntimeUnknown,
1752            function: span.key.function.clone(),
1753            line: span.key.line,
1754            invocations: None,
1755            stable_id: None,
1756            evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
1757        })
1758    })
1759}
1760
1761fn runtime_rank(finding: &SecurityFinding) -> u8 {
1762    match finding.runtime.as_ref().map(|runtime| runtime.state) {
1763        Some(SecurityRuntimeState::RuntimeHot) => 0,
1764        Some(SecurityRuntimeState::LowTraffic) => 1,
1765        None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
1766        Some(SecurityRuntimeState::CoverageUnavailable) => 3,
1767        Some(SecurityRuntimeState::RuntimeCold) => 4,
1768        Some(SecurityRuntimeState::NeverExecuted) => 5,
1769    }
1770}
1771
1772fn apply_security_severity(findings: &mut [SecurityFinding]) {
1773    for finding in findings {
1774        finding.severity = derive_security_severity(finding);
1775    }
1776}
1777
1778fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
1779    findings.sort_by(compare_security_priority);
1780}
1781
1782fn compare_security_priority(left: &SecurityFinding, right: &SecurityFinding) -> Ordering {
1783    security_severity_rank(left.severity)
1784        .cmp(&security_severity_rank(right.severity))
1785        .then_with(|| runtime_rank(left).cmp(&runtime_rank(right)))
1786        .then_with(|| {
1787            right
1788                .reachability
1789                .as_ref()
1790                .is_some_and(|reach| reach.reachable_from_entry)
1791                .cmp(
1792                    &left
1793                        .reachability
1794                        .as_ref()
1795                        .is_some_and(|reach| reach.reachable_from_entry),
1796                )
1797        })
1798        .then_with(|| taint_rank(left).cmp(&taint_rank(right)))
1799        .then_with(|| security_blast_radius(right).cmp(&security_blast_radius(left)))
1800        .then_with(|| security_crosses_boundary(right).cmp(&security_crosses_boundary(left)))
1801        .then_with(|| left.dead_code.is_some().cmp(&right.dead_code.is_some()))
1802        .then_with(|| left.path.cmp(&right.path))
1803        .then_with(|| left.line.cmp(&right.line))
1804        .then_with(|| left.col.cmp(&right.col))
1805        .then_with(|| left.category.cmp(&right.category))
1806}
1807
1808fn taint_rank(finding: &SecurityFinding) -> u8 {
1809    match finding
1810        .reachability
1811        .as_ref()
1812        .and_then(|reach| reach.taint_confidence)
1813    {
1814        Some(TaintConfidence::ArgLevel) => 0,
1815        Some(TaintConfidence::ModuleLevel) => 1,
1816        None if finding.source_backed => 0,
1817        None if finding
1818            .reachability
1819            .as_ref()
1820            .is_some_and(|reach| reach.reachable_from_untrusted_source) =>
1821        {
1822            1
1823        }
1824        None => 2,
1825    }
1826}
1827
1828fn security_blast_radius(finding: &SecurityFinding) -> u32 {
1829    finding
1830        .reachability
1831        .as_ref()
1832        .map_or(0, |reach| reach.blast_radius)
1833}
1834
1835fn security_crosses_boundary(finding: &SecurityFinding) -> bool {
1836    finding
1837        .reachability
1838        .as_ref()
1839        .is_some_and(|reach| reach.crosses_boundary)
1840}
1841
1842const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
1843    match severity {
1844        SecuritySeverity::High => 0,
1845        SecuritySeverity::Medium => 1,
1846        SecuritySeverity::Low => 2,
1847    }
1848}
1849
1850fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
1851    RuntimeFunctionKey {
1852        path: path_key(&hot.path),
1853        function: hot.function.clone(),
1854        line: hot.line,
1855    }
1856}
1857
1858fn runtime_finding_context(
1859    finding: &RuntimeCoverageFinding,
1860) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
1861    let state = match finding.verdict {
1862        RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
1863        RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
1864            SecurityRuntimeState::RuntimeCold
1865        }
1866        RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
1867        RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
1868            SecurityRuntimeState::CoverageUnavailable
1869        }
1870        RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
1871            SecurityRuntimeState::RuntimeUnknown
1872        }
1873    };
1874    (
1875        RuntimeFunctionKey {
1876            path: path_key(&finding.path),
1877            function: finding.function.clone(),
1878            line: finding.line,
1879        },
1880        SecurityRuntimeContext {
1881            state,
1882            function: finding.function.clone(),
1883            line: finding.line,
1884            invocations: finding.invocations,
1885            stable_id: finding.stable_id.clone(),
1886            evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
1887        },
1888    )
1889}
1890
1891fn relative_key(path: &Path, root: &Path) -> String {
1892    path_key(path.strip_prefix(root).unwrap_or(path))
1893}
1894
1895fn path_key(path: &Path) -> String {
1896    path.to_string_lossy().replace('\\', "/")
1897}
1898
1899fn unresolved_callee_diagnostics(
1900    diagnostics: &[SecurityUnresolvedCalleeDiagnostic],
1901    root: &Path,
1902) -> Option<SecurityUnresolvedCalleeDiagnostics> {
1903    if diagnostics.is_empty() {
1904        return None;
1905    }
1906
1907    let mut sorted = diagnostics.to_vec();
1908    sorted.sort_by(|a, b| {
1909        a.path
1910            .cmp(&b.path)
1911            .then(a.line.cmp(&b.line))
1912            .then(a.col.cmp(&b.col))
1913            .then(a.reason.cmp(&b.reason))
1914            .then(a.expression_kind.cmp(&b.expression_kind))
1915    });
1916
1917    let sampled = sorted
1918        .iter()
1919        .take(UNRESOLVED_CALLEE_SAMPLE_LIMIT)
1920        .map(|diagnostic| SecurityUnresolvedCalleeSample {
1921            path: relative_key(&diagnostic.path, root),
1922            line: diagnostic.line,
1923            col: diagnostic.col,
1924            reason: diagnostic.reason,
1925            expression_kind: diagnostic.expression_kind,
1926        })
1927        .collect();
1928
1929    let mut by_file: BTreeMap<String, usize> = BTreeMap::new();
1930    let mut by_reason: BTreeMap<fallow_types::extract::SkippedSecurityCalleeReason, usize> =
1931        BTreeMap::new();
1932    for diagnostic in &sorted {
1933        *by_file
1934            .entry(relative_key(&diagnostic.path, root))
1935            .or_insert(0) += 1;
1936        *by_reason.entry(diagnostic.reason).or_insert(0) += 1;
1937    }
1938
1939    let mut top_files: Vec<_> = by_file
1940        .into_iter()
1941        .map(|(path, count)| SecurityUnresolvedCalleeTopFile { path, count })
1942        .collect();
1943    top_files.sort_by(|a, b| b.count.cmp(&a.count).then(a.path.cmp(&b.path)));
1944    top_files.truncate(UNRESOLVED_CALLEE_TOP_FILES_LIMIT);
1945
1946    let mut by_reason: Vec<_> = by_reason
1947        .into_iter()
1948        .map(|(reason, count)| SecurityUnresolvedCalleeReasonCount { reason, count })
1949        .collect();
1950    by_reason.sort_by(|a, b| b.count.cmp(&a.count).then(a.reason.cmp(&b.reason)));
1951
1952    Some(SecurityUnresolvedCalleeDiagnostics {
1953        sampled,
1954        top_files,
1955        by_reason,
1956        sample_limit: UNRESOLVED_CALLEE_SAMPLE_LIMIT,
1957        top_files_limit: UNRESOLVED_CALLEE_TOP_FILES_LIMIT,
1958    })
1959}
1960
1961fn filter_to_files(
1962    results: &mut fallow_core::results::AnalysisResults,
1963    root: &Path,
1964    files: &[PathBuf],
1965    quiet: bool,
1966) {
1967    if files.is_empty() {
1968        return;
1969    }
1970
1971    let resolved_files: Vec<PathBuf> = files
1972        .iter()
1973        .map(|path| {
1974            if crate::path_util::is_absolute_path_any_platform(path) {
1975                path.clone()
1976            } else {
1977                root.join(path)
1978            }
1979        })
1980        .collect();
1981
1982    if !quiet {
1983        for (original, resolved) in files.iter().zip(&resolved_files) {
1984            if !resolved.exists() {
1985                eprintln!(
1986                    "Warning: --file '{}' (resolved to '{}') was not found in the project",
1987                    original.display(),
1988                    resolved.display()
1989                );
1990            }
1991        }
1992    }
1993
1994    let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
1995    fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
1996}
1997
1998fn prepare_findings(
1999    findings: Vec<SecurityFinding>,
2000    root: &Path,
2001    include_surface: bool,
2002) -> (
2003    Vec<SecurityFinding>,
2004    Option<Vec<SecurityAttackSurfaceEntry>>,
2005) {
2006    let mut findings: Vec<SecurityFinding> = findings
2007        .into_iter()
2008        .map(|f| {
2009            let mut f = relativize_finding(f, root);
2010            f.finding_id = security_finding_id(&f);
2011            f
2012        })
2013        .collect();
2014    let attack_surface = include_surface.then(|| {
2015        findings
2016            .iter()
2017            .filter_map(|finding| finding.attack_surface.clone())
2018            .collect()
2019    });
2020    for finding in &mut findings {
2021        finding.attack_surface = None;
2022    }
2023    (findings, attack_surface)
2024}
2025
2026/// Rewrite a finding's anchor + every trace hop path to be project-root-relative
2027/// (forward-slash normalization happens at serialize time via `serde_path`).
2028fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
2029    finding.path = relativize(&finding.path, root);
2030    for hop in &mut finding.trace {
2031        hop.path = relativize(&hop.path, root);
2032    }
2033    if let Some(reachability) = &mut finding.reachability {
2034        for hop in &mut reachability.untrusted_source_trace {
2035            hop.path = relativize(&hop.path, root);
2036        }
2037    }
2038    finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
2039    if let Some(flow) = &mut finding.taint_flow {
2040        flow.source.path = relativize(&flow.source.path, root);
2041        flow.sink.path = relativize(&flow.sink.path, root);
2042    }
2043    if let Some(surface) = &mut finding.attack_surface {
2044        surface.source.path = relativize(&surface.source.path, root);
2045        surface.sink.path = relativize(&surface.sink.path, root);
2046        for hop in &mut surface.path {
2047            hop.path = relativize(&hop.path, root);
2048        }
2049        for control in &mut surface.defensive_boundary.controls {
2050            control.path = relativize(&control.path, root);
2051        }
2052    }
2053    finding
2054}
2055
2056fn relativize(path: &Path, root: &Path) -> PathBuf {
2057    path.strip_prefix(root)
2058        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
2059}
2060
2061/// JSON: the `SecurityOutput` envelope, pretty-printed.
2062#[must_use]
2063pub fn render_json(output: &SecurityOutput) -> String {
2064    let Ok(value) = crate::output_envelope::serialize_root_output(
2065        crate::output_envelope::FallowOutput::Security(output.clone()),
2066    ) else {
2067        return "{\"error\":\"failed to serialize security output\"}".to_owned();
2068    };
2069    serde_json::to_string_pretty(&value)
2070        .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
2071}
2072
2073/// JSON summary: compact aggregate payload without per-finding arrays.
2074#[must_use]
2075pub fn render_json_summary(output: &SecurityOutput) -> String {
2076    let summary = SecuritySummaryOutput {
2077        schema_version: output.schema_version,
2078        version: output.version.clone(),
2079        elapsed_ms: output.elapsed_ms,
2080        config: output.config.clone(),
2081        meta: output.meta.clone(),
2082        gate: output.gate,
2083        summary: security_summary(output),
2084    };
2085    let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
2086        crate::output_envelope::FallowOutput::SecuritySummary(summary),
2087    ) else {
2088        return "{\"error\":\"failed to serialize security summary output\"}".to_owned();
2089    };
2090    serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2091        "{\"error\":\"failed to serialize security summary output\"}".to_owned()
2092    })
2093}
2094
2095fn render_survivors_output(
2096    output_format: OutputFormat,
2097    output: &SecuritySurvivorsOutput,
2098) -> String {
2099    match output_format {
2100        OutputFormat::Json => render_survivors_json(output),
2101        _ => render_survivors_human(output),
2102    }
2103}
2104
2105#[must_use]
2106pub fn render_survivors_json(output: &SecuritySurvivorsOutput) -> String {
2107    let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
2108        crate::output_envelope::FallowOutput::SecuritySurvivors(output.clone()),
2109    ) else {
2110        return "{\"error\":\"failed to serialize security survivors output\"}".to_owned();
2111    };
2112    serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2113        "{\"error\":\"failed to serialize security survivors output\"}".to_owned()
2114    })
2115}
2116
2117#[must_use]
2118fn render_survivors_human(output: &SecuritySurvivorsOutput) -> String {
2119    use crate::report::plural;
2120    use std::fmt::Write as _;
2121
2122    let mut out = String::new();
2123    let _ = writeln!(
2124        out,
2125        "Security survivors: {} verifier-retained candidate{}.",
2126        output.summary.survivors,
2127        plural(output.summary.survivors)
2128    );
2129    let _ = writeln!(
2130        out,
2131        "Verdicts: {}/{} candidates covered, {} dismissed.",
2132        output.summary.verdicts, output.summary.candidates, output.summary.dismissed
2133    );
2134    if output.summary.needs_human_review > 0 {
2135        let _ = writeln!(
2136            out,
2137            "Needs human review: {} candidate{}.",
2138            output.summary.needs_human_review,
2139            plural(output.summary.needs_human_review)
2140        );
2141    }
2142    if output.summary.unverdicted > 0 {
2143        let _ = writeln!(
2144            out,
2145            "Unreviewed candidates: {} candidate{}.",
2146            output.summary.unverdicted,
2147            plural(output.summary.unverdicted)
2148        );
2149    }
2150    out.push_str(
2151        "Retained and human-review rows are verifier dispositions, not vulnerabilities proven by fallow.\n",
2152    );
2153    if output.summary.unverdicted > 0 {
2154        out.push_str("Unreviewed candidates have no verifier disposition yet.\n");
2155    }
2156
2157    if output.survivors.is_empty() && output.needs_human_review.is_empty() {
2158        if output.summary.unverdicted > 0 {
2159            out.push_str("\nNo retained or human-review details to show yet.\n");
2160        } else {
2161            out.push_str("\nNo retained candidate details to show.\n");
2162        }
2163        return out;
2164    }
2165
2166    push_survivor_group(&mut out, "Survivors", &output.survivors);
2167    push_survivor_group(&mut out, "Needs human review", &output.needs_human_review);
2168    out
2169}
2170
2171fn push_survivor_group(
2172    out: &mut String,
2173    title: &str,
2174    survivors: &BTreeMap<String, SecuritySurvivor>,
2175) {
2176    use std::fmt::Write as _;
2177
2178    if survivors.is_empty() {
2179        return;
2180    }
2181    let _ = writeln!(out, "\n{title}:");
2182    for survivor in survivors.values() {
2183        let path = survivor.candidate.path.to_string_lossy().replace('\\', "/");
2184        let line = survivor.candidate.line;
2185        let category = survivor
2186            .candidate
2187            .category
2188            .as_deref()
2189            .unwrap_or_else(|| security_kind_key(survivor.candidate.kind));
2190        let _ = writeln!(
2191            out,
2192            "- {}:{} ({}) [{}]",
2193            path, line, category, survivor.finding_id
2194        );
2195        if let Some(reason) = survivor.reason.as_ref().or(survivor.rationale.as_ref()) {
2196            let _ = writeln!(out, "  reason: {reason}");
2197        }
2198        if let Some(impact) = &survivor.impact {
2199            let _ = writeln!(out, "  impact: {impact}");
2200        }
2201        if let Some(fix_direction) = &survivor.fix_direction {
2202            let _ = writeln!(out, "  fix direction: {fix_direction}");
2203        }
2204        out.push_str("  Next: review the original candidate evidence before editing code.\n");
2205    }
2206}
2207
2208fn build_blind_spots_output(output: &SecurityOutput) -> SecurityBlindSpotsOutput {
2209    let diagnostics = output.unresolved_callee_diagnostics.as_ref();
2210    let groups = diagnostics
2211        .map(group_blind_spot_samples)
2212        .unwrap_or_default();
2213    let sampled_callee_sites = diagnostics.map_or(0, |diagnostics| diagnostics.sampled.len());
2214    let unresolved_callee_sites =
2215        diagnostics.map_or(output.unresolved_callee_sites, |diagnostics| {
2216            diagnostics
2217                .by_reason
2218                .iter()
2219                .map(|reason| reason.count)
2220                .sum()
2221        });
2222
2223    SecurityBlindSpotsOutput {
2224        schema_version: SecurityBlindSpotsSchemaVersion::V1,
2225        version: output.version.clone(),
2226        elapsed_ms: output.elapsed_ms,
2227        summary: SecurityBlindSpotsSummary {
2228            unresolved_edge_files: output.unresolved_edge_files,
2229            unresolved_callee_sites,
2230            sampled_callee_sites,
2231        },
2232        groups,
2233    }
2234}
2235
2236fn group_blind_spot_samples(
2237    diagnostics: &SecurityUnresolvedCalleeDiagnostics,
2238) -> Vec<SecurityBlindSpotGroup> {
2239    let mut groups: BTreeMap<
2240        (
2241            fallow_types::extract::SkippedSecurityCalleeReason,
2242            fallow_types::extract::SkippedSecurityCalleeExpressionKind,
2243        ),
2244        BTreeMap<String, usize>,
2245    > = BTreeMap::new();
2246
2247    for sample in &diagnostics.sampled {
2248        let files = groups
2249            .entry((sample.reason, sample.expression_kind))
2250            .or_default();
2251        *files.entry(sample.path.clone()).or_insert(0) += 1;
2252    }
2253
2254    let mut groups: Vec<SecurityBlindSpotGroup> = groups
2255        .into_iter()
2256        .map(|((reason, expression_kind), files)| {
2257            let sampled_count = files.values().sum();
2258            let mut files: Vec<SecurityBlindSpotFile> = files
2259                .into_iter()
2260                .map(|(path, sampled_count)| SecurityBlindSpotFile {
2261                    path,
2262                    sampled_count,
2263                })
2264                .collect();
2265            files.sort_by(|a, b| {
2266                b.sampled_count
2267                    .cmp(&a.sampled_count)
2268                    .then_with(|| a.path.cmp(&b.path))
2269            });
2270            SecurityBlindSpotGroup {
2271                reason,
2272                expression_kind,
2273                sampled_count,
2274                files,
2275                suggestion: blind_spot_suggestion(reason).to_owned(),
2276            }
2277        })
2278        .collect();
2279
2280    groups.sort_by(|a, b| {
2281        b.sampled_count
2282            .cmp(&a.sampled_count)
2283            .then_with(|| {
2284                unresolved_callee_reason_label(a.reason)
2285                    .cmp(unresolved_callee_reason_label(b.reason))
2286            })
2287            .then_with(|| {
2288                unresolved_callee_expression_label(a.expression_kind)
2289                    .cmp(unresolved_callee_expression_label(b.expression_kind))
2290            })
2291    });
2292    groups
2293}
2294
2295fn render_blind_spots_output(
2296    output_format: OutputFormat,
2297    output: &SecurityBlindSpotsOutput,
2298) -> String {
2299    match output_format {
2300        OutputFormat::Json => render_blind_spots_json(output),
2301        _ => render_blind_spots_human(output),
2302    }
2303}
2304
2305#[must_use]
2306pub fn render_blind_spots_json(output: &SecurityBlindSpotsOutput) -> String {
2307    let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
2308        crate::output_envelope::FallowOutput::SecurityBlindSpots(output.clone()),
2309    ) else {
2310        return "{\"error\":\"failed to serialize security blind-spots output\"}".to_owned();
2311    };
2312    serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
2313        "{\"error\":\"failed to serialize security blind-spots output\"}".to_owned()
2314    })
2315}
2316
2317#[must_use]
2318fn render_blind_spots_human(output: &SecurityBlindSpotsOutput) -> String {
2319    use crate::report::plural;
2320    use std::fmt::Write as _;
2321
2322    let mut out = String::new();
2323    let callee_count = output.summary.unresolved_callee_sites;
2324    let edge_count = output.summary.unresolved_edge_files;
2325    if callee_count == 0 && edge_count == 0 {
2326        out.push_str("Security blind spots: no unresolved security edges or callees found.\n");
2327        return out;
2328    }
2329
2330    let _ = writeln!(
2331        out,
2332        "Security blind spots: {callee_count} unresolved callee{} and {edge_count} unresolved client import edge{}.",
2333        plural(callee_count),
2334        plural(edge_count)
2335    );
2336    out.push_str("A non-zero blind-spot count means fallow may have missed security candidates behind dynamic code shapes.\n");
2337
2338    for group in &output.groups {
2339        let reason = unresolved_callee_reason_label(group.reason);
2340        let expression = unresolved_callee_expression_label(group.expression_kind);
2341        let _ = writeln!(
2342            out,
2343            "\n{} Blind spot: {reason} / {expression}, {} sampled site{}.",
2344            "[I]".blue().bold(),
2345            group.sampled_count,
2346            plural(group.sampled_count)
2347        );
2348        for file in group.files.iter().take(3) {
2349            let _ = writeln!(out, "  {} ({})", file.path, file.sampled_count);
2350        }
2351        let _ = writeln!(out, "  Next: {}", group.suggestion);
2352    }
2353
2354    out
2355}
2356
2357fn unresolved_callee_expression_label(
2358    expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
2359) -> &'static str {
2360    match expression_kind {
2361        fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression => {
2362            "computed-member"
2363        }
2364        fallow_types::extract::SkippedSecurityCalleeExpressionKind::Identifier => "identifier",
2365        fallow_types::extract::SkippedSecurityCalleeExpressionKind::StaticMemberExpression => {
2366            "member-expression"
2367        }
2368        fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other => "other",
2369    }
2370}
2371
2372fn blind_spot_suggestion(
2373    reason: fallow_types::extract::SkippedSecurityCalleeReason,
2374) -> &'static str {
2375    match reason {
2376        fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => {
2377            "inspect computed property names or convert hot sinks to explicit calls."
2378        }
2379        fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => {
2380            "inspect dynamic dispatch targets and add a narrow wrapper or catalogue shape if the sink is real."
2381        }
2382        fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
2383            "inspect assignment targets and simplify the object shape if security sink calls are hidden there."
2384        }
2385    }
2386}
2387
2388fn security_summary(output: &SecurityOutput) -> SecuritySummary {
2389    let mut counts = SecuritySummaryCounts::default();
2390
2391    for finding in &output.security_findings {
2392        counts.record(finding);
2393    }
2394
2395    SecuritySummary {
2396        security_findings: output.security_findings.len(),
2397        by_severity: counts.severity,
2398        by_category: counts.category,
2399        by_reachability: counts.reachability,
2400        by_runtime_state: counts.runtime_state,
2401        unresolved_edge_files: output.unresolved_edge_files,
2402        unresolved_callee_sites: output.unresolved_callee_sites,
2403        attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
2404    }
2405}
2406
2407#[derive(Default)]
2408struct SecuritySummaryCounts {
2409    severity: SecuritySeverityCounts,
2410    category: BTreeMap<String, usize>,
2411    reachability: SecurityReachabilityCounts,
2412    runtime_state: SecurityRuntimeStateCounts,
2413}
2414
2415impl SecuritySummaryCounts {
2416    fn record(&mut self, finding: &SecurityFinding) {
2417        record_security_severity(finding.severity, &mut self.severity);
2418        record_security_category(finding, &mut self.category);
2419        record_security_reachability(finding, &mut self.reachability);
2420        record_security_runtime_state(finding, &mut self.runtime_state);
2421    }
2422}
2423
2424fn record_security_severity(severity: SecuritySeverity, by_severity: &mut SecuritySeverityCounts) {
2425    match severity {
2426        SecuritySeverity::High => by_severity.high += 1,
2427        SecuritySeverity::Medium => by_severity.medium += 1,
2428        SecuritySeverity::Low => by_severity.low += 1,
2429    }
2430}
2431
2432fn record_security_category(finding: &SecurityFinding, by_category: &mut BTreeMap<String, usize>) {
2433    let category = finding
2434        .category
2435        .clone()
2436        .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
2437    *by_category.entry(category).or_insert(0) += 1;
2438}
2439
2440fn record_security_reachability(
2441    finding: &SecurityFinding,
2442    by_reachability: &mut SecurityReachabilityCounts,
2443) {
2444    if finding.source_backed {
2445        by_reachability.source_backed += 1;
2446    }
2447    let Some(reachability) = &finding.reachability else {
2448        return;
2449    };
2450
2451    if reachability.reachable_from_entry {
2452        by_reachability.entry_reachable += 1;
2453    }
2454    if reachability.reachable_from_untrusted_source {
2455        by_reachability.untrusted_source_reachable += 1;
2456    }
2457    if reachability.crosses_boundary {
2458        by_reachability.crosses_boundary += 1;
2459    }
2460    match reachability.taint_confidence {
2461        Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
2462        Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
2463        None => {}
2464    }
2465}
2466
2467fn record_security_runtime_state(
2468    finding: &SecurityFinding,
2469    by_runtime_state: &mut SecurityRuntimeStateCounts,
2470) {
2471    match finding.runtime.as_ref().map(|runtime| runtime.state) {
2472        Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
2473        Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
2474        Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
2475        Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
2476        Some(SecurityRuntimeState::CoverageUnavailable) => {
2477            by_runtime_state.coverage_unavailable += 1;
2478        }
2479        Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
2480        None => by_runtime_state.not_collected += 1,
2481    }
2482}
2483
2484fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
2485    if let Some(parent) = path.parent()
2486        && !parent.as_os_str().is_empty()
2487    {
2488        std::fs::create_dir_all(parent).map_err(|err| {
2489            format!(
2490                "Failed to create directory for SARIF file {}: {err}",
2491                path.display()
2492            )
2493        })?;
2494    }
2495    std::fs::write(path, render_sarif(output))
2496        .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
2497}
2498
2499/// One-line gate verdict header. Leads with the ACTION ("REVIEW REQUIRED") and
2500/// immediately qualifies with the candidate framing, so a human never reads the
2501/// gate as "fallow confirmed a vulnerability". The wire `verdict` token stays
2502/// `fail`; only this human prose says "REVIEW REQUIRED".
2503fn gate_human_header(gate: &SecurityGate) -> String {
2504    use crate::report::plural;
2505    let checked = match gate.mode {
2506        SecurityGateMode::New => "in changed lines",
2507        SecurityGateMode::NewlyReachable => "newly reachable from entry points",
2508    };
2509    match gate.verdict {
2510        SecurityGateVerdict::Fail => format!(
2511            "Gate: REVIEW REQUIRED, {} new security item{} {checked}. fallow has not confirmed a vulnerability.",
2512            gate.new_count,
2513            plural(gate.new_count),
2514        ),
2515        SecurityGateVerdict::Pass => {
2516            format!("Gate: PASS, no new security items {checked}.")
2517        }
2518    }
2519}
2520
2521fn unresolved_callee_human_hint(output: &SecurityOutput) -> Option<String> {
2522    let diagnostics = output.unresolved_callee_diagnostics.as_ref()?;
2523    let top_reason = diagnostics.by_reason.first()?;
2524    let top_file = diagnostics.top_files.first()?;
2525    Some(format!(
2526        "Most unresolved callees: {} in {}.",
2527        unresolved_callee_reason_label(top_reason.reason),
2528        top_file.path
2529    ))
2530}
2531
2532fn unresolved_callee_reason_label(
2533    reason: fallow_types::extract::SkippedSecurityCalleeReason,
2534) -> &'static str {
2535    match reason {
2536        fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => "computed-member",
2537        fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => "dynamic-dispatch",
2538        fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
2539            "unsupported-assignment-object"
2540        }
2541    }
2542}
2543
2544#[must_use]
2545fn render_human_summary(output: &SecurityOutput) -> String {
2546    use crate::report::plural;
2547    use std::fmt::Write as _;
2548
2549    let mut out = String::new();
2550    if let Some(gate) = &output.gate {
2551        out.push_str(&gate_human_header(gate));
2552        out.push('\n');
2553    }
2554    let count = output.security_findings.len();
2555    if count == 0 {
2556        out.push_str("Security review: no items to check in the scanned code.\n");
2557    } else {
2558        let _ = writeln!(
2559            out,
2560            "Security review: {count} item{} to check. These are unverified security candidates, not confirmed vulnerabilities.",
2561            plural(count),
2562        );
2563        out.push_str(
2564            "Next: check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
2565        );
2566    }
2567    if output.unresolved_edge_files > 0 {
2568        let n = output.unresolved_edge_files;
2569        let verb = if n == 1 { "uses" } else { "use" };
2570        let _ = writeln!(
2571            out,
2572            "Blind spot: {n} client file{} {verb} dynamic imports that fallow could not follow.",
2573            plural(n)
2574        );
2575    }
2576    if output.unresolved_callee_sites > 0 {
2577        let n = output.unresolved_callee_sites;
2578        let verb = if n == 1 { "uses" } else { "use" };
2579        let _ = writeln!(
2580            out,
2581            "Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve.",
2582            plural(n)
2583        );
2584        if let Some(hint) = unresolved_callee_human_hint(output) {
2585            let _ = writeln!(out, "{hint}");
2586        }
2587    }
2588    out
2589}
2590
2591/// Human output. Frames findings as candidates and states the next human action
2592/// per finding; surfaces the unresolved-edge blind spot as a counted line.
2593#[must_use]
2594#[expect(
2595    clippy::format_push_string,
2596    reason = "small report renderer; readability over avoiding the extra allocation"
2597)]
2598pub fn render_human(output: &SecurityOutput) -> String {
2599    use crate::report::plural;
2600
2601    let mut out = String::new();
2602    push_human_gate(&mut out, output);
2603    let count = output.security_findings.len();
2604    out.push_str(&format!("Security review: {count} item{}", plural(count)));
2605    if count == 0 {
2606        out.push_str(" to check in the scanned code.\n");
2607    } else {
2608        out.push_str(" to check.\n");
2609        out.push_str(
2610            "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",
2611        );
2612    }
2613    out.push('\n');
2614
2615    if output.security_findings.is_empty() {
2616        out.push_str("No security details to show.\n");
2617    } else {
2618        push_human_findings(&mut out, &output.security_findings);
2619    }
2620
2621    push_human_blind_spots(&mut out, output);
2622
2623    out.push_str(&format!(
2624        "\nResult: {count} security item{} to check.",
2625        plural(count),
2626    ));
2627    if count > 0 {
2628        out.push_str(" Review the listed evidence and trace before changing code.");
2629    }
2630    out.push('\n');
2631    out
2632}
2633
2634fn push_human_gate(out: &mut String, output: &SecurityOutput) {
2635    if let Some(gate) = &output.gate {
2636        out.push_str(&gate_human_header(gate));
2637        out.push_str("\n\n");
2638    }
2639}
2640
2641fn push_human_findings(out: &mut String, findings: &[SecurityFinding]) {
2642    for finding in findings {
2643        push_human_finding(out, finding);
2644    }
2645}
2646
2647fn push_human_finding(out: &mut String, finding: &SecurityFinding) {
2648    use std::fmt::Write as _;
2649
2650    push_human_finding_header(out, finding);
2651    let _ = writeln!(out, "    evidence: {}", finding.evidence);
2652    if let Some(hint) = dead_code_hint(finding) {
2653        let _ = writeln!(out, "    dead-code: {hint}");
2654    }
2655    if let Some(runtime) = finding.runtime.as_ref() {
2656        let _ = writeln!(out, "    runtime: {}", runtime_hint_text(runtime));
2657    }
2658    push_human_reachability(out, finding);
2659    push_human_import_trace(out, finding);
2660    push_human_next_step(out, finding);
2661    out.push('\n');
2662}
2663
2664fn push_human_finding_header(out: &mut String, finding: &SecurityFinding) {
2665    use colored::Colorize;
2666    use std::fmt::Write as _;
2667
2668    let kind = security_finding_label(finding);
2669    let (glyph, label) = human_severity_marker(finding.severity);
2670    let _ = writeln!(
2671        out,
2672        "{} {label} {kind}  {}:{}",
2673        glyph,
2674        finding.path.to_string_lossy().replace('\\', "/").bold(),
2675        finding.line,
2676    );
2677}
2678
2679fn push_human_reachability(out: &mut String, finding: &SecurityFinding) {
2680    use std::fmt::Write as _;
2681
2682    let Some(reach) = finding.reachability.as_ref() else {
2683        return;
2684    };
2685    let entry = if reach.reachable_from_entry {
2686        "reachable from a runtime entry point"
2687    } else {
2688        "not reached from any runtime entry point"
2689    };
2690    let boundary = if reach.crosses_boundary {
2691        "; crosses an architecture boundary"
2692    } else {
2693        ""
2694    };
2695    let _ = writeln!(
2696        out,
2697        "    code path: {entry} (blast radius {}){boundary}",
2698        reach.blast_radius,
2699    );
2700    if reach.reachable_from_untrusted_source {
2701        push_human_untrusted_trace(out, finding);
2702    }
2703}
2704
2705fn push_human_untrusted_trace(out: &mut String, finding: &SecurityFinding) {
2706    use std::fmt::Write as _;
2707
2708    let Some(reach) = finding.reachability.as_ref() else {
2709        return;
2710    };
2711    let hops = reach.untrusted_source_hop_count.unwrap_or(0);
2712    let _ = writeln!(
2713        out,
2714        "    input path: this module is reachable from a module that receives \
2715         untrusted input via {hops} import hop{}",
2716        crate::report::plural(hops as usize),
2717    );
2718    if !reach.untrusted_source_trace.is_empty() {
2719        out.push_str("    input import trace:\n");
2720        for hop in &reach.untrusted_source_trace {
2721            let _ = writeln!(
2722                out,
2723                "      {}:{} ({})",
2724                hop.path.to_string_lossy().replace('\\', "/"),
2725                hop.line,
2726                hop_role_label(hop.role),
2727            );
2728        }
2729    }
2730}
2731
2732fn push_human_import_trace(out: &mut String, finding: &SecurityFinding) {
2733    use std::fmt::Write as _;
2734
2735    if finding.trace.is_empty() {
2736        return;
2737    }
2738    out.push_str("    import trace:\n");
2739    for hop in &finding.trace {
2740        let _ = writeln!(
2741            out,
2742            "      {}:{} ({})",
2743            hop.path.to_string_lossy().replace('\\', "/"),
2744            hop.line,
2745            hop_role_label(hop.role),
2746        );
2747    }
2748}
2749
2750fn push_human_next_step(out: &mut String, finding: &SecurityFinding) {
2751    if is_server_only_leak(finding) {
2752        out.push_str(
2753            "    Next: check whether this server-only code is meant to run on the client. \
2754             If it is pulled in only through next/dynamic(..., { ssr: false }), type-only, \
2755             or removed at build time, mark it as a false positive.\n",
2756        );
2757    } else if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
2758        out.push_str(
2759            "    Next: check whether this import can ship a secret to the browser. If \
2760             it is type-only, server-only, or removed at build time, mark it as a false \
2761             positive.\n",
2762        );
2763    } else if finding.dead_code.is_some() {
2764        out.push_str(
2765            "    Next: first verify the dead-code finding. If the code is safe to \
2766             remove, delete it. Otherwise check and harden the risky call.\n",
2767        );
2768    } else {
2769        out.push_str(
2770            "    Next: check whether unsafe input, secrets, or settings can reach this \
2771             risky call without a safe guard. If not, mark it as a false positive.\n",
2772        );
2773    }
2774}
2775
2776fn push_human_blind_spots(out: &mut String, output: &SecurityOutput) {
2777    use crate::report::plural;
2778    use std::fmt::Write as _;
2779
2780    if output.unresolved_edge_files > 0 {
2781        let n = output.unresolved_edge_files;
2782        let verb = if n == 1 { "uses" } else { "use" };
2783        let _ = writeln!(
2784            out,
2785            "{} Blind spot: {n} client file{} {verb} dynamic imports that fallow could not \
2786             follow. Code behind those imports may be missing from this report.",
2787            "[I]".blue().bold(),
2788            plural(n),
2789        );
2790    }
2791
2792    if output.unresolved_callee_sites > 0 {
2793        let n = output.unresolved_callee_sites;
2794        let verb = if n == 1 { "uses" } else { "use" };
2795        let _ = writeln!(
2796            out,
2797            "{} Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve, \
2798             such as dynamic dispatch, computed members, or aliased bindings.",
2799            "[I]".blue().bold(),
2800            plural(n),
2801        );
2802        if let Some(hint) = unresolved_callee_human_hint(output) {
2803            let _ = writeln!(out, "    {hint}");
2804        }
2805    }
2806}
2807
2808/// Render the human-facing label for a finding. The secret-leak
2809/// `ClientServerLeak` keeps its bespoke kebab kind; the server-only variant uses
2810/// its own kebab label so a reader tells the two apart; `TaintedSink` uses the
2811/// catalogue title plus the CWE number carried on the finding.
2812fn security_finding_label(finding: &SecurityFinding) -> String {
2813    match finding.kind {
2814        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2815            "server-only-import".to_string()
2816        }
2817        SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
2818        SecurityFindingKind::TaintedSink => {
2819            let title = finding
2820                .category
2821                .as_deref()
2822                .and_then(fallow_core::analyze::security_catalogue_title)
2823                .or(finding.category.as_deref())
2824                .unwrap_or("tainted-sink");
2825            match finding.cwe {
2826                Some(cwe) => format!("{title} (CWE-{cwe})"),
2827                None => title.to_string(),
2828            }
2829        }
2830    }
2831}
2832
2833fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
2834    use colored::Colorize;
2835    match severity {
2836        SecuritySeverity::High => ("[H]".red().bold(), "high"),
2837        SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
2838        SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
2839    }
2840}
2841
2842fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
2843    let context = finding.dead_code.as_ref()?;
2844    match context.kind {
2845        SecurityDeadCodeKind::UnusedFile => Some(
2846            "also reported as unused-file; delete this file instead of hardening the sink"
2847                .to_string(),
2848        ),
2849        SecurityDeadCodeKind::UnusedExport => Some(format!(
2850            "also reported as unused-export{}; remove the export instead of hardening the sink",
2851            context
2852                .export_name
2853                .as_ref()
2854                .map_or(String::new(), |name| format!(" `{name}`"))
2855        )),
2856    }
2857}
2858
2859const fn hop_role_label(role: TraceHopRole) -> &'static str {
2860    match role {
2861        TraceHopRole::ClientBoundary => "client boundary",
2862        TraceHopRole::UntrustedSource => "untrusted source",
2863        TraceHopRole::ModuleSource => "source module",
2864        TraceHopRole::Intermediate => "intermediate",
2865        TraceHopRole::SecretSource => "secret source",
2866        TraceHopRole::Sink => "sink site",
2867    }
2868}
2869
2870fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
2871    finding
2872        .reachability
2873        .as_ref()
2874        .filter(|reach| reach.reachable_from_untrusted_source)
2875        .map(|_| {
2876            "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
2877        })
2878}
2879
2880fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
2881    use std::fmt::Write as _;
2882
2883    let mut text = format!(
2884        "{} in {}:{}",
2885        runtime_state_label(runtime.state),
2886        runtime.function,
2887        runtime.line
2888    );
2889    if let Some(invocations) = runtime.invocations {
2890        let _ = write!(
2891            text,
2892            " ({} invocation{})",
2893            invocations,
2894            crate::report::plural(invocations as usize)
2895        );
2896    }
2897    if let Some(evidence) = runtime.evidence.as_deref() {
2898        text.push_str("; ");
2899        text.push_str(evidence);
2900    }
2901    text
2902}
2903
2904const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
2905    match state {
2906        SecurityRuntimeState::RuntimeHot => "runtime-hot",
2907        SecurityRuntimeState::RuntimeCold => "runtime-cold",
2908        SecurityRuntimeState::NeverExecuted => "never-executed",
2909        SecurityRuntimeState::LowTraffic => "low-traffic",
2910        SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
2911        SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
2912    }
2913}
2914
2915/// The `category` string distinguishing the server-only-import sink from the
2916/// secret-leak sink (both `ClientServerLeak` kind). Matches the constant in
2917/// `crates/core/src/analyze/security/mod.rs`.
2918const SERVER_ONLY_CATEGORY: &str = "server-only-import";
2919
2920/// Whether a `ClientServerLeak` finding is the server-only-import variant rather
2921/// than the original secret-leak variant. Keys on `category` because both share
2922/// the `ClientServerLeak` kind and the same rule.
2923fn is_server_only_leak(finding: &SecurityFinding) -> bool {
2924    matches!(finding.kind, SecurityFindingKind::ClientServerLeak)
2925        && finding.category.as_deref() == Some(SERVER_ONLY_CATEGORY)
2926}
2927
2928/// The SARIF ruleId for a finding. The secret-leak `client-server-leak` keeps its
2929/// bespoke id; the server-only variant gets `security/server-only-import` so the
2930/// GitHub Security tab tells "reaches server-only code" apart from "reads a
2931/// secret"; each `TaintedSink` category gets `security/<category>` so candidates
2932/// group and label per CWE class.
2933fn sarif_rule_id(finding: &SecurityFinding) -> String {
2934    match finding.kind {
2935        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2936            "security/server-only-import".to_owned()
2937        }
2938        SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
2939        SecurityFindingKind::TaintedSink => {
2940            format!(
2941                "security/{}",
2942                finding.category.as_deref().unwrap_or("tainted-sink")
2943            )
2944        }
2945    }
2946}
2947
2948fn security_help_text(title: &str) -> String {
2949    format!(
2950        "Verify this unverified {title} candidate before acting. Review the source, sink, \
2951         SARIF code flow, and any runtime or dead-code context. fallow does not prove \
2952         exploitability, attacker control, or missing sanitization."
2953    )
2954}
2955
2956fn security_help_markdown(title: &str) -> String {
2957    format!(
2958        "Verify this unverified **{title}** candidate before acting.\n\n\
2959         1. Review the source and sink in the SARIF code flow.\n\
2960         2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
2961         3. Use runtime and dead-code context only as triage signals."
2962    )
2963}
2964
2965fn cwe_taxon_id(cwe: u32) -> String {
2966    format!("CWE-{cwe}")
2967}
2968
2969fn cwe_taxon(cwe: u32) -> serde_json::Value {
2970    let id = cwe_taxon_id(cwe);
2971    serde_json::json!({
2972        "id": id,
2973        "name": id,
2974        "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
2975        "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
2976        "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
2977    })
2978}
2979
2980fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
2981    serde_json::json!({
2982        "target": {
2983            "id": cwe_taxon_id(cwe),
2984            "index": taxon_index,
2985            "toolComponent": {
2986                "name": "CWE",
2987                "index": 0
2988            }
2989        },
2990        "kinds": ["superset"]
2991    })
2992}
2993
2994fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
2995    let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
2996    cwes.sort_unstable();
2997    cwes.dedup();
2998    cwes
2999}
3000
3001fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
3002    cwes.iter().position(|existing| *existing == cwe)
3003}
3004
3005fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
3006    if cwes.is_empty() {
3007        return None;
3008    }
3009    let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
3010    Some(serde_json::json!({
3011        "name": "CWE",
3012        "fullName": "Common Weakness Enumeration",
3013        "organization": "MITRE",
3014        "informationUri": "https://cwe.mitre.org/",
3015        "taxa": taxa
3016    }))
3017}
3018
3019/// Build the SARIF rule definition for a ruleId, deriving per-category metadata
3020/// (catalogue title + CWE tag and relationship) for `TaintedSink` findings so
3021/// CWE grouping survives in SARIF-aware consumers.
3022fn sarif_rule_def(
3023    rule_id: &str,
3024    finding: &SecurityFinding,
3025    cwe_taxon_index: Option<usize>,
3026) -> serde_json::Value {
3027    match finding.kind {
3028        SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
3029            sarif_rule_def_server_only_leak(rule_id)
3030        }
3031        SecurityFindingKind::ClientServerLeak => sarif_rule_def_secret_leak(rule_id),
3032        SecurityFindingKind::TaintedSink => {
3033            sarif_rule_def_tainted_sink(rule_id, finding, cwe_taxon_index)
3034        }
3035    }
3036}
3037
3038/// SARIF rule definition for the server-only-import flavor of `ClientServerLeak`.
3039fn sarif_rule_def_server_only_leak(rule_id: &str) -> serde_json::Value {
3040    let title = "Client imports server-only code";
3041    serde_json::json!({
3042        "id": rule_id,
3043        "name": title,
3044        "shortDescription": { "text": "Client imports server-only code candidate (unverified)" },
3045        "fullDescription": { "text":
3046            "Unverified candidate, requires verification: a \"use client\" file \
3047             transitively imports a server-only module (one carrying a \"use server\" \
3048             directive or importing server-only code such as server-only, next/headers, \
3049             next/server, or node:fs / node:child_process). fallow does not prove this \
3050             code runs on the client; a module pulled in only through \
3051             next/dynamic(..., { ssr: false }) is a false positive." },
3052        "help": {
3053            "text": security_help_text(title),
3054            "markdown": security_help_markdown(title)
3055        },
3056        "helpUri": "https://github.com/fallow-rs/fallow",
3057        "defaultConfiguration": { "level": "note" }
3058    })
3059}
3060
3061/// SARIF rule definition for the secret-leak flavor of `ClientServerLeak`.
3062fn sarif_rule_def_secret_leak(rule_id: &str) -> serde_json::Value {
3063    let title = "Client-server secret leak";
3064    serde_json::json!({
3065        "id": rule_id,
3066        "name": title,
3067        "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
3068        "fullDescription": { "text":
3069            "Unverified candidate, requires verification: a \"use client\" file \
3070             transitively imports a module that reads a non-public process.env \
3071             secret. fallow does not prove the secret reaches client-bundled code." },
3072        "help": {
3073            "text": security_help_text(title),
3074            "markdown": security_help_markdown(title)
3075        },
3076        "helpUri": "https://github.com/fallow-rs/fallow",
3077        "defaultConfiguration": { "level": "note" }
3078    })
3079}
3080
3081/// SARIF rule definition for `TaintedSink` findings, attaching CWE tags and the
3082/// CWE taxonomy relationship when the finding carries a CWE id.
3083fn sarif_rule_def_tainted_sink(
3084    rule_id: &str,
3085    finding: &SecurityFinding,
3086    cwe_taxon_index: Option<usize>,
3087) -> serde_json::Value {
3088    let title = finding
3089        .category
3090        .as_deref()
3091        .and_then(fallow_core::analyze::security_catalogue_title)
3092        .or(finding.category.as_deref())
3093        .unwrap_or("tainted-sink");
3094    let mut rule = serde_json::json!({
3095        "id": rule_id,
3096        "name": title,
3097        "shortDescription": { "text": format!("{title} candidate (unverified)") },
3098        "fullDescription": { "text": format!(
3099            "Unverified candidate, requires verification: {title}. fallow flags a \
3100             syntactic sink reached by a non-literal argument; it does not prove the \
3101             value is attacker-controlled or reaches the sink unsanitized."
3102        ) },
3103        "help": {
3104            "text": security_help_text(title),
3105            "markdown": security_help_markdown(title)
3106        },
3107        "helpUri": "https://github.com/fallow-rs/fallow",
3108        "defaultConfiguration": { "level": "note" }
3109    });
3110    if let Some(cwe) = finding.cwe {
3111        rule["properties"] = serde_json::json!({
3112            "tags": [format!("external/cwe/cwe-{cwe}")]
3113        });
3114        if let Some(taxon_index) = cwe_taxon_index {
3115            rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
3116        }
3117    }
3118    rule
3119}
3120
3121fn hop_role_token(role: TraceHopRole) -> &'static str {
3122    match role {
3123        TraceHopRole::ClientBoundary => "client-boundary",
3124        TraceHopRole::UntrustedSource => "untrusted-source",
3125        TraceHopRole::ModuleSource => "module-source",
3126        TraceHopRole::Intermediate => "intermediate",
3127        TraceHopRole::SecretSource => "secret-source",
3128        TraceHopRole::Sink => "sink",
3129    }
3130}
3131
3132fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
3133    let role = hop_role_token(hop.role);
3134    serde_json::json!({
3135        "location": sarif_location(&hop.path, hop.line, hop.col),
3136        "kinds": [role],
3137        "properties": { "fallowTraceRole": role }
3138    })
3139}
3140
3141fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
3142    if let Some(reachability) = finding.reachability.as_ref()
3143        && !reachability.untrusted_source_trace.is_empty()
3144    {
3145        return &reachability.untrusted_source_trace;
3146    }
3147    &finding.trace
3148}
3149
3150fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
3151    let hops = primary_code_flow_hops(finding);
3152    if hops.is_empty() {
3153        return None;
3154    }
3155    let locations = hops
3156        .iter()
3157        .map(sarif_thread_flow_location)
3158        .collect::<Vec<_>>();
3159    Some(serde_json::json!([
3160        {
3161            "threadFlows": [
3162                { "locations": locations }
3163            ]
3164        }
3165    ]))
3166}
3167
3168fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
3169    let location = sarif_location(&hop.path, hop.line, hop.col);
3170    if !related.iter().any(|existing| existing == &location) {
3171        related.push(location);
3172    }
3173}
3174
3175fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
3176    let mut related = Vec::new();
3177    for hop in &finding.trace {
3178        push_related_location(&mut related, hop);
3179    }
3180    if let Some(reachability) = finding.reachability.as_ref() {
3181        for hop in &reachability.untrusted_source_trace {
3182            push_related_location(&mut related, hop);
3183        }
3184    }
3185    related
3186}
3187
3188const fn sarif_level(severity: SecuritySeverity) -> &'static str {
3189    match severity {
3190        SecuritySeverity::High | SecuritySeverity::Medium => "warning",
3191        SecuritySeverity::Low => "note",
3192    }
3193}
3194
3195/// Build the SARIF `result` object for a single finding, composing the
3196/// candidate-framed message, related locations, fingerprint, and code flows.
3197fn sarif_result_for_finding(finding: &SecurityFinding) -> serde_json::Value {
3198    let rule_id = sarif_rule_id(finding);
3199    let mut message = dead_code_hint(finding).map_or_else(
3200        || finding.evidence.clone(),
3201        |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
3202    );
3203    if let Some(hint) = source_reachability_hint(finding) {
3204        message.push(' ');
3205        message.push_str(hint);
3206    }
3207    if let Some(runtime) = finding.runtime.as_ref() {
3208        message.push_str(" Runtime context: ");
3209        message.push_str(&runtime_hint_text(runtime));
3210        message.push('.');
3211    }
3212    let related = sarif_related_locations(finding);
3213    // Stable dedup key for GHAS: rule + anchor path + line. Without
3214    // partialFingerprints, every run re-opens previously triaged alerts.
3215    // Same helper as the JSON `finding_id` field so the two never drift
3216    // (issue #900).
3217    let mut result = serde_json::json!({
3218        "ruleId": rule_id,
3219        "level": sarif_level(finding.severity),
3220        "message": { "text": message },
3221        "locations": [sarif_location(&finding.path, finding.line, finding.col)],
3222        "relatedLocations": related,
3223        "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
3224    });
3225    if let Some(code_flows) = sarif_code_flows(finding) {
3226        result["codeFlows"] = code_flows;
3227    }
3228    result
3229}
3230
3231/// Collect one SARIF rule definition per distinct ruleId present in `findings`,
3232/// in first-seen order, attaching the CWE taxonomy index when available.
3233fn sarif_rule_defs(findings: &[SecurityFinding], cwes: &[u32]) -> Vec<serde_json::Value> {
3234    let mut seen: Vec<String> = Vec::new();
3235    let mut rules: Vec<serde_json::Value> = Vec::new();
3236    for finding in findings {
3237        let rule_id = sarif_rule_id(finding);
3238        if seen.iter().any(|s| s == &rule_id) {
3239            continue;
3240        }
3241        seen.push(rule_id.clone());
3242        let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(cwes, cwe));
3243        rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
3244    }
3245    rules
3246}
3247
3248/// SARIF output. Maps the candidate's verification-priority tier to SARIF
3249/// `level` while keeping the message text candidate-framed. Each finding's ruleId is
3250/// per-category (`security/<category>` for tainted-sink, `security/client-server-leak`
3251/// for the graph rule); the `rules` array carries one definition per distinct
3252/// ruleId present, with the CWE tag for tainted-sink categories. Detector trace
3253/// hops and source-reachability hops become `relatedLocations` of the result.
3254#[must_use]
3255fn render_sarif(output: &SecurityOutput) -> String {
3256    let cwes = collect_cwes(&output.security_findings);
3257    let results: Vec<serde_json::Value> = output
3258        .security_findings
3259        .iter()
3260        .map(sarif_result_for_finding)
3261        .collect();
3262    let rules = sarif_rule_defs(&output.security_findings, &cwes);
3263
3264    let mut run = serde_json::json!({
3265        "tool": { "driver": {
3266            "name": "fallow",
3267            "version": env!("CARGO_PKG_VERSION"),
3268            "informationUri": "https://github.com/fallow-rs/fallow",
3269            "rules": rules,
3270        }},
3271        "results": results,
3272    });
3273    if let Some(taxonomy) = cwe_taxonomy(&cwes) {
3274        run["taxonomies"] = serde_json::json!([taxonomy]);
3275        run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
3276            { "name": "CWE", "index": 0 }
3277        ]);
3278    }
3279    // Gate verdict rides as a RUN-level property, never on result severity.
3280    // Result levels come from candidate review-priority severity and deliberately
3281    // avoid `error`, so GHAS does not frame candidates as confirmed problems.
3282    if let Some(gate) = &output.gate
3283        && let Ok(gate_value) = serde_json::to_value(gate)
3284    {
3285        run["properties"] = serde_json::json!({ "fallowGate": gate_value });
3286    }
3287
3288    let sarif = serde_json::json!({
3289        "version": "2.1.0",
3290        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3291        "runs": [run],
3292    });
3293    serde_json::to_string_pretty(&sarif)
3294        .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
3295}
3296
3297/// Small FNV-1a hex digest for SARIF `partialFingerprints` dedup stability.
3298fn fnv_hex(input: &str) -> String {
3299    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
3300    for byte in input.bytes() {
3301        hash ^= u64::from(byte);
3302        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
3303    }
3304    format!("{hash:016x}")
3305}
3306
3307/// Stable per-finding correlation id: FNV-1a hex of `rule:path:line`. The single
3308/// source of truth for BOTH the JSON `finding_id` field and the SARIF
3309/// `partialFingerprints` value, so an agent can join the two and they never
3310/// drift. Computed on the project-relative path, so it must run after the
3311/// finding is relativized (issue #900).
3312fn security_finding_id(finding: &SecurityFinding) -> String {
3313    let fp = format!(
3314        "{}:{}:{}",
3315        sarif_rule_id(finding),
3316        finding.path.to_string_lossy().replace('\\', "/"),
3317        finding.line,
3318    );
3319    fnv_hex(&fp)
3320}
3321
3322fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
3323    serde_json::json!({
3324        "physicalLocation": {
3325            "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
3326            "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
3327        }
3328    })
3329}
3330
3331#[cfg(test)]
3332mod tests {
3333    use super::*;
3334    use fallow_core::results::{
3335        SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
3336        SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
3337        TraceHop, TraceHopRole,
3338    };
3339    use fallow_types::results::{
3340        SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
3341    };
3342
3343    /// Build a finding anchored under `root` with a three-hop client -> secret trace.
3344    fn sample_finding(root: &Path) -> SecurityFinding {
3345        SecurityFinding {
3346            kind: SecurityFindingKind::ClientServerLeak,
3347            path: root.join("src/app.tsx"),
3348            line: 12,
3349            col: 3,
3350            evidence: "reaches process.env.SECRET_KEY".to_owned(),
3351            source_backed: false,
3352            source_read: None,
3353            severity: SecuritySeverity::High,
3354            trace: vec![
3355                TraceHop {
3356                    path: root.join("src/app.tsx"),
3357                    line: 12,
3358                    col: 3,
3359                    role: TraceHopRole::ClientBoundary,
3360                },
3361                TraceHop {
3362                    path: root.join("src/lib/util.ts"),
3363                    line: 4,
3364                    col: 0,
3365                    role: TraceHopRole::Intermediate,
3366                },
3367                TraceHop {
3368                    path: root.join("src/lib/secret.ts"),
3369                    line: 8,
3370                    col: 2,
3371                    role: TraceHopRole::SecretSource,
3372                },
3373            ],
3374            actions: vec![],
3375            category: None,
3376            cwe: None,
3377            dead_code: None,
3378            reachability: None,
3379            finding_id: String::new(),
3380            candidate: SecurityCandidate {
3381                source_kind: None,
3382                sink: SecurityCandidateSink {
3383                    path: root.join("src/app.tsx"),
3384                    line: 12,
3385                    col: 3,
3386                    category: None,
3387                    cwe: None,
3388                    callee: None,
3389                    url_shape: None,
3390                },
3391                boundary: SecurityCandidateBoundary {
3392                    client_server: true,
3393                    cross_module: false,
3394                    architecture_zone: None,
3395                },
3396                network: None,
3397            },
3398            taint_flow: None,
3399            runtime: None,
3400            attack_surface: None,
3401        }
3402    }
3403
3404    fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
3405        SecurityOutput {
3406            schema_version: SecuritySchemaVersion::V7,
3407            version: ToolVersion("test".to_string()),
3408            elapsed_ms: ElapsedMs(0),
3409            config: test_output_config(),
3410            meta: None,
3411            gate: None,
3412            security_findings: findings,
3413            attack_surface: None,
3414            unresolved_edge_files,
3415            unresolved_callee_sites: 0,
3416            unresolved_callee_diagnostics: None,
3417        }
3418    }
3419
3420    fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
3421        SecurityOutput {
3422            schema_version: SecuritySchemaVersion::V7,
3423            version: ToolVersion("test".to_string()),
3424            elapsed_ms: ElapsedMs(0),
3425            config: test_output_config(),
3426            meta: None,
3427            gate: Some(SecurityGate {
3428                mode: SecurityGateMode::New,
3429                verdict,
3430                new_count,
3431            }),
3432            security_findings: vec![],
3433            attack_surface: None,
3434            unresolved_edge_files: 0,
3435            unresolved_callee_sites: 0,
3436            unresolved_callee_diagnostics: None,
3437        }
3438    }
3439
3440    fn survivor_candidate_json(
3441        finding_id: &str,
3442        path: &str,
3443        line: u32,
3444        kind: SecurityFindingKind,
3445        category: Option<&str>,
3446    ) -> serde_json::Value {
3447        let root = Path::new("/proj/root");
3448        let mut finding = relativize_finding(sample_finding(root), root);
3449        finding.finding_id = finding_id.to_owned();
3450        finding.path = PathBuf::from(path);
3451        finding.line = line;
3452        finding.kind = kind;
3453        finding.category = category.map(str::to_owned);
3454        finding.candidate.sink.path = PathBuf::from(path);
3455        finding.candidate.sink.line = line;
3456        finding.candidate.sink.category = category.map(str::to_owned);
3457        serde_json::to_value(finding).expect("security finding serializes")
3458    }
3459
3460    fn sample_unresolved_callee_diagnostics(root: &Path) -> SecurityUnresolvedCalleeDiagnostics {
3461        unresolved_callee_diagnostics(
3462            &[
3463                SecurityUnresolvedCalleeDiagnostic {
3464                    path: root.join("src/z.ts"),
3465                    line: 9,
3466                    col: 4,
3467                    reason: fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember,
3468                    expression_kind:
3469                        fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
3470                },
3471                SecurityUnresolvedCalleeDiagnostic {
3472                    path: root.join("src/a.ts"),
3473                    line: 3,
3474                    col: 2,
3475                    reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
3476                    expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
3477                },
3478                SecurityUnresolvedCalleeDiagnostic {
3479                    path: root.join("src/a.ts"),
3480                    line: 4,
3481                    col: 2,
3482                    reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
3483                    expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
3484                },
3485            ],
3486            root,
3487        )
3488        .expect("diagnostics summarized")
3489    }
3490
3491    fn test_output_config() -> SecurityOutputConfig {
3492        SecurityOutputConfig {
3493            rules: SecurityOutputRulesConfig {
3494                security_client_server_leak: SecurityRuleSeverityConfig {
3495                    configured: Severity::Off,
3496                    effective: Severity::Warn,
3497                },
3498                security_sink: SecurityRuleSeverityConfig {
3499                    configured: Severity::Off,
3500                    effective: Severity::Warn,
3501                },
3502            },
3503            categories_include: None,
3504            categories_exclude: None,
3505        }
3506    }
3507
3508    #[test]
3509    fn survivors_json_keeps_survivors_and_review_candidates_by_finding_id() {
3510        let dir = tempfile::tempdir().expect("temp dir");
3511        let candidates = dir.path().join("candidates.json");
3512        let verdicts = dir.path().join("verdicts.json");
3513        std::fs::write(
3514            &candidates,
3515            serde_json::json!({
3516                "kind": "security",
3517                "security_findings": [
3518                    survivor_candidate_json("sec-a", "src/a.ts", 10, SecurityFindingKind::TaintedSink, Some("ssrf")),
3519                    survivor_candidate_json("sec-b", "src/b.ts", 11, SecurityFindingKind::TaintedSink, Some("redos-regex")),
3520                    survivor_candidate_json("sec-c", "src/c.ts", 12, SecurityFindingKind::ClientServerLeak, None)
3521                ]
3522            })
3523            .to_string(),
3524        )
3525        .expect("write candidates");
3526        std::fs::write(
3527            &verdicts,
3528            serde_json::json!({
3529                "schema_version": "fallow-security-verdicts/v1",
3530                "verdicts": [
3531                    { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-b", "verdict": "dismissed" },
3532                    { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-a", "verdict": "survivor", "rationale": "input controls URL" },
3533                    { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-c", "verdict": "needs-human-review" }
3534                ]
3535            })
3536            .to_string(),
3537        )
3538        .expect("write verdicts");
3539
3540        let output = build_survivors_output(
3541            &SecuritySurvivorsOptions {
3542                output: OutputFormat::Json,
3543                candidates: &candidates,
3544                verdicts: &verdicts,
3545                require_verdict_for_each_candidate: false,
3546            },
3547            Instant::now(),
3548        )
3549        .expect("survivors output");
3550        let rendered: serde_json::Value =
3551            serde_json::from_str(&render_survivors_json(&output)).expect("json");
3552
3553        assert_eq!(rendered["kind"], "security-survivors");
3554        assert!(rendered["survivors"]["sec-a"].is_object());
3555        assert!(rendered["survivors"]["sec-b"].is_null());
3556        assert!(rendered["needs_human_review"]["sec-c"].is_object());
3557        assert_eq!(rendered["summary"]["dismissed"], 1);
3558    }
3559
3560    #[test]
3561    fn survivors_reject_duplicate_verdicts_and_unknown_candidates() {
3562        let dir = tempfile::tempdir().expect("temp dir");
3563        let candidates = dir.path().join("candidates.json");
3564        let verdicts = dir.path().join("verdicts.json");
3565        std::fs::write(
3566            &candidates,
3567            serde_json::json!({
3568                "security_findings": [
3569                    survivor_candidate_json("sec-a", "src/a.ts", 1, SecurityFindingKind::TaintedSink, Some("ssrf"))
3570                ]
3571            })
3572            .to_string(),
3573        )
3574        .expect("write candidates");
3575        std::fs::write(
3576            &verdicts,
3577            r#"[
3578                {"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"survivor"},
3579                {"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"dismissed"}
3580            ]"#,
3581        )
3582        .expect("write duplicate verdicts");
3583        let duplicate = build_survivors_output(
3584            &SecuritySurvivorsOptions {
3585                output: OutputFormat::Json,
3586                candidates: &candidates,
3587                verdicts: &verdicts,
3588                require_verdict_for_each_candidate: false,
3589            },
3590            Instant::now(),
3591        )
3592        .expect_err("duplicate verdict should fail");
3593        assert!(duplicate.contains("duplicate verdict"));
3594
3595        std::fs::write(
3596            &verdicts,
3597            r#"[{"schema_version":"fallow-security-verdict/v1","finding_id":"sec-missing","verdict":"survivor"}]"#,
3598        )
3599        .expect("write missing verdict");
3600        let missing = build_survivors_output(
3601            &SecuritySurvivorsOptions {
3602                output: OutputFormat::Json,
3603                candidates: &candidates,
3604                verdicts: &verdicts,
3605                require_verdict_for_each_candidate: false,
3606            },
3607            Instant::now(),
3608        )
3609        .expect_err("missing candidate should fail");
3610        assert!(missing.contains("unknown finding_id"));
3611    }
3612
3613    #[test]
3614    fn survivors_reject_malformed_schema_versions_and_unknown_verdicts() {
3615        let dir = tempfile::tempdir().expect("temp dir");
3616        let candidates = dir.path().join("candidates.json");
3617        let verdicts = dir.path().join("verdicts.json");
3618        std::fs::write(
3619            &candidates,
3620            serde_json::json!({
3621                "security_findings": [
3622                    survivor_candidate_json("sec-a", "src/a.ts", 1, SecurityFindingKind::TaintedSink, Some("ssrf"))
3623                ]
3624            })
3625            .to_string(),
3626        )
3627        .expect("write candidates");
3628        std::fs::write(
3629            &verdicts,
3630            r#"[{"schema_version":"wrong","finding_id":"sec-a","verdict":"survivor"}]"#,
3631        )
3632        .expect("write bad schema");
3633        let bad_schema = build_survivors_output(
3634            &SecuritySurvivorsOptions {
3635                output: OutputFormat::Json,
3636                candidates: &candidates,
3637                verdicts: &verdicts,
3638                require_verdict_for_each_candidate: false,
3639            },
3640            Instant::now(),
3641        )
3642        .expect_err("bad schema should fail");
3643        assert!(bad_schema.contains("schema_version"));
3644
3645        std::fs::write(
3646            &verdicts,
3647            r#"[{"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"maybe"}]"#,
3648        )
3649        .expect("write unknown verdict");
3650        let unknown = build_survivors_output(
3651            &SecuritySurvivorsOptions {
3652                output: OutputFormat::Json,
3653                candidates: &candidates,
3654                verdicts: &verdicts,
3655                require_verdict_for_each_candidate: false,
3656            },
3657            Instant::now(),
3658        )
3659        .expect_err("unknown verdict should fail");
3660        assert!(unknown.contains("Failed to parse verifier verdict file"));
3661    }
3662
3663    #[test]
3664    fn blind_spots_group_existing_diagnostics_with_suggestions() {
3665        let root = Path::new("/proj/root");
3666        let mut output = output_with(vec![], 2);
3667        output.unresolved_callee_sites = 99;
3668        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3669
3670        let blind_spots = build_blind_spots_output(&output);
3671        let rendered: serde_json::Value =
3672            serde_json::from_str(&render_blind_spots_json(&blind_spots)).expect("json");
3673
3674        assert_eq!(rendered["kind"], "security-blind-spots");
3675        assert_eq!(rendered["summary"]["unresolved_edge_files"], 2);
3676        assert_eq!(rendered["summary"]["unresolved_callee_sites"], 3);
3677        assert_eq!(rendered["groups"][0]["reason"], "dynamic-dispatch");
3678        assert_eq!(rendered["groups"][0]["expression_kind"], "other");
3679        assert_eq!(rendered["groups"][0]["files"][0]["path"], "src/a.ts");
3680        assert!(rendered["groups"][0]["suggestion"].is_string());
3681    }
3682
3683    #[test]
3684    fn blind_spots_human_preserves_non_clean_bill_framing() {
3685        let root = Path::new("/proj/root");
3686        let mut output = output_with(vec![], 0);
3687        output.unresolved_callee_sites = 3;
3688        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3689
3690        let out = render_blind_spots_human(&build_blind_spots_output(&output));
3691
3692        assert!(out.contains("may have missed security candidates"));
3693        assert!(out.contains("dynamic-dispatch / other"));
3694        assert!(out.contains("Next: inspect dynamic dispatch targets"));
3695    }
3696
3697    fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
3698        let mut finding = sample_finding(root);
3699        finding.kind = SecurityFindingKind::TaintedSink;
3700        finding.category = Some("dangerous-html".to_owned());
3701        finding.cwe = Some(79);
3702        finding.runtime = state.map(|state| SecurityRuntimeContext {
3703            state,
3704            function: "render".to_owned(),
3705            line: 10,
3706            invocations: Some(123),
3707            stable_id: Some("fallow:fn:test".to_owned()),
3708            evidence: Some("production runtime evidence".to_owned()),
3709        });
3710        finding
3711    }
3712
3713    #[test]
3714    fn runtime_rank_promotes_hot_and_demotes_never_executed() {
3715        let root = Path::new("/proj/root");
3716        let mut findings = [
3717            tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
3718            tainted_with_runtime(root, None),
3719            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3720            tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
3721        ];
3722
3723        findings.sort_by_key(runtime_rank);
3724
3725        assert_eq!(
3726            findings
3727                .iter()
3728                .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
3729                .collect::<Vec<_>>(),
3730            vec![
3731                Some(SecurityRuntimeState::RuntimeHot),
3732                None,
3733                Some(SecurityRuntimeState::CoverageUnavailable),
3734                Some(SecurityRuntimeState::NeverExecuted),
3735            ]
3736        );
3737    }
3738
3739    #[test]
3740    fn severity_sort_orders_tiers_then_location() {
3741        let root = Path::new("/proj/root");
3742        let mut high = sample_finding(root);
3743        high.path = root.join("z.ts");
3744        high.severity = SecuritySeverity::High;
3745        let mut low = sample_finding(root);
3746        low.path = root.join("a.ts");
3747        low.severity = SecuritySeverity::Low;
3748        let mut medium_a = sample_finding(root);
3749        medium_a.path = root.join("a.ts");
3750        medium_a.severity = SecuritySeverity::Medium;
3751        medium_a.reachability = Some(fallow_types::results::SecurityReachability {
3752            reachable_from_entry: false,
3753            reachable_from_untrusted_source: true,
3754            taint_confidence: Some(TaintConfidence::ModuleLevel),
3755            untrusted_source_hop_count: Some(1),
3756            untrusted_source_trace: vec![],
3757            blast_radius: 10,
3758            crosses_boundary: false,
3759        });
3760        let mut medium_b = sample_finding(root);
3761        medium_b.path = root.join("b.ts");
3762        medium_b.severity = SecuritySeverity::Medium;
3763        medium_b.source_backed = true;
3764        medium_b.reachability = Some(fallow_types::results::SecurityReachability {
3765            reachable_from_entry: false,
3766            reachable_from_untrusted_source: true,
3767            taint_confidence: Some(TaintConfidence::ArgLevel),
3768            untrusted_source_hop_count: Some(0),
3769            untrusted_source_trace: vec![],
3770            blast_radius: 1,
3771            crosses_boundary: false,
3772        });
3773        let mut findings = vec![low, medium_b, high, medium_a];
3774
3775        sort_by_security_severity(&mut findings);
3776
3777        assert_eq!(
3778            findings
3779                .iter()
3780                .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
3781                .collect::<Vec<_>>(),
3782            vec![
3783                (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
3784                (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
3785                (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
3786                (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
3787            ]
3788        );
3789    }
3790
3791    #[test]
3792    fn human_render_includes_runtime_context_line() {
3793        let root = Path::new("/proj/root");
3794        let finding = relativize_finding(
3795            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3796            root,
3797        );
3798        let out = render_human(&output_with(vec![finding], 0));
3799
3800        assert!(
3801            out.contains("runtime: runtime-hot in render:10"),
3802            "got: {out}"
3803        );
3804        assert!(out.contains("production runtime evidence"), "got: {out}");
3805    }
3806
3807    #[test]
3808    fn sarif_render_includes_runtime_context_in_message() {
3809        let root = Path::new("/proj/root");
3810        let finding = relativize_finding(
3811            tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3812            root,
3813        );
3814        let rendered = render_sarif(&output_with(vec![finding], 0));
3815        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3816        let message = sarif["runs"][0]["results"][0]["message"]["text"]
3817            .as_str()
3818            .expect("message text");
3819
3820        assert!(message.contains("Runtime context"), "got: {message}");
3821        assert!(
3822            message.contains("runtime-hot in render:10"),
3823            "got: {message}"
3824        );
3825    }
3826
3827    #[test]
3828    fn gate_human_header_fail_says_review_required_not_fail() {
3829        let gate = SecurityGate {
3830            mode: SecurityGateMode::New,
3831            verdict: SecurityGateVerdict::Fail,
3832            new_count: 2,
3833        };
3834        let header = gate_human_header(&gate);
3835        assert!(header.contains("REVIEW REQUIRED"));
3836        assert!(header.contains("2 new security items"));
3837        assert!(header.contains("not confirmed a vulnerability"));
3838        assert!(!header.to_uppercase().contains("GATE: FAIL"));
3839    }
3840
3841    #[test]
3842    fn gate_human_header_fail_singular_for_one_candidate() {
3843        // The gate makes new_count == 1 the common case (one PR adds one sink).
3844        let gate = SecurityGate {
3845            mode: SecurityGateMode::New,
3846            verdict: SecurityGateVerdict::Fail,
3847            new_count: 1,
3848        };
3849        let header = gate_human_header(&gate);
3850        assert!(header.contains("1 new security item in changed lines"));
3851        assert!(!header.contains("1 new security candidates"));
3852    }
3853
3854    #[test]
3855    fn gate_human_header_pass() {
3856        let gate = SecurityGate {
3857            mode: SecurityGateMode::New,
3858            verdict: SecurityGateVerdict::Pass,
3859            new_count: 0,
3860        };
3861        assert!(gate_human_header(&gate).contains("Gate: PASS"));
3862    }
3863
3864    #[test]
3865    fn gate_json_block_is_snake_case_and_present_on_pass() {
3866        let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
3867        assert!(json.contains("\"gate\""));
3868        assert!(json.contains("\"mode\": \"new\""));
3869        assert!(json.contains("\"verdict\": \"pass\""));
3870        assert!(json.contains("\"new_count\": 0"));
3871    }
3872
3873    #[test]
3874    fn reachability_key_includes_path_kind_and_category() {
3875        let root = Path::new("/proj/root");
3876        let mut leak = sample_finding(root);
3877        leak.reachability = Some(SecurityReachability {
3878            reachable_from_entry: true,
3879            reachable_from_untrusted_source: false,
3880            taint_confidence: None,
3881            untrusted_source_hop_count: None,
3882            untrusted_source_trace: vec![],
3883            blast_radius: 0,
3884            crosses_boundary: false,
3885        });
3886        let mut sink = leak.clone();
3887        sink.kind = SecurityFindingKind::TaintedSink;
3888        sink.category = Some("dangerous-html".to_owned());
3889
3890        assert_eq!(
3891            security_reachability_key(&leak, root).as_deref(),
3892            Some("security-reach:src/app.tsx:client-server-leak:none")
3893        );
3894        assert_eq!(
3895            security_reachability_key(&sink, root).as_deref(),
3896            Some("security-reach:src/app.tsx:tainted-sink:dangerous-html")
3897        );
3898    }
3899
3900    #[test]
3901    fn reachability_key_ignores_unreachable_findings() {
3902        let root = Path::new("/proj/root");
3903        let finding = sample_finding(root);
3904
3905        assert!(security_reachability_key(&finding, root).is_none());
3906    }
3907
3908    #[test]
3909    fn gate_absent_from_json_when_no_gate_ran() {
3910        let json = render_json(&output_with(vec![], 0));
3911        assert!(!json.contains("\"gate\""));
3912    }
3913
3914    #[test]
3915    fn gate_sarif_is_a_run_property_not_result_severity() {
3916        let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
3917        assert!(sarif.contains("fallowGate"));
3918        // The gate verdict is a run property and creates no result severity.
3919        assert!(!sarif.contains("\"level\": \"error\""));
3920        assert!(!sarif.contains("\"level\": \"warning\""));
3921    }
3922
3923    fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
3924        finding.reachability = Some(SecurityReachability {
3925            reachable_from_entry: true,
3926            reachable_from_untrusted_source: true,
3927            // Cross-module reachability is module-level (issue #1093).
3928            taint_confidence: Some(fallow_core::results::TaintConfidence::ModuleLevel),
3929            untrusted_source_hop_count: Some(1),
3930            untrusted_source_trace: vec![
3931                TraceHop {
3932                    path: root.join("src/routes/api.ts"),
3933                    line: 3,
3934                    col: 0,
3935                    role: TraceHopRole::ModuleSource,
3936                },
3937                TraceHop {
3938                    path: root.join("src/lib/sink.ts"),
3939                    line: 9,
3940                    col: 2,
3941                    role: TraceHopRole::Sink,
3942                },
3943            ],
3944            blast_radius: 2,
3945            crosses_boundary: false,
3946        });
3947    }
3948
3949    fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
3950        finding.taint_flow = Some(SecurityTaintFlow {
3951            source: TaintEndpoint {
3952                path: root.join("src/routes/api.ts"),
3953                line: 3,
3954                col: 0,
3955            },
3956            sink: TaintEndpoint {
3957                path: root.join("src/lib/sink.ts"),
3958                line: 9,
3959                col: 2,
3960            },
3961            path: TaintPath {
3962                intra_module: false,
3963                cross_module_hops: 1,
3964            },
3965        });
3966    }
3967
3968    #[test]
3969    fn relativize_strips_root_prefix() {
3970        let root = Path::new("/proj/root");
3971        let abs = root.join("src/app.tsx");
3972        let rel = relativize(&abs, root);
3973        assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
3974    }
3975
3976    #[test]
3977    fn relativize_keeps_path_when_outside_root() {
3978        let root = Path::new("/proj/root");
3979        let outside = Path::new("/elsewhere/file.ts");
3980        // Not under root: the original path is returned unchanged.
3981        assert_eq!(relativize(outside, root), outside.to_path_buf());
3982    }
3983
3984    #[test]
3985    fn relativize_finding_relativizes_anchor_and_every_hop() {
3986        let root = Path::new("/proj/root");
3987        let finding = relativize_finding(sample_finding(root), root);
3988        assert_eq!(
3989            finding.path.to_string_lossy().replace('\\', "/"),
3990            "src/app.tsx"
3991        );
3992        let hop_paths: Vec<String> = finding
3993            .trace
3994            .iter()
3995            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
3996            .collect();
3997        assert_eq!(
3998            hop_paths,
3999            vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
4000        );
4001    }
4002
4003    #[test]
4004    fn relativize_finding_relativizes_untrusted_source_trace() {
4005        let root = Path::new("/proj/root");
4006        let mut finding = sample_finding(root);
4007        add_untrusted_source_reachability(&mut finding, root);
4008        let finding = relativize_finding(finding, root);
4009        let reach = finding.reachability.as_ref().expect("reachability");
4010        let hop_paths: Vec<String> = reach
4011            .untrusted_source_trace
4012            .iter()
4013            .map(|h| h.path.to_string_lossy().replace('\\', "/"))
4014            .collect();
4015        assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
4016    }
4017
4018    #[test]
4019    fn fnv_hex_is_deterministic_and_16_hex_digits() {
4020        let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
4021        let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
4022        assert_eq!(a, b, "same input must hash identically");
4023        assert_eq!(a.len(), 16);
4024        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
4025        // Distinct input yields a distinct digest (anchor line differs).
4026        assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
4027    }
4028
4029    #[test]
4030    fn hop_role_labels_cover_every_role() {
4031        assert_eq!(
4032            hop_role_label(TraceHopRole::ClientBoundary),
4033            "client boundary"
4034        );
4035        assert_eq!(
4036            hop_role_label(TraceHopRole::UntrustedSource),
4037            "untrusted source"
4038        );
4039        assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
4040        assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
4041        assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
4042        assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
4043    }
4044
4045    #[test]
4046    fn sarif_location_clamps_line_and_offsets_column() {
4047        // A zero line clamps to 1; the 0-based column becomes 1-based.
4048        let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
4049        let region = &loc["physicalLocation"]["region"];
4050        assert_eq!(region["startLine"], 1);
4051        assert_eq!(region["startColumn"], 1);
4052        // Backslash separators normalize to forward slashes in the URI.
4053        assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
4054    }
4055
4056    #[test]
4057    fn human_summary_reports_zero_without_edge_line() {
4058        let out = render_human_summary(&output_with(vec![], 0));
4059        assert!(
4060            out.contains("Security review: no items to check in the scanned code."),
4061            "got: {out}"
4062        );
4063        assert!(!out.contains("Blind spot"));
4064    }
4065
4066    #[test]
4067    fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
4068        let root = Path::new("/proj/root");
4069        let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
4070        assert!(
4071            out.contains("Security review: 1 item to check."),
4072            "got: {out}"
4073        );
4074        assert!(out.contains("not confirmed vulnerabilities"));
4075        assert!(out.contains("unsafe input, secrets, or settings"));
4076        assert!(out.contains("Blind spot: 2 client files use dynamic imports"));
4077    }
4078
4079    #[test]
4080    fn human_render_empty_states_no_candidates() {
4081        colored::control::set_override(false);
4082        let out = render_human(&output_with(vec![], 0));
4083        assert!(out.contains("Security review: 0 items to check"));
4084        assert!(out.contains("No security details to show."));
4085        assert!(out.contains("Result: 0 security items to check."));
4086    }
4087
4088    #[test]
4089    fn human_render_shows_finding_trace_and_next_action() {
4090        colored::control::set_override(false);
4091        let root = Path::new("/proj/root");
4092        let finding = relativize_finding(sample_finding(root), root);
4093        let out = render_human(&output_with(vec![finding], 0));
4094        assert!(out.contains("[H] high client-server-leak"));
4095        assert!(out.contains("client-server-leak"));
4096        assert!(out.contains("src/app.tsx:12"));
4097        assert!(out.contains("evidence: reaches process.env.SECRET_KEY"));
4098        assert!(out.contains("import trace:"));
4099        assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
4100        assert!(out.contains("src/app.tsx:12 (client boundary)"));
4101        assert!(out.contains("Next: check whether this import can ship a secret to the browser"));
4102        assert!(out.contains("Result: 1 security item to check."));
4103    }
4104
4105    #[test]
4106    fn human_render_shows_dead_code_hint_and_delete_next_step() {
4107        colored::control::set_override(false);
4108        let root = Path::new("/proj/root");
4109        let mut finding = relativize_finding(sample_finding(root), root);
4110        finding.kind = SecurityFindingKind::TaintedSink;
4111        finding.dead_code = Some(SecurityDeadCodeContext {
4112            kind: SecurityDeadCodeKind::UnusedFile,
4113            export_name: None,
4114            line: None,
4115            guidance: "delete instead of harden".to_string(),
4116        });
4117        let out = render_human(&output_with(vec![finding], 0));
4118        assert!(
4119            out.contains("dead-code: also reported as unused-file"),
4120            "got: {out}"
4121        );
4122        assert!(
4123            out.contains("If the code is safe to remove, delete it"),
4124            "got: {out}"
4125        );
4126    }
4127
4128    #[test]
4129    fn human_render_shows_untrusted_source_path_as_module_context() {
4130        colored::control::set_override(false);
4131        let root = Path::new("/proj/root");
4132        let mut finding = sample_finding(root);
4133        finding.kind = SecurityFindingKind::TaintedSink;
4134        finding.category = Some("command-injection".to_string());
4135        add_untrusted_source_reachability(&mut finding, root);
4136        let finding = relativize_finding(finding, root);
4137
4138        let out = render_human(&output_with(vec![finding], 0));
4139
4140        assert!(
4141            out.contains("reachable from a module that receives untrusted input via 1 import hop"),
4142            "got: {out}"
4143        );
4144        assert!(out.contains("input import trace:"), "got: {out}");
4145        assert!(
4146            out.contains("src/routes/api.ts:3 (source module)"),
4147            "got: {out}"
4148        );
4149    }
4150
4151    #[test]
4152    fn human_render_surfaces_unresolved_edge_blind_spot() {
4153        colored::control::set_override(false);
4154        let out = render_human(&output_with(vec![], 3));
4155        assert!(out.contains("Blind spot: 3 client files use dynamic imports"));
4156        assert!(out.contains("Code behind those imports may be missing from this report."));
4157    }
4158
4159    #[test]
4160    fn human_render_blind_spots_use_singular_verbs() {
4161        colored::control::set_override(false);
4162        let mut output = output_with(vec![], 1);
4163        output.unresolved_callee_sites = 1;
4164
4165        let out = render_human(&output);
4166
4167        assert!(out.contains("Blind spot: 1 client file uses dynamic imports"));
4168        assert!(out.contains("Blind spot: 1 call site uses code patterns"));
4169    }
4170
4171    #[test]
4172    fn human_render_mentions_top_unresolved_callee_reason_and_file() {
4173        colored::control::set_override(false);
4174        let root = Path::new("/proj/root");
4175        let mut output = output_with(vec![], 0);
4176        output.unresolved_callee_sites = 3;
4177        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
4178
4179        let out = render_human(&output);
4180
4181        assert!(
4182            out.contains("Most unresolved callees: dynamic-dispatch in src/a.ts."),
4183            "got: {out}"
4184        );
4185    }
4186
4187    #[test]
4188    fn json_render_carries_schema_version_and_findings() {
4189        let root = Path::new("/proj/root");
4190        let finding = relativize_finding(sample_finding(root), root);
4191        let rendered = render_json(&output_with(vec![finding], 1));
4192        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4193        assert_eq!(value["schema_version"], "7");
4194        assert_eq!(value["version"], "test");
4195        assert_eq!(value["elapsed_ms"], 0);
4196        assert_eq!(
4197            value["config"]["rules"]["security_client_server_leak"]["configured"],
4198            "off"
4199        );
4200        assert_eq!(
4201            value["config"]["rules"]["security_client_server_leak"]["effective"],
4202            "warn"
4203        );
4204        assert!(value["config"]["categories_include"].is_null());
4205        assert!(value["config"]["categories_exclude"].is_null());
4206        assert_eq!(value["unresolved_edge_files"], 1);
4207        let findings = value["security_findings"].as_array().expect("array");
4208        assert_eq!(findings.len(), 1);
4209        assert_eq!(findings[0]["kind"], "client-server-leak");
4210        assert_eq!(findings[0]["path"], "src/app.tsx");
4211        assert_eq!(findings[0]["severity"], "high");
4212    }
4213
4214    #[test]
4215    fn json_render_carries_bounded_unresolved_callee_diagnostics() {
4216        let root = Path::new("/proj/root");
4217        let mut output = output_with(vec![], 0);
4218        output.unresolved_callee_sites = 3;
4219        output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
4220
4221        let rendered = render_json(&output);
4222        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4223        let diagnostics = &value["unresolved_callee_diagnostics"];
4224
4225        assert_eq!(diagnostics["sample_limit"], 25);
4226        assert_eq!(diagnostics["top_files_limit"], 10);
4227        assert_eq!(diagnostics["sampled"][0]["path"], "src/a.ts");
4228        assert_eq!(diagnostics["sampled"][0]["reason"], "dynamic-dispatch");
4229        assert_eq!(diagnostics["sampled"][0]["expression_kind"], "other");
4230        assert_eq!(diagnostics["top_files"][0]["path"], "src/a.ts");
4231        assert_eq!(diagnostics["top_files"][0]["count"], 2);
4232        assert_eq!(diagnostics["by_reason"][0]["reason"], "dynamic-dispatch");
4233        assert_eq!(diagnostics["by_reason"][0]["count"], 2);
4234    }
4235
4236    #[test]
4237    fn json_summary_omits_finding_arrays_and_counts_security_findings() {
4238        let root = Path::new("/proj/root");
4239        let mut leak = relativize_finding(sample_finding(root), root);
4240        leak.severity = SecuritySeverity::High;
4241
4242        let mut sink = relativize_finding(sample_finding(root), root);
4243        sink.kind = SecurityFindingKind::TaintedSink;
4244        sink.category = Some("dangerous-html".to_string());
4245        sink.severity = SecuritySeverity::Medium;
4246        sink.source_backed = true;
4247        sink.reachability = Some(SecurityReachability {
4248            reachable_from_entry: true,
4249            reachable_from_untrusted_source: true,
4250            taint_confidence: Some(TaintConfidence::ArgLevel),
4251            untrusted_source_hop_count: Some(0),
4252            untrusted_source_trace: vec![],
4253            blast_radius: 3,
4254            crosses_boundary: true,
4255        });
4256        sink.runtime = Some(SecurityRuntimeContext {
4257            state: SecurityRuntimeState::RuntimeHot,
4258            function: "render".to_owned(),
4259            line: 10,
4260            invocations: Some(120),
4261            stable_id: Some("src/app.tsx::render:10".to_owned()),
4262            evidence: Some("production hot path observed".to_owned()),
4263        });
4264
4265        let mut output = output_with(vec![leak, sink], 2);
4266        output.elapsed_ms = ElapsedMs(17);
4267        output.unresolved_callee_sites = 3;
4268
4269        let rendered = render_json_summary(&output);
4270        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4271
4272        assert_eq!(value["kind"], "security");
4273        assert_eq!(value["schema_version"], "7");
4274        assert_eq!(value["version"], "test");
4275        assert_eq!(value["elapsed_ms"], 17);
4276        assert!(value.get("config").is_some());
4277        assert!(value.get("security_findings").is_none());
4278        assert!(value.get("attack_surface").is_none());
4279        assert!(value.get("_meta").is_none());
4280        assert_eq!(value["summary"]["security_findings"], 2);
4281        assert_eq!(value["summary"]["by_severity"]["high"], 1);
4282        assert_eq!(value["summary"]["by_severity"]["medium"], 1);
4283        assert_eq!(value["summary"]["by_severity"]["low"], 0);
4284        assert_eq!(value["summary"]["by_category"]["client-server-leak"], 1);
4285        assert_eq!(value["summary"]["by_category"]["dangerous-html"], 1);
4286        assert_eq!(value["summary"]["by_reachability"]["entry_reachable"], 1);
4287        assert_eq!(
4288            value["summary"]["by_reachability"]["untrusted_source_reachable"],
4289            1
4290        );
4291        assert_eq!(value["summary"]["by_reachability"]["arg_level"], 1);
4292        assert_eq!(value["summary"]["by_reachability"]["module_level"], 0);
4293        assert_eq!(value["summary"]["by_reachability"]["crosses_boundary"], 1);
4294        assert_eq!(value["summary"]["by_reachability"]["source_backed"], 1);
4295        assert_eq!(value["summary"]["by_runtime_state"]["runtime_hot"], 1);
4296        assert_eq!(value["summary"]["by_runtime_state"]["runtime_cold"], 0);
4297        assert_eq!(value["summary"]["by_runtime_state"]["never_executed"], 0);
4298        assert_eq!(value["summary"]["by_runtime_state"]["low_traffic"], 0);
4299        assert_eq!(
4300            value["summary"]["by_runtime_state"]["coverage_unavailable"],
4301            0
4302        );
4303        assert_eq!(value["summary"]["by_runtime_state"]["runtime_unknown"], 0);
4304        assert_eq!(value["summary"]["by_runtime_state"]["not_collected"], 1);
4305        assert_eq!(value["summary"]["unresolved_edge_files"], 2);
4306        assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
4307        assert_eq!(value["summary"]["attack_surface_entries"], 0);
4308    }
4309
4310    #[test]
4311    fn json_summary_carries_security_meta_when_explain_requested() {
4312        let root = Path::new("/proj/root");
4313        let mut output = output_with(vec![relativize_finding(sample_finding(root), root)], 0);
4314        output.meta = Some(crate::explain::security_meta());
4315
4316        let rendered = render_json_summary(&output);
4317        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4318
4319        assert!(value.get("security_findings").is_none());
4320        assert!(value["_meta"]["field_definitions"]["security_findings[]"].is_string());
4321        assert!(value["_meta"]["field_definitions"]["summary.by_reachability"].is_string());
4322        assert!(value["_meta"]["field_definitions"]["summary.by_runtime_state"].is_string());
4323        assert!(value["_meta"]["field_definitions"]["unresolved_callee_sites"].is_string());
4324    }
4325
4326    #[test]
4327    fn json_summary_preserves_gate_block() {
4328        let output = output_with_gate(SecurityGateVerdict::Fail, 1);
4329        let rendered = render_json_summary(&output);
4330        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4331
4332        assert_eq!(value["kind"], "security");
4333        assert_eq!(value["gate"]["mode"], "new");
4334        assert_eq!(value["gate"]["verdict"], "fail");
4335        assert_eq!(value["gate"]["new_count"], 1);
4336        assert_eq!(value["summary"]["security_findings"], 0);
4337    }
4338
4339    #[test]
4340    fn json_render_carries_security_meta_when_explain_requested() {
4341        let mut output = output_with(vec![], 0);
4342        output.meta = Some(crate::explain::security_meta());
4343
4344        let rendered = render_json(&output);
4345        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4346
4347        assert_eq!(
4348            value["_meta"]["field_definitions"]["security_findings[]"],
4349            "Unverified security candidates for downstream human or agent verification."
4350        );
4351        assert!(value["_meta"]["rules"]["security/tainted-sink"].is_object());
4352    }
4353
4354    #[test]
4355    fn json_render_carries_candidate_record_and_omits_impact() {
4356        // Issue #900: every finding carries a 3-slot candidate record; there is
4357        // NO `impact` key on the wire (agent-owned, documented in the schema). A
4358        // client-server-leak has no source kind and no taint flow.
4359        let root = Path::new("/proj/root");
4360        let finding = relativize_finding(sample_finding(root), root);
4361        let rendered = render_json(&output_with(vec![finding], 0));
4362        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4363        let finding = &value["security_findings"][0];
4364
4365        let candidate = &finding["candidate"];
4366        assert!(candidate.is_object(), "candidate record present");
4367        assert!(candidate["sink"].is_object(), "sink slot present");
4368        assert_eq!(candidate["boundary"]["client_server"], true);
4369        assert!(
4370            candidate.get("impact").is_none(),
4371            "impact must NOT be a wire field"
4372        );
4373        assert!(
4374            candidate.get("source_kind").is_none(),
4375            "client-server-leak has no source kind"
4376        );
4377        assert!(
4378            finding.get("taint_flow").is_none(),
4379            "no untrusted-source flow on a client-server-leak"
4380        );
4381        assert!(
4382            finding.get("finding_id").is_some(),
4383            "finding_id is on the wire"
4384        );
4385    }
4386
4387    #[test]
4388    fn finding_id_is_stable_and_matches_sarif_fingerprint() {
4389        // Issue #900: one helper computes both the JSON finding_id and the SARIF
4390        // partialFingerprint, so an agent can join the two and they never drift.
4391        let root = Path::new("/proj/root");
4392        let finding = relativize_finding(sample_finding(root), root);
4393        let id = security_finding_id(&finding);
4394        assert!(!id.is_empty());
4395        assert_eq!(
4396            id,
4397            security_finding_id(&finding),
4398            "deterministic across calls"
4399        );
4400
4401        let sarif: serde_json::Value =
4402            serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
4403                .expect("valid SARIF");
4404        assert_eq!(
4405            sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
4406            serde_json::Value::String(id)
4407        );
4408    }
4409
4410    #[test]
4411    fn json_render_carries_dead_code_context() {
4412        let root = Path::new("/proj/root");
4413        let mut finding = relativize_finding(sample_finding(root), root);
4414        finding.kind = SecurityFindingKind::TaintedSink;
4415        finding.dead_code = Some(SecurityDeadCodeContext {
4416            kind: SecurityDeadCodeKind::UnusedExport,
4417            export_name: Some("handler".to_string()),
4418            line: Some(12),
4419            guidance: "remove export instead of harden".to_string(),
4420        });
4421        let rendered = render_json(&output_with(vec![finding], 0));
4422        let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
4423        let context = &value["security_findings"][0]["dead_code"];
4424        assert_eq!(context["kind"], "unused-export");
4425        assert_eq!(context["export_name"], "handler");
4426        assert_eq!(context["line"], 12);
4427    }
4428
4429    #[test]
4430    fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
4431        let root = Path::new("/proj/root");
4432        let finding = relativize_finding(sample_finding(root), root);
4433        let rendered = render_sarif(&output_with(vec![finding], 0));
4434        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4435        assert_eq!(sarif["version"], "2.1.0");
4436        let run = &sarif["runs"][0];
4437        assert_eq!(run["tool"]["driver"]["name"], "fallow");
4438        let result = &run["results"][0];
4439        // Candidate framing: a high-priority finding is warning, never error.
4440        assert_eq!(result["level"], "warning");
4441        assert_eq!(result["ruleId"], "security/client-server-leak");
4442        assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
4443        // Trace hops surface as relatedLocations and codeFlows.
4444        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
4445        let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
4446            .as_array()
4447            .expect("thread flow locations");
4448        assert_eq!(flow_locations.len(), 3);
4449        assert_eq!(
4450            flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
4451            "src/app.tsx"
4452        );
4453        assert_eq!(
4454            flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
4455            "src/lib/secret.ts"
4456        );
4457        assert_eq!(
4458            flow_locations[2]["kinds"][0],
4459            serde_json::json!("secret-source")
4460        );
4461        // Stable dedup fingerprint present for GHAS.
4462        assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
4463
4464        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
4465        assert_eq!(rules[0]["name"], "Client-server secret leak");
4466        assert!(rules[0]["help"]["text"].is_string());
4467        assert!(rules[0].get("relationships").is_none());
4468        assert!(run.get("taxonomies").is_none());
4469    }
4470
4471    #[test]
4472    fn sarif_render_keeps_low_severity_as_note() {
4473        let root = Path::new("/proj/root");
4474        let mut finding = sample_finding(root);
4475        finding.severity = SecuritySeverity::Low;
4476        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
4477        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4478
4479        assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
4480    }
4481
4482    #[test]
4483    fn sarif_render_includes_dead_code_hint_in_message() {
4484        let root = Path::new("/proj/root");
4485        let mut finding = relativize_finding(sample_finding(root), root);
4486        finding.kind = SecurityFindingKind::TaintedSink;
4487        finding.dead_code = Some(SecurityDeadCodeContext {
4488            kind: SecurityDeadCodeKind::UnusedFile,
4489            export_name: None,
4490            line: None,
4491            guidance: "delete instead of harden".to_string(),
4492        });
4493        let rendered = render_sarif(&output_with(vec![finding], 0));
4494        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4495        let message = sarif["runs"][0]["results"][0]["message"]["text"]
4496            .as_str()
4497            .expect("message text");
4498        assert!(message.contains("Dead-code cross-link"), "got: {message}");
4499        assert!(
4500            message.contains("delete this file instead of hardening"),
4501            "got: {message}"
4502        );
4503    }
4504
4505    #[test]
4506    fn sarif_render_includes_untrusted_source_context_and_related_locations() {
4507        let root = Path::new("/proj/root");
4508        let mut finding = sample_finding(root);
4509        finding.kind = SecurityFindingKind::TaintedSink;
4510        finding.category = Some("command-injection".to_string());
4511        add_untrusted_source_reachability(&mut finding, root);
4512        add_taint_flow(&mut finding, root);
4513        finding.trace.push(TraceHop {
4514            path: root.join("src/lib/sink.ts"),
4515            line: 9,
4516            col: 2,
4517            role: TraceHopRole::Sink,
4518        });
4519        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
4520        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4521        let result = &sarif["runs"][0]["results"][0];
4522        let message = result["message"]["text"].as_str().expect("message text");
4523        assert!(message.contains("Module-level context"), "got: {message}");
4524        assert!(
4525            message.contains("does not prove value flow"),
4526            "got: {message}"
4527        );
4528        // The sink appears in both trace families, but SARIF relatedLocations requires unique items.
4529        assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
4530        let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
4531            .as_array()
4532            .expect("thread flow locations");
4533        assert_eq!(flow_locations.len(), 2);
4534        assert_eq!(
4535            flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
4536            "src/routes/api.ts"
4537        );
4538        assert_eq!(
4539            flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
4540            "src/lib/sink.ts"
4541        );
4542    }
4543
4544    #[test]
4545    fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
4546        let root = Path::new("/proj/root");
4547        let mut finding = sample_finding(root);
4548        finding.kind = SecurityFindingKind::TaintedSink;
4549        finding.category = Some("dangerous-html".to_owned());
4550        finding.cwe = Some(79);
4551        let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
4552        let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4553        let run = &sarif["runs"][0];
4554        // The finding is grouped under its own per-category rule, not collapsed
4555        // into client-server-leak, and stays candidate-framed.
4556        let result = &run["results"][0];
4557        assert_eq!(result["level"], "warning");
4558        assert_eq!(result["ruleId"], "security/dangerous-html");
4559        // Exactly one rule definition, carrying compatible tags plus SARIF-native CWE taxonomy.
4560        let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
4561        assert_eq!(rules.len(), 1);
4562        assert_eq!(rules[0]["id"], "security/dangerous-html");
4563        assert_eq!(rules[0]["name"], "Dangerous HTML sink");
4564        assert!(
4565            rules[0]["help"]["text"]
4566                .as_str()
4567                .expect("help text")
4568                .contains("Verify this unverified")
4569        );
4570        assert!(
4571            rules[0]["help"]["markdown"]
4572                .as_str()
4573                .expect("help markdown")
4574                .contains("**Dangerous HTML sink**")
4575        );
4576        let tags = rules[0]["properties"]["tags"].as_array().unwrap();
4577        assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
4578        let relationship = &rules[0]["relationships"][0];
4579        assert_eq!(relationship["target"]["id"], "CWE-79");
4580        assert_eq!(relationship["target"]["index"], 0);
4581        assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
4582        assert_eq!(relationship["kinds"][0], "superset");
4583
4584        let taxonomy = &run["taxonomies"][0];
4585        assert_eq!(taxonomy["name"], "CWE");
4586        assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
4587        assert_eq!(
4588            run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
4589            "CWE"
4590        );
4591    }
4592
4593    #[test]
4594    fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
4595        let root = Path::new("/proj/root");
4596        let finding = relativize_finding(sample_finding(root), root);
4597        let output = output_with(vec![finding], 0);
4598        let dir = tempfile::tempdir().expect("tempdir");
4599        let path = dir.path().join("nested/out.sarif");
4600        write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
4601        let written = std::fs::read_to_string(&path).expect("file exists");
4602        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
4603        assert_eq!(sarif["version"], "2.1.0");
4604    }
4605
4606    /// No explicit `--config`; static so the `&'a Option<PathBuf>` field borrows it.
4607    const NO_CONFIG: Option<PathBuf> = None;
4608
4609    fn leak_fixture_root() -> PathBuf {
4610        Path::new(env!("CARGO_MANIFEST_DIR"))
4611            .join("../../tests/fixtures/security-client-server-leak")
4612    }
4613
4614    fn source_reachability_fixture_root() -> PathBuf {
4615        Path::new(env!("CARGO_MANIFEST_DIR"))
4616            .join("../../tests/fixtures/security-source-reachability-885")
4617    }
4618
4619    fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
4620        SecurityOptions {
4621            root,
4622            config_path: &NO_CONFIG,
4623            output,
4624            no_cache: true,
4625            threads: 1,
4626            quiet: true,
4627            fail_on_issues,
4628            sarif_file: None,
4629            summary: false,
4630            changed_since: None,
4631            use_shared_diff_index: false,
4632            workspace: None,
4633            changed_workspaces: None,
4634            file: &[],
4635            surface: false,
4636            gate: None,
4637            runtime_coverage: None,
4638            min_invocations_hot: 100,
4639            explain: false,
4640        }
4641    }
4642
4643    #[test]
4644    #[expect(
4645        deprecated,
4646        reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
4647    )]
4648    fn source_reachability_fixture_marks_cross_module_sink() {
4649        let root = source_reachability_fixture_root();
4650        let mut config = load_config_for_analysis(
4651            &root,
4652            &NO_CONFIG,
4653            crate::ConfigLoadOptions {
4654                output: OutputFormat::Json,
4655                no_cache: true,
4656                threads: 1,
4657                production_override: None,
4658                quiet: true,
4659            },
4660            ProductionAnalysis::DeadCode,
4661        )
4662        .expect("fixture config loads");
4663        config.rules.security_sink = Severity::Warn;
4664
4665        let results = fallow_core::analyze(&config).expect("fixture analyzes");
4666        let finding = results
4667            .security_findings
4668            .iter()
4669            .find(|finding| finding.path.ends_with("src/runner.ts"))
4670            .expect("runner sink finding");
4671        let reach = finding.reachability.as_ref().expect("reachability");
4672
4673        assert!(reach.reachable_from_untrusted_source);
4674        assert_eq!(reach.untrusted_source_hop_count, Some(1));
4675        // Cross-module reachability is module-level: the structured discriminator
4676        // says so, and the source node is honestly labeled `ModuleSource`, never
4677        // `UntrustedSource` (which is reserved for an arg-level same-module read).
4678        assert_eq!(
4679            reach.taint_confidence,
4680            Some(fallow_core::results::TaintConfidence::ModuleLevel)
4681        );
4682        assert_eq!(
4683            reach
4684                .untrusted_source_trace
4685                .iter()
4686                .map(|hop| hop.role)
4687                .collect::<Vec<_>>(),
4688            vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
4689        );
4690        assert!(
4691            reach.untrusted_source_trace[0]
4692                .path
4693                .ends_with("src/route.ts")
4694        );
4695
4696        // Issue #900: the candidate boundary slot records the cross-module hop,
4697        // and the taint-flow triple re-projects the reachability endpoints + a
4698        // compact path (not a duplicate hop array).
4699        assert!(
4700            finding.candidate.boundary.cross_module,
4701            "a sink reached across a module hop crosses a module boundary"
4702        );
4703        let flow = finding.taint_flow.as_ref().expect("taint_flow present");
4704        assert!(!flow.path.intra_module);
4705        assert_eq!(flow.path.cross_module_hops, 1);
4706        assert!(flow.source.path.ends_with("src/route.ts"));
4707        assert!(flow.sink.path.ends_with("src/runner.ts"));
4708    }
4709
4710    #[test]
4711    fn file_scope_keeps_security_finding_when_anchor_matches() {
4712        let root = Path::new("/proj/root");
4713        let mut results = fallow_core::results::AnalysisResults::default();
4714        results.security_findings.push(sample_finding(root));
4715
4716        filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
4717
4718        assert_eq!(results.security_findings.len(), 1);
4719    }
4720
4721    #[test]
4722    fn file_scope_keeps_security_finding_when_trace_hop_matches() {
4723        let root = Path::new("/proj/root");
4724        let mut results = fallow_core::results::AnalysisResults::default();
4725        results.security_findings.push(sample_finding(root));
4726
4727        filter_to_files(
4728            &mut results,
4729            root,
4730            &[PathBuf::from("src/lib/secret.ts")],
4731            true,
4732        );
4733
4734        assert_eq!(results.security_findings.len(), 1);
4735    }
4736
4737    #[test]
4738    fn file_scope_drops_unrelated_security_finding() {
4739        let root = Path::new("/proj/root");
4740        let mut results = fallow_core::results::AnalysisResults::default();
4741        results.security_findings.push(sample_finding(root));
4742
4743        filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
4744
4745        assert!(results.security_findings.is_empty());
4746    }
4747
4748    #[test]
4749    fn run_is_advisory_and_exits_zero_even_with_candidates() {
4750        // The rule defaults to off; the command forces it to warn, so findings on
4751        // the fixture are surfaced but the exit stays 0 (advisory) by default.
4752        let root = leak_fixture_root();
4753        let code = run(&run_opts(&root, OutputFormat::Json, false));
4754        assert_eq!(code, ExitCode::SUCCESS);
4755    }
4756
4757    #[test]
4758    fn run_with_fail_on_issues_exits_one_when_candidates_found() {
4759        // The fixture has real leak candidates, so --fail-on-issues raises exit 1.
4760        let root = leak_fixture_root();
4761        let code = run(&run_opts(&root, OutputFormat::Human, true));
4762        assert_eq!(code, ExitCode::from(1));
4763    }
4764
4765    #[test]
4766    fn run_rejects_unsupported_output_format() {
4767        // Only human / json / sarif are supported; compact exits 2 before analysis.
4768        let root = leak_fixture_root();
4769        let code = run(&run_opts(&root, OutputFormat::Compact, false));
4770        assert_eq!(code, ExitCode::from(2));
4771    }
4772
4773    #[test]
4774    fn run_summary_mode_dispatches_compact_human_renderer() {
4775        let root = leak_fixture_root();
4776        let opts = SecurityOptions {
4777            summary: true,
4778            ..run_opts(&root, OutputFormat::Human, false)
4779        };
4780        assert_eq!(run(&opts), ExitCode::SUCCESS);
4781    }
4782
4783    #[test]
4784    fn run_sarif_format_dispatches_sarif_renderer() {
4785        let root = leak_fixture_root();
4786        assert_eq!(
4787            run(&run_opts(&root, OutputFormat::Sarif, false)),
4788            ExitCode::SUCCESS
4789        );
4790    }
4791
4792    #[test]
4793    fn run_writes_sarif_sidecar_file_when_requested() {
4794        let root = leak_fixture_root();
4795        let dir = tempfile::tempdir().expect("tempdir");
4796        let sidecar = dir.path().join("security.sarif");
4797        let opts = SecurityOptions {
4798            sarif_file: Some(&sidecar),
4799            ..run_opts(&root, OutputFormat::Human, false)
4800        };
4801        assert_eq!(run(&opts), ExitCode::SUCCESS);
4802        let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
4803        let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
4804        assert_eq!(sarif["version"], "2.1.0");
4805    }
4806}