1use std::path::{Path, PathBuf};
12use std::process::ExitCode;
13
14use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
15use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHopRole};
16use serde::Serialize;
17
18use crate::error::emit_error;
19use crate::load_config_for_analysis;
20
21#[derive(Debug, Clone, Copy, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25pub enum SecuritySchemaVersion {
26 #[serde(rename = "1")]
28 V1,
29}
30
31#[derive(Debug, Clone, Serialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct SecurityOutput {
36 pub schema_version: SecuritySchemaVersion,
38 pub security_findings: Vec<SecurityFinding>,
40 pub unresolved_edge_files: usize,
45 pub unresolved_callee_sites: usize,
50}
51
52pub struct SecurityOptions<'a> {
54 pub root: &'a Path,
56 pub config_path: &'a Option<PathBuf>,
58 pub output: OutputFormat,
60 pub no_cache: bool,
62 pub threads: usize,
64 pub quiet: bool,
66 pub fail_on_issues: bool,
68 pub sarif_file: Option<&'a Path>,
70 pub summary: bool,
72 pub changed_since: Option<&'a str>,
74 pub use_shared_diff_index: bool,
76 pub workspace: Option<&'a [String]>,
78 pub changed_workspaces: Option<&'a str>,
80}
81
82#[expect(
87 deprecated,
88 reason = "ADR-008 deprecates fallow_core::analyze externally; the CLI uses the workspace path dependency"
89)]
90pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
91 if !matches!(
92 opts.output,
93 OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
94 ) {
95 return emit_error(
96 "fallow security supports --format human, json, or sarif only.",
97 2,
98 opts.output,
99 );
100 }
101
102 let mut config = match load_config_for_analysis(
103 opts.root,
104 opts.config_path,
105 opts.output,
106 opts.no_cache,
107 opts.threads,
108 None,
109 opts.quiet,
110 ProductionAnalysis::DeadCode,
111 ) {
112 Ok(config) => config,
113 Err(code) => return code,
114 };
115
116 let effective_severity = config.rules.security_client_server_leak;
120 if effective_severity == Severity::Off {
121 config.rules.security_client_server_leak = Severity::Warn;
122 }
123 let effective_sink_severity = config.rules.security_sink;
124 if effective_sink_severity == Severity::Off {
125 config.rules.security_sink = Severity::Warn;
126 }
127
128 let mut results = match fallow_core::analyze(&config) {
129 Ok(results) => results,
130 Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
131 };
132
133 let ws_roots = match crate::check::filtering::resolve_workspace_scope(
135 opts.root,
136 opts.workspace,
137 opts.changed_workspaces,
138 opts.output,
139 ) {
140 Ok(roots) => roots,
141 Err(code) => return code,
142 };
143 if let Some(ref roots) = ws_roots {
144 crate::check::filtering::filter_to_workspaces(&mut results, roots);
145 }
146
147 if let Some(git_ref) = opts.changed_since
150 && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
151 {
152 fallow_core::changed_files::filter_results_by_changed_files(&mut results, &changed);
153 }
154 if opts.use_shared_diff_index
155 && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
156 {
157 crate::check::filtering::filter_results_by_diff(&mut results, diff_index, opts.root);
158 }
159
160 let unresolved_edge_files = results.security_unresolved_edge_files;
161 let unresolved_callee_sites = results.security_unresolved_callee_sites;
162 let findings: Vec<SecurityFinding> = std::mem::take(&mut results.security_findings)
163 .into_iter()
164 .map(|f| relativize_finding(f, &config.root))
165 .collect();
166
167 let fail = (opts.fail_on_issues
168 || effective_severity == Severity::Error
169 || effective_sink_severity == Severity::Error)
170 && !findings.is_empty();
171
172 let output = SecurityOutput {
173 schema_version: SecuritySchemaVersion::V1,
174 security_findings: findings,
175 unresolved_edge_files,
176 unresolved_callee_sites,
177 };
178
179 if let Some(path) = opts.sarif_file
180 && let Err(message) = write_sarif_file(&output, path)
181 {
182 return emit_error(&message, 2, opts.output);
183 }
184
185 let rendered = match opts.output {
186 OutputFormat::Json => render_json(&output),
187 OutputFormat::Sarif => render_sarif(&output),
188 _ if opts.summary => render_human_summary(&output),
189 _ => render_human(&output),
190 };
191 println!("{rendered}");
192
193 if fail {
194 ExitCode::from(1)
195 } else {
196 ExitCode::SUCCESS
197 }
198}
199
200fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
203 finding.path = relativize(&finding.path, root);
204 for hop in &mut finding.trace {
205 hop.path = relativize(&hop.path, root);
206 }
207 finding
208}
209
210fn relativize(path: &Path, root: &Path) -> PathBuf {
211 path.strip_prefix(root)
212 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
213}
214
215#[must_use]
217pub fn render_json(output: &SecurityOutput) -> String {
218 let Ok(value) = crate::output_envelope::serialize_root_output(
219 crate::output_envelope::FallowOutput::Security(output.clone()),
220 ) else {
221 return "{\"error\":\"failed to serialize security output\"}".to_owned();
222 };
223 serde_json::to_string_pretty(&value)
224 .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
225}
226
227fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
228 if let Some(parent) = path.parent()
229 && !parent.as_os_str().is_empty()
230 {
231 std::fs::create_dir_all(parent).map_err(|err| {
232 format!(
233 "Failed to create directory for SARIF file {}: {err}",
234 path.display()
235 )
236 })?;
237 }
238 std::fs::write(path, render_sarif(output))
239 .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
240}
241
242#[must_use]
243fn render_human_summary(output: &SecurityOutput) -> String {
244 use crate::report::plural;
245 use std::fmt::Write as _;
246
247 let count = output.security_findings.len();
248 let mut out = format!(
249 "Security candidates: {count} candidate{} found. These are NOT verified vulnerabilities; verify each before acting.\n",
250 plural(count),
251 );
252 if output.unresolved_edge_files > 0 {
253 let n = output.unresolved_edge_files;
254 let _ = writeln!(
255 out,
256 "Unresolved dynamic import cones: {n} client file{}.",
257 plural(n)
258 );
259 }
260 if output.unresolved_callee_sites > 0 {
261 let n = output.unresolved_callee_sites;
262 let _ = writeln!(out, "Unresolved sink callees: {n} site{}.", plural(n));
263 }
264 out
265}
266
267#[must_use]
270#[expect(
271 clippy::format_push_string,
272 reason = "small report renderer; readability over avoiding the extra allocation"
273)]
274pub fn render_human(output: &SecurityOutput) -> String {
275 use crate::report::plural;
276 use colored::Colorize;
277
278 let mut out = String::new();
279 out.push_str("Security candidates (unverified; for agent or human verification)\n\n");
280
281 if output.security_findings.is_empty() {
282 out.push_str("No security candidates found.\n");
283 } else {
284 for finding in &output.security_findings {
285 let kind = security_finding_label(finding);
286 out.push_str(&format!(
289 "{} {kind} {}:{}\n",
290 "[I]".blue().bold(),
291 finding.path.to_string_lossy().replace('\\', "/").bold(),
292 finding.line,
293 ));
294 out.push_str(&format!(" {}\n", finding.evidence));
295 if let Some(reach) = finding.reachability {
296 let entry = if reach.reachable_from_entry {
297 "reachable from a runtime entry point"
298 } else {
299 "not reached from any runtime entry point"
300 };
301 let boundary = if reach.crosses_boundary {
302 "; crosses an architecture boundary"
303 } else {
304 ""
305 };
306 out.push_str(&format!(
307 " reach: {entry} (blast radius {}){boundary}\n",
308 reach.blast_radius,
309 ));
310 }
311 if !finding.trace.is_empty() {
312 out.push_str(" trace:\n");
313 for hop in &finding.trace {
314 out.push_str(&format!(
315 " {}:{} ({})\n",
316 hop.path.to_string_lossy().replace('\\', "/"),
317 hop.line,
318 hop_role_label(hop.role),
319 ));
320 }
321 }
322 if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
323 out.push_str(
324 " Next: check whether the import is type-only, server-only, or behind a \
325 build-time guard; if the value never ships to the client bundle, this \
326 candidate is a false positive.\n",
327 );
328 }
329 out.push('\n');
330 }
331 }
332
333 if output.unresolved_edge_files > 0 {
334 let n = output.unresolved_edge_files;
335 out.push_str(&format!(
336 "{} {n} client file{} reached a dynamic import the reachability scan could not \
337 follow; a leak behind those edges would not be reported, so an empty result is \
338 not a clean bill.\n",
339 "[I]".blue().bold(),
340 plural(n),
341 ));
342 }
343
344 if output.unresolved_callee_sites > 0 {
345 let n = output.unresolved_callee_sites;
346 out.push_str(&format!(
347 "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
348 path (dynamic dispatch, computed members, aliased bindings); an empty result is \
349 not a clean bill.\n",
350 "[I]".blue().bold(),
351 plural(n),
352 ));
353 }
354
355 let count = output.security_findings.len();
356 out.push_str(&format!(
357 "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
358 each before acting.\n",
359 plural(count),
360 ));
361 out
362}
363
364fn security_finding_label(finding: &SecurityFinding) -> String {
368 match finding.kind {
369 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
370 SecurityFindingKind::TaintedSink => {
371 let title = finding
372 .category
373 .as_deref()
374 .and_then(fallow_core::analyze::security_catalogue_title)
375 .or(finding.category.as_deref())
376 .unwrap_or("tainted-sink");
377 match finding.cwe {
378 Some(cwe) => format!("{title} (CWE-{cwe})"),
379 None => title.to_string(),
380 }
381 }
382 }
383}
384
385const fn hop_role_label(role: TraceHopRole) -> &'static str {
386 match role {
387 TraceHopRole::ClientBoundary => "client boundary",
388 TraceHopRole::Intermediate => "intermediate",
389 TraceHopRole::SecretSource => "secret source",
390 TraceHopRole::Sink => "sink site",
391 }
392}
393
394fn sarif_rule_id(finding: &SecurityFinding) -> String {
399 match finding.kind {
400 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
401 SecurityFindingKind::TaintedSink => {
402 format!(
403 "security/{}",
404 finding.category.as_deref().unwrap_or("tainted-sink")
405 )
406 }
407 }
408}
409
410fn sarif_rule_def(rule_id: &str, finding: &SecurityFinding) -> serde_json::Value {
414 match finding.kind {
415 SecurityFindingKind::ClientServerLeak => serde_json::json!({
416 "id": rule_id,
417 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
418 "fullDescription": { "text":
419 "Unverified candidate, requires verification: a \"use client\" file \
420 transitively imports a module that reads a non-public process.env \
421 secret. fallow does not prove the secret reaches client-bundled code." },
422 "helpUri": "https://github.com/fallow-rs/fallow",
423 "defaultConfiguration": { "level": "note" }
424 }),
425 SecurityFindingKind::TaintedSink => {
426 let title = finding
427 .category
428 .as_deref()
429 .and_then(fallow_core::analyze::security_catalogue_title)
430 .or(finding.category.as_deref())
431 .unwrap_or("tainted-sink");
432 let mut rule = serde_json::json!({
433 "id": rule_id,
434 "shortDescription": { "text": format!("{title} candidate (unverified)") },
435 "fullDescription": { "text": format!(
436 "Unverified candidate, requires verification: {title}. fallow flags a \
437 syntactic sink reached by a non-literal argument; it does not prove the \
438 value is attacker-controlled or reaches the sink unsanitized."
439 ) },
440 "helpUri": "https://github.com/fallow-rs/fallow",
441 "defaultConfiguration": { "level": "note" }
442 });
443 if let Some(cwe) = finding.cwe {
444 rule["properties"] = serde_json::json!({
445 "tags": [format!("external/cwe/cwe-{cwe}")]
446 });
447 }
448 rule
449 }
450 }
451}
452
453#[must_use]
460fn render_sarif(output: &SecurityOutput) -> String {
461 let results: Vec<serde_json::Value> = output
462 .security_findings
463 .iter()
464 .map(|finding| {
465 let rule_id = sarif_rule_id(finding);
466 let related: Vec<serde_json::Value> = finding
467 .trace
468 .iter()
469 .map(|hop| sarif_location(&hop.path, hop.line, hop.col))
470 .collect();
471 let fp = format!(
474 "{rule_id}:{}:{}",
475 finding.path.to_string_lossy().replace('\\', "/"),
476 finding.line,
477 );
478 serde_json::json!({
479 "ruleId": rule_id,
480 "level": "note",
481 "message": { "text": finding.evidence },
482 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
483 "relatedLocations": related,
484 "partialFingerprints": { "fallowSecurity/v1": fnv_hex(&fp) },
485 })
486 })
487 .collect();
488
489 let mut seen: Vec<String> = Vec::new();
491 let mut rules: Vec<serde_json::Value> = Vec::new();
492 for finding in &output.security_findings {
493 let rule_id = sarif_rule_id(finding);
494 if seen.iter().any(|s| s == &rule_id) {
495 continue;
496 }
497 seen.push(rule_id.clone());
498 rules.push(sarif_rule_def(&rule_id, finding));
499 }
500
501 let sarif = serde_json::json!({
502 "version": "2.1.0",
503 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
504 "runs": [{
505 "tool": { "driver": {
506 "name": "fallow",
507 "version": env!("CARGO_PKG_VERSION"),
508 "informationUri": "https://github.com/fallow-rs/fallow",
509 "rules": rules,
510 }},
511 "results": results,
512 }],
513 });
514 serde_json::to_string_pretty(&sarif)
515 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
516}
517
518fn fnv_hex(input: &str) -> String {
520 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
521 for byte in input.bytes() {
522 hash ^= u64::from(byte);
523 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
524 }
525 format!("{hash:016x}")
526}
527
528fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
529 serde_json::json!({
530 "physicalLocation": {
531 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
532 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
533 }
534 })
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHop, TraceHopRole};
541
542 fn sample_finding(root: &Path) -> SecurityFinding {
544 SecurityFinding {
545 kind: SecurityFindingKind::ClientServerLeak,
546 path: root.join("src/app.tsx"),
547 line: 12,
548 col: 3,
549 evidence: "reaches process.env.SECRET_KEY".to_owned(),
550 source_backed: false,
551 trace: vec![
552 TraceHop {
553 path: root.join("src/app.tsx"),
554 line: 12,
555 col: 3,
556 role: TraceHopRole::ClientBoundary,
557 },
558 TraceHop {
559 path: root.join("src/lib/util.ts"),
560 line: 4,
561 col: 0,
562 role: TraceHopRole::Intermediate,
563 },
564 TraceHop {
565 path: root.join("src/lib/secret.ts"),
566 line: 8,
567 col: 2,
568 role: TraceHopRole::SecretSource,
569 },
570 ],
571 actions: vec![],
572 category: None,
573 cwe: None,
574 reachability: None,
575 }
576 }
577
578 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
579 SecurityOutput {
580 schema_version: SecuritySchemaVersion::V1,
581 security_findings: findings,
582 unresolved_edge_files,
583 unresolved_callee_sites: 0,
584 }
585 }
586
587 #[test]
588 fn relativize_strips_root_prefix() {
589 let root = Path::new("/proj/root");
590 let abs = root.join("src/app.tsx");
591 let rel = relativize(&abs, root);
592 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
593 }
594
595 #[test]
596 fn relativize_keeps_path_when_outside_root() {
597 let root = Path::new("/proj/root");
598 let outside = Path::new("/elsewhere/file.ts");
599 assert_eq!(relativize(outside, root), outside.to_path_buf());
601 }
602
603 #[test]
604 fn relativize_finding_relativizes_anchor_and_every_hop() {
605 let root = Path::new("/proj/root");
606 let finding = relativize_finding(sample_finding(root), root);
607 assert_eq!(
608 finding.path.to_string_lossy().replace('\\', "/"),
609 "src/app.tsx"
610 );
611 let hop_paths: Vec<String> = finding
612 .trace
613 .iter()
614 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
615 .collect();
616 assert_eq!(
617 hop_paths,
618 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
619 );
620 }
621
622 #[test]
623 fn fnv_hex_is_deterministic_and_16_hex_digits() {
624 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
625 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
626 assert_eq!(a, b, "same input must hash identically");
627 assert_eq!(a.len(), 16);
628 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
629 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
631 }
632
633 #[test]
634 fn hop_role_labels_cover_every_role() {
635 assert_eq!(
636 hop_role_label(TraceHopRole::ClientBoundary),
637 "client boundary"
638 );
639 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
640 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
641 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
642 }
643
644 #[test]
645 fn sarif_location_clamps_line_and_offsets_column() {
646 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
648 let region = &loc["physicalLocation"]["region"];
649 assert_eq!(region["startLine"], 1);
650 assert_eq!(region["startColumn"], 1);
651 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
653 }
654
655 #[test]
656 fn human_summary_reports_zero_without_edge_line() {
657 let out = render_human_summary(&output_with(vec![], 0));
658 assert!(out.contains("0 candidates found"), "got: {out}");
659 assert!(!out.contains("Unresolved dynamic import cones"));
660 }
661
662 #[test]
663 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
664 let root = Path::new("/proj/root");
665 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
666 assert!(out.contains("1 candidate found"), "got: {out}");
667 assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
668 }
669
670 #[test]
671 fn human_render_empty_states_no_candidates() {
672 colored::control::set_override(false);
673 let out = render_human(&output_with(vec![], 0));
674 assert!(out.contains("No security candidates found."));
675 assert!(out.contains("Found 0 security candidates"));
676 }
677
678 #[test]
679 fn human_render_shows_finding_trace_and_next_action() {
680 colored::control::set_override(false);
681 let root = Path::new("/proj/root");
682 let finding = relativize_finding(sample_finding(root), root);
683 let out = render_human(&output_with(vec![finding], 0));
684 assert!(out.contains("client-server-leak"));
685 assert!(out.contains("src/app.tsx:12"));
686 assert!(out.contains("reaches process.env.SECRET_KEY"));
687 assert!(out.contains("trace:"));
688 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
689 assert!(out.contains("src/app.tsx:12 (client boundary)"));
690 assert!(out.contains("Next:"));
691 assert!(out.contains("Found 1 security candidate."));
692 }
693
694 #[test]
695 fn human_render_surfaces_unresolved_edge_blind_spot() {
696 colored::control::set_override(false);
697 let out = render_human(&output_with(vec![], 3));
698 assert!(out.contains("3 client files reached a dynamic import"));
699 assert!(out.contains("not a clean bill"));
700 }
701
702 #[test]
703 fn json_render_carries_schema_version_and_findings() {
704 let root = Path::new("/proj/root");
705 let finding = relativize_finding(sample_finding(root), root);
706 let rendered = render_json(&output_with(vec![finding], 1));
707 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
708 assert_eq!(value["schema_version"], "1");
709 assert_eq!(value["unresolved_edge_files"], 1);
710 let findings = value["security_findings"].as_array().expect("array");
711 assert_eq!(findings.len(), 1);
712 assert_eq!(findings[0]["kind"], "client-server-leak");
713 assert_eq!(findings[0]["path"], "src/app.tsx");
714 }
715
716 #[test]
717 fn sarif_render_emits_note_level_with_fingerprint_and_related_locations() {
718 let root = Path::new("/proj/root");
719 let finding = relativize_finding(sample_finding(root), root);
720 let rendered = render_sarif(&output_with(vec![finding], 0));
721 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
722 assert_eq!(sarif["version"], "2.1.0");
723 let run = &sarif["runs"][0];
724 assert_eq!(run["tool"]["driver"]["name"], "fallow");
725 let result = &run["results"][0];
726 assert_eq!(result["level"], "note");
728 assert_eq!(result["ruleId"], "security/client-server-leak");
729 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
730 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
732 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
734 }
735
736 #[test]
737 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_tag() {
738 let root = Path::new("/proj/root");
739 let mut finding = sample_finding(root);
740 finding.kind = SecurityFindingKind::TaintedSink;
741 finding.category = Some("dangerous-html".to_owned());
742 finding.cwe = Some(79);
743 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
744 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
745 let run = &sarif["runs"][0];
746 let result = &run["results"][0];
749 assert_eq!(result["level"], "note");
750 assert_eq!(result["ruleId"], "security/dangerous-html");
751 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
753 assert_eq!(rules.len(), 1);
754 assert_eq!(rules[0]["id"], "security/dangerous-html");
755 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
756 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
757 }
758
759 #[test]
760 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
761 let root = Path::new("/proj/root");
762 let finding = relativize_finding(sample_finding(root), root);
763 let output = output_with(vec![finding], 0);
764 let dir = tempfile::tempdir().expect("tempdir");
765 let path = dir.path().join("nested/out.sarif");
766 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
767 let written = std::fs::read_to_string(&path).expect("file exists");
768 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
769 assert_eq!(sarif["version"], "2.1.0");
770 }
771
772 const NO_CONFIG: Option<PathBuf> = None;
774
775 fn leak_fixture_root() -> PathBuf {
776 Path::new(env!("CARGO_MANIFEST_DIR"))
777 .join("../../tests/fixtures/security-client-server-leak")
778 }
779
780 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
781 SecurityOptions {
782 root,
783 config_path: &NO_CONFIG,
784 output,
785 no_cache: true,
786 threads: 1,
787 quiet: true,
788 fail_on_issues,
789 sarif_file: None,
790 summary: false,
791 changed_since: None,
792 use_shared_diff_index: false,
793 workspace: None,
794 changed_workspaces: None,
795 }
796 }
797
798 #[test]
799 fn run_is_advisory_and_exits_zero_even_with_candidates() {
800 let root = leak_fixture_root();
803 let code = run(&run_opts(&root, OutputFormat::Json, false));
804 assert_eq!(code, ExitCode::SUCCESS);
805 }
806
807 #[test]
808 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
809 let root = leak_fixture_root();
811 let code = run(&run_opts(&root, OutputFormat::Human, true));
812 assert_eq!(code, ExitCode::from(1));
813 }
814
815 #[test]
816 fn run_rejects_unsupported_output_format() {
817 let root = leak_fixture_root();
819 let code = run(&run_opts(&root, OutputFormat::Compact, false));
820 assert_eq!(code, ExitCode::from(2));
821 }
822
823 #[test]
824 fn run_summary_mode_dispatches_compact_human_renderer() {
825 let root = leak_fixture_root();
826 let opts = SecurityOptions {
827 summary: true,
828 ..run_opts(&root, OutputFormat::Human, false)
829 };
830 assert_eq!(run(&opts), ExitCode::SUCCESS);
831 }
832
833 #[test]
834 fn run_sarif_format_dispatches_sarif_renderer() {
835 let root = leak_fixture_root();
836 assert_eq!(
837 run(&run_opts(&root, OutputFormat::Sarif, false)),
838 ExitCode::SUCCESS
839 );
840 }
841
842 #[test]
843 fn run_writes_sarif_sidecar_file_when_requested() {
844 let root = leak_fixture_root();
845 let dir = tempfile::tempdir().expect("tempdir");
846 let sidecar = dir.path().join("security.sarif");
847 let opts = SecurityOptions {
848 sarif_file: Some(&sidecar),
849 ..run_opts(&root, OutputFormat::Human, false)
850 };
851 assert_eq!(run(&opts), ExitCode::SUCCESS);
852 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
853 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
854 assert_eq!(sarif["version"], "2.1.0");
855 }
856}