Skip to main content

fallow_cli/
security.rs

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