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