1use crate::report::sink::outln;
13use std::cmp::Ordering;
14use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18use std::time::Instant;
19
20use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
21use fallow_core::analyze::derive_security_severity;
22use fallow_core::results::{
23 AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
24 SecurityFindingKind, TraceHop, TraceHopRole,
25};
26use fallow_types::discover::DiscoveredFile;
27use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
28use fallow_types::extract::ModuleInfo;
29use fallow_types::results::{
30 SecurityRuntimeContext, SecurityRuntimeState, SecuritySeverity,
31 SecurityUnresolvedCalleeDiagnostic, TaintConfidence,
32};
33use rustc_hash::FxHashSet;
34use serde::Serialize;
35use xxhash_rust::xxh3::xxh3_64;
36
37use crate::base_worktree::{BaseWorktree, git_rev_parse};
38use crate::error::emit_error;
39use crate::health::{HealthOptions, SharedParseData, SortBy};
40use crate::health_types::{
41 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
42};
43use crate::load_config_for_analysis;
44
45const UNRESOLVED_CALLEE_SAMPLE_LIMIT: usize = 25;
46const UNRESOLVED_CALLEE_TOP_FILES_LIMIT: usize = 10;
47
48#[derive(Debug, Clone, Copy, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52pub enum SecuritySchemaVersion {
53 #[allow(
55 dead_code,
56 reason = "kept so the generated schema documents historical v1"
57 )]
58 #[serde(rename = "1")]
59 V1,
60 #[allow(
62 dead_code,
63 reason = "kept so the generated schema documents historical v2"
64 )]
65 #[serde(rename = "2")]
66 V2,
67 #[allow(
69 dead_code,
70 reason = "kept so the generated schema documents historical v3"
71 )]
72 #[serde(rename = "3")]
73 V3,
74 #[allow(
76 dead_code,
77 reason = "kept so the generated schema documents historical v4"
78 )]
79 #[serde(rename = "4")]
80 V4,
81 #[allow(
83 dead_code,
84 reason = "kept so the generated schema documents historical v5"
85 )]
86 #[serde(rename = "5")]
87 V5,
88 #[serde(rename = "6")]
90 V6,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96#[serde(rename_all = "kebab-case")]
97pub enum SecurityGateMode {
98 New,
103 NewlyReachable,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[serde(rename_all = "kebab-case")]
114pub enum SecurityGateVerdict {
115 Pass,
117 Fail,
119}
120
121#[derive(Debug, Clone, Copy, Serialize)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125pub struct SecurityGate {
126 pub mode: SecurityGateMode,
128 pub verdict: SecurityGateVerdict,
130 pub new_count: usize,
132}
133
134#[derive(Debug, Clone, Serialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137#[cfg_attr(
138 feature = "schema",
139 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
140)]
141pub struct SecurityOutputConfig {
142 pub rules: SecurityOutputRulesConfig,
145 pub categories_include: Option<Vec<String>>,
148 pub categories_exclude: Option<Vec<String>>,
151}
152
153#[derive(Debug, Clone, Copy, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct SecurityOutputRulesConfig {
156 pub security_client_server_leak: SecurityRuleSeverityConfig,
157 pub security_sink: SecurityRuleSeverityConfig,
158}
159
160#[derive(Debug, Clone, Copy, Serialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct SecurityRuleSeverityConfig {
163 pub configured: Severity,
166 pub effective: Severity,
168}
169
170#[derive(Debug, Clone, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct SecurityOutput {
176 pub schema_version: SecuritySchemaVersion,
178 pub version: ToolVersion,
180 pub elapsed_ms: ElapsedMs,
182 pub config: SecurityOutputConfig,
184 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
186 pub meta: Option<Meta>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub gate: Option<SecurityGate>,
192 pub security_findings: Vec<SecurityFinding>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
198 pub unresolved_edge_files: usize,
203 pub unresolved_callee_sites: usize,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
211}
212
213#[derive(Debug, Clone, Serialize)]
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
216pub struct SecurityUnresolvedCalleeDiagnostics {
217 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
219 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
221 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
223 pub sample_limit: usize,
225 pub top_files_limit: usize,
227}
228
229#[derive(Debug, Clone, Serialize)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232pub struct SecurityUnresolvedCalleeSample {
233 pub path: String,
235 pub line: u32,
237 pub col: u32,
239 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
241 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
243}
244
245#[derive(Debug, Clone, Serialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct SecurityUnresolvedCalleeTopFile {
249 pub path: String,
251 pub count: usize,
253}
254
255#[derive(Debug, Clone, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258pub struct SecurityUnresolvedCalleeReasonCount {
259 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
261 pub count: usize,
263}
264
265#[derive(Debug, Clone, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct SecuritySummaryOutput {
271 pub schema_version: SecuritySchemaVersion,
273 pub version: ToolVersion,
275 pub elapsed_ms: ElapsedMs,
277 pub config: SecurityOutputConfig,
279 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
281 pub meta: Option<Meta>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub gate: Option<SecurityGate>,
285 pub summary: SecuritySummary,
287}
288
289#[derive(Debug, Clone, Serialize)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292pub struct SecuritySummary {
293 pub security_findings: usize,
295 pub by_severity: SecuritySeverityCounts,
297 pub by_category: BTreeMap<String, usize>,
300 pub by_reachability: SecurityReachabilityCounts,
302 pub by_runtime_state: SecurityRuntimeStateCounts,
304 pub unresolved_edge_files: usize,
306 pub unresolved_callee_sites: usize,
308 pub attack_surface_entries: usize,
310}
311
312#[derive(Debug, Clone, Copy, Default, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct SecuritySeverityCounts {
316 pub high: usize,
317 pub medium: usize,
318 pub low: usize,
319}
320
321#[derive(Debug, Clone, Copy, Default, Serialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct SecurityReachabilityCounts {
325 pub entry_reachable: usize,
326 pub untrusted_source_reachable: usize,
327 pub arg_level: usize,
328 pub module_level: usize,
329 pub crosses_boundary: usize,
330 pub source_backed: usize,
331}
332
333#[derive(Debug, Clone, Copy, Default, Serialize)]
335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
336pub struct SecurityRuntimeStateCounts {
337 pub runtime_hot: usize,
338 pub runtime_cold: usize,
339 pub never_executed: usize,
340 pub low_traffic: usize,
341 pub coverage_unavailable: usize,
342 pub runtime_unknown: usize,
343 pub not_collected: usize,
344}
345
346pub struct SecurityOptions<'a> {
348 pub root: &'a Path,
350 pub config_path: &'a Option<PathBuf>,
352 pub output: OutputFormat,
354 pub no_cache: bool,
356 pub threads: usize,
358 pub quiet: bool,
360 pub fail_on_issues: bool,
362 pub sarif_file: Option<&'a Path>,
364 pub summary: bool,
366 pub changed_since: Option<&'a str>,
368 pub use_shared_diff_index: bool,
370 pub workspace: Option<&'a [String]>,
372 pub changed_workspaces: Option<&'a str>,
374 pub file: &'a [PathBuf],
376 pub surface: bool,
378 pub gate: Option<SecurityGateMode>,
383 pub runtime_coverage: Option<&'a Path>,
385 pub min_invocations_hot: u64,
387 pub explain: bool,
389}
390
391pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
396 let started = Instant::now();
397 if let Err(code) = validate_security_output(opts.output) {
398 return code;
399 }
400
401 let mut config = match load_config_for_analysis(
402 opts.root,
403 opts.config_path,
404 opts.output,
405 opts.no_cache,
406 opts.threads,
407 None,
408 opts.quiet,
409 ProductionAnalysis::DeadCode,
410 ) {
411 Ok(config) => config,
412 Err(code) => return code,
413 };
414
415 let configured_severities = security_rule_severities(&config);
416 force_security_rules(&mut config);
417 let effective_severities = security_rule_severities(&config);
418
419 let mut analysis = match analyze_security_candidates(opts, &config) {
420 Ok(analysis) => analysis,
421 Err(code) => return code,
422 };
423
424 if let Err(code) = apply_security_scopes(opts, &mut analysis) {
425 return code;
426 }
427
428 let gate_mode = match apply_security_gate(opts, &config, &mut analysis.results) {
429 Ok(mode) => mode,
430 Err(code) => return code,
431 };
432
433 let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
434 let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
435 let unresolved_callee_diagnostics = unresolved_callee_diagnostics(
436 &analysis.results.security_unresolved_callee_diagnostics,
437 &config.root,
438 );
439 let runtime_report = match security_runtime_report(opts, &mut analysis) {
440 Ok(report) => report,
441 Err(code) => return code,
442 };
443 let PreparedSecurityFindings {
444 findings,
445 attack_surface,
446 } = prepare_security_findings(
447 &mut analysis,
448 runtime_report.as_ref(),
449 &config.root,
450 opts.surface,
451 );
452
453 let output = build_security_output(SecurityOutputInput {
454 opts,
455 started,
456 config: &config,
457 configured_severities,
458 effective_severities,
459 gate_mode,
460 findings,
461 attack_surface,
462 unresolved_edge_files,
463 unresolved_callee_sites,
464 unresolved_callee_diagnostics,
465 });
466 crate::telemetry::note_result_count(output.security_findings.len());
467
468 if let Err(code) = maybe_write_security_sarif(opts, &output) {
469 return code;
470 }
471
472 outln!("{}", render_security_output(opts, &output));
473 security_exit_code(opts, &output, effective_severities)
474}
475
476#[derive(Clone, Copy)]
477struct SecurityRuleSeverities {
478 leak: Severity,
479 sink: Severity,
480}
481
482struct SecurityOutputInput<'a, 'b> {
483 opts: &'a SecurityOptions<'b>,
484 started: Instant,
485 config: &'a fallow_config::ResolvedConfig,
486 configured_severities: SecurityRuleSeverities,
487 effective_severities: SecurityRuleSeverities,
488 gate_mode: Option<SecurityGateMode>,
489 findings: Vec<SecurityFinding>,
490 attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
491 unresolved_edge_files: usize,
492 unresolved_callee_sites: usize,
493 unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
494}
495
496fn validate_security_output(output: OutputFormat) -> Result<(), ExitCode> {
497 if matches!(
498 output,
499 OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
500 ) {
501 Ok(())
502 } else {
503 Err(emit_error(
504 "fallow security supports --format human, json, or sarif only.",
505 2,
506 output,
507 ))
508 }
509}
510
511fn security_rule_severities(config: &fallow_config::ResolvedConfig) -> SecurityRuleSeverities {
512 SecurityRuleSeverities {
513 leak: config.rules.security_client_server_leak,
514 sink: config.rules.security_sink,
515 }
516}
517
518fn build_security_output(input: SecurityOutputInput<'_, '_>) -> SecurityOutput {
519 SecurityOutput {
520 schema_version: SecuritySchemaVersion::V6,
521 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
522 elapsed_ms: ElapsedMs(input.started.elapsed().as_millis() as u64),
523 config: security_output_config(
524 input.config,
525 input.configured_severities.leak,
526 input.effective_severities.leak,
527 input.configured_severities.sink,
528 input.effective_severities.sink,
529 ),
530 meta: input.opts.explain.then(crate::explain::security_meta),
531 gate: input
532 .gate_mode
533 .map(|mode| security_gate_output(mode, input.findings.len())),
534 security_findings: input.findings,
535 attack_surface: input.attack_surface,
536 unresolved_edge_files: input.unresolved_edge_files,
537 unresolved_callee_sites: input.unresolved_callee_sites,
538 unresolved_callee_diagnostics: input.unresolved_callee_diagnostics,
539 }
540}
541
542fn security_gate_output(mode: SecurityGateMode, finding_count: usize) -> SecurityGate {
543 SecurityGate {
547 mode,
548 verdict: if finding_count > 0 {
549 SecurityGateVerdict::Fail
550 } else {
551 SecurityGateVerdict::Pass
552 },
553 new_count: finding_count,
554 }
555}
556
557fn maybe_write_security_sarif(
558 opts: &SecurityOptions<'_>,
559 output: &SecurityOutput,
560) -> Result<(), ExitCode> {
561 if let Some(path) = opts.sarif_file
562 && let Err(message) = write_sarif_file(output, path)
563 {
564 return Err(emit_error(&message, 2, opts.output));
565 }
566 Ok(())
567}
568
569fn render_security_output(opts: &SecurityOptions<'_>, output: &SecurityOutput) -> String {
570 match opts.output {
571 OutputFormat::Json if opts.summary => render_json_summary(output),
572 OutputFormat::Json => render_json(output),
573 OutputFormat::Sarif => render_sarif(output),
574 _ if opts.summary => render_human_summary(output),
575 _ => render_human(output),
576 }
577}
578
579fn security_exit_code(
580 opts: &SecurityOptions<'_>,
581 output: &SecurityOutput,
582 effective_severities: SecurityRuleSeverities,
583) -> ExitCode {
584 if let Some(gate) = &output.gate {
585 if gate.verdict == SecurityGateVerdict::Fail {
586 ExitCode::from(8)
587 } else {
588 ExitCode::SUCCESS
589 }
590 } else if security_advisory_failed(opts, output, effective_severities) {
591 ExitCode::from(1)
592 } else {
593 ExitCode::SUCCESS
594 }
595}
596
597fn security_advisory_failed(
598 opts: &SecurityOptions<'_>,
599 output: &SecurityOutput,
600 effective_severities: SecurityRuleSeverities,
601) -> bool {
602 (opts.fail_on_issues
603 || effective_severities.leak == Severity::Error
604 || effective_severities.sink == Severity::Error)
605 && !output.security_findings.is_empty()
606}
607
608struct PreparedSecurityFindings {
609 findings: Vec<SecurityFinding>,
610 attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
611}
612
613fn prepare_security_findings(
614 analysis: &mut SecurityAnalysisState,
615 runtime_report: Option<&RuntimeCoverageReport>,
616 root: &Path,
617 include_surface: bool,
618) -> PreparedSecurityFindings {
619 let mut findings: Vec<SecurityFinding> =
620 std::mem::take(&mut analysis.results.security_findings)
621 .into_iter()
622 .map(|f| relativize_finding(f, root))
623 .collect();
624 if let (Some(report), Some(modules), Some(files)) = (
625 runtime_report,
626 analysis.modules.as_ref(),
627 analysis.files.as_ref(),
628 ) {
629 apply_runtime_context(&mut findings, modules, files, root, report);
630 }
631 apply_security_severity(&mut findings);
632 sort_by_security_severity(&mut findings);
633 for finding in &mut findings {
634 finding.finding_id = security_finding_id(finding);
635 }
636 let (findings, attack_surface) = prepare_findings(findings, root, include_surface);
637 PreparedSecurityFindings {
638 findings,
639 attack_surface,
640 }
641}
642
643fn force_security_rules(config: &mut fallow_config::ResolvedConfig) {
644 if config.rules.security_client_server_leak == Severity::Off {
647 config.rules.security_client_server_leak = Severity::Warn;
648 }
649 if config.rules.security_sink == Severity::Off {
650 config.rules.security_sink = Severity::Warn;
651 }
652}
653
654fn security_output_config(
655 config: &fallow_config::ResolvedConfig,
656 configured_severity: Severity,
657 effective_severity: Severity,
658 configured_sink_severity: Severity,
659 effective_sink_severity: Severity,
660) -> SecurityOutputConfig {
661 let categories = config.security.categories.as_ref();
662 SecurityOutputConfig {
663 rules: SecurityOutputRulesConfig {
664 security_client_server_leak: SecurityRuleSeverityConfig {
665 configured: configured_severity,
666 effective: effective_severity,
667 },
668 security_sink: SecurityRuleSeverityConfig {
669 configured: configured_sink_severity,
670 effective: effective_sink_severity,
671 },
672 },
673 categories_include: categories.and_then(|categories| categories.include.clone()),
674 categories_exclude: categories.and_then(|categories| categories.exclude.clone()),
675 }
676}
677
678fn apply_changed_scope(opts: &SecurityOptions<'_>, results: &mut AnalysisResults) {
679 if let Some(git_ref) = opts.changed_since
680 && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
681 {
682 fallow_core::changed_files::filter_results_by_changed_files(results, &changed);
683 }
684 if opts.use_shared_diff_index
685 && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
686 {
687 crate::check::filtering::filter_results_by_diff(results, diff_index, opts.root);
688 }
689}
690
691fn apply_security_scopes(
692 opts: &SecurityOptions<'_>,
693 analysis: &mut SecurityAnalysisState,
694) -> Result<(), ExitCode> {
695 let ws_roots = crate::check::filtering::resolve_workspace_scope(
696 opts.root,
697 opts.workspace,
698 opts.changed_workspaces,
699 opts.output,
700 )?;
701 if let Some(ref roots) = ws_roots {
702 crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
703 }
704
705 if !matches!(opts.gate, Some(SecurityGateMode::NewlyReachable)) {
706 apply_changed_scope(opts, &mut analysis.results);
707 }
708 filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
709
710 Ok(())
711}
712
713fn apply_security_gate(
714 opts: &SecurityOptions<'_>,
715 config: &fallow_config::ResolvedConfig,
716 results: &mut AnalysisResults,
717) -> Result<Option<SecurityGateMode>, ExitCode> {
718 let Some(mode) = opts.gate else {
719 return Ok(None);
720 };
721
722 if matches!(mode, SecurityGateMode::NewlyReachable) {
723 retain_gate_newly_reachable(opts, config, results)?;
724 return Ok(Some(mode));
725 }
726
727 let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
732 let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
733 if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
734 shared
735 } else if let Some(git_ref) = opts.changed_since {
736 match fallow_core::changed_files::try_get_changed_diff(opts.root, git_ref) {
737 Ok(text) => owned_gate_diff
738 .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
739 Err(err) => {
740 return Err(emit_error(
741 &format!(
742 "fallow security --gate could not compute the diff for '{git_ref}': {}",
743 err.describe()
744 ),
745 2,
746 opts.output,
747 ));
748 }
749 }
750 } else {
751 return Err(emit_error(
752 "fallow security --gate requires a diff source: --changed-since <ref>, \
753 --diff-file <path>, or --diff-stdin.",
754 2,
755 opts.output,
756 ));
757 };
758 crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
759 Ok(Some(mode))
760}
761
762const SECURITY_BASE_SNAPSHOT_CACHE_VERSION: u8 = 1;
763const MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE: usize = 8 * 1024 * 1024;
764
765#[derive(Debug, Clone)]
766struct SecurityKeySnapshot {
767 reachable: FxHashSet<String>,
768}
769
770struct SecurityBaseSnapshotCacheKey {
771 hash: u64,
772 base_sha: String,
773}
774
775#[derive(bitcode::Encode, bitcode::Decode)]
776struct CachedSecurityKeySnapshot {
777 version: u8,
778 cli_version: String,
779 key_hash: u64,
780 base_sha: String,
781 reachable: Vec<String>,
782}
783
784fn retain_gate_newly_reachable(
785 opts: &SecurityOptions<'_>,
786 config: &fallow_config::ResolvedConfig,
787 results: &mut AnalysisResults,
788) -> Result<(), ExitCode> {
789 let Some(base_ref) = opts.changed_since else {
790 return Err(emit_error(
791 "fallow security --gate newly-reachable requires --changed-since <ref>; \
792 --diff-file and --diff-stdin do not identify a base tree.",
793 2,
794 opts.output,
795 ));
796 };
797 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
798 return Err(emit_error(
799 &format!(
800 "fallow security --gate newly-reachable could not resolve base ref '{base_ref}'."
801 ),
802 2,
803 opts.output,
804 ));
805 };
806 let cache_key = security_base_snapshot_cache_key(opts, config, &base_sha)?;
807 let base = if let Some(snapshot) = load_cached_security_base_snapshot(config, &cache_key) {
808 snapshot
809 } else {
810 let snapshot = compute_base_security_snapshot(opts, config, base_ref, &base_sha)?;
811 save_cached_security_base_snapshot(config, &cache_key, &snapshot);
812 snapshot
813 };
814 results.security_findings.retain(|finding| {
815 security_reachability_key(finding, opts.root)
816 .is_some_and(|key| !base.reachable.contains(&key))
817 });
818 Ok(())
819}
820
821fn compute_base_security_snapshot(
822 opts: &SecurityOptions<'_>,
823 config: &fallow_config::ResolvedConfig,
824 base_ref: &str,
825 base_sha: &str,
826) -> Result<SecurityKeySnapshot, ExitCode> {
827 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, Some(base_sha)) else {
828 return Err(emit_error(
829 &format!("could not create a temporary worktree for base ref '{base_ref}'"),
830 2,
831 opts.output,
832 ));
833 };
834 let base_root = base_analysis_root(opts.root, worktree.path());
835 let current_config_path = opts
836 .config_path
837 .clone()
838 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
839 let mut base_config = load_config_for_analysis(
840 &base_root,
841 ¤t_config_path,
842 opts.output,
843 opts.no_cache,
844 opts.threads,
845 None,
846 true,
847 ProductionAnalysis::DeadCode,
848 )?;
849 base_config.cache_dir =
850 remap_cache_dir_for_base_worktree(opts.root, &base_root, &config.cache_dir);
851 force_security_rules(&mut base_config);
852 let mut base_analysis = analyze_security_candidates(
853 &SecurityOptions {
854 root: &base_root,
855 config_path: ¤t_config_path,
856 output: opts.output,
857 no_cache: opts.no_cache,
858 threads: opts.threads,
859 quiet: true,
860 fail_on_issues: false,
861 sarif_file: None,
862 summary: false,
863 changed_since: None,
864 use_shared_diff_index: false,
865 workspace: opts.workspace,
866 changed_workspaces: None,
867 file: &[],
868 surface: false,
869 gate: None,
870 runtime_coverage: None,
871 min_invocations_hot: opts.min_invocations_hot,
872 explain: false,
873 },
874 &base_config,
875 )?;
876 if let Some(ref roots) = crate::check::filtering::resolve_workspace_scope(
877 &base_root,
878 opts.workspace,
879 None,
880 opts.output,
881 )? {
882 crate::check::filtering::filter_to_workspaces(&mut base_analysis.results, roots);
883 }
884 Ok(SecurityKeySnapshot {
885 reachable: security_reachable_keys(&base_analysis.results.security_findings, &base_root),
886 })
887}
888
889fn security_reachable_keys(findings: &[SecurityFinding], root: &Path) -> FxHashSet<String> {
890 findings
891 .iter()
892 .filter_map(|finding| security_reachability_key(finding, root))
893 .collect()
894}
895
896fn security_reachability_key(finding: &SecurityFinding, root: &Path) -> Option<String> {
897 if !finding
898 .reachability
899 .as_ref()
900 .is_some_and(|reachability| reachability.reachable_from_entry)
901 {
902 return None;
903 }
904 let category = finding.category.as_deref().unwrap_or("none");
905 Some(format!(
906 "security-reach:{}:{}:{}",
907 relative_key(&finding.path, root),
908 security_kind_key(finding.kind),
909 category,
910 ))
911}
912
913fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
914 match kind {
915 SecurityFindingKind::ClientServerLeak => "client-server-leak",
916 SecurityFindingKind::TaintedSink => "tainted-sink",
917 }
918}
919
920fn security_base_snapshot_cache_key(
921 opts: &SecurityOptions<'_>,
922 config: &fallow_config::ResolvedConfig,
923 base_sha: &str,
924) -> Result<SecurityBaseSnapshotCacheKey, ExitCode> {
925 let payload = serde_json::json!({
926 "cache_version": SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
927 "cli_version": env!("CARGO_PKG_VERSION"),
928 "base_sha": base_sha,
929 "config_hash": format!("{:016x}", config.cache_config_hash),
930 "security_client_server_leak": format!("{:?}", config.rules.security_client_server_leak),
931 "security_sink": format!("{:?}", config.rules.security_sink),
932 "workspace": opts.workspace,
933 "changed_workspaces": opts.changed_workspaces,
934 });
935 let bytes = serde_json::to_vec(&payload).map_err(|err| {
936 emit_error(
937 &format!("failed to build security gate cache key: {err}"),
938 2,
939 opts.output,
940 )
941 })?;
942 Ok(SecurityBaseSnapshotCacheKey {
943 hash: xxh3_64(&bytes),
944 base_sha: base_sha.to_owned(),
945 })
946}
947
948fn security_base_snapshot_cache_dir(config: &fallow_config::ResolvedConfig) -> PathBuf {
949 config.cache_dir.join("cache").join(format!(
950 "security-base-v{SECURITY_BASE_SNAPSHOT_CACHE_VERSION}"
951 ))
952}
953
954fn security_base_snapshot_cache_file(
955 config: &fallow_config::ResolvedConfig,
956 key: &SecurityBaseSnapshotCacheKey,
957) -> PathBuf {
958 security_base_snapshot_cache_dir(config).join(format!("{:016x}.bin", key.hash))
959}
960
961fn ensure_security_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
962 std::fs::create_dir_all(dir)?;
963 let gitignore = dir.join(".gitignore");
964 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
965 std::fs::write(gitignore, "*\n")?;
966 }
967 Ok(())
968}
969
970fn load_cached_security_base_snapshot(
971 config: &fallow_config::ResolvedConfig,
972 key: &SecurityBaseSnapshotCacheKey,
973) -> Option<SecurityKeySnapshot> {
974 if config.no_cache {
975 return None;
976 }
977 let path = security_base_snapshot_cache_file(config, key);
978 let data = std::fs::read(path).ok()?;
979 if data.len() > MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE {
980 return None;
981 }
982 let cached: CachedSecurityKeySnapshot = bitcode::decode(&data).ok()?;
983 if cached.version != SECURITY_BASE_SNAPSHOT_CACHE_VERSION
984 || cached.cli_version != env!("CARGO_PKG_VERSION")
985 || cached.key_hash != key.hash
986 || cached.base_sha != key.base_sha
987 {
988 return None;
989 }
990 Some(SecurityKeySnapshot {
991 reachable: cached.reachable.into_iter().collect(),
992 })
993}
994
995fn save_cached_security_base_snapshot(
996 config: &fallow_config::ResolvedConfig,
997 key: &SecurityBaseSnapshotCacheKey,
998 snapshot: &SecurityKeySnapshot,
999) {
1000 if config.no_cache {
1001 return;
1002 }
1003 let dir = security_base_snapshot_cache_dir(config);
1004 if ensure_security_base_snapshot_cache_dir(&dir).is_err() {
1005 return;
1006 }
1007 let mut reachable = snapshot.reachable.iter().cloned().collect::<Vec<_>>();
1008 reachable.sort_unstable();
1009 let data = bitcode::encode(&CachedSecurityKeySnapshot {
1010 version: SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
1011 cli_version: env!("CARGO_PKG_VERSION").to_owned(),
1012 key_hash: key.hash,
1013 base_sha: key.base_sha.clone(),
1014 reachable,
1015 });
1016 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
1017 return;
1018 };
1019 if tmp.write_all(&data).is_err() {
1020 return;
1021 }
1022 let _ = tmp.persist(security_base_snapshot_cache_file(config, key));
1023}
1024
1025fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1026 if current_root.is_absolute()
1027 && let Some(git_root) = crate::base_worktree::git_toplevel(current_root)
1028 && let Ok(relative) = current_root.strip_prefix(git_root)
1029 {
1030 return base_worktree_root.join(relative);
1031 }
1032 base_worktree_root.to_path_buf()
1033}
1034
1035fn remap_cache_dir_for_base_worktree(
1036 current_root: &Path,
1037 base_worktree_root: &Path,
1038 cache_dir: &Path,
1039) -> PathBuf {
1040 if cache_dir.is_absolute()
1041 && let Ok(relative) = cache_dir.strip_prefix(current_root)
1042 {
1043 return base_worktree_root.join(relative);
1044 }
1045 cache_dir.to_path_buf()
1046}
1047
1048struct SecurityAnalysisState {
1049 results: AnalysisResults,
1050 modules: Option<Vec<ModuleInfo>>,
1051 files: Option<Vec<DiscoveredFile>>,
1052 analysis_output: Option<fallow_core::AnalysisOutput>,
1053}
1054
1055#[expect(
1056 deprecated,
1057 reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
1058)]
1059fn analyze_security_candidates(
1060 opts: &SecurityOptions<'_>,
1061 config: &fallow_config::ResolvedConfig,
1062) -> Result<SecurityAnalysisState, ExitCode> {
1063 if opts.runtime_coverage.is_none() {
1064 return fallow_core::analyze(config)
1065 .map(|results| SecurityAnalysisState {
1066 results,
1067 modules: None,
1068 files: None,
1069 analysis_output: None,
1070 })
1071 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
1072 }
1073
1074 fallow_core::analyze_retaining_modules(config, true, true)
1075 .map(|mut output| {
1076 let modules = output.modules.take();
1077 let files = output.files.take();
1078 let results = output.results.clone();
1079 SecurityAnalysisState {
1080 results,
1081 modules,
1082 files,
1083 analysis_output: Some(output),
1084 }
1085 })
1086 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
1087}
1088
1089fn security_runtime_report(
1090 opts: &SecurityOptions<'_>,
1091 analysis: &mut SecurityAnalysisState,
1092) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1093 let Some(path) = opts.runtime_coverage else {
1094 return Ok(None);
1095 };
1096 let (Some(modules), Some(files), Some(analysis_output)) = (
1097 analysis.modules.as_ref(),
1098 analysis.files.as_ref(),
1099 analysis.analysis_output.take(),
1100 ) else {
1101 return Ok(None);
1102 };
1103 analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
1104}
1105
1106fn analyze_security_runtime(
1107 opts: &SecurityOptions<'_>,
1108 path: &Path,
1109 modules: Vec<ModuleInfo>,
1110 files: Vec<DiscoveredFile>,
1111 analysis_output: fallow_core::AnalysisOutput,
1112) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1113 let runtime_coverage = crate::health::coverage::prepare_options(
1114 path,
1115 opts.min_invocations_hot,
1116 None,
1117 None,
1118 opts.output,
1119 )?;
1120 let result = crate::health::execute_health_with_shared_parse(
1121 &HealthOptions {
1122 root: opts.root,
1123 config_path: opts.config_path,
1124 output: opts.output,
1125 no_cache: opts.no_cache,
1126 threads: opts.threads,
1127 quiet: opts.quiet,
1128 max_cyclomatic: None,
1129 max_cognitive: None,
1130 max_crap: None,
1131 top: None,
1132 sort: SortBy::Cyclomatic,
1133 production: true,
1134 production_override: Some(true),
1135 changed_since: opts.changed_since,
1136 diff_index: None,
1137 use_shared_diff_index: opts.use_shared_diff_index,
1138 workspace: opts.workspace,
1139 changed_workspaces: opts.changed_workspaces,
1140 baseline: None,
1141 save_baseline: None,
1142 complexity: false,
1143 complexity_breakdown: false,
1144 file_scores: false,
1145 coverage_gaps: false,
1146 config_activates_coverage_gaps: false,
1147 hotspots: false,
1148 ownership: false,
1149 ownership_emails: None,
1150 targets: false,
1151 force_full: false,
1152 score_only_output: false,
1153 enforce_coverage_gap_gate: false,
1154 effort: None,
1155 score: false,
1156 min_score: None,
1157 since: None,
1158 min_commits: None,
1159 explain: false,
1160 summary: false,
1161 save_snapshot: None,
1162 trend: false,
1163 group_by: None,
1164 coverage: None,
1165 coverage_root: None,
1166 performance: false,
1167 min_severity: None,
1168 report_only: false,
1169 runtime_coverage: Some(runtime_coverage),
1170 churn_file: None,
1171 },
1172 SharedParseData {
1173 files,
1174 modules,
1175 analysis_output: Some(analysis_output),
1176 },
1177 )?;
1178 Ok(result.report.runtime_coverage)
1179}
1180
1181#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1182struct RuntimeFunctionKey {
1183 path: String,
1184 function: String,
1185 line: u32,
1186}
1187
1188#[derive(Debug, Clone)]
1189struct FunctionSpan {
1190 key: RuntimeFunctionKey,
1191 end_line: u32,
1192}
1193
1194fn apply_runtime_context(
1195 findings: &mut Vec<SecurityFinding>,
1196 modules: &[ModuleInfo],
1197 files: &[fallow_types::discover::DiscoveredFile],
1198 root: &Path,
1199 report: &RuntimeCoverageReport,
1200) {
1201 let spans = function_spans(modules, files, root);
1202 let runtime = SecurityRuntimeIndex::new(report);
1203 let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
1204 for (_, finding) in &mut indexed {
1205 if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
1206 continue;
1207 }
1208 finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
1209 }
1210 indexed.sort_by(|(left_index, left), (right_index, right)| {
1211 runtime_rank(left)
1212 .cmp(&runtime_rank(right))
1213 .then_with(|| left_index.cmp(right_index))
1214 });
1215 findings.extend(indexed.into_iter().map(|(_, finding)| finding));
1216}
1217
1218fn function_spans(
1219 modules: &[ModuleInfo],
1220 files: &[fallow_types::discover::DiscoveredFile],
1221 root: &Path,
1222) -> Vec<FunctionSpan> {
1223 let paths_by_id = files
1224 .iter()
1225 .map(|file| (file.id, &file.path))
1226 .collect::<rustc_hash::FxHashMap<_, _>>();
1227 let mut spans = Vec::new();
1228 for module in modules {
1229 let Some(path) = paths_by_id.get(&module.file_id) else {
1230 continue;
1231 };
1232 let path = relative_key(path, root);
1233 for function in &module.complexity {
1234 spans.push(FunctionSpan {
1235 key: RuntimeFunctionKey {
1236 path: path.clone(),
1237 function: function.name.clone(),
1238 line: function.line,
1239 },
1240 end_line: function.line.saturating_add(function.line_count),
1241 });
1242 }
1243 }
1244 spans
1245}
1246
1247struct SecurityRuntimeIndex {
1248 hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
1249 findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
1250}
1251
1252impl SecurityRuntimeIndex {
1253 fn new(report: &RuntimeCoverageReport) -> Self {
1254 let hot_paths = report
1255 .hot_paths
1256 .iter()
1257 .map(|hot| {
1258 (
1259 runtime_hot_key(hot),
1260 hot.end_line.max(hot.line),
1261 SecurityRuntimeContext {
1262 state: SecurityRuntimeState::RuntimeHot,
1263 function: hot.function.clone(),
1264 line: hot.line,
1265 invocations: Some(hot.invocations),
1266 stable_id: hot.stable_id.clone(),
1267 evidence: Some(format!(
1268 "production hot path observed with {} invocation{}",
1269 hot.invocations,
1270 crate::report::plural(hot.invocations as usize)
1271 )),
1272 },
1273 )
1274 })
1275 .collect();
1276 let findings = report
1277 .findings
1278 .iter()
1279 .map(runtime_finding_context)
1280 .collect();
1281 Self {
1282 hot_paths,
1283 findings,
1284 }
1285 }
1286}
1287
1288fn runtime_context_for_finding(
1289 finding: &SecurityFinding,
1290 spans: &[FunctionSpan],
1291 runtime: &SecurityRuntimeIndex,
1292) -> Option<SecurityRuntimeContext> {
1293 let path = path_key(&finding.path);
1294 let span = spans
1295 .iter()
1296 .filter(|span| {
1297 span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
1298 })
1299 .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
1300 if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
1301 key == &span.key && key.line <= finding.line && finding.line <= *end_line
1302 }) {
1303 return Some(context.clone());
1304 }
1305 runtime.findings.get(&span.key).cloned().or_else(|| {
1306 Some(SecurityRuntimeContext {
1307 state: SecurityRuntimeState::RuntimeUnknown,
1308 function: span.key.function.clone(),
1309 line: span.key.line,
1310 invocations: None,
1311 stable_id: None,
1312 evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
1313 })
1314 })
1315}
1316
1317fn runtime_rank(finding: &SecurityFinding) -> u8 {
1318 match finding.runtime.as_ref().map(|runtime| runtime.state) {
1319 Some(SecurityRuntimeState::RuntimeHot) => 0,
1320 Some(SecurityRuntimeState::LowTraffic) => 1,
1321 None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
1322 Some(SecurityRuntimeState::CoverageUnavailable) => 3,
1323 Some(SecurityRuntimeState::RuntimeCold) => 4,
1324 Some(SecurityRuntimeState::NeverExecuted) => 5,
1325 }
1326}
1327
1328fn apply_security_severity(findings: &mut [SecurityFinding]) {
1329 for finding in findings {
1330 finding.severity = derive_security_severity(finding);
1331 }
1332}
1333
1334fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
1335 findings.sort_by(compare_security_priority);
1336}
1337
1338fn compare_security_priority(left: &SecurityFinding, right: &SecurityFinding) -> Ordering {
1339 security_severity_rank(left.severity)
1340 .cmp(&security_severity_rank(right.severity))
1341 .then_with(|| runtime_rank(left).cmp(&runtime_rank(right)))
1342 .then_with(|| {
1343 right
1344 .reachability
1345 .as_ref()
1346 .is_some_and(|reach| reach.reachable_from_entry)
1347 .cmp(
1348 &left
1349 .reachability
1350 .as_ref()
1351 .is_some_and(|reach| reach.reachable_from_entry),
1352 )
1353 })
1354 .then_with(|| taint_rank(left).cmp(&taint_rank(right)))
1355 .then_with(|| security_blast_radius(right).cmp(&security_blast_radius(left)))
1356 .then_with(|| security_crosses_boundary(right).cmp(&security_crosses_boundary(left)))
1357 .then_with(|| left.dead_code.is_some().cmp(&right.dead_code.is_some()))
1358 .then_with(|| left.path.cmp(&right.path))
1359 .then_with(|| left.line.cmp(&right.line))
1360 .then_with(|| left.col.cmp(&right.col))
1361 .then_with(|| left.category.cmp(&right.category))
1362}
1363
1364fn taint_rank(finding: &SecurityFinding) -> u8 {
1365 match finding
1366 .reachability
1367 .as_ref()
1368 .and_then(|reach| reach.taint_confidence)
1369 {
1370 Some(TaintConfidence::ArgLevel) => 0,
1371 Some(TaintConfidence::ModuleLevel) => 1,
1372 None if finding.source_backed => 0,
1373 None if finding
1374 .reachability
1375 .as_ref()
1376 .is_some_and(|reach| reach.reachable_from_untrusted_source) =>
1377 {
1378 1
1379 }
1380 None => 2,
1381 }
1382}
1383
1384fn security_blast_radius(finding: &SecurityFinding) -> u32 {
1385 finding
1386 .reachability
1387 .as_ref()
1388 .map_or(0, |reach| reach.blast_radius)
1389}
1390
1391fn security_crosses_boundary(finding: &SecurityFinding) -> bool {
1392 finding
1393 .reachability
1394 .as_ref()
1395 .is_some_and(|reach| reach.crosses_boundary)
1396}
1397
1398const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
1399 match severity {
1400 SecuritySeverity::High => 0,
1401 SecuritySeverity::Medium => 1,
1402 SecuritySeverity::Low => 2,
1403 }
1404}
1405
1406fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
1407 RuntimeFunctionKey {
1408 path: path_key(&hot.path),
1409 function: hot.function.clone(),
1410 line: hot.line,
1411 }
1412}
1413
1414fn runtime_finding_context(
1415 finding: &RuntimeCoverageFinding,
1416) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
1417 let state = match finding.verdict {
1418 RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
1419 RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
1420 SecurityRuntimeState::RuntimeCold
1421 }
1422 RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
1423 RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
1424 SecurityRuntimeState::CoverageUnavailable
1425 }
1426 RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
1427 SecurityRuntimeState::RuntimeUnknown
1428 }
1429 };
1430 (
1431 RuntimeFunctionKey {
1432 path: path_key(&finding.path),
1433 function: finding.function.clone(),
1434 line: finding.line,
1435 },
1436 SecurityRuntimeContext {
1437 state,
1438 function: finding.function.clone(),
1439 line: finding.line,
1440 invocations: finding.invocations,
1441 stable_id: finding.stable_id.clone(),
1442 evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
1443 },
1444 )
1445}
1446
1447fn relative_key(path: &Path, root: &Path) -> String {
1448 path_key(path.strip_prefix(root).unwrap_or(path))
1449}
1450
1451fn path_key(path: &Path) -> String {
1452 path.to_string_lossy().replace('\\', "/")
1453}
1454
1455fn unresolved_callee_diagnostics(
1456 diagnostics: &[SecurityUnresolvedCalleeDiagnostic],
1457 root: &Path,
1458) -> Option<SecurityUnresolvedCalleeDiagnostics> {
1459 if diagnostics.is_empty() {
1460 return None;
1461 }
1462
1463 let mut sorted = diagnostics.to_vec();
1464 sorted.sort_by(|a, b| {
1465 a.path
1466 .cmp(&b.path)
1467 .then(a.line.cmp(&b.line))
1468 .then(a.col.cmp(&b.col))
1469 .then(a.reason.cmp(&b.reason))
1470 .then(a.expression_kind.cmp(&b.expression_kind))
1471 });
1472
1473 let sampled = sorted
1474 .iter()
1475 .take(UNRESOLVED_CALLEE_SAMPLE_LIMIT)
1476 .map(|diagnostic| SecurityUnresolvedCalleeSample {
1477 path: relative_key(&diagnostic.path, root),
1478 line: diagnostic.line,
1479 col: diagnostic.col,
1480 reason: diagnostic.reason,
1481 expression_kind: diagnostic.expression_kind,
1482 })
1483 .collect();
1484
1485 let mut by_file: BTreeMap<String, usize> = BTreeMap::new();
1486 let mut by_reason: BTreeMap<fallow_types::extract::SkippedSecurityCalleeReason, usize> =
1487 BTreeMap::new();
1488 for diagnostic in &sorted {
1489 *by_file
1490 .entry(relative_key(&diagnostic.path, root))
1491 .or_insert(0) += 1;
1492 *by_reason.entry(diagnostic.reason).or_insert(0) += 1;
1493 }
1494
1495 let mut top_files: Vec<_> = by_file
1496 .into_iter()
1497 .map(|(path, count)| SecurityUnresolvedCalleeTopFile { path, count })
1498 .collect();
1499 top_files.sort_by(|a, b| b.count.cmp(&a.count).then(a.path.cmp(&b.path)));
1500 top_files.truncate(UNRESOLVED_CALLEE_TOP_FILES_LIMIT);
1501
1502 let mut by_reason: Vec<_> = by_reason
1503 .into_iter()
1504 .map(|(reason, count)| SecurityUnresolvedCalleeReasonCount { reason, count })
1505 .collect();
1506 by_reason.sort_by(|a, b| b.count.cmp(&a.count).then(a.reason.cmp(&b.reason)));
1507
1508 Some(SecurityUnresolvedCalleeDiagnostics {
1509 sampled,
1510 top_files,
1511 by_reason,
1512 sample_limit: UNRESOLVED_CALLEE_SAMPLE_LIMIT,
1513 top_files_limit: UNRESOLVED_CALLEE_TOP_FILES_LIMIT,
1514 })
1515}
1516
1517fn filter_to_files(
1518 results: &mut fallow_core::results::AnalysisResults,
1519 root: &Path,
1520 files: &[PathBuf],
1521 quiet: bool,
1522) {
1523 if files.is_empty() {
1524 return;
1525 }
1526
1527 let resolved_files: Vec<PathBuf> = files
1528 .iter()
1529 .map(|path| {
1530 if crate::path_util::is_absolute_path_any_platform(path) {
1531 path.clone()
1532 } else {
1533 root.join(path)
1534 }
1535 })
1536 .collect();
1537
1538 if !quiet {
1539 for (original, resolved) in files.iter().zip(&resolved_files) {
1540 if !resolved.exists() {
1541 eprintln!(
1542 "Warning: --file '{}' (resolved to '{}') was not found in the project",
1543 original.display(),
1544 resolved.display()
1545 );
1546 }
1547 }
1548 }
1549
1550 let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
1551 fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
1552}
1553
1554fn prepare_findings(
1555 findings: Vec<SecurityFinding>,
1556 root: &Path,
1557 include_surface: bool,
1558) -> (
1559 Vec<SecurityFinding>,
1560 Option<Vec<SecurityAttackSurfaceEntry>>,
1561) {
1562 let mut findings: Vec<SecurityFinding> = findings
1563 .into_iter()
1564 .map(|f| {
1565 let mut f = relativize_finding(f, root);
1566 f.finding_id = security_finding_id(&f);
1567 f
1568 })
1569 .collect();
1570 let attack_surface = include_surface.then(|| {
1571 findings
1572 .iter()
1573 .filter_map(|finding| finding.attack_surface.clone())
1574 .collect()
1575 });
1576 for finding in &mut findings {
1577 finding.attack_surface = None;
1578 }
1579 (findings, attack_surface)
1580}
1581
1582fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
1585 finding.path = relativize(&finding.path, root);
1586 for hop in &mut finding.trace {
1587 hop.path = relativize(&hop.path, root);
1588 }
1589 if let Some(reachability) = &mut finding.reachability {
1590 for hop in &mut reachability.untrusted_source_trace {
1591 hop.path = relativize(&hop.path, root);
1592 }
1593 }
1594 finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
1595 if let Some(flow) = &mut finding.taint_flow {
1596 flow.source.path = relativize(&flow.source.path, root);
1597 flow.sink.path = relativize(&flow.sink.path, root);
1598 }
1599 if let Some(surface) = &mut finding.attack_surface {
1600 surface.source.path = relativize(&surface.source.path, root);
1601 surface.sink.path = relativize(&surface.sink.path, root);
1602 for hop in &mut surface.path {
1603 hop.path = relativize(&hop.path, root);
1604 }
1605 for control in &mut surface.defensive_boundary.controls {
1606 control.path = relativize(&control.path, root);
1607 }
1608 }
1609 finding
1610}
1611
1612fn relativize(path: &Path, root: &Path) -> PathBuf {
1613 path.strip_prefix(root)
1614 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
1615}
1616
1617#[must_use]
1619pub fn render_json(output: &SecurityOutput) -> String {
1620 let Ok(value) = crate::output_envelope::serialize_root_output(
1621 crate::output_envelope::FallowOutput::Security(output.clone()),
1622 ) else {
1623 return "{\"error\":\"failed to serialize security output\"}".to_owned();
1624 };
1625 serde_json::to_string_pretty(&value)
1626 .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
1627}
1628
1629#[must_use]
1631pub fn render_json_summary(output: &SecurityOutput) -> String {
1632 let summary = SecuritySummaryOutput {
1633 schema_version: output.schema_version,
1634 version: output.version.clone(),
1635 elapsed_ms: output.elapsed_ms,
1636 config: output.config.clone(),
1637 meta: output.meta.clone(),
1638 gate: output.gate,
1639 summary: security_summary(output),
1640 };
1641 let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
1642 crate::output_envelope::FallowOutput::SecuritySummary(summary),
1643 ) else {
1644 return "{\"error\":\"failed to serialize security summary output\"}".to_owned();
1645 };
1646 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1647 "{\"error\":\"failed to serialize security summary output\"}".to_owned()
1648 })
1649}
1650
1651fn security_summary(output: &SecurityOutput) -> SecuritySummary {
1652 let mut by_severity = SecuritySeverityCounts::default();
1653 let mut by_reachability = SecurityReachabilityCounts::default();
1654 let mut by_runtime_state = SecurityRuntimeStateCounts::default();
1655 let mut by_category = BTreeMap::new();
1656
1657 for finding in &output.security_findings {
1658 match finding.severity {
1659 SecuritySeverity::High => by_severity.high += 1,
1660 SecuritySeverity::Medium => by_severity.medium += 1,
1661 SecuritySeverity::Low => by_severity.low += 1,
1662 }
1663 let category = finding
1664 .category
1665 .clone()
1666 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
1667 *by_category.entry(category).or_insert(0) += 1;
1668
1669 if finding.source_backed {
1670 by_reachability.source_backed += 1;
1671 }
1672 if let Some(reachability) = &finding.reachability {
1673 if reachability.reachable_from_entry {
1674 by_reachability.entry_reachable += 1;
1675 }
1676 if reachability.reachable_from_untrusted_source {
1677 by_reachability.untrusted_source_reachable += 1;
1678 }
1679 if reachability.crosses_boundary {
1680 by_reachability.crosses_boundary += 1;
1681 }
1682 match reachability.taint_confidence {
1683 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
1684 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
1685 None => {}
1686 }
1687 }
1688
1689 match finding.runtime.as_ref().map(|runtime| runtime.state) {
1690 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
1691 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
1692 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
1693 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
1694 Some(SecurityRuntimeState::CoverageUnavailable) => {
1695 by_runtime_state.coverage_unavailable += 1;
1696 }
1697 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
1698 None => by_runtime_state.not_collected += 1,
1699 }
1700 }
1701
1702 SecuritySummary {
1703 security_findings: output.security_findings.len(),
1704 by_severity,
1705 by_category,
1706 by_reachability,
1707 by_runtime_state,
1708 unresolved_edge_files: output.unresolved_edge_files,
1709 unresolved_callee_sites: output.unresolved_callee_sites,
1710 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
1711 }
1712}
1713
1714fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
1715 if let Some(parent) = path.parent()
1716 && !parent.as_os_str().is_empty()
1717 {
1718 std::fs::create_dir_all(parent).map_err(|err| {
1719 format!(
1720 "Failed to create directory for SARIF file {}: {err}",
1721 path.display()
1722 )
1723 })?;
1724 }
1725 std::fs::write(path, render_sarif(output))
1726 .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
1727}
1728
1729fn gate_human_header(gate: &SecurityGate) -> String {
1734 use crate::report::plural;
1735 let checked = match gate.mode {
1736 SecurityGateMode::New => "in changed lines",
1737 SecurityGateMode::NewlyReachable => "newly reachable from entry points",
1738 };
1739 match gate.verdict {
1740 SecurityGateVerdict::Fail => format!(
1741 "Gate: REVIEW REQUIRED, {} new security item{} {checked}. fallow has not confirmed a vulnerability.",
1742 gate.new_count,
1743 plural(gate.new_count),
1744 ),
1745 SecurityGateVerdict::Pass => {
1746 format!("Gate: PASS, no new security items {checked}.")
1747 }
1748 }
1749}
1750
1751fn unresolved_callee_human_hint(output: &SecurityOutput) -> Option<String> {
1752 let diagnostics = output.unresolved_callee_diagnostics.as_ref()?;
1753 let top_reason = diagnostics.by_reason.first()?;
1754 let top_file = diagnostics.top_files.first()?;
1755 Some(format!(
1756 "Most unresolved callees: {} in {}.",
1757 unresolved_callee_reason_label(top_reason.reason),
1758 top_file.path
1759 ))
1760}
1761
1762fn unresolved_callee_reason_label(
1763 reason: fallow_types::extract::SkippedSecurityCalleeReason,
1764) -> &'static str {
1765 match reason {
1766 fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => "computed-member",
1767 fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => "dynamic-dispatch",
1768 fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
1769 "unsupported-assignment-object"
1770 }
1771 }
1772}
1773
1774#[must_use]
1775fn render_human_summary(output: &SecurityOutput) -> String {
1776 use crate::report::plural;
1777 use std::fmt::Write as _;
1778
1779 let mut out = String::new();
1780 if let Some(gate) = &output.gate {
1781 out.push_str(&gate_human_header(gate));
1782 out.push('\n');
1783 }
1784 let count = output.security_findings.len();
1785 if count == 0 {
1786 out.push_str("Security review: no items to check in the scanned code.\n");
1787 } else {
1788 let _ = writeln!(
1789 out,
1790 "Security review: {count} item{} to check. These are unverified security candidates, not confirmed vulnerabilities.",
1791 plural(count),
1792 );
1793 out.push_str(
1794 "Next: check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
1795 );
1796 }
1797 if output.unresolved_edge_files > 0 {
1798 let n = output.unresolved_edge_files;
1799 let verb = if n == 1 { "uses" } else { "use" };
1800 let _ = writeln!(
1801 out,
1802 "Blind spot: {n} client file{} {verb} dynamic imports that fallow could not follow.",
1803 plural(n)
1804 );
1805 }
1806 if output.unresolved_callee_sites > 0 {
1807 let n = output.unresolved_callee_sites;
1808 let verb = if n == 1 { "uses" } else { "use" };
1809 let _ = writeln!(
1810 out,
1811 "Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve.",
1812 plural(n)
1813 );
1814 if let Some(hint) = unresolved_callee_human_hint(output) {
1815 let _ = writeln!(out, "{hint}");
1816 }
1817 }
1818 out
1819}
1820
1821#[must_use]
1824#[expect(
1825 clippy::format_push_string,
1826 reason = "small report renderer; readability over avoiding the extra allocation"
1827)]
1828pub fn render_human(output: &SecurityOutput) -> String {
1829 use crate::report::plural;
1830
1831 let mut out = String::new();
1832 push_human_gate(&mut out, output);
1833 let count = output.security_findings.len();
1834 out.push_str(&format!("Security review: {count} item{}", plural(count)));
1835 if count == 0 {
1836 out.push_str(" to check in the scanned code.\n");
1837 } else {
1838 out.push_str(" to check.\n");
1839 out.push_str(
1840 "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",
1841 );
1842 }
1843 out.push('\n');
1844
1845 if output.security_findings.is_empty() {
1846 out.push_str("No security details to show.\n");
1847 } else {
1848 push_human_findings(&mut out, &output.security_findings);
1849 }
1850
1851 push_human_blind_spots(&mut out, output);
1852
1853 out.push_str(&format!(
1854 "\nResult: {count} security item{} to check.",
1855 plural(count),
1856 ));
1857 if count > 0 {
1858 out.push_str(" Review the listed evidence and trace before changing code.");
1859 }
1860 out.push('\n');
1861 out
1862}
1863
1864fn push_human_gate(out: &mut String, output: &SecurityOutput) {
1865 if let Some(gate) = &output.gate {
1866 out.push_str(&gate_human_header(gate));
1867 out.push_str("\n\n");
1868 }
1869}
1870
1871fn push_human_findings(out: &mut String, findings: &[SecurityFinding]) {
1872 for finding in findings {
1873 push_human_finding(out, finding);
1874 }
1875}
1876
1877fn push_human_finding(out: &mut String, finding: &SecurityFinding) {
1878 use std::fmt::Write as _;
1879
1880 push_human_finding_header(out, finding);
1881 let _ = writeln!(out, " evidence: {}", finding.evidence);
1882 if let Some(hint) = dead_code_hint(finding) {
1883 let _ = writeln!(out, " dead-code: {hint}");
1884 }
1885 if let Some(runtime) = finding.runtime.as_ref() {
1886 let _ = writeln!(out, " runtime: {}", runtime_hint_text(runtime));
1887 }
1888 push_human_reachability(out, finding);
1889 push_human_import_trace(out, finding);
1890 push_human_next_step(out, finding);
1891 out.push('\n');
1892}
1893
1894fn push_human_finding_header(out: &mut String, finding: &SecurityFinding) {
1895 use colored::Colorize;
1896 use std::fmt::Write as _;
1897
1898 let kind = security_finding_label(finding);
1899 let (glyph, label) = human_severity_marker(finding.severity);
1900 let _ = writeln!(
1901 out,
1902 "{} {label} {kind} {}:{}",
1903 glyph,
1904 finding.path.to_string_lossy().replace('\\', "/").bold(),
1905 finding.line,
1906 );
1907}
1908
1909fn push_human_reachability(out: &mut String, finding: &SecurityFinding) {
1910 use std::fmt::Write as _;
1911
1912 let Some(reach) = finding.reachability.as_ref() else {
1913 return;
1914 };
1915 let entry = if reach.reachable_from_entry {
1916 "reachable from a runtime entry point"
1917 } else {
1918 "not reached from any runtime entry point"
1919 };
1920 let boundary = if reach.crosses_boundary {
1921 "; crosses an architecture boundary"
1922 } else {
1923 ""
1924 };
1925 let _ = writeln!(
1926 out,
1927 " code path: {entry} (blast radius {}){boundary}",
1928 reach.blast_radius,
1929 );
1930 if reach.reachable_from_untrusted_source {
1931 push_human_untrusted_trace(out, finding);
1932 }
1933}
1934
1935fn push_human_untrusted_trace(out: &mut String, finding: &SecurityFinding) {
1936 use std::fmt::Write as _;
1937
1938 let Some(reach) = finding.reachability.as_ref() else {
1939 return;
1940 };
1941 let hops = reach.untrusted_source_hop_count.unwrap_or(0);
1942 let _ = writeln!(
1943 out,
1944 " input path: this module is reachable from a module that receives \
1945 untrusted input via {hops} import hop{}",
1946 crate::report::plural(hops as usize),
1947 );
1948 if !reach.untrusted_source_trace.is_empty() {
1949 out.push_str(" input import trace:\n");
1950 for hop in &reach.untrusted_source_trace {
1951 let _ = writeln!(
1952 out,
1953 " {}:{} ({})",
1954 hop.path.to_string_lossy().replace('\\', "/"),
1955 hop.line,
1956 hop_role_label(hop.role),
1957 );
1958 }
1959 }
1960}
1961
1962fn push_human_import_trace(out: &mut String, finding: &SecurityFinding) {
1963 use std::fmt::Write as _;
1964
1965 if finding.trace.is_empty() {
1966 return;
1967 }
1968 out.push_str(" import trace:\n");
1969 for hop in &finding.trace {
1970 let _ = writeln!(
1971 out,
1972 " {}:{} ({})",
1973 hop.path.to_string_lossy().replace('\\', "/"),
1974 hop.line,
1975 hop_role_label(hop.role),
1976 );
1977 }
1978}
1979
1980fn push_human_next_step(out: &mut String, finding: &SecurityFinding) {
1981 if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
1982 out.push_str(
1983 " Next: check whether this import can ship a secret to the browser. If \
1984 it is type-only, server-only, or removed at build time, mark it as a false \
1985 positive.\n",
1986 );
1987 } else if finding.dead_code.is_some() {
1988 out.push_str(
1989 " Next: first verify the dead-code finding. If the code is safe to \
1990 remove, delete it. Otherwise check and harden the risky call.\n",
1991 );
1992 } else {
1993 out.push_str(
1994 " Next: check whether unsafe input, secrets, or settings can reach this \
1995 risky call without a safe guard. If not, mark it as a false positive.\n",
1996 );
1997 }
1998}
1999
2000fn push_human_blind_spots(out: &mut String, output: &SecurityOutput) {
2001 use crate::report::plural;
2002 use colored::Colorize;
2003 use std::fmt::Write as _;
2004
2005 if output.unresolved_edge_files > 0 {
2006 let n = output.unresolved_edge_files;
2007 let verb = if n == 1 { "uses" } else { "use" };
2008 let _ = writeln!(
2009 out,
2010 "{} Blind spot: {n} client file{} {verb} dynamic imports that fallow could not \
2011 follow. Code behind those imports may be missing from this report.",
2012 "[I]".blue().bold(),
2013 plural(n),
2014 );
2015 }
2016
2017 if output.unresolved_callee_sites > 0 {
2018 let n = output.unresolved_callee_sites;
2019 let verb = if n == 1 { "uses" } else { "use" };
2020 let _ = writeln!(
2021 out,
2022 "{} Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve, \
2023 such as dynamic dispatch, computed members, or aliased bindings.",
2024 "[I]".blue().bold(),
2025 plural(n),
2026 );
2027 if let Some(hint) = unresolved_callee_human_hint(output) {
2028 let _ = writeln!(out, " {hint}");
2029 }
2030 }
2031}
2032
2033fn security_finding_label(finding: &SecurityFinding) -> String {
2037 match finding.kind {
2038 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
2039 SecurityFindingKind::TaintedSink => {
2040 let title = finding
2041 .category
2042 .as_deref()
2043 .and_then(fallow_core::analyze::security_catalogue_title)
2044 .or(finding.category.as_deref())
2045 .unwrap_or("tainted-sink");
2046 match finding.cwe {
2047 Some(cwe) => format!("{title} (CWE-{cwe})"),
2048 None => title.to_string(),
2049 }
2050 }
2051 }
2052}
2053
2054fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
2055 use colored::Colorize;
2056 match severity {
2057 SecuritySeverity::High => ("[H]".red().bold(), "high"),
2058 SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
2059 SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
2060 }
2061}
2062
2063fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
2064 let context = finding.dead_code.as_ref()?;
2065 match context.kind {
2066 SecurityDeadCodeKind::UnusedFile => Some(
2067 "also reported as unused-file; delete this file instead of hardening the sink"
2068 .to_string(),
2069 ),
2070 SecurityDeadCodeKind::UnusedExport => Some(format!(
2071 "also reported as unused-export{}; remove the export instead of hardening the sink",
2072 context
2073 .export_name
2074 .as_ref()
2075 .map_or(String::new(), |name| format!(" `{name}`"))
2076 )),
2077 }
2078}
2079
2080const fn hop_role_label(role: TraceHopRole) -> &'static str {
2081 match role {
2082 TraceHopRole::ClientBoundary => "client boundary",
2083 TraceHopRole::UntrustedSource => "untrusted source",
2084 TraceHopRole::ModuleSource => "source module",
2085 TraceHopRole::Intermediate => "intermediate",
2086 TraceHopRole::SecretSource => "secret source",
2087 TraceHopRole::Sink => "sink site",
2088 }
2089}
2090
2091fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
2092 finding
2093 .reachability
2094 .as_ref()
2095 .filter(|reach| reach.reachable_from_untrusted_source)
2096 .map(|_| {
2097 "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
2098 })
2099}
2100
2101fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
2102 use std::fmt::Write as _;
2103
2104 let mut text = format!(
2105 "{} in {}:{}",
2106 runtime_state_label(runtime.state),
2107 runtime.function,
2108 runtime.line
2109 );
2110 if let Some(invocations) = runtime.invocations {
2111 let _ = write!(
2112 text,
2113 " ({} invocation{})",
2114 invocations,
2115 crate::report::plural(invocations as usize)
2116 );
2117 }
2118 if let Some(evidence) = runtime.evidence.as_deref() {
2119 text.push_str("; ");
2120 text.push_str(evidence);
2121 }
2122 text
2123}
2124
2125const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
2126 match state {
2127 SecurityRuntimeState::RuntimeHot => "runtime-hot",
2128 SecurityRuntimeState::RuntimeCold => "runtime-cold",
2129 SecurityRuntimeState::NeverExecuted => "never-executed",
2130 SecurityRuntimeState::LowTraffic => "low-traffic",
2131 SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
2132 SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
2133 }
2134}
2135
2136fn sarif_rule_id(finding: &SecurityFinding) -> String {
2141 match finding.kind {
2142 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
2143 SecurityFindingKind::TaintedSink => {
2144 format!(
2145 "security/{}",
2146 finding.category.as_deref().unwrap_or("tainted-sink")
2147 )
2148 }
2149 }
2150}
2151
2152fn security_help_text(title: &str) -> String {
2153 format!(
2154 "Verify this unverified {title} candidate before acting. Review the source, sink, \
2155 SARIF code flow, and any runtime or dead-code context. fallow does not prove \
2156 exploitability, attacker control, or missing sanitization."
2157 )
2158}
2159
2160fn security_help_markdown(title: &str) -> String {
2161 format!(
2162 "Verify this unverified **{title}** candidate before acting.\n\n\
2163 1. Review the source and sink in the SARIF code flow.\n\
2164 2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
2165 3. Use runtime and dead-code context only as triage signals."
2166 )
2167}
2168
2169fn cwe_taxon_id(cwe: u32) -> String {
2170 format!("CWE-{cwe}")
2171}
2172
2173fn cwe_taxon(cwe: u32) -> serde_json::Value {
2174 let id = cwe_taxon_id(cwe);
2175 serde_json::json!({
2176 "id": id,
2177 "name": id,
2178 "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
2179 "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
2180 "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
2181 })
2182}
2183
2184fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
2185 serde_json::json!({
2186 "target": {
2187 "id": cwe_taxon_id(cwe),
2188 "index": taxon_index,
2189 "toolComponent": {
2190 "name": "CWE",
2191 "index": 0
2192 }
2193 },
2194 "kinds": ["superset"]
2195 })
2196}
2197
2198fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
2199 let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
2200 cwes.sort_unstable();
2201 cwes.dedup();
2202 cwes
2203}
2204
2205fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
2206 cwes.iter().position(|existing| *existing == cwe)
2207}
2208
2209fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
2210 if cwes.is_empty() {
2211 return None;
2212 }
2213 let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
2214 Some(serde_json::json!({
2215 "name": "CWE",
2216 "fullName": "Common Weakness Enumeration",
2217 "organization": "MITRE",
2218 "informationUri": "https://cwe.mitre.org/",
2219 "taxa": taxa
2220 }))
2221}
2222
2223fn sarif_rule_def(
2227 rule_id: &str,
2228 finding: &SecurityFinding,
2229 cwe_taxon_index: Option<usize>,
2230) -> serde_json::Value {
2231 match finding.kind {
2232 SecurityFindingKind::ClientServerLeak => {
2233 let title = "Client-server secret leak";
2234 serde_json::json!({
2235 "id": rule_id,
2236 "name": title,
2237 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
2238 "fullDescription": { "text":
2239 "Unverified candidate, requires verification: a \"use client\" file \
2240 transitively imports a module that reads a non-public process.env \
2241 secret. fallow does not prove the secret reaches client-bundled code." },
2242 "help": {
2243 "text": security_help_text(title),
2244 "markdown": security_help_markdown(title)
2245 },
2246 "helpUri": "https://github.com/fallow-rs/fallow",
2247 "defaultConfiguration": { "level": "note" }
2248 })
2249 }
2250 SecurityFindingKind::TaintedSink => {
2251 let title = finding
2252 .category
2253 .as_deref()
2254 .and_then(fallow_core::analyze::security_catalogue_title)
2255 .or(finding.category.as_deref())
2256 .unwrap_or("tainted-sink");
2257 let mut rule = serde_json::json!({
2258 "id": rule_id,
2259 "name": title,
2260 "shortDescription": { "text": format!("{title} candidate (unverified)") },
2261 "fullDescription": { "text": format!(
2262 "Unverified candidate, requires verification: {title}. fallow flags a \
2263 syntactic sink reached by a non-literal argument; it does not prove the \
2264 value is attacker-controlled or reaches the sink unsanitized."
2265 ) },
2266 "help": {
2267 "text": security_help_text(title),
2268 "markdown": security_help_markdown(title)
2269 },
2270 "helpUri": "https://github.com/fallow-rs/fallow",
2271 "defaultConfiguration": { "level": "note" }
2272 });
2273 if let Some(cwe) = finding.cwe {
2274 rule["properties"] = serde_json::json!({
2275 "tags": [format!("external/cwe/cwe-{cwe}")]
2276 });
2277 if let Some(taxon_index) = cwe_taxon_index {
2278 rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
2279 }
2280 }
2281 rule
2282 }
2283 }
2284}
2285
2286fn hop_role_token(role: TraceHopRole) -> &'static str {
2287 match role {
2288 TraceHopRole::ClientBoundary => "client-boundary",
2289 TraceHopRole::UntrustedSource => "untrusted-source",
2290 TraceHopRole::ModuleSource => "module-source",
2291 TraceHopRole::Intermediate => "intermediate",
2292 TraceHopRole::SecretSource => "secret-source",
2293 TraceHopRole::Sink => "sink",
2294 }
2295}
2296
2297fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
2298 let role = hop_role_token(hop.role);
2299 serde_json::json!({
2300 "location": sarif_location(&hop.path, hop.line, hop.col),
2301 "kinds": [role],
2302 "properties": { "fallowTraceRole": role }
2303 })
2304}
2305
2306fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
2307 if let Some(reachability) = finding.reachability.as_ref()
2308 && !reachability.untrusted_source_trace.is_empty()
2309 {
2310 return &reachability.untrusted_source_trace;
2311 }
2312 &finding.trace
2313}
2314
2315fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
2316 let hops = primary_code_flow_hops(finding);
2317 if hops.is_empty() {
2318 return None;
2319 }
2320 let locations = hops
2321 .iter()
2322 .map(sarif_thread_flow_location)
2323 .collect::<Vec<_>>();
2324 Some(serde_json::json!([
2325 {
2326 "threadFlows": [
2327 { "locations": locations }
2328 ]
2329 }
2330 ]))
2331}
2332
2333fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
2334 let location = sarif_location(&hop.path, hop.line, hop.col);
2335 if !related.iter().any(|existing| existing == &location) {
2336 related.push(location);
2337 }
2338}
2339
2340fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
2341 let mut related = Vec::new();
2342 for hop in &finding.trace {
2343 push_related_location(&mut related, hop);
2344 }
2345 if let Some(reachability) = finding.reachability.as_ref() {
2346 for hop in &reachability.untrusted_source_trace {
2347 push_related_location(&mut related, hop);
2348 }
2349 }
2350 related
2351}
2352
2353const fn sarif_level(severity: SecuritySeverity) -> &'static str {
2354 match severity {
2355 SecuritySeverity::High | SecuritySeverity::Medium => "warning",
2356 SecuritySeverity::Low => "note",
2357 }
2358}
2359
2360#[must_use]
2367fn render_sarif(output: &SecurityOutput) -> String {
2368 let cwes = collect_cwes(&output.security_findings);
2369 let results: Vec<serde_json::Value> = output
2370 .security_findings
2371 .iter()
2372 .map(|finding| {
2373 let rule_id = sarif_rule_id(finding);
2374 let mut message = dead_code_hint(finding).map_or_else(
2375 || finding.evidence.clone(),
2376 |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
2377 );
2378 if let Some(hint) = source_reachability_hint(finding) {
2379 message.push(' ');
2380 message.push_str(hint);
2381 }
2382 if let Some(runtime) = finding.runtime.as_ref() {
2383 message.push_str(" Runtime context: ");
2384 message.push_str(&runtime_hint_text(runtime));
2385 message.push('.');
2386 }
2387 let related = sarif_related_locations(finding);
2388 let mut result = serde_json::json!({
2393 "ruleId": rule_id,
2394 "level": sarif_level(finding.severity),
2395 "message": { "text": message },
2396 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
2397 "relatedLocations": related,
2398 "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
2399 });
2400 if let Some(code_flows) = sarif_code_flows(finding) {
2401 result["codeFlows"] = code_flows;
2402 }
2403 result
2404 })
2405 .collect();
2406
2407 let mut seen: Vec<String> = Vec::new();
2409 let mut rules: Vec<serde_json::Value> = Vec::new();
2410 for finding in &output.security_findings {
2411 let rule_id = sarif_rule_id(finding);
2412 if seen.iter().any(|s| s == &rule_id) {
2413 continue;
2414 }
2415 seen.push(rule_id.clone());
2416 let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(&cwes, cwe));
2417 rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
2418 }
2419
2420 let mut run = serde_json::json!({
2421 "tool": { "driver": {
2422 "name": "fallow",
2423 "version": env!("CARGO_PKG_VERSION"),
2424 "informationUri": "https://github.com/fallow-rs/fallow",
2425 "rules": rules,
2426 }},
2427 "results": results,
2428 });
2429 if let Some(taxonomy) = cwe_taxonomy(&cwes) {
2430 run["taxonomies"] = serde_json::json!([taxonomy]);
2431 run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
2432 { "name": "CWE", "index": 0 }
2433 ]);
2434 }
2435 if let Some(gate) = &output.gate
2439 && let Ok(gate_value) = serde_json::to_value(gate)
2440 {
2441 run["properties"] = serde_json::json!({ "fallowGate": gate_value });
2442 }
2443
2444 let sarif = serde_json::json!({
2445 "version": "2.1.0",
2446 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2447 "runs": [run],
2448 });
2449 serde_json::to_string_pretty(&sarif)
2450 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
2451}
2452
2453fn fnv_hex(input: &str) -> String {
2455 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2456 for byte in input.bytes() {
2457 hash ^= u64::from(byte);
2458 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2459 }
2460 format!("{hash:016x}")
2461}
2462
2463fn security_finding_id(finding: &SecurityFinding) -> String {
2469 let fp = format!(
2470 "{}:{}:{}",
2471 sarif_rule_id(finding),
2472 finding.path.to_string_lossy().replace('\\', "/"),
2473 finding.line,
2474 );
2475 fnv_hex(&fp)
2476}
2477
2478fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
2479 serde_json::json!({
2480 "physicalLocation": {
2481 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
2482 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
2483 }
2484 })
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::*;
2490 use fallow_core::results::{
2491 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
2492 SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
2493 TraceHop, TraceHopRole,
2494 };
2495 use fallow_types::results::{
2496 SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
2497 };
2498
2499 fn sample_finding(root: &Path) -> SecurityFinding {
2501 SecurityFinding {
2502 kind: SecurityFindingKind::ClientServerLeak,
2503 path: root.join("src/app.tsx"),
2504 line: 12,
2505 col: 3,
2506 evidence: "reaches process.env.SECRET_KEY".to_owned(),
2507 source_backed: false,
2508 source_read: None,
2509 severity: SecuritySeverity::High,
2510 trace: vec![
2511 TraceHop {
2512 path: root.join("src/app.tsx"),
2513 line: 12,
2514 col: 3,
2515 role: TraceHopRole::ClientBoundary,
2516 },
2517 TraceHop {
2518 path: root.join("src/lib/util.ts"),
2519 line: 4,
2520 col: 0,
2521 role: TraceHopRole::Intermediate,
2522 },
2523 TraceHop {
2524 path: root.join("src/lib/secret.ts"),
2525 line: 8,
2526 col: 2,
2527 role: TraceHopRole::SecretSource,
2528 },
2529 ],
2530 actions: vec![],
2531 category: None,
2532 cwe: None,
2533 dead_code: None,
2534 reachability: None,
2535 finding_id: String::new(),
2536 candidate: SecurityCandidate {
2537 source_kind: None,
2538 sink: SecurityCandidateSink {
2539 path: root.join("src/app.tsx"),
2540 line: 12,
2541 col: 3,
2542 category: None,
2543 cwe: None,
2544 callee: None,
2545 url_shape: None,
2546 },
2547 boundary: SecurityCandidateBoundary {
2548 client_server: true,
2549 cross_module: false,
2550 architecture_zone: None,
2551 },
2552 network: None,
2553 },
2554 taint_flow: None,
2555 runtime: None,
2556 attack_surface: None,
2557 }
2558 }
2559
2560 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
2561 SecurityOutput {
2562 schema_version: SecuritySchemaVersion::V6,
2563 version: ToolVersion("test".to_string()),
2564 elapsed_ms: ElapsedMs(0),
2565 config: test_output_config(),
2566 meta: None,
2567 gate: None,
2568 security_findings: findings,
2569 attack_surface: None,
2570 unresolved_edge_files,
2571 unresolved_callee_sites: 0,
2572 unresolved_callee_diagnostics: None,
2573 }
2574 }
2575
2576 fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
2577 SecurityOutput {
2578 schema_version: SecuritySchemaVersion::V6,
2579 version: ToolVersion("test".to_string()),
2580 elapsed_ms: ElapsedMs(0),
2581 config: test_output_config(),
2582 meta: None,
2583 gate: Some(SecurityGate {
2584 mode: SecurityGateMode::New,
2585 verdict,
2586 new_count,
2587 }),
2588 security_findings: vec![],
2589 attack_surface: None,
2590 unresolved_edge_files: 0,
2591 unresolved_callee_sites: 0,
2592 unresolved_callee_diagnostics: None,
2593 }
2594 }
2595
2596 fn sample_unresolved_callee_diagnostics(root: &Path) -> SecurityUnresolvedCalleeDiagnostics {
2597 unresolved_callee_diagnostics(
2598 &[
2599 SecurityUnresolvedCalleeDiagnostic {
2600 path: root.join("src/z.ts"),
2601 line: 9,
2602 col: 4,
2603 reason: fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember,
2604 expression_kind:
2605 fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
2606 },
2607 SecurityUnresolvedCalleeDiagnostic {
2608 path: root.join("src/a.ts"),
2609 line: 3,
2610 col: 2,
2611 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2612 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2613 },
2614 SecurityUnresolvedCalleeDiagnostic {
2615 path: root.join("src/a.ts"),
2616 line: 4,
2617 col: 2,
2618 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2619 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2620 },
2621 ],
2622 root,
2623 )
2624 .expect("diagnostics summarized")
2625 }
2626
2627 fn test_output_config() -> SecurityOutputConfig {
2628 SecurityOutputConfig {
2629 rules: SecurityOutputRulesConfig {
2630 security_client_server_leak: SecurityRuleSeverityConfig {
2631 configured: Severity::Off,
2632 effective: Severity::Warn,
2633 },
2634 security_sink: SecurityRuleSeverityConfig {
2635 configured: Severity::Off,
2636 effective: Severity::Warn,
2637 },
2638 },
2639 categories_include: None,
2640 categories_exclude: None,
2641 }
2642 }
2643
2644 fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
2645 let mut finding = sample_finding(root);
2646 finding.kind = SecurityFindingKind::TaintedSink;
2647 finding.category = Some("dangerous-html".to_owned());
2648 finding.cwe = Some(79);
2649 finding.runtime = state.map(|state| SecurityRuntimeContext {
2650 state,
2651 function: "render".to_owned(),
2652 line: 10,
2653 invocations: Some(123),
2654 stable_id: Some("fallow:fn:test".to_owned()),
2655 evidence: Some("production runtime evidence".to_owned()),
2656 });
2657 finding
2658 }
2659
2660 #[test]
2661 fn runtime_rank_promotes_hot_and_demotes_never_executed() {
2662 let root = Path::new("/proj/root");
2663 let mut findings = [
2664 tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
2665 tainted_with_runtime(root, None),
2666 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2667 tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
2668 ];
2669
2670 findings.sort_by_key(runtime_rank);
2671
2672 assert_eq!(
2673 findings
2674 .iter()
2675 .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
2676 .collect::<Vec<_>>(),
2677 vec![
2678 Some(SecurityRuntimeState::RuntimeHot),
2679 None,
2680 Some(SecurityRuntimeState::CoverageUnavailable),
2681 Some(SecurityRuntimeState::NeverExecuted),
2682 ]
2683 );
2684 }
2685
2686 #[test]
2687 fn severity_sort_orders_tiers_then_location() {
2688 let root = Path::new("/proj/root");
2689 let mut high = sample_finding(root);
2690 high.path = root.join("z.ts");
2691 high.severity = SecuritySeverity::High;
2692 let mut low = sample_finding(root);
2693 low.path = root.join("a.ts");
2694 low.severity = SecuritySeverity::Low;
2695 let mut medium_a = sample_finding(root);
2696 medium_a.path = root.join("a.ts");
2697 medium_a.severity = SecuritySeverity::Medium;
2698 medium_a.reachability = Some(fallow_types::results::SecurityReachability {
2699 reachable_from_entry: false,
2700 reachable_from_untrusted_source: true,
2701 taint_confidence: Some(TaintConfidence::ModuleLevel),
2702 untrusted_source_hop_count: Some(1),
2703 untrusted_source_trace: vec![],
2704 blast_radius: 10,
2705 crosses_boundary: false,
2706 });
2707 let mut medium_b = sample_finding(root);
2708 medium_b.path = root.join("b.ts");
2709 medium_b.severity = SecuritySeverity::Medium;
2710 medium_b.source_backed = true;
2711 medium_b.reachability = Some(fallow_types::results::SecurityReachability {
2712 reachable_from_entry: false,
2713 reachable_from_untrusted_source: true,
2714 taint_confidence: Some(TaintConfidence::ArgLevel),
2715 untrusted_source_hop_count: Some(0),
2716 untrusted_source_trace: vec![],
2717 blast_radius: 1,
2718 crosses_boundary: false,
2719 });
2720 let mut findings = vec![low, medium_b, high, medium_a];
2721
2722 sort_by_security_severity(&mut findings);
2723
2724 assert_eq!(
2725 findings
2726 .iter()
2727 .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
2728 .collect::<Vec<_>>(),
2729 vec![
2730 (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
2731 (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
2732 (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
2733 (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
2734 ]
2735 );
2736 }
2737
2738 #[test]
2739 fn human_render_includes_runtime_context_line() {
2740 let root = Path::new("/proj/root");
2741 let finding = relativize_finding(
2742 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2743 root,
2744 );
2745 let out = render_human(&output_with(vec![finding], 0));
2746
2747 assert!(
2748 out.contains("runtime: runtime-hot in render:10"),
2749 "got: {out}"
2750 );
2751 assert!(out.contains("production runtime evidence"), "got: {out}");
2752 }
2753
2754 #[test]
2755 fn sarif_render_includes_runtime_context_in_message() {
2756 let root = Path::new("/proj/root");
2757 let finding = relativize_finding(
2758 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2759 root,
2760 );
2761 let rendered = render_sarif(&output_with(vec![finding], 0));
2762 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2763 let message = sarif["runs"][0]["results"][0]["message"]["text"]
2764 .as_str()
2765 .expect("message text");
2766
2767 assert!(message.contains("Runtime context"), "got: {message}");
2768 assert!(
2769 message.contains("runtime-hot in render:10"),
2770 "got: {message}"
2771 );
2772 }
2773
2774 #[test]
2775 fn gate_human_header_fail_says_review_required_not_fail() {
2776 let gate = SecurityGate {
2777 mode: SecurityGateMode::New,
2778 verdict: SecurityGateVerdict::Fail,
2779 new_count: 2,
2780 };
2781 let header = gate_human_header(&gate);
2782 assert!(header.contains("REVIEW REQUIRED"));
2783 assert!(header.contains("2 new security items"));
2784 assert!(header.contains("not confirmed a vulnerability"));
2785 assert!(!header.to_uppercase().contains("GATE: FAIL"));
2786 }
2787
2788 #[test]
2789 fn gate_human_header_fail_singular_for_one_candidate() {
2790 let gate = SecurityGate {
2792 mode: SecurityGateMode::New,
2793 verdict: SecurityGateVerdict::Fail,
2794 new_count: 1,
2795 };
2796 let header = gate_human_header(&gate);
2797 assert!(header.contains("1 new security item in changed lines"));
2798 assert!(!header.contains("1 new security candidates"));
2799 }
2800
2801 #[test]
2802 fn gate_human_header_pass() {
2803 let gate = SecurityGate {
2804 mode: SecurityGateMode::New,
2805 verdict: SecurityGateVerdict::Pass,
2806 new_count: 0,
2807 };
2808 assert!(gate_human_header(&gate).contains("Gate: PASS"));
2809 }
2810
2811 #[test]
2812 fn gate_json_block_is_snake_case_and_present_on_pass() {
2813 let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
2814 assert!(json.contains("\"gate\""));
2815 assert!(json.contains("\"mode\": \"new\""));
2816 assert!(json.contains("\"verdict\": \"pass\""));
2817 assert!(json.contains("\"new_count\": 0"));
2818 }
2819
2820 #[test]
2821 fn reachability_key_includes_path_kind_and_category() {
2822 let root = Path::new("/proj/root");
2823 let mut leak = sample_finding(root);
2824 leak.reachability = Some(SecurityReachability {
2825 reachable_from_entry: true,
2826 reachable_from_untrusted_source: false,
2827 taint_confidence: None,
2828 untrusted_source_hop_count: None,
2829 untrusted_source_trace: vec![],
2830 blast_radius: 0,
2831 crosses_boundary: false,
2832 });
2833 let mut sink = leak.clone();
2834 sink.kind = SecurityFindingKind::TaintedSink;
2835 sink.category = Some("dangerous-html".to_owned());
2836
2837 assert_eq!(
2838 security_reachability_key(&leak, root).as_deref(),
2839 Some("security-reach:src/app.tsx:client-server-leak:none")
2840 );
2841 assert_eq!(
2842 security_reachability_key(&sink, root).as_deref(),
2843 Some("security-reach:src/app.tsx:tainted-sink:dangerous-html")
2844 );
2845 }
2846
2847 #[test]
2848 fn reachability_key_ignores_unreachable_findings() {
2849 let root = Path::new("/proj/root");
2850 let finding = sample_finding(root);
2851
2852 assert!(security_reachability_key(&finding, root).is_none());
2853 }
2854
2855 #[test]
2856 fn gate_absent_from_json_when_no_gate_ran() {
2857 let json = render_json(&output_with(vec![], 0));
2858 assert!(!json.contains("\"gate\""));
2859 }
2860
2861 #[test]
2862 fn gate_sarif_is_a_run_property_not_result_severity() {
2863 let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
2864 assert!(sarif.contains("fallowGate"));
2865 assert!(!sarif.contains("\"level\": \"error\""));
2867 assert!(!sarif.contains("\"level\": \"warning\""));
2868 }
2869
2870 fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
2871 finding.reachability = Some(SecurityReachability {
2872 reachable_from_entry: true,
2873 reachable_from_untrusted_source: true,
2874 taint_confidence: Some(fallow_core::results::TaintConfidence::ModuleLevel),
2876 untrusted_source_hop_count: Some(1),
2877 untrusted_source_trace: vec![
2878 TraceHop {
2879 path: root.join("src/routes/api.ts"),
2880 line: 3,
2881 col: 0,
2882 role: TraceHopRole::ModuleSource,
2883 },
2884 TraceHop {
2885 path: root.join("src/lib/sink.ts"),
2886 line: 9,
2887 col: 2,
2888 role: TraceHopRole::Sink,
2889 },
2890 ],
2891 blast_radius: 2,
2892 crosses_boundary: false,
2893 });
2894 }
2895
2896 fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
2897 finding.taint_flow = Some(SecurityTaintFlow {
2898 source: TaintEndpoint {
2899 path: root.join("src/routes/api.ts"),
2900 line: 3,
2901 col: 0,
2902 },
2903 sink: TaintEndpoint {
2904 path: root.join("src/lib/sink.ts"),
2905 line: 9,
2906 col: 2,
2907 },
2908 path: TaintPath {
2909 intra_module: false,
2910 cross_module_hops: 1,
2911 },
2912 });
2913 }
2914
2915 #[test]
2916 fn relativize_strips_root_prefix() {
2917 let root = Path::new("/proj/root");
2918 let abs = root.join("src/app.tsx");
2919 let rel = relativize(&abs, root);
2920 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
2921 }
2922
2923 #[test]
2924 fn relativize_keeps_path_when_outside_root() {
2925 let root = Path::new("/proj/root");
2926 let outside = Path::new("/elsewhere/file.ts");
2927 assert_eq!(relativize(outside, root), outside.to_path_buf());
2929 }
2930
2931 #[test]
2932 fn relativize_finding_relativizes_anchor_and_every_hop() {
2933 let root = Path::new("/proj/root");
2934 let finding = relativize_finding(sample_finding(root), root);
2935 assert_eq!(
2936 finding.path.to_string_lossy().replace('\\', "/"),
2937 "src/app.tsx"
2938 );
2939 let hop_paths: Vec<String> = finding
2940 .trace
2941 .iter()
2942 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
2943 .collect();
2944 assert_eq!(
2945 hop_paths,
2946 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
2947 );
2948 }
2949
2950 #[test]
2951 fn relativize_finding_relativizes_untrusted_source_trace() {
2952 let root = Path::new("/proj/root");
2953 let mut finding = sample_finding(root);
2954 add_untrusted_source_reachability(&mut finding, root);
2955 let finding = relativize_finding(finding, root);
2956 let reach = finding.reachability.as_ref().expect("reachability");
2957 let hop_paths: Vec<String> = reach
2958 .untrusted_source_trace
2959 .iter()
2960 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
2961 .collect();
2962 assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
2963 }
2964
2965 #[test]
2966 fn fnv_hex_is_deterministic_and_16_hex_digits() {
2967 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
2968 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
2969 assert_eq!(a, b, "same input must hash identically");
2970 assert_eq!(a.len(), 16);
2971 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
2972 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
2974 }
2975
2976 #[test]
2977 fn hop_role_labels_cover_every_role() {
2978 assert_eq!(
2979 hop_role_label(TraceHopRole::ClientBoundary),
2980 "client boundary"
2981 );
2982 assert_eq!(
2983 hop_role_label(TraceHopRole::UntrustedSource),
2984 "untrusted source"
2985 );
2986 assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
2987 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
2988 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
2989 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
2990 }
2991
2992 #[test]
2993 fn sarif_location_clamps_line_and_offsets_column() {
2994 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
2996 let region = &loc["physicalLocation"]["region"];
2997 assert_eq!(region["startLine"], 1);
2998 assert_eq!(region["startColumn"], 1);
2999 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
3001 }
3002
3003 #[test]
3004 fn human_summary_reports_zero_without_edge_line() {
3005 let out = render_human_summary(&output_with(vec![], 0));
3006 assert!(
3007 out.contains("Security review: no items to check in the scanned code."),
3008 "got: {out}"
3009 );
3010 assert!(!out.contains("Blind spot"));
3011 }
3012
3013 #[test]
3014 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
3015 let root = Path::new("/proj/root");
3016 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
3017 assert!(
3018 out.contains("Security review: 1 item to check."),
3019 "got: {out}"
3020 );
3021 assert!(out.contains("not confirmed vulnerabilities"));
3022 assert!(out.contains("unsafe input, secrets, or settings"));
3023 assert!(out.contains("Blind spot: 2 client files use dynamic imports"));
3024 }
3025
3026 #[test]
3027 fn human_render_empty_states_no_candidates() {
3028 colored::control::set_override(false);
3029 let out = render_human(&output_with(vec![], 0));
3030 assert!(out.contains("Security review: 0 items to check"));
3031 assert!(out.contains("No security details to show."));
3032 assert!(out.contains("Result: 0 security items to check."));
3033 }
3034
3035 #[test]
3036 fn human_render_shows_finding_trace_and_next_action() {
3037 colored::control::set_override(false);
3038 let root = Path::new("/proj/root");
3039 let finding = relativize_finding(sample_finding(root), root);
3040 let out = render_human(&output_with(vec![finding], 0));
3041 assert!(out.contains("[H] high client-server-leak"));
3042 assert!(out.contains("client-server-leak"));
3043 assert!(out.contains("src/app.tsx:12"));
3044 assert!(out.contains("evidence: reaches process.env.SECRET_KEY"));
3045 assert!(out.contains("import trace:"));
3046 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
3047 assert!(out.contains("src/app.tsx:12 (client boundary)"));
3048 assert!(out.contains("Next: check whether this import can ship a secret to the browser"));
3049 assert!(out.contains("Result: 1 security item to check."));
3050 }
3051
3052 #[test]
3053 fn human_render_shows_dead_code_hint_and_delete_next_step() {
3054 colored::control::set_override(false);
3055 let root = Path::new("/proj/root");
3056 let mut finding = relativize_finding(sample_finding(root), root);
3057 finding.kind = SecurityFindingKind::TaintedSink;
3058 finding.dead_code = Some(SecurityDeadCodeContext {
3059 kind: SecurityDeadCodeKind::UnusedFile,
3060 export_name: None,
3061 line: None,
3062 guidance: "delete instead of harden".to_string(),
3063 });
3064 let out = render_human(&output_with(vec![finding], 0));
3065 assert!(
3066 out.contains("dead-code: also reported as unused-file"),
3067 "got: {out}"
3068 );
3069 assert!(
3070 out.contains("If the code is safe to remove, delete it"),
3071 "got: {out}"
3072 );
3073 }
3074
3075 #[test]
3076 fn human_render_shows_untrusted_source_path_as_module_context() {
3077 colored::control::set_override(false);
3078 let root = Path::new("/proj/root");
3079 let mut finding = sample_finding(root);
3080 finding.kind = SecurityFindingKind::TaintedSink;
3081 finding.category = Some("command-injection".to_string());
3082 add_untrusted_source_reachability(&mut finding, root);
3083 let finding = relativize_finding(finding, root);
3084
3085 let out = render_human(&output_with(vec![finding], 0));
3086
3087 assert!(
3088 out.contains("reachable from a module that receives untrusted input via 1 import hop"),
3089 "got: {out}"
3090 );
3091 assert!(out.contains("input import trace:"), "got: {out}");
3092 assert!(
3093 out.contains("src/routes/api.ts:3 (source module)"),
3094 "got: {out}"
3095 );
3096 }
3097
3098 #[test]
3099 fn human_render_surfaces_unresolved_edge_blind_spot() {
3100 colored::control::set_override(false);
3101 let out = render_human(&output_with(vec![], 3));
3102 assert!(out.contains("Blind spot: 3 client files use dynamic imports"));
3103 assert!(out.contains("Code behind those imports may be missing from this report."));
3104 }
3105
3106 #[test]
3107 fn human_render_blind_spots_use_singular_verbs() {
3108 colored::control::set_override(false);
3109 let mut output = output_with(vec![], 1);
3110 output.unresolved_callee_sites = 1;
3111
3112 let out = render_human(&output);
3113
3114 assert!(out.contains("Blind spot: 1 client file uses dynamic imports"));
3115 assert!(out.contains("Blind spot: 1 call site uses code patterns"));
3116 }
3117
3118 #[test]
3119 fn human_render_mentions_top_unresolved_callee_reason_and_file() {
3120 colored::control::set_override(false);
3121 let root = Path::new("/proj/root");
3122 let mut output = output_with(vec![], 0);
3123 output.unresolved_callee_sites = 3;
3124 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3125
3126 let out = render_human(&output);
3127
3128 assert!(
3129 out.contains("Most unresolved callees: dynamic-dispatch in src/a.ts."),
3130 "got: {out}"
3131 );
3132 }
3133
3134 #[test]
3135 fn json_render_carries_schema_version_and_findings() {
3136 let root = Path::new("/proj/root");
3137 let finding = relativize_finding(sample_finding(root), root);
3138 let rendered = render_json(&output_with(vec![finding], 1));
3139 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3140 assert_eq!(value["schema_version"], "6");
3141 assert_eq!(value["version"], "test");
3142 assert_eq!(value["elapsed_ms"], 0);
3143 assert_eq!(
3144 value["config"]["rules"]["security_client_server_leak"]["configured"],
3145 "off"
3146 );
3147 assert_eq!(
3148 value["config"]["rules"]["security_client_server_leak"]["effective"],
3149 "warn"
3150 );
3151 assert!(value["config"]["categories_include"].is_null());
3152 assert!(value["config"]["categories_exclude"].is_null());
3153 assert_eq!(value["unresolved_edge_files"], 1);
3154 let findings = value["security_findings"].as_array().expect("array");
3155 assert_eq!(findings.len(), 1);
3156 assert_eq!(findings[0]["kind"], "client-server-leak");
3157 assert_eq!(findings[0]["path"], "src/app.tsx");
3158 assert_eq!(findings[0]["severity"], "high");
3159 }
3160
3161 #[test]
3162 fn json_render_carries_bounded_unresolved_callee_diagnostics() {
3163 let root = Path::new("/proj/root");
3164 let mut output = output_with(vec![], 0);
3165 output.unresolved_callee_sites = 3;
3166 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3167
3168 let rendered = render_json(&output);
3169 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3170 let diagnostics = &value["unresolved_callee_diagnostics"];
3171
3172 assert_eq!(diagnostics["sample_limit"], 25);
3173 assert_eq!(diagnostics["top_files_limit"], 10);
3174 assert_eq!(diagnostics["sampled"][0]["path"], "src/a.ts");
3175 assert_eq!(diagnostics["sampled"][0]["reason"], "dynamic-dispatch");
3176 assert_eq!(diagnostics["sampled"][0]["expression_kind"], "other");
3177 assert_eq!(diagnostics["top_files"][0]["path"], "src/a.ts");
3178 assert_eq!(diagnostics["top_files"][0]["count"], 2);
3179 assert_eq!(diagnostics["by_reason"][0]["reason"], "dynamic-dispatch");
3180 assert_eq!(diagnostics["by_reason"][0]["count"], 2);
3181 }
3182
3183 #[test]
3184 fn json_summary_omits_finding_arrays_and_counts_security_findings() {
3185 let root = Path::new("/proj/root");
3186 let mut leak = relativize_finding(sample_finding(root), root);
3187 leak.severity = SecuritySeverity::High;
3188
3189 let mut sink = relativize_finding(sample_finding(root), root);
3190 sink.kind = SecurityFindingKind::TaintedSink;
3191 sink.category = Some("dangerous-html".to_string());
3192 sink.severity = SecuritySeverity::Medium;
3193 sink.source_backed = true;
3194 sink.reachability = Some(SecurityReachability {
3195 reachable_from_entry: true,
3196 reachable_from_untrusted_source: true,
3197 taint_confidence: Some(TaintConfidence::ArgLevel),
3198 untrusted_source_hop_count: Some(0),
3199 untrusted_source_trace: vec![],
3200 blast_radius: 3,
3201 crosses_boundary: true,
3202 });
3203 sink.runtime = Some(SecurityRuntimeContext {
3204 state: SecurityRuntimeState::RuntimeHot,
3205 function: "render".to_owned(),
3206 line: 10,
3207 invocations: Some(120),
3208 stable_id: Some("src/app.tsx::render:10".to_owned()),
3209 evidence: Some("production hot path observed".to_owned()),
3210 });
3211
3212 let mut output = output_with(vec![leak, sink], 2);
3213 output.elapsed_ms = ElapsedMs(17);
3214 output.unresolved_callee_sites = 3;
3215
3216 let rendered = render_json_summary(&output);
3217 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3218
3219 assert_eq!(value["kind"], "security");
3220 assert_eq!(value["schema_version"], "6");
3221 assert_eq!(value["version"], "test");
3222 assert_eq!(value["elapsed_ms"], 17);
3223 assert!(value.get("config").is_some());
3224 assert!(value.get("security_findings").is_none());
3225 assert!(value.get("attack_surface").is_none());
3226 assert!(value.get("_meta").is_none());
3227 assert_eq!(value["summary"]["security_findings"], 2);
3228 assert_eq!(value["summary"]["by_severity"]["high"], 1);
3229 assert_eq!(value["summary"]["by_severity"]["medium"], 1);
3230 assert_eq!(value["summary"]["by_severity"]["low"], 0);
3231 assert_eq!(value["summary"]["by_category"]["client-server-leak"], 1);
3232 assert_eq!(value["summary"]["by_category"]["dangerous-html"], 1);
3233 assert_eq!(value["summary"]["by_reachability"]["entry_reachable"], 1);
3234 assert_eq!(
3235 value["summary"]["by_reachability"]["untrusted_source_reachable"],
3236 1
3237 );
3238 assert_eq!(value["summary"]["by_reachability"]["arg_level"], 1);
3239 assert_eq!(value["summary"]["by_reachability"]["module_level"], 0);
3240 assert_eq!(value["summary"]["by_reachability"]["crosses_boundary"], 1);
3241 assert_eq!(value["summary"]["by_reachability"]["source_backed"], 1);
3242 assert_eq!(value["summary"]["by_runtime_state"]["runtime_hot"], 1);
3243 assert_eq!(value["summary"]["by_runtime_state"]["runtime_cold"], 0);
3244 assert_eq!(value["summary"]["by_runtime_state"]["never_executed"], 0);
3245 assert_eq!(value["summary"]["by_runtime_state"]["low_traffic"], 0);
3246 assert_eq!(
3247 value["summary"]["by_runtime_state"]["coverage_unavailable"],
3248 0
3249 );
3250 assert_eq!(value["summary"]["by_runtime_state"]["runtime_unknown"], 0);
3251 assert_eq!(value["summary"]["by_runtime_state"]["not_collected"], 1);
3252 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
3253 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
3254 assert_eq!(value["summary"]["attack_surface_entries"], 0);
3255 }
3256
3257 #[test]
3258 fn json_summary_carries_security_meta_when_explain_requested() {
3259 let root = Path::new("/proj/root");
3260 let mut output = output_with(vec![relativize_finding(sample_finding(root), root)], 0);
3261 output.meta = Some(crate::explain::security_meta());
3262
3263 let rendered = render_json_summary(&output);
3264 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3265
3266 assert!(value.get("security_findings").is_none());
3267 assert!(value["_meta"]["field_definitions"]["security_findings[]"].is_string());
3268 assert!(value["_meta"]["field_definitions"]["summary.by_reachability"].is_string());
3269 assert!(value["_meta"]["field_definitions"]["summary.by_runtime_state"].is_string());
3270 assert!(value["_meta"]["field_definitions"]["unresolved_callee_sites"].is_string());
3271 }
3272
3273 #[test]
3274 fn json_summary_preserves_gate_block() {
3275 let output = output_with_gate(SecurityGateVerdict::Fail, 1);
3276 let rendered = render_json_summary(&output);
3277 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3278
3279 assert_eq!(value["kind"], "security");
3280 assert_eq!(value["gate"]["mode"], "new");
3281 assert_eq!(value["gate"]["verdict"], "fail");
3282 assert_eq!(value["gate"]["new_count"], 1);
3283 assert_eq!(value["summary"]["security_findings"], 0);
3284 }
3285
3286 #[test]
3287 fn json_render_carries_security_meta_when_explain_requested() {
3288 let mut output = output_with(vec![], 0);
3289 output.meta = Some(crate::explain::security_meta());
3290
3291 let rendered = render_json(&output);
3292 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3293
3294 assert_eq!(
3295 value["_meta"]["field_definitions"]["security_findings[]"],
3296 "Unverified security candidates for downstream human or agent verification."
3297 );
3298 assert!(value["_meta"]["rules"]["security/tainted-sink"].is_object());
3299 }
3300
3301 #[test]
3302 fn json_render_carries_candidate_record_and_omits_impact() {
3303 let root = Path::new("/proj/root");
3307 let finding = relativize_finding(sample_finding(root), root);
3308 let rendered = render_json(&output_with(vec![finding], 0));
3309 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3310 let finding = &value["security_findings"][0];
3311
3312 let candidate = &finding["candidate"];
3313 assert!(candidate.is_object(), "candidate record present");
3314 assert!(candidate["sink"].is_object(), "sink slot present");
3315 assert_eq!(candidate["boundary"]["client_server"], true);
3316 assert!(
3317 candidate.get("impact").is_none(),
3318 "impact must NOT be a wire field"
3319 );
3320 assert!(
3321 candidate.get("source_kind").is_none(),
3322 "client-server-leak has no source kind"
3323 );
3324 assert!(
3325 finding.get("taint_flow").is_none(),
3326 "no untrusted-source flow on a client-server-leak"
3327 );
3328 assert!(
3329 finding.get("finding_id").is_some(),
3330 "finding_id is on the wire"
3331 );
3332 }
3333
3334 #[test]
3335 fn finding_id_is_stable_and_matches_sarif_fingerprint() {
3336 let root = Path::new("/proj/root");
3339 let finding = relativize_finding(sample_finding(root), root);
3340 let id = security_finding_id(&finding);
3341 assert!(!id.is_empty());
3342 assert_eq!(
3343 id,
3344 security_finding_id(&finding),
3345 "deterministic across calls"
3346 );
3347
3348 let sarif: serde_json::Value =
3349 serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
3350 .expect("valid SARIF");
3351 assert_eq!(
3352 sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
3353 serde_json::Value::String(id)
3354 );
3355 }
3356
3357 #[test]
3358 fn json_render_carries_dead_code_context() {
3359 let root = Path::new("/proj/root");
3360 let mut finding = relativize_finding(sample_finding(root), root);
3361 finding.kind = SecurityFindingKind::TaintedSink;
3362 finding.dead_code = Some(SecurityDeadCodeContext {
3363 kind: SecurityDeadCodeKind::UnusedExport,
3364 export_name: Some("handler".to_string()),
3365 line: Some(12),
3366 guidance: "remove export instead of harden".to_string(),
3367 });
3368 let rendered = render_json(&output_with(vec![finding], 0));
3369 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3370 let context = &value["security_findings"][0]["dead_code"];
3371 assert_eq!(context["kind"], "unused-export");
3372 assert_eq!(context["export_name"], "handler");
3373 assert_eq!(context["line"], 12);
3374 }
3375
3376 #[test]
3377 fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
3378 let root = Path::new("/proj/root");
3379 let finding = relativize_finding(sample_finding(root), root);
3380 let rendered = render_sarif(&output_with(vec![finding], 0));
3381 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3382 assert_eq!(sarif["version"], "2.1.0");
3383 let run = &sarif["runs"][0];
3384 assert_eq!(run["tool"]["driver"]["name"], "fallow");
3385 let result = &run["results"][0];
3386 assert_eq!(result["level"], "warning");
3388 assert_eq!(result["ruleId"], "security/client-server-leak");
3389 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
3390 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
3392 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3393 .as_array()
3394 .expect("thread flow locations");
3395 assert_eq!(flow_locations.len(), 3);
3396 assert_eq!(
3397 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3398 "src/app.tsx"
3399 );
3400 assert_eq!(
3401 flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3402 "src/lib/secret.ts"
3403 );
3404 assert_eq!(
3405 flow_locations[2]["kinds"][0],
3406 serde_json::json!("secret-source")
3407 );
3408 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
3410
3411 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3412 assert_eq!(rules[0]["name"], "Client-server secret leak");
3413 assert!(rules[0]["help"]["text"].is_string());
3414 assert!(rules[0].get("relationships").is_none());
3415 assert!(run.get("taxonomies").is_none());
3416 }
3417
3418 #[test]
3419 fn sarif_render_keeps_low_severity_as_note() {
3420 let root = Path::new("/proj/root");
3421 let mut finding = sample_finding(root);
3422 finding.severity = SecuritySeverity::Low;
3423 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3424 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3425
3426 assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
3427 }
3428
3429 #[test]
3430 fn sarif_render_includes_dead_code_hint_in_message() {
3431 let root = Path::new("/proj/root");
3432 let mut finding = relativize_finding(sample_finding(root), root);
3433 finding.kind = SecurityFindingKind::TaintedSink;
3434 finding.dead_code = Some(SecurityDeadCodeContext {
3435 kind: SecurityDeadCodeKind::UnusedFile,
3436 export_name: None,
3437 line: None,
3438 guidance: "delete instead of harden".to_string(),
3439 });
3440 let rendered = render_sarif(&output_with(vec![finding], 0));
3441 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3442 let message = sarif["runs"][0]["results"][0]["message"]["text"]
3443 .as_str()
3444 .expect("message text");
3445 assert!(message.contains("Dead-code cross-link"), "got: {message}");
3446 assert!(
3447 message.contains("delete this file instead of hardening"),
3448 "got: {message}"
3449 );
3450 }
3451
3452 #[test]
3453 fn sarif_render_includes_untrusted_source_context_and_related_locations() {
3454 let root = Path::new("/proj/root");
3455 let mut finding = sample_finding(root);
3456 finding.kind = SecurityFindingKind::TaintedSink;
3457 finding.category = Some("command-injection".to_string());
3458 add_untrusted_source_reachability(&mut finding, root);
3459 add_taint_flow(&mut finding, root);
3460 finding.trace.push(TraceHop {
3461 path: root.join("src/lib/sink.ts"),
3462 line: 9,
3463 col: 2,
3464 role: TraceHopRole::Sink,
3465 });
3466 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3467 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3468 let result = &sarif["runs"][0]["results"][0];
3469 let message = result["message"]["text"].as_str().expect("message text");
3470 assert!(message.contains("Module-level context"), "got: {message}");
3471 assert!(
3472 message.contains("does not prove value flow"),
3473 "got: {message}"
3474 );
3475 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
3477 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3478 .as_array()
3479 .expect("thread flow locations");
3480 assert_eq!(flow_locations.len(), 2);
3481 assert_eq!(
3482 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3483 "src/routes/api.ts"
3484 );
3485 assert_eq!(
3486 flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3487 "src/lib/sink.ts"
3488 );
3489 }
3490
3491 #[test]
3492 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
3493 let root = Path::new("/proj/root");
3494 let mut finding = sample_finding(root);
3495 finding.kind = SecurityFindingKind::TaintedSink;
3496 finding.category = Some("dangerous-html".to_owned());
3497 finding.cwe = Some(79);
3498 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3499 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3500 let run = &sarif["runs"][0];
3501 let result = &run["results"][0];
3504 assert_eq!(result["level"], "warning");
3505 assert_eq!(result["ruleId"], "security/dangerous-html");
3506 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3508 assert_eq!(rules.len(), 1);
3509 assert_eq!(rules[0]["id"], "security/dangerous-html");
3510 assert_eq!(rules[0]["name"], "Dangerous HTML sink");
3511 assert!(
3512 rules[0]["help"]["text"]
3513 .as_str()
3514 .expect("help text")
3515 .contains("Verify this unverified")
3516 );
3517 assert!(
3518 rules[0]["help"]["markdown"]
3519 .as_str()
3520 .expect("help markdown")
3521 .contains("**Dangerous HTML sink**")
3522 );
3523 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
3524 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
3525 let relationship = &rules[0]["relationships"][0];
3526 assert_eq!(relationship["target"]["id"], "CWE-79");
3527 assert_eq!(relationship["target"]["index"], 0);
3528 assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
3529 assert_eq!(relationship["kinds"][0], "superset");
3530
3531 let taxonomy = &run["taxonomies"][0];
3532 assert_eq!(taxonomy["name"], "CWE");
3533 assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
3534 assert_eq!(
3535 run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
3536 "CWE"
3537 );
3538 }
3539
3540 #[test]
3541 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
3542 let root = Path::new("/proj/root");
3543 let finding = relativize_finding(sample_finding(root), root);
3544 let output = output_with(vec![finding], 0);
3545 let dir = tempfile::tempdir().expect("tempdir");
3546 let path = dir.path().join("nested/out.sarif");
3547 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
3548 let written = std::fs::read_to_string(&path).expect("file exists");
3549 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3550 assert_eq!(sarif["version"], "2.1.0");
3551 }
3552
3553 const NO_CONFIG: Option<PathBuf> = None;
3555
3556 fn leak_fixture_root() -> PathBuf {
3557 Path::new(env!("CARGO_MANIFEST_DIR"))
3558 .join("../../tests/fixtures/security-client-server-leak")
3559 }
3560
3561 fn source_reachability_fixture_root() -> PathBuf {
3562 Path::new(env!("CARGO_MANIFEST_DIR"))
3563 .join("../../tests/fixtures/security-source-reachability-885")
3564 }
3565
3566 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
3567 SecurityOptions {
3568 root,
3569 config_path: &NO_CONFIG,
3570 output,
3571 no_cache: true,
3572 threads: 1,
3573 quiet: true,
3574 fail_on_issues,
3575 sarif_file: None,
3576 summary: false,
3577 changed_since: None,
3578 use_shared_diff_index: false,
3579 workspace: None,
3580 changed_workspaces: None,
3581 file: &[],
3582 surface: false,
3583 gate: None,
3584 runtime_coverage: None,
3585 min_invocations_hot: 100,
3586 explain: false,
3587 }
3588 }
3589
3590 #[test]
3591 #[expect(
3592 deprecated,
3593 reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
3594 )]
3595 fn source_reachability_fixture_marks_cross_module_sink() {
3596 let root = source_reachability_fixture_root();
3597 let mut config = load_config_for_analysis(
3598 &root,
3599 &NO_CONFIG,
3600 OutputFormat::Json,
3601 true,
3602 1,
3603 None,
3604 true,
3605 ProductionAnalysis::DeadCode,
3606 )
3607 .expect("fixture config loads");
3608 config.rules.security_sink = Severity::Warn;
3609
3610 let results = fallow_core::analyze(&config).expect("fixture analyzes");
3611 let finding = results
3612 .security_findings
3613 .iter()
3614 .find(|finding| finding.path.ends_with("src/runner.ts"))
3615 .expect("runner sink finding");
3616 let reach = finding.reachability.as_ref().expect("reachability");
3617
3618 assert!(reach.reachable_from_untrusted_source);
3619 assert_eq!(reach.untrusted_source_hop_count, Some(1));
3620 assert_eq!(
3624 reach.taint_confidence,
3625 Some(fallow_core::results::TaintConfidence::ModuleLevel)
3626 );
3627 assert_eq!(
3628 reach
3629 .untrusted_source_trace
3630 .iter()
3631 .map(|hop| hop.role)
3632 .collect::<Vec<_>>(),
3633 vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
3634 );
3635 assert!(
3636 reach.untrusted_source_trace[0]
3637 .path
3638 .ends_with("src/route.ts")
3639 );
3640
3641 assert!(
3645 finding.candidate.boundary.cross_module,
3646 "a sink reached across a module hop crosses a module boundary"
3647 );
3648 let flow = finding.taint_flow.as_ref().expect("taint_flow present");
3649 assert!(!flow.path.intra_module);
3650 assert_eq!(flow.path.cross_module_hops, 1);
3651 assert!(flow.source.path.ends_with("src/route.ts"));
3652 assert!(flow.sink.path.ends_with("src/runner.ts"));
3653 }
3654
3655 #[test]
3656 fn file_scope_keeps_security_finding_when_anchor_matches() {
3657 let root = Path::new("/proj/root");
3658 let mut results = fallow_core::results::AnalysisResults::default();
3659 results.security_findings.push(sample_finding(root));
3660
3661 filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
3662
3663 assert_eq!(results.security_findings.len(), 1);
3664 }
3665
3666 #[test]
3667 fn file_scope_keeps_security_finding_when_trace_hop_matches() {
3668 let root = Path::new("/proj/root");
3669 let mut results = fallow_core::results::AnalysisResults::default();
3670 results.security_findings.push(sample_finding(root));
3671
3672 filter_to_files(
3673 &mut results,
3674 root,
3675 &[PathBuf::from("src/lib/secret.ts")],
3676 true,
3677 );
3678
3679 assert_eq!(results.security_findings.len(), 1);
3680 }
3681
3682 #[test]
3683 fn file_scope_drops_unrelated_security_finding() {
3684 let root = Path::new("/proj/root");
3685 let mut results = fallow_core::results::AnalysisResults::default();
3686 results.security_findings.push(sample_finding(root));
3687
3688 filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
3689
3690 assert!(results.security_findings.is_empty());
3691 }
3692
3693 #[test]
3694 fn run_is_advisory_and_exits_zero_even_with_candidates() {
3695 let root = leak_fixture_root();
3698 let code = run(&run_opts(&root, OutputFormat::Json, false));
3699 assert_eq!(code, ExitCode::SUCCESS);
3700 }
3701
3702 #[test]
3703 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
3704 let root = leak_fixture_root();
3706 let code = run(&run_opts(&root, OutputFormat::Human, true));
3707 assert_eq!(code, ExitCode::from(1));
3708 }
3709
3710 #[test]
3711 fn run_rejects_unsupported_output_format() {
3712 let root = leak_fixture_root();
3714 let code = run(&run_opts(&root, OutputFormat::Compact, false));
3715 assert_eq!(code, ExitCode::from(2));
3716 }
3717
3718 #[test]
3719 fn run_summary_mode_dispatches_compact_human_renderer() {
3720 let root = leak_fixture_root();
3721 let opts = SecurityOptions {
3722 summary: true,
3723 ..run_opts(&root, OutputFormat::Human, false)
3724 };
3725 assert_eq!(run(&opts), ExitCode::SUCCESS);
3726 }
3727
3728 #[test]
3729 fn run_sarif_format_dispatches_sarif_renderer() {
3730 let root = leak_fixture_root();
3731 assert_eq!(
3732 run(&run_opts(&root, OutputFormat::Sarif, false)),
3733 ExitCode::SUCCESS
3734 );
3735 }
3736
3737 #[test]
3738 fn run_writes_sarif_sidecar_file_when_requested() {
3739 let root = leak_fixture_root();
3740 let dir = tempfile::tempdir().expect("tempdir");
3741 let sidecar = dir.path().join("security.sarif");
3742 let opts = SecurityOptions {
3743 sarif_file: Some(&sidecar),
3744 ..run_opts(&root, OutputFormat::Human, false)
3745 };
3746 assert_eq!(run(&opts), ExitCode::SUCCESS);
3747 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
3748 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3749 assert_eq!(sarif["version"], "2.1.0");
3750 }
3751}