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