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