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 !finding.trace.is_empty() {
296 out.push_str(" trace:\n");
297 for hop in &finding.trace {
298 out.push_str(&format!(
299 " {}:{} ({})\n",
300 hop.path.to_string_lossy().replace('\\', "/"),
301 hop.line,
302 hop_role_label(hop.role),
303 ));
304 }
305 }
306 if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
307 out.push_str(
308 " Next: check whether the import is type-only, server-only, or behind a \
309 build-time guard; if the value never ships to the client bundle, this \
310 candidate is a false positive.\n",
311 );
312 }
313 out.push('\n');
314 }
315 }
316
317 if output.unresolved_edge_files > 0 {
318 let n = output.unresolved_edge_files;
319 out.push_str(&format!(
320 "{} {n} client file{} reached a dynamic import the reachability scan could not \
321 follow; a leak behind those edges would not be reported, so an empty result is \
322 not a clean bill.\n",
323 "[I]".blue().bold(),
324 plural(n),
325 ));
326 }
327
328 if output.unresolved_callee_sites > 0 {
329 let n = output.unresolved_callee_sites;
330 out.push_str(&format!(
331 "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
332 path (dynamic dispatch, computed members, aliased bindings); an empty result is \
333 not a clean bill.\n",
334 "[I]".blue().bold(),
335 plural(n),
336 ));
337 }
338
339 let count = output.security_findings.len();
340 out.push_str(&format!(
341 "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
342 each before acting.\n",
343 plural(count),
344 ));
345 out
346}
347
348fn security_finding_label(finding: &SecurityFinding) -> String {
352 match finding.kind {
353 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
354 SecurityFindingKind::TaintedSink => {
355 let title = finding
356 .category
357 .as_deref()
358 .and_then(fallow_core::analyze::security_catalogue_title)
359 .or(finding.category.as_deref())
360 .unwrap_or("tainted-sink");
361 match finding.cwe {
362 Some(cwe) => format!("{title} (CWE-{cwe})"),
363 None => title.to_string(),
364 }
365 }
366 }
367}
368
369const fn hop_role_label(role: TraceHopRole) -> &'static str {
370 match role {
371 TraceHopRole::ClientBoundary => "client boundary",
372 TraceHopRole::Intermediate => "intermediate",
373 TraceHopRole::SecretSource => "secret source",
374 TraceHopRole::Sink => "sink site",
375 }
376}
377
378fn sarif_rule_id(finding: &SecurityFinding) -> String {
383 match finding.kind {
384 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
385 SecurityFindingKind::TaintedSink => {
386 format!(
387 "security/{}",
388 finding.category.as_deref().unwrap_or("tainted-sink")
389 )
390 }
391 }
392}
393
394fn sarif_rule_def(rule_id: &str, finding: &SecurityFinding) -> serde_json::Value {
398 match finding.kind {
399 SecurityFindingKind::ClientServerLeak => serde_json::json!({
400 "id": rule_id,
401 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
402 "fullDescription": { "text":
403 "Unverified candidate, requires verification: a \"use client\" file \
404 transitively imports a module that reads a non-public process.env \
405 secret. fallow does not prove the secret reaches client-bundled code." },
406 "helpUri": "https://github.com/fallow-rs/fallow",
407 "defaultConfiguration": { "level": "note" }
408 }),
409 SecurityFindingKind::TaintedSink => {
410 let title = finding
411 .category
412 .as_deref()
413 .and_then(fallow_core::analyze::security_catalogue_title)
414 .or(finding.category.as_deref())
415 .unwrap_or("tainted-sink");
416 let mut rule = serde_json::json!({
417 "id": rule_id,
418 "shortDescription": { "text": format!("{title} candidate (unverified)") },
419 "fullDescription": { "text": format!(
420 "Unverified candidate, requires verification: {title}. fallow flags a \
421 syntactic sink reached by a non-literal argument; it does not prove the \
422 value is attacker-controlled or reaches the sink unsanitized."
423 ) },
424 "helpUri": "https://github.com/fallow-rs/fallow",
425 "defaultConfiguration": { "level": "note" }
426 });
427 if let Some(cwe) = finding.cwe {
428 rule["properties"] = serde_json::json!({
429 "tags": [format!("external/cwe/cwe-{cwe}")]
430 });
431 }
432 rule
433 }
434 }
435}
436
437#[must_use]
444fn render_sarif(output: &SecurityOutput) -> String {
445 let results: Vec<serde_json::Value> = output
446 .security_findings
447 .iter()
448 .map(|finding| {
449 let rule_id = sarif_rule_id(finding);
450 let related: Vec<serde_json::Value> = finding
451 .trace
452 .iter()
453 .map(|hop| sarif_location(&hop.path, hop.line, hop.col))
454 .collect();
455 let fp = format!(
458 "{rule_id}:{}:{}",
459 finding.path.to_string_lossy().replace('\\', "/"),
460 finding.line,
461 );
462 serde_json::json!({
463 "ruleId": rule_id,
464 "level": "note",
465 "message": { "text": finding.evidence },
466 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
467 "relatedLocations": related,
468 "partialFingerprints": { "fallowSecurity/v1": fnv_hex(&fp) },
469 })
470 })
471 .collect();
472
473 let mut seen: Vec<String> = Vec::new();
475 let mut rules: Vec<serde_json::Value> = Vec::new();
476 for finding in &output.security_findings {
477 let rule_id = sarif_rule_id(finding);
478 if seen.iter().any(|s| s == &rule_id) {
479 continue;
480 }
481 seen.push(rule_id.clone());
482 rules.push(sarif_rule_def(&rule_id, finding));
483 }
484
485 let sarif = serde_json::json!({
486 "version": "2.1.0",
487 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
488 "runs": [{
489 "tool": { "driver": {
490 "name": "fallow",
491 "version": env!("CARGO_PKG_VERSION"),
492 "informationUri": "https://github.com/fallow-rs/fallow",
493 "rules": rules,
494 }},
495 "results": results,
496 }],
497 });
498 serde_json::to_string_pretty(&sarif)
499 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
500}
501
502fn fnv_hex(input: &str) -> String {
504 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
505 for byte in input.bytes() {
506 hash ^= u64::from(byte);
507 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
508 }
509 format!("{hash:016x}")
510}
511
512fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
513 serde_json::json!({
514 "physicalLocation": {
515 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
516 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
517 }
518 })
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use fallow_core::results::{SecurityFinding, SecurityFindingKind, TraceHop, TraceHopRole};
525
526 fn sample_finding(root: &Path) -> SecurityFinding {
528 SecurityFinding {
529 kind: SecurityFindingKind::ClientServerLeak,
530 path: root.join("src/app.tsx"),
531 line: 12,
532 col: 3,
533 evidence: "reaches process.env.SECRET_KEY".to_owned(),
534 trace: vec![
535 TraceHop {
536 path: root.join("src/app.tsx"),
537 line: 12,
538 col: 3,
539 role: TraceHopRole::ClientBoundary,
540 },
541 TraceHop {
542 path: root.join("src/lib/util.ts"),
543 line: 4,
544 col: 0,
545 role: TraceHopRole::Intermediate,
546 },
547 TraceHop {
548 path: root.join("src/lib/secret.ts"),
549 line: 8,
550 col: 2,
551 role: TraceHopRole::SecretSource,
552 },
553 ],
554 actions: vec![],
555 category: None,
556 cwe: None,
557 }
558 }
559
560 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
561 SecurityOutput {
562 schema_version: SecuritySchemaVersion::V1,
563 security_findings: findings,
564 unresolved_edge_files,
565 unresolved_callee_sites: 0,
566 }
567 }
568
569 #[test]
570 fn relativize_strips_root_prefix() {
571 let root = Path::new("/proj/root");
572 let abs = root.join("src/app.tsx");
573 let rel = relativize(&abs, root);
574 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
575 }
576
577 #[test]
578 fn relativize_keeps_path_when_outside_root() {
579 let root = Path::new("/proj/root");
580 let outside = Path::new("/elsewhere/file.ts");
581 assert_eq!(relativize(outside, root), outside.to_path_buf());
583 }
584
585 #[test]
586 fn relativize_finding_relativizes_anchor_and_every_hop() {
587 let root = Path::new("/proj/root");
588 let finding = relativize_finding(sample_finding(root), root);
589 assert_eq!(
590 finding.path.to_string_lossy().replace('\\', "/"),
591 "src/app.tsx"
592 );
593 let hop_paths: Vec<String> = finding
594 .trace
595 .iter()
596 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
597 .collect();
598 assert_eq!(
599 hop_paths,
600 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
601 );
602 }
603
604 #[test]
605 fn fnv_hex_is_deterministic_and_16_hex_digits() {
606 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
607 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
608 assert_eq!(a, b, "same input must hash identically");
609 assert_eq!(a.len(), 16);
610 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
611 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
613 }
614
615 #[test]
616 fn hop_role_labels_cover_every_role() {
617 assert_eq!(
618 hop_role_label(TraceHopRole::ClientBoundary),
619 "client boundary"
620 );
621 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
622 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
623 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
624 }
625
626 #[test]
627 fn sarif_location_clamps_line_and_offsets_column() {
628 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
630 let region = &loc["physicalLocation"]["region"];
631 assert_eq!(region["startLine"], 1);
632 assert_eq!(region["startColumn"], 1);
633 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
635 }
636
637 #[test]
638 fn human_summary_reports_zero_without_edge_line() {
639 let out = render_human_summary(&output_with(vec![], 0));
640 assert!(out.contains("0 candidates found"), "got: {out}");
641 assert!(!out.contains("Unresolved dynamic import cones"));
642 }
643
644 #[test]
645 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
646 let root = Path::new("/proj/root");
647 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
648 assert!(out.contains("1 candidate found"), "got: {out}");
649 assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
650 }
651
652 #[test]
653 fn human_render_empty_states_no_candidates() {
654 colored::control::set_override(false);
655 let out = render_human(&output_with(vec![], 0));
656 assert!(out.contains("No security candidates found."));
657 assert!(out.contains("Found 0 security candidates"));
658 }
659
660 #[test]
661 fn human_render_shows_finding_trace_and_next_action() {
662 colored::control::set_override(false);
663 let root = Path::new("/proj/root");
664 let finding = relativize_finding(sample_finding(root), root);
665 let out = render_human(&output_with(vec![finding], 0));
666 assert!(out.contains("client-server-leak"));
667 assert!(out.contains("src/app.tsx:12"));
668 assert!(out.contains("reaches process.env.SECRET_KEY"));
669 assert!(out.contains("trace:"));
670 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
671 assert!(out.contains("src/app.tsx:12 (client boundary)"));
672 assert!(out.contains("Next:"));
673 assert!(out.contains("Found 1 security candidate."));
674 }
675
676 #[test]
677 fn human_render_surfaces_unresolved_edge_blind_spot() {
678 colored::control::set_override(false);
679 let out = render_human(&output_with(vec![], 3));
680 assert!(out.contains("3 client files reached a dynamic import"));
681 assert!(out.contains("not a clean bill"));
682 }
683
684 #[test]
685 fn json_render_carries_schema_version_and_findings() {
686 let root = Path::new("/proj/root");
687 let finding = relativize_finding(sample_finding(root), root);
688 let rendered = render_json(&output_with(vec![finding], 1));
689 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
690 assert_eq!(value["schema_version"], "1");
691 assert_eq!(value["unresolved_edge_files"], 1);
692 let findings = value["security_findings"].as_array().expect("array");
693 assert_eq!(findings.len(), 1);
694 assert_eq!(findings[0]["kind"], "client-server-leak");
695 assert_eq!(findings[0]["path"], "src/app.tsx");
696 }
697
698 #[test]
699 fn sarif_render_emits_note_level_with_fingerprint_and_related_locations() {
700 let root = Path::new("/proj/root");
701 let finding = relativize_finding(sample_finding(root), root);
702 let rendered = render_sarif(&output_with(vec![finding], 0));
703 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
704 assert_eq!(sarif["version"], "2.1.0");
705 let run = &sarif["runs"][0];
706 assert_eq!(run["tool"]["driver"]["name"], "fallow");
707 let result = &run["results"][0];
708 assert_eq!(result["level"], "note");
710 assert_eq!(result["ruleId"], "security/client-server-leak");
711 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
712 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
714 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
716 }
717
718 #[test]
719 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_tag() {
720 let root = Path::new("/proj/root");
721 let mut finding = sample_finding(root);
722 finding.kind = SecurityFindingKind::TaintedSink;
723 finding.category = Some("dangerous-html".to_owned());
724 finding.cwe = Some(79);
725 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
726 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
727 let run = &sarif["runs"][0];
728 let result = &run["results"][0];
731 assert_eq!(result["level"], "note");
732 assert_eq!(result["ruleId"], "security/dangerous-html");
733 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
735 assert_eq!(rules.len(), 1);
736 assert_eq!(rules[0]["id"], "security/dangerous-html");
737 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
738 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
739 }
740
741 #[test]
742 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
743 let root = Path::new("/proj/root");
744 let finding = relativize_finding(sample_finding(root), root);
745 let output = output_with(vec![finding], 0);
746 let dir = tempfile::tempdir().expect("tempdir");
747 let path = dir.path().join("nested/out.sarif");
748 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
749 let written = std::fs::read_to_string(&path).expect("file exists");
750 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
751 assert_eq!(sarif["version"], "2.1.0");
752 }
753
754 const NO_CONFIG: Option<PathBuf> = None;
756
757 fn leak_fixture_root() -> PathBuf {
758 Path::new(env!("CARGO_MANIFEST_DIR"))
759 .join("../../tests/fixtures/security-client-server-leak")
760 }
761
762 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
763 SecurityOptions {
764 root,
765 config_path: &NO_CONFIG,
766 output,
767 no_cache: true,
768 threads: 1,
769 quiet: true,
770 fail_on_issues,
771 sarif_file: None,
772 summary: false,
773 changed_since: None,
774 use_shared_diff_index: false,
775 workspace: None,
776 changed_workspaces: None,
777 }
778 }
779
780 #[test]
781 fn run_is_advisory_and_exits_zero_even_with_candidates() {
782 let root = leak_fixture_root();
785 let code = run(&run_opts(&root, OutputFormat::Json, false));
786 assert_eq!(code, ExitCode::SUCCESS);
787 }
788
789 #[test]
790 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
791 let root = leak_fixture_root();
793 let code = run(&run_opts(&root, OutputFormat::Human, true));
794 assert_eq!(code, ExitCode::from(1));
795 }
796
797 #[test]
798 fn run_rejects_unsupported_output_format() {
799 let root = leak_fixture_root();
801 let code = run(&run_opts(&root, OutputFormat::Compact, false));
802 assert_eq!(code, ExitCode::from(2));
803 }
804
805 #[test]
806 fn run_summary_mode_dispatches_compact_human_renderer() {
807 let root = leak_fixture_root();
808 let opts = SecurityOptions {
809 summary: true,
810 ..run_opts(&root, OutputFormat::Human, false)
811 };
812 assert_eq!(run(&opts), ExitCode::SUCCESS);
813 }
814
815 #[test]
816 fn run_sarif_format_dispatches_sarif_renderer() {
817 let root = leak_fixture_root();
818 assert_eq!(
819 run(&run_opts(&root, OutputFormat::Sarif, false)),
820 ExitCode::SUCCESS
821 );
822 }
823
824 #[test]
825 fn run_writes_sarif_sidecar_file_when_requested() {
826 let root = leak_fixture_root();
827 let dir = tempfile::tempdir().expect("tempdir");
828 let sidecar = dir.path().join("security.sarif");
829 let opts = SecurityOptions {
830 sarif_file: Some(&sidecar),
831 ..run_opts(&root, OutputFormat::Human, false)
832 };
833 assert_eq!(run(&opts), ExitCode::SUCCESS);
834 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
835 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
836 assert_eq!(sarif["version"], "2.1.0");
837 }
838}