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