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