1use std::borrow::Cow;
2
3use colored::Colorize;
4use taudit_core::error::TauditError;
5use taudit_core::finding::{Finding, FindingSource, Recommendation, Severity};
6use taudit_core::graph::{AuthorityCompleteness, AuthorityGraph, EdgeKind, GapKind, NodeKind};
7use taudit_core::ports::ReportSink;
8
9pub fn strip_control_chars(s: &str) -> Cow<'_, str> {
35 if !needs_control_strip(s) {
36 return Cow::Borrowed(s);
37 }
38 let mut out = String::with_capacity(s.len());
39 for c in s.chars() {
40 if is_disallowed_control(c) {
41 continue;
46 }
47 out.push(c);
48 }
49 Cow::Owned(out)
50}
51
52#[inline]
53fn is_disallowed_control(c: char) -> bool {
54 match c {
55 '\n' | '\t' => false,
56 '\x00'..='\x1F' | '\x7F' => true,
58 '\u{80}'..='\u{9F}' => true,
61 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' | '\u{FEFF}' => true,
78 _ => false,
79 }
80}
81
82#[inline]
83fn needs_control_strip(s: &str) -> bool {
84 s.chars().any(is_disallowed_control)
85}
86
87#[inline]
92fn clean(s: &str) -> String {
93 strip_control_chars(s).into_owned()
94}
95
96macro_rules! w {
97 ($w:expr, $($arg:tt)*) => {
98 write!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
99 };
100}
101
102macro_rules! wln {
103 ($w:expr) => {
104 writeln!($w).map_err(|e| TauditError::Report(e.to_string()))
105 };
106 ($w:expr, $($arg:tt)*) => {
107 writeln!($w, $($arg)*).map_err(|e| TauditError::Report(e.to_string()))
108 };
109}
110
111const RULE_WIDTH: usize = 60;
112
113#[derive(Default)]
114pub struct TerminalReport {
115 pub verbose: bool,
116}
117
118impl<W: std::io::Write> ReportSink<W> for TerminalReport {
119 fn emit(
120 &self,
121 w: &mut W,
122 graph: &AuthorityGraph,
123 findings: &[Finding],
124 ) -> Result<(), TauditError> {
125 let is_partial = graph.completeness == AuthorityCompleteness::Partial
126 || graph.completeness == AuthorityCompleteness::Unknown;
127
128 let source_file_clean = clean(&graph.source.file);
137 wln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
138 wln!(
139 w,
140 "{}",
141 format!("Authority Graph: {source_file_clean}")
142 .bright_white()
143 .bold()
144 )?;
145
146 let steps = graph.nodes_of_kind(NodeKind::Step).count();
147 let secrets = graph.nodes_of_kind(NodeKind::Secret).count();
148 let images = graph.nodes_of_kind(NodeKind::Image).count();
149 let identities = graph.nodes_of_kind(NodeKind::Identity).count();
150 wln!(
151 w,
152 "{}",
153 format!(
154 " Steps: {steps} | Secrets: {secrets} | Actions: {images} | Identities: {identities}"
155 )
156 .bright_black()
157 )?;
158
159 if is_partial {
161 wln!(w)?;
162 match graph.completeness {
163 AuthorityCompleteness::Partial => {
164 let header_prefix = match graph.worst_gap_kind() {
165 Some(GapKind::Opaque) => "error: ⛔".red().bold().to_string(),
166 Some(GapKind::Expression) => "note: ·".dimmed().to_string(),
167 _ => "note: ⚠".bright_yellow().bold().to_string(),
169 };
170 wln!(
171 w,
172 " {} partial graph — findings below tagged {}",
173 header_prefix,
174 "[partial]".yellow().dimmed()
175 )?;
176 for (kind, gap) in graph
177 .completeness_gap_kinds
178 .iter()
179 .zip(graph.completeness_gaps.iter())
180 {
181 let kind_label = match kind {
182 GapKind::Opaque => "[opaque]".red().to_string(),
183 GapKind::Structural => "[structural]".yellow().to_string(),
184 GapKind::Expression => "[expression]".dimmed().to_string(),
185 };
186 let gap_clean = clean(gap);
191 wln!(w, " {} {}", kind_label, gap_clean.dimmed())?;
192 }
193 for gap in graph
196 .completeness_gaps
197 .iter()
198 .skip(graph.completeness_gap_kinds.len())
199 {
200 let gap_clean = clean(gap);
201 wln!(w, " {}", format!("- {gap_clean}").yellow())?;
202 }
203 }
204 AuthorityCompleteness::Unknown => {
205 wln!(
206 w,
207 " {} completeness unknown — treat as partial",
208 "note: ⚠".bright_yellow().bold()
209 )?;
210 }
211 AuthorityCompleteness::Complete => {}
212 }
213 }
214
215 if findings.is_empty() {
217 wln!(w, "\n {}", "✓ no findings".green().bold())?;
218 return Ok(());
219 }
220
221 wln!(w)?;
223 for finding in findings {
224 let sev_tag = severity_tag(finding.severity);
225 let partial_tag = if is_partial {
233 let always_show = graph.worst_gap_kind() == Some(GapKind::Opaque);
234 if self.verbose || always_show {
235 if always_show && !self.verbose {
236 format!(" {}", "[partial:opaque]".red().bold())
237 } else {
238 format!(" {}", "[partial]".yellow().dimmed())
239 }
240 } else {
241 String::new()
243 }
244 } else {
245 String::new()
246 };
247
248 let custom_tag = match &finding.source {
257 FindingSource::Custom { source_file } => {
258 let label = if source_file.as_os_str().is_empty() {
259 "custom".to_string()
260 } else {
261 let name = source_file
264 .file_name()
265 .and_then(|s| s.to_str())
266 .unwrap_or_else(|| source_file.to_str().unwrap_or("custom"));
267 format!("custom: {}", clean(name))
268 };
269 format!(" {}", format!("[{label}]").magenta().dimmed())
270 }
271 FindingSource::BuiltIn => String::new(),
272 };
273
274 let message_clean = clean(&finding.message);
282 wln!(
283 w,
284 "{}{}{} {}",
285 sev_tag,
286 partial_tag,
287 custom_tag,
288 message_clean.bold()
289 )?;
290
291 if let Some(ref path) = finding.path {
296 let source_name_owned = graph
297 .node(path.source)
298 .map(|n| clean(&n.name))
299 .unwrap_or_else(|| "?".to_string());
300 let source_kind = graph
301 .node(path.source)
302 .map(|n| node_kind_label(n.kind))
303 .unwrap_or("");
304
305 if path.edges.len() <= 2 {
306 w!(
308 w,
309 " {} {} {}",
310 "Path:".bright_black(),
311 source_name_owned.bright_white(),
312 format!("({source_kind})").bright_black()
313 )?;
314 for edge_id in &path.edges {
315 if let Some(edge) = graph.edge(*edge_id) {
316 let target = graph.node(edge.to);
317 let name = target
318 .map(|n| clean(&n.name))
319 .unwrap_or_else(|| "?".to_string());
320 let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
321 w!(
322 w,
323 " {} {} {}",
324 "→".bright_black(),
325 name.bright_white(),
326 format!("({kind})").bright_black()
327 )?;
328 }
329 }
330 wln!(w)?;
331 } else {
332 wln!(w, " {}:", "Path".bright_black())?;
334 wln!(
335 w,
336 " {} {}",
337 source_name_owned.bright_white(),
338 format!("({source_kind})").bright_black()
339 )?;
340 for edge_id in &path.edges {
341 if let Some(edge) = graph.edge(*edge_id) {
342 let target = graph.node(edge.to);
343 let name = target
344 .map(|n| clean(&n.name))
345 .unwrap_or_else(|| "?".to_string());
346 let kind = target.map(|n| node_kind_label(n.kind)).unwrap_or("");
347 wln!(
348 w,
349 " {} {} {}",
350 "→".bright_black(),
351 name.bright_white(),
352 format!("({kind})").bright_black()
353 )?;
354 }
355 }
356 }
357
358 if self.verbose {
359 let mut path_nodes = vec![path.source];
360 for edge_id in &path.edges {
361 if let Some(edge) = graph.edge(*edge_id) {
362 path_nodes.push(edge.to);
363 }
364 }
365 emit_verbose_nodes(w, graph, &path_nodes)?;
366 }
367 } else if !finding.nodes_involved.is_empty() {
368 let nodes = &finding.nodes_involved;
372 let display: Vec<String> = nodes
373 .iter()
374 .take(4)
375 .filter_map(|&id| graph.node(id))
376 .map(|n| {
377 format!(
378 "{} {}",
379 clean(&n.name).bright_white(),
380 format!("({})", node_kind_label(n.kind)).bright_black()
381 )
382 })
383 .collect();
384
385 let suffix = if nodes.len() > 4 {
386 format!(
387 " {}",
388 format!("…(+{} more)", nodes.len() - 4).bright_black()
389 )
390 } else {
391 String::new()
392 };
393
394 let connector = if finding.nodes_involved.windows(2).any(|w| {
396 graph
397 .edges_from(w[0])
398 .any(|e| e.to == w[1] && e.kind == EdgeKind::PersistsTo)
399 }) {
400 format!(" {} ", "persists→".bright_black())
401 } else {
402 format!(" {} ", "→".bright_black())
403 };
404
405 wln!(
406 w,
407 " {} {}{}",
408 "Nodes:".bright_black(),
409 display.join(&connector),
410 suffix
411 )?;
412
413 if self.verbose {
414 emit_verbose_nodes(w, graph, nodes)?;
415 }
416 }
417
418 let rec = clean(&format_recommendation(&finding.recommendation));
423 wln!(w, " {} {}", "Recommendation:".green().bold(), rec.green())?;
424 wln!(w)?;
425 }
426
427 Ok(())
428 }
429}
430
431fn severity_tag(sev: Severity) -> String {
432 match sev {
433 Severity::Critical => format!("[{}]", "CRITICAL".bright_red().bold().reversed()),
434 Severity::High => format!("[{}]", "HIGH".bright_red().bold()),
435 Severity::Medium => format!("[{}]", "MEDIUM".yellow().bold()),
436 Severity::Low => format!("[{}]", "LOW".bright_yellow()),
437 Severity::Info => format!("[{}]", "INFO".bright_cyan()),
438 }
439}
440
441fn node_kind_label(kind: NodeKind) -> &'static str {
442 match kind {
443 NodeKind::Step => "step",
444 NodeKind::Secret => "secret",
445 NodeKind::Identity => "identity",
446 NodeKind::Artifact => "artifact",
447 NodeKind::Image => "action/image",
448 }
449}
450
451fn format_recommendation(rec: &Recommendation) -> String {
452 match rec {
453 Recommendation::TsafeRemediation { command, .. } => command.clone(),
454 Recommendation::CellosRemediation { spec_hint, .. } => spec_hint.clone(),
455 Recommendation::PinAction { pinned, .. } => format!("Pin to {pinned}"),
456 Recommendation::ReducePermissions { minimum, .. } => {
457 format!("Reduce permissions to {minimum}")
458 }
459 Recommendation::FederateIdentity { oidc_provider, .. } => {
460 format!("Replace with {oidc_provider} OIDC")
461 }
462 Recommendation::Manual { action } => action.clone(),
463 }
464}
465
466fn emit_verbose_nodes<W: std::io::Write>(
467 w: &mut W,
468 graph: &AuthorityGraph,
469 node_ids: &[usize],
470) -> Result<(), TauditError> {
471 for &id in node_ids {
479 if let Some(node) = graph.node(id) {
480 let kind = node_kind_label(node.kind);
481 let zone = format!("{:?}", node.trust_zone).to_lowercase();
482 let name_clean = clean(&node.name);
483 w!(w, " {} ({kind}, {zone})", name_clean.bright_black())?;
484 if let Some(scope) = node.metadata.get("identity_scope") {
485 w!(w, ", scope: {}", clean(scope))?;
486 }
487 if let Some(perms) = node.metadata.get("permissions") {
488 w!(w, ", permissions: {}", clean(perms))?;
489 }
490 if let Some(digest) = node.metadata.get("digest") {
491 let digest_clean = clean(digest);
492 w!(w, ", pin: {}…", &digest_clean[..digest_clean.len().min(12)])?;
493 }
494 if node
495 .metadata
496 .get("inferred")
497 .map(|v| v == "true")
498 .unwrap_or(false)
499 {
500 w!(w, " (inferred)")?;
501 }
502 wln!(w)?;
503 }
504 }
505 Ok(())
506}
507
508pub fn print_banner<W: std::io::Write>(w: &mut W, file_count: usize) -> std::io::Result<()> {
510 writeln!(
511 w,
512 "{}",
513 format!(
514 "taudit {} — {} {}",
515 env!("CARGO_PKG_VERSION"),
516 file_count,
517 if file_count == 1 { "file" } else { "files" }
518 )
519 .bright_white()
520 .bold()
521 )
522}
523
524pub struct RunSummary {
526 pub total_files: usize,
527 pub files_with_findings: usize,
528 pub clean_files: usize,
529 pub partial_files: usize,
530 pub critical: usize,
531 pub high: usize,
532 pub medium: usize,
533 pub low: usize,
534}
535
536pub fn print_summary<W: std::io::Write>(w: &mut W, s: &RunSummary) -> std::io::Result<()> {
538 writeln!(w, "{}", "─".repeat(RULE_WIDTH).bright_black())?;
539
540 if s.clean_files > 0 {
541 writeln!(
542 w,
543 "{}",
544 format!(
545 "✓ {} {} clean",
546 s.clean_files,
547 if s.clean_files == 1 { "file" } else { "files" }
548 )
549 .green()
550 .bold()
551 )?;
552 }
553
554 let total_findings = s.critical + s.high + s.medium + s.low;
555 if total_findings == 0 {
556 writeln!(w, "{}", "✓ no findings across all files".green().bold())?;
557 return Ok(());
558 }
559
560 write!(w, "{} ", "Summary".bright_white().bold())?;
561 let mut parts = Vec::new();
562 if s.critical > 0 {
563 parts.push(format!(
564 "{}",
565 format!("{} critical", s.critical).bright_red().bold()
566 ));
567 }
568 if s.high > 0 {
569 parts.push(format!("{}", format!("{} high", s.high).bright_red()));
570 }
571 if s.medium > 0 {
572 parts.push(format!("{}", format!("{} medium", s.medium).yellow()));
573 }
574 if s.low > 0 {
575 parts.push(format!("{}", format!("{} low", s.low).bright_yellow()));
576 }
577 writeln!(w, "{}", parts.join(" "))?;
578
579 writeln!(
580 w,
581 "{}",
582 format!(
583 " Files with findings: {} / {}",
584 s.files_with_findings, s.total_files
585 )
586 .bright_black()
587 )?;
588
589 if s.partial_files > 0 {
590 writeln!(
591 w,
592 "{}",
593 format!(
594 " Partial graphs: {} — findings from partial graphs may be incomplete",
595 s.partial_files
596 )
597 .yellow()
598 )?;
599 }
600
601 Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use taudit_core::finding::{
608 Finding, FindingCategory, FindingExtras, FindingSource, Recommendation, Severity,
609 };
610 use taudit_core::graph::{GapKind, PipelineSource};
611 use taudit_core::ports::ReportSink;
612
613 #[test]
616 fn strip_control_chars_passes_clean_text_unchanged() {
617 let clean = "AWS_KEY reaches deploy";
618 let out = strip_control_chars(clean);
619 assert!(
620 matches!(out, Cow::Borrowed(_)),
621 "clean input must zero-alloc"
622 );
623 assert_eq!(out, clean);
624 }
625
626 #[test]
627 fn strip_control_chars_preserves_newline_and_tab() {
628 let s = "line1\nline2\tcol";
629 let out = strip_control_chars(s);
630 assert!(matches!(out, Cow::Borrowed(_)));
631 assert_eq!(out, s);
632 }
633
634 #[test]
635 fn strip_control_chars_drops_esc_and_clear_screen() {
636 let hostile = "\x1b[2J\x1b[Hfake clean output\x1b[0m";
638 let out = strip_control_chars(hostile);
639 assert!(!out.contains('\x1b'), "ESC byte must be stripped");
640 assert_eq!(out, "[2J[Hfake clean output[0m");
641 }
642
643 #[test]
644 fn strip_control_chars_drops_bel_and_del() {
645 let hostile = "ding\x07then\x7Fdel";
646 let out = strip_control_chars(hostile);
647 assert!(!out.bytes().any(|b| b == 0x07));
648 assert!(!out.bytes().any(|b| b == 0x7F));
649 assert_eq!(out, "dingthendel");
650 }
651
652 #[test]
653 fn strip_control_chars_drops_rtl_and_zwj() {
654 let hostile = "user\u{202E}name\u{200D}joiner";
655 let out = strip_control_chars(hostile);
656 assert!(!out.contains('\u{202E}'));
657 assert!(!out.contains('\u{200D}'));
658 assert_eq!(out, "usernamejoiner");
659 }
660
661 #[test]
662 fn strip_control_chars_preserves_emoji_and_unicode_prose() {
663 let s = "✓ no findings — Authority Graph: ci.yml";
664 let out = strip_control_chars(s);
665 assert!(matches!(out, Cow::Borrowed(_)));
666 assert_eq!(out, s);
667 }
668
669 #[test]
670 fn strip_control_chars_drops_c1_range() {
671 let hostile = "before\u{0080}\u{009F}after";
673 let out = strip_control_chars(hostile);
674 assert_eq!(out, "beforeafter");
675 }
676
677 fn test_graph() -> AuthorityGraph {
680 AuthorityGraph::new(PipelineSource {
681 file: "test.yml".into(),
682 repo: None,
683 git_ref: None,
684 commit_sha: None,
685 })
686 }
687
688 fn render(graph: &AuthorityGraph) -> String {
691 colored::control::set_override(false);
697 let reporter = TerminalReport { verbose: false };
698 let mut buf: Vec<u8> = Vec::new();
699 reporter
700 .emit(&mut buf, graph, &[])
701 .expect("emit should succeed");
702 let raw = String::from_utf8(buf).expect("utf-8 output");
703 strip_ansi(&raw)
704 }
705
706 fn strip_ansi(s: &str) -> String {
711 let mut out = String::with_capacity(s.len());
712 let mut chars = s.chars().peekable();
713 while let Some(c) = chars.next() {
714 if c == '\u{1B}' && chars.peek() == Some(&'[') {
715 chars.next(); for fc in chars.by_ref() {
718 let cp = fc as u32;
719 if (0x40..=0x7E).contains(&cp) {
720 break;
721 }
722 }
723 } else {
724 out.push(c);
725 }
726 }
727 out
728 }
729
730 #[test]
731 fn opaque_gap_header_shows_error_prefix() {
732 let mut g = test_graph();
733 g.mark_partial(GapKind::Opaque, "zero steps");
734 let out = render(&g);
735 assert!(
736 out.contains("error: ⛔"),
737 "expected opaque header 'error: ⛔', got:\n{out}"
738 );
739 assert!(
740 out.contains("[opaque]"),
741 "expected [opaque] gap label, got:\n{out}"
742 );
743 }
744
745 #[test]
746 fn structural_gap_header_shows_warning_prefix() {
747 let mut g = test_graph();
748 g.mark_partial(GapKind::Structural, "composite unresolved");
749 let out = render(&g);
750 assert!(
751 out.contains("note: ⚠"),
752 "expected structural header 'note: ⚠', got:\n{out}"
753 );
754 assert!(
755 out.contains("[structural]"),
756 "expected [structural] gap label, got:\n{out}"
757 );
758 }
759
760 #[test]
761 fn expression_gap_header_shows_note_prefix() {
762 let mut g = test_graph();
763 g.mark_partial(GapKind::Expression, "matrix hides paths");
764 let out = render(&g);
765 assert!(
766 out.contains("note: ·"),
767 "expected expression header 'note: ·', got:\n{out}"
768 );
769 assert!(
770 out.contains("[expression]"),
771 "expected [expression] gap label, got:\n{out}"
772 );
773 }
774
775 fn test_finding() -> Finding {
779 Finding {
780 severity: Severity::Medium,
781 category: FindingCategory::UnpinnedAction,
782 path: None,
783 nodes_involved: vec![],
784 message: "test finding for verbosity gating".into(),
785 recommendation: Recommendation::Manual {
786 action: "fix".into(),
787 },
788 source: FindingSource::BuiltIn,
789 extras: FindingExtras::default(),
790 }
791 }
792
793 fn render_with(graph: &AuthorityGraph, findings: &[Finding], verbose: bool) -> String {
797 colored::control::set_override(false);
798 let reporter = TerminalReport { verbose };
799 let mut buf: Vec<u8> = Vec::new();
800 reporter
801 .emit(&mut buf, graph, findings)
802 .expect("emit should succeed");
803 let raw = String::from_utf8(buf).expect("utf-8 output");
804 strip_ansi(&raw)
805 }
806
807 #[test]
808 fn default_quiet_structural_gap_suppresses_inline_tag() {
809 let mut g = test_graph();
810 g.mark_partial(GapKind::Structural, "composite unresolved");
811 let findings = vec![test_finding()];
812 let out = render_with(&g, &findings, false);
813
814 assert!(
816 out.contains("note: ⚠"),
817 "expected structural header 'note: ⚠', got:\n{out}"
818 );
819 let finding_line = out
824 .lines()
825 .find(|l| l.contains("[MEDIUM]"))
826 .expect("expected a finding line containing [MEDIUM]");
827 assert!(
828 !finding_line.contains("[partial]"),
829 "default-quiet should suppress inline [partial] for Structural, \
830 but finding line had it: {finding_line}"
831 );
832 assert!(
833 !finding_line.contains("[partial:opaque]"),
834 "Structural gap must not render [partial:opaque]: {finding_line}"
835 );
836 }
837
838 #[test]
839 fn default_quiet_opaque_gap_shows_inline_tag() {
840 let mut g = test_graph();
841 g.mark_partial(GapKind::Opaque, "zero steps");
842 let findings = vec![test_finding()];
843 let out = render_with(&g, &findings, false);
844
845 assert!(
847 out.contains("error: ⛔"),
848 "expected opaque header 'error: ⛔', got:\n{out}"
849 );
850 let finding_line = out
854 .lines()
855 .find(|l| l.contains("[MEDIUM]"))
856 .expect("expected a finding line containing [MEDIUM]");
857 assert!(
858 finding_line.contains("[partial:opaque]"),
859 "Opaque gap must render inline [partial:opaque] even in quiet mode: {finding_line}"
860 );
861 }
862
863 #[test]
864 fn verbose_structural_gap_shows_inline_tag() {
865 let mut g = test_graph();
866 g.mark_partial(GapKind::Structural, "composite unresolved");
867 let findings = vec![test_finding()];
868 let out = render_with(&g, &findings, true);
869
870 let finding_line = out
873 .lines()
874 .find(|l| l.contains("[MEDIUM]"))
875 .expect("expected a finding line containing [MEDIUM]");
876 assert!(
877 finding_line.contains("[partial]"),
878 "verbose should render inline [partial] for Structural gap: {finding_line}"
879 );
880 assert!(
883 !finding_line.contains("[partial:opaque]"),
884 "Structural gap must not render [partial:opaque] under --verbose: {finding_line}"
885 );
886 }
887
888 #[test]
896 fn terminal_output_is_byte_deterministic_across_runs() {
897 use std::collections::HashMap;
898 use taudit_core::graph::{EdgeKind, NodeKind, TrustZone};
899
900 fn build_graph() -> (AuthorityGraph, Vec<Finding>) {
901 let mut graph = AuthorityGraph::new(PipelineSource {
902 file: "ci.yml".into(),
903 repo: None,
904 git_ref: None,
905 commit_sha: None,
906 });
907 let secret_a = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
908 let secret_b = graph.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
909 let step = graph.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
910 graph.add_edge(step, secret_a, EdgeKind::HasAccessTo);
911 graph.add_edge(step, secret_b, EdgeKind::HasAccessTo);
912 if let Some(node) = graph.nodes.get_mut(step) {
913 let mut meta: HashMap<String, String> = HashMap::new();
914 meta.insert("z_field".into(), "z".into());
915 meta.insert("a_field".into(), "a".into());
916 meta.insert("m_field".into(), "m".into());
917 meta.insert("k_field".into(), "k".into());
918 meta.insert("c_field".into(), "c".into());
919 node.metadata = meta;
920 }
921 graph
922 .metadata
923 .insert("trigger".into(), "pull_request".into());
924 graph.metadata.insert("platform".into(), "github".into());
925 let findings = vec![Finding {
926 severity: Severity::High,
927 category: FindingCategory::AuthorityPropagation,
928 path: None,
929 nodes_involved: vec![secret_a, step],
930 message: "AWS_KEY reaches deploy".into(),
931 recommendation: Recommendation::Manual {
932 action: "scope it".into(),
933 },
934 source: FindingSource::BuiltIn,
935 extras: FindingExtras::default(),
936 }];
937 (graph, findings)
938 }
939
940 colored::control::set_override(false);
943
944 let mut runs: Vec<Vec<u8>> = Vec::with_capacity(9);
945 for _ in 0..9 {
946 let (g, f) = build_graph();
947 let mut buf: Vec<u8> = Vec::new();
948 TerminalReport { verbose: false }
949 .emit(&mut buf, &g, &f)
950 .expect("emit should succeed");
951 runs.push(buf);
952 }
953
954 let first = &runs[0];
955 for (i, run) in runs.iter().enumerate().skip(1) {
956 assert_eq!(
957 first, run,
958 "run 0 and run {i} produced byte-different terminal output (non-determinism regression)"
959 );
960 }
961 }
962}