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