1use taudit_core::error::TauditError;
2use taudit_core::finding::{
3 compute_finding_group_id, compute_fingerprint, compute_suppression_key, rule_id_for, Finding,
4};
5use taudit_core::graph::{
6 is_docker_digest_pinned, is_pin_semantically_valid, AuthorityCompleteness, AuthorityGraph,
7 EdgeKind, GapKind, NodeKind, META_CONTAINER, META_OIDC, META_SERVICE_CONNECTION,
8 META_SERVICE_CONNECTION_NAME, META_VARIABLE_GROUP,
9};
10use taudit_core::ports::ReportSink;
11
12use serde::Serialize;
13
14const JSON_REPORT_SCHEMA_VERSION: &str = "1.0.0";
15const JSON_REPORT_SCHEMA_URI: &str = "https://taudit.dev/schemas/taudit-report.schema.json";
16
17pub const AUTHORITY_GRAPH_SCHEMA_VERSION: &str = "1.0.0";
21
22pub const AUTHORITY_GRAPH_SCHEMA_URI: &str = "https://taudit.dev/schemas/authority-graph.v1.json";
24
25#[derive(Serialize)]
27pub struct JsonReport<'a> {
28 pub schema_version: &'static str,
29 pub schema_uri: &'static str,
32 pub graph: &'a AuthorityGraph,
33 pub findings: Vec<FindingWithFingerprint>,
34 pub summary: Summary,
35}
36
37#[derive(Serialize)]
55pub struct FindingWithFingerprint {
56 pub rule_id: String,
57 #[serde(flatten)]
58 pub finding: Finding,
59 pub fingerprint: String,
60 pub suppression_key: String,
61}
62
63#[derive(Serialize)]
68pub struct GraphExport<'a> {
69 pub schema_version: &'static str,
71 pub schema_uri: &'static str,
73 pub graph: &'a AuthorityGraph,
75}
76
77impl<'a> GraphExport<'a> {
78 pub fn new(graph: &'a AuthorityGraph) -> Self {
80 Self {
81 schema_version: AUTHORITY_GRAPH_SCHEMA_VERSION,
82 schema_uri: AUTHORITY_GRAPH_SCHEMA_URI,
83 graph,
84 }
85 }
86
87 pub fn to_json_pretty(&self) -> Result<String, TauditError> {
89 serde_json::to_string_pretty(self)
90 .map_err(|e| TauditError::Report(format!("graph JSON serialization error: {e}")))
91 }
92}
93
94#[derive(Serialize)]
99pub struct CompletenessGap {
100 pub kind: GapKind,
101 pub reason: String,
102}
103
104#[derive(Serialize)]
105pub struct Summary {
106 pub total_findings: usize,
107 pub critical: usize,
108 pub high: usize,
109 pub medium: usize,
110 pub low: usize,
111 pub info: usize,
112 pub total_nodes: usize,
113 pub total_edges: usize,
114 pub completeness: AuthorityCompleteness,
115 #[serde(skip_serializing_if = "Vec::is_empty")]
119 pub completeness_gaps: Vec<CompletenessGap>,
120 #[serde(skip_serializing_if = "GraphRiskSummary::is_empty")]
125 pub graph_risk_summary: GraphRiskSummary,
126}
127
128#[derive(Serialize, Default)]
130pub struct GraphRiskSummary {
131 pub authority_roots: usize,
132 pub untrusted_sinks: usize,
133 pub mutable_refs: usize,
134 pub publication_adjacent_sinks: usize,
135 pub delegation_hops: usize,
136 #[serde(skip_serializing_if = "Vec::is_empty")]
137 pub protected_resource_categories: Vec<String>,
138}
139
140impl GraphRiskSummary {
141 fn is_empty(&self) -> bool {
142 self.authority_roots == 0
143 && self.untrusted_sinks == 0
144 && self.mutable_refs == 0
145 && self.publication_adjacent_sinks == 0
146 && self.delegation_hops == 0
147 && self.protected_resource_categories.is_empty()
148 }
149}
150
151fn graph_risk_summary(graph: &AuthorityGraph) -> GraphRiskSummary {
152 let mut protected = std::collections::BTreeSet::<String>::new();
153 let mut summary = GraphRiskSummary::default();
154
155 for node in &graph.nodes {
156 match node.kind {
157 NodeKind::Secret | NodeKind::Identity => {
158 summary.authority_roots += 1;
159 }
160 _ => {}
161 }
162
163 if node.trust_zone == taudit_core::graph::TrustZone::Untrusted {
164 summary.untrusted_sinks += 1;
165 }
166
167 if node.kind == NodeKind::Image
168 && !node
169 .metadata
170 .get(META_CONTAINER)
171 .map(|v| v == "true")
172 .unwrap_or(false)
173 && !is_pin_semantically_valid(&node.name)
174 && !is_docker_digest_pinned(&node.name)
175 {
176 summary.mutable_refs += 1;
177 }
178
179 let lower = node.name.to_ascii_lowercase();
180 if node.kind == NodeKind::Step
181 && ["publish", "release", "deploy", "push", "upload"]
182 .iter()
183 .any(|needle| lower.contains(needle))
184 {
185 summary.publication_adjacent_sinks += 1;
186 }
187
188 if node.metadata.contains_key(META_VARIABLE_GROUP) {
189 protected.insert("variable_group".into());
190 }
191 if node.metadata.contains_key(META_SERVICE_CONNECTION)
192 || node.metadata.contains_key(META_SERVICE_CONNECTION_NAME)
193 {
194 protected.insert("service_connection".into());
195 }
196 if node.metadata.contains_key(META_OIDC) {
197 protected.insert("oidc_identity".into());
198 }
199 if node.kind == NodeKind::Secret {
200 protected.insert("secret".into());
201 }
202 if node.kind == NodeKind::Identity {
203 protected.insert("identity".into());
204 }
205 }
206
207 summary.delegation_hops = graph
208 .edges
209 .iter()
210 .filter(|edge| edge.kind == EdgeKind::DelegatesTo)
211 .count();
212 summary.protected_resource_categories = protected.into_iter().collect();
213 summary
214}
215
216pub struct JsonReportSink;
217
218impl<W: std::io::Write> ReportSink<W> for JsonReportSink {
219 fn emit(
220 &self,
221 w: &mut W,
222 graph: &AuthorityGraph,
223 findings: &[Finding],
224 ) -> Result<(), TauditError> {
225 use taudit_core::finding::Severity;
226
227 let findings_with_fp: Vec<FindingWithFingerprint> = findings
232 .iter()
233 .map(|f| {
234 let fingerprint = compute_fingerprint(f, graph);
235 let rule_id = rule_id_for(f);
236 let mut owned = f.clone();
237 if owned.extras.finding_group_id.is_none() {
238 owned.extras.finding_group_id = Some(compute_finding_group_id(&fingerprint));
239 }
240 FindingWithFingerprint {
241 rule_id,
242 finding: owned,
243 suppression_key: compute_suppression_key(f, graph),
244 fingerprint,
245 }
246 })
247 .collect();
248
249 let report = JsonReport {
250 schema_version: JSON_REPORT_SCHEMA_VERSION,
251 schema_uri: JSON_REPORT_SCHEMA_URI,
252 graph,
253 findings: findings_with_fp,
254 summary: Summary {
255 total_findings: findings.len(),
256 critical: findings
257 .iter()
258 .filter(|f| f.severity == Severity::Critical)
259 .count(),
260 high: findings
261 .iter()
262 .filter(|f| f.severity == Severity::High)
263 .count(),
264 medium: findings
265 .iter()
266 .filter(|f| f.severity == Severity::Medium)
267 .count(),
268 low: findings
269 .iter()
270 .filter(|f| f.severity == Severity::Low)
271 .count(),
272 info: findings
273 .iter()
274 .filter(|f| f.severity == Severity::Info)
275 .count(),
276 total_nodes: graph.nodes.len(),
277 total_edges: graph.edges.len(),
278 completeness: graph.completeness,
279 completeness_gaps: graph
286 .completeness_gap_kinds
287 .iter()
288 .zip(graph.completeness_gaps.iter())
289 .map(|(kind, reason)| CompletenessGap {
290 kind: *kind,
291 reason: reason.clone(),
292 })
293 .collect(),
294 graph_risk_summary: graph_risk_summary(graph),
295 },
296 };
297
298 serde_json::to_writer_pretty(w, &report)
299 .map_err(|e| TauditError::Report(format!("JSON serialization error: {e}")))?;
300
301 Ok(())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use crate::JsonReportSink;
308 use std::{fs, path::PathBuf};
309 use taudit_core::finding::{Finding, FindingExtras, Recommendation, Severity};
310 use taudit_core::graph::PipelineSource;
311 use taudit_core::ports::ReportSink;
312
313 fn workspace_file(relative: &str) -> PathBuf {
314 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
315 .join("../..")
316 .join(relative)
317 }
318
319 fn read_json(relative: &str) -> serde_json::Value {
320 let path = workspace_file(relative);
321 let text = fs::read_to_string(&path)
322 .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
323 serde_json::from_str(&text)
324 .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()))
325 }
326
327 fn assert_schema_validates_instance(schema_relative: &str, instance_relative: &str) {
328 let schema = read_json(schema_relative);
329 let instance = read_json(instance_relative);
330 let validator = jsonschema::validator_for(&schema)
331 .unwrap_or_else(|err| panic!("invalid schema {schema_relative}: {err}"));
332 let errors: Vec<String> = validator
333 .iter_errors(&instance)
334 .map(|err| err.to_string())
335 .collect();
336 assert!(
337 errors.is_empty(),
338 "{instance_relative} does not match {schema_relative}:\n{}",
339 errors.join("\n")
340 );
341 }
342
343 #[test]
344 fn emitted_report_includes_schema_version_and_matches_schema() {
345 let graph = taudit_core::graph::AuthorityGraph::new(PipelineSource {
346 file: ".github/workflows/ci.yml".into(),
347 repo: None,
348 git_ref: None,
349 commit_sha: None,
350 });
351 let findings = vec![Finding {
352 severity: Severity::Medium,
353 category: taudit_core::finding::FindingCategory::UnpinnedAction,
354 path: None,
355 nodes_involved: vec![],
356 message: "test finding".into(),
357 recommendation: Recommendation::Manual {
358 action: "pin the action".into(),
359 },
360 source: taudit_core::finding::FindingSource::BuiltIn,
361 extras: FindingExtras::default(),
362 }];
363
364 let mut buf = Vec::new();
365 JsonReportSink.emit(&mut buf, &graph, &findings).unwrap();
366
367 let report: serde_json::Value = serde_json::from_slice(&buf).unwrap();
368 assert_eq!(report["schema_version"], "1.0.0");
369
370 let schema = read_json("contracts/schemas/taudit-report.schema.json");
371 let validator = jsonschema::validator_for(&schema).expect("report schema should compile");
372 let errors: Vec<String> = validator
373 .iter_errors(&report)
374 .map(|err| err.to_string())
375 .collect();
376
377 assert!(
378 errors.is_empty(),
379 "emitted report does not match report schema:\n{}",
380 errors.join("\n")
381 );
382 }
383
384 #[test]
385 fn clean_report_example_matches_schema() {
386 assert_schema_validates_instance(
387 "contracts/schemas/taudit-report.schema.json",
388 "contracts/examples/clean-report.json",
389 );
390 }
391
392 #[test]
393 fn over_privileged_report_example_matches_schema() {
394 assert_schema_validates_instance(
395 "contracts/schemas/taudit-report.schema.json",
396 "contracts/examples/over-privileged-report.json",
397 );
398 }
399
400 #[test]
406 fn authority_graph_export_matches_v1_schema() {
407 use taudit_core::graph::{
408 AuthorityGraph, EdgeKind, GapKind, NodeKind, PipelineSource, TrustZone,
409 };
410
411 let mut graph = AuthorityGraph::new(PipelineSource {
412 file: "tests/fixtures/over-privileged.yml".into(),
413 repo: Some("0ryant/taudit".into()),
414 git_ref: Some("main".into()),
415 commit_sha: None,
416 });
417 graph.mark_partial(
418 GapKind::Expression,
419 "inline shell scripts not fully resolved",
420 );
421
422 let secret = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
423 let identity = graph.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
424 let image = graph.add_node(NodeKind::Image, "ubuntu-latest", TrustZone::ThirdParty);
425 let step_build = graph.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
426 let artifact = graph.add_node(NodeKind::Artifact, "dist.tar.gz", TrustZone::FirstParty);
427 let step_deploy = graph.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
428
429 graph.add_edge(step_build, secret, EdgeKind::HasAccessTo);
430 graph.add_edge(step_build, identity, EdgeKind::HasAccessTo);
431 graph.add_edge(step_build, image, EdgeKind::UsesImage);
432 graph.add_edge(step_build, artifact, EdgeKind::Produces);
433 graph.add_edge(artifact, step_deploy, EdgeKind::Consumes);
434 graph.add_edge(step_build, step_deploy, EdgeKind::DelegatesTo);
435 graph.add_edge(step_build, secret, EdgeKind::PersistsTo);
436
437 graph.stamp_edge_authority_summaries();
438
439 let export = crate::GraphExport::new(&graph);
440 let json = export.to_json_pretty().expect("export serializes");
441 let value: serde_json::Value =
442 serde_json::from_str(&json).expect("export round-trips through serde_json");
443
444 assert_eq!(
445 value["schema_version"],
446 crate::AUTHORITY_GRAPH_SCHEMA_VERSION
447 );
448 assert_eq!(value["schema_uri"], crate::AUTHORITY_GRAPH_SCHEMA_URI);
449
450 assert_eq!(
455 value["graph"]["completeness_gaps"][0],
456 "inline shell scripts not fully resolved"
457 );
458 assert_eq!(value["graph"]["completeness_gap_kinds"][0], "expression");
459
460 let schema = read_json("schemas/authority-graph.v1.json");
461 let validator =
462 jsonschema::validator_for(&schema).expect("authority-graph schema should compile");
463 let errors: Vec<String> = validator
464 .iter_errors(&value)
465 .map(|err| err.to_string())
466 .collect();
467
468 assert!(
469 errors.is_empty(),
470 "graph export does not match authority-graph.v1.json:\n{}",
471 errors.join("\n")
472 );
473 }
474
475 #[test]
483 fn json_output_is_byte_deterministic_across_runs() {
484 use std::collections::HashMap;
485 use taudit_core::graph::{AuthorityGraph, EdgeKind, NodeKind, PipelineSource, TrustZone};
486
487 fn build_graph() -> (AuthorityGraph, Vec<Finding>) {
492 let mut graph = AuthorityGraph::new(PipelineSource {
493 file: "ci.yml".into(),
494 repo: None,
495 git_ref: None,
496 commit_sha: None,
497 });
498 let secret_a = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
499 let secret_b = graph.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
500 let step = graph.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
501 graph.add_edge(step, secret_a, EdgeKind::HasAccessTo);
502 graph.add_edge(step, secret_b, EdgeKind::HasAccessTo);
503 if let Some(node) = graph.nodes.get_mut(step) {
505 let mut meta: HashMap<String, String> = HashMap::new();
506 meta.insert("z_field".into(), "z".into());
507 meta.insert("a_field".into(), "a".into());
508 meta.insert("m_field".into(), "m".into());
509 meta.insert("k_field".into(), "k".into());
510 meta.insert("c_field".into(), "c".into());
511 node.metadata = meta;
512 }
513 graph
514 .metadata
515 .insert("trigger".into(), "pull_request".into());
516 graph.metadata.insert("platform".into(), "github".into());
517 let findings = vec![Finding {
518 severity: Severity::High,
519 category: taudit_core::finding::FindingCategory::AuthorityPropagation,
520 path: None,
521 nodes_involved: vec![secret_a, step],
522 message: "AWS_KEY reaches deploy".into(),
523 recommendation: Recommendation::Manual {
524 action: "scope it".into(),
525 },
526 source: taudit_core::finding::FindingSource::BuiltIn,
527 extras: taudit_core::finding::FindingExtras::default(),
528 }];
529 (graph, findings)
530 }
531
532 let mut runs: Vec<Vec<u8>> = Vec::with_capacity(9);
533 for _ in 0..9 {
534 let (g, f) = build_graph();
535 let mut buf = Vec::new();
536 JsonReportSink.emit(&mut buf, &g, &f).unwrap();
537 runs.push(buf);
538 }
539
540 let first = &runs[0];
541 for (i, run) in runs.iter().enumerate().skip(1) {
542 assert_eq!(
543 first, run,
544 "run 0 and run {i} produced byte-different JSON output (non-determinism regression)"
545 );
546 }
547 }
548
549 #[test]
557 fn each_finding_has_non_null_snake_case_rule_id() {
558 let graph = taudit_core::graph::AuthorityGraph::new(PipelineSource {
559 file: ".github/workflows/ci.yml".into(),
560 repo: None,
561 git_ref: None,
562 commit_sha: None,
563 });
564 let findings = vec![
565 Finding {
566 severity: Severity::High,
567 category: taudit_core::finding::FindingCategory::AuthorityPropagation,
568 path: None,
569 nodes_involved: vec![],
570 message: "GITHUB_TOKEN propagated".into(),
571 recommendation: Recommendation::Manual {
572 action: "scope it".into(),
573 },
574 source: taudit_core::finding::FindingSource::BuiltIn,
575 extras: taudit_core::finding::FindingExtras::default(),
576 },
577 Finding {
578 severity: Severity::Medium,
579 category: taudit_core::finding::FindingCategory::UnpinnedAction,
580 path: None,
581 nodes_involved: vec![],
582 message: "[my_custom_rule] custom rule fired".into(),
583 recommendation: Recommendation::Manual {
584 action: "pin it".into(),
585 },
586 source: taudit_core::finding::FindingSource::BuiltIn,
587 extras: taudit_core::finding::FindingExtras::default(),
588 },
589 ];
590
591 let mut buf = Vec::new();
592 JsonReportSink.emit(&mut buf, &graph, &findings).unwrap();
593 let report: serde_json::Value = serde_json::from_slice(&buf).unwrap();
594
595 let findings_arr = report["findings"].as_array().expect("findings is an array");
596 assert_eq!(findings_arr.len(), 2);
597
598 for f in findings_arr {
600 let id = f["rule_id"].as_str();
601 assert!(
602 id.is_some(),
603 "every finding must have a string rule_id, got: {:?}",
604 f["rule_id"]
605 );
606 assert!(
607 !id.unwrap().is_empty(),
608 "rule_id must be non-empty, got: {:?}",
609 f["rule_id"]
610 );
611 }
612
613 assert_eq!(findings_arr[0]["rule_id"], "authority_propagation");
615 assert_eq!(findings_arr[1]["rule_id"], "my_custom_rule");
617 }
618
619 #[test]
626 fn summary_completeness_gaps_serialize_as_kind_reason_objects() {
627 use taudit_core::graph::GapKind;
628
629 let mut graph = taudit_core::graph::AuthorityGraph::new(PipelineSource {
630 file: ".github/workflows/ci.yml".into(),
631 repo: None,
632 git_ref: None,
633 commit_sha: None,
634 });
635 graph.mark_partial(GapKind::Structural, "composite action not found: ./action");
636 graph.mark_partial(
637 GapKind::Expression,
638 "matrix strategy hides some authority paths",
639 );
640 graph.mark_partial(GapKind::Opaque, "platform unknown; zero steps produced");
641
642 let mut buf = Vec::new();
643 JsonReportSink.emit(&mut buf, &graph, &[]).unwrap();
644 let report: serde_json::Value = serde_json::from_slice(&buf).unwrap();
645
646 let gaps = report["summary"]["completeness_gaps"]
647 .as_array()
648 .expect("summary.completeness_gaps must be an array");
649 assert_eq!(gaps.len(), 3, "all three gaps round-trip");
650
651 assert_eq!(gaps[0]["kind"], "structural");
653 assert_eq!(gaps[0]["reason"], "composite action not found: ./action");
654 assert_eq!(gaps[1]["kind"], "expression");
656 assert_eq!(
657 gaps[1]["reason"],
658 "matrix strategy hides some authority paths"
659 );
660 assert_eq!(gaps[2]["kind"], "opaque");
662 assert_eq!(gaps[2]["reason"], "platform unknown; zero steps produced");
663
664 for (i, gap) in gaps.iter().enumerate() {
668 assert!(gap.is_object(), "gap[{i}] must be an object, got: {gap:?}");
669 assert!(
670 gap.get("kind").and_then(|v| v.as_str()).is_some(),
671 "gap[{i}].kind must be a string"
672 );
673 assert!(
674 gap.get("reason").and_then(|v| v.as_str()).is_some(),
675 "gap[{i}].reason must be a string"
676 );
677 }
678
679 let schema = read_json("contracts/schemas/taudit-report.schema.json");
683 let validator = jsonschema::validator_for(&schema).expect("report schema should compile");
684 let errors: Vec<String> = validator
685 .iter_errors(&report)
686 .map(|err| err.to_string())
687 .collect();
688 assert!(
689 errors.is_empty(),
690 "partial-graph report does not match report schema:\n{}",
691 errors.join("\n")
692 );
693 }
694
695 #[test]
717 fn every_finding_category_variant_validates_against_report_schema() {
718 use taudit_core::finding::FindingCategory as C;
719
720 let all: Vec<C> = vec![
726 C::AuthorityPropagation,
727 C::OverPrivilegedIdentity,
728 C::UnpinnedAction,
729 C::UntrustedWithAuthority,
730 C::ArtifactBoundaryCrossing,
731 C::FloatingImage,
732 C::LongLivedCredential,
733 C::PersistedCredential,
734 C::TriggerContextMismatch,
735 C::CrossWorkflowAuthorityChain,
736 C::AuthorityCycle,
737 C::UpliftWithoutAttestation,
738 C::SelfMutatingPipeline,
739 C::CheckoutSelfPrExposure,
740 C::VariableGroupInPrJob,
741 C::SelfHostedPoolPrHijack,
742 C::SharedSelfHostedPoolNoIsolation,
743 C::ServiceConnectionScopeMismatch,
744 C::TemplateExtendsUnpinnedBranch,
745 C::TemplateRepoRefIsFeatureBranch,
746 C::VmRemoteExecViaPipelineSecret,
747 C::ShortLivedSasInCommandLine,
748 C::SecretToInlineScriptEnvExport,
749 C::SecretMaterialisedToWorkspaceFile,
750 C::KeyVaultSecretToPlaintext,
751 C::TerraformAutoApproveInProd,
752 C::AddSpnWithInlineScript,
753 C::ParameterInterpolationIntoShell,
754 C::RuntimeScriptFetchedFromFloatingUrl,
755 C::PrTriggerWithFloatingActionRef,
756 C::UntrustedApiResponseToEnvSink,
757 C::PrBuildPushesImageWithFloatingCredentials,
758 C::SecretViaEnvGateToUntrustedConsumer,
759 C::NoWorkflowLevelPermissionsBlock,
760 C::ProdDeployJobNoEnvironmentGate,
761 C::LongLivedSecretWithoutOidcRecommendation,
762 C::PullRequestWorkflowInconsistentForkCheck,
763 C::GitlabDeployJobMissingProtectedBranchOnly,
764 C::TerraformOutputViaSetvariableShellExpansion,
765 C::RiskyTriggerWithAuthority,
766 C::SensitiveValueInJobOutput,
767 C::ManualDispatchInputToUrlOrCommand,
768 C::SecretsInheritOverscopedPassthrough,
769 C::UnsafePrArtifactInWorkflowRunConsumer,
770 C::ScriptInjectionViaUntrustedContext,
771 C::InteractiveDebugActionInAuthorityWorkflow,
772 C::PrSpecificCacheKeyInDefaultBranchConsumer,
773 C::GhCliWithDefaultTokenEscalating,
774 C::GhaScriptInjectionToPrivilegedShell,
775 C::GhaWorkflowRunArtifactPoisoningToPrivilegedConsumer,
776 C::GhaRemoteScriptInAuthorityJob,
777 C::GhaPatRemoteUrlWrite,
778 C::GhaIssueCommentCommandToWriteToken,
779 C::GhaPrBuildPushesPublishableImage,
780 C::GhaManualDispatchRefToPrivilegedCheckout,
781 C::CiJobTokenToExternalApi,
782 C::IdTokenAudienceOverscoped,
783 C::UntrustedCiVarInShellInterpolation,
784 C::UnpinnedIncludeRemoteOrBranchRef,
785 C::DindServiceGrantsHostAuthority,
786 C::SecurityJobSilentlySkipped,
787 C::ChildPipelineTriggerInheritsAuthority,
788 C::CacheKeyCrossesTrustBoundary,
789 C::PatEmbeddedInGitRemoteUrl,
790 C::CiTokenTriggersDownstreamWithVariablePassthrough,
791 C::DotenvArtifactFlowsToPrivilegedDeployment,
792 C::SetvariableIssecretFalse,
793 C::HomoglyphInActionRef,
794 C::GhaHelperPathSensitiveArgv,
795 C::GhaHelperPathSensitiveStdin,
796 C::GhaHelperPathSensitiveEnv,
797 C::GhaPostAmbientEnvCleanupPath,
798 C::GhaActionMintedSecretToHelper,
799 C::GhaHelperUntrustedPathResolution,
800 C::GhaSecretOutputAfterHelperLogin,
801 C::LaterSecretMaterializedAfterPathMutation,
802 C::GhaSetupNodeCacheHelperPathHandoff,
803 C::GhaSetupPythonCacheHelperPathHandoff,
804 C::GhaSetupPythonPipInstallAuthorityEnv,
805 C::GhaSetupGoCacheHelperPathHandoff,
806 C::GhaDockerSetupQemuPrivilegedDockerHelper,
807 C::GhaToolInstallerThenShellHelperAuthority,
808 C::GhaWorkflowShellAuthorityConcentration,
809 C::GhaActionTokenEnvBeforeBareDownloadHelper,
810 C::GhaPostActionInputRetargetToCacheSave,
811 C::GhaTerraformWrapperSensitiveOutput,
812 C::GhaCompositeBareHelperAfterPathInstallWithSecretEnv,
813 C::GhaPulumiPathResolvedCliWithAuthority,
814 C::GhaPypiPublishOidcAfterPathMutation,
815 C::GhaChangesetsPublishCommandWithAuthority,
816 C::GhaRubygemsReleaseGitTokenAndOidcHelper,
817 C::GhaCompositeEntrypointPathShadowWithSecretEnv,
818 C::GhaDockerBuildxAuthorityPathHandoff,
819 C::GhaGoogleDeployGcloudCredentialPath,
820 C::GhaDatadogTestVisibilityInstallerAuthority,
821 C::GhaKubernetesHelperKubeconfigAuthority,
822 C::GhaAzureCompanionHelperAuthority,
823 C::GhaCreatePrGitTokenPathHandoff,
824 C::GhaImportGpgPrivateKeyHelperPath,
825 C::GhaSshAgentPrivateKeyToPathHelper,
826 C::GhaMacosCodesignCertSecurityPath,
827 C::GhaPagesDeployTokenUrlToGitHelper,
828 C::GhaToolcacheAbsolutePathDowngrade,
829 C::EgressBlindspot,
833 C::MissingAuditTrail,
834 ];
835
836 assert_eq!(
840 all.len(),
841 105,
842 "FindingCategory enumeration is out of sync with the schema generator (expected 105, got {})",
843 all.len()
844 );
845
846 let schema = read_json("contracts/schemas/taudit-report.schema.json");
847 let validator = jsonschema::validator_for(&schema).expect("report schema should compile");
848
849 for category in all {
850 let graph = taudit_core::graph::AuthorityGraph::new(PipelineSource {
851 file: ".github/workflows/ci.yml".into(),
852 repo: None,
853 git_ref: None,
854 commit_sha: None,
855 });
856 let findings = vec![Finding {
857 severity: Severity::Medium,
858 category,
859 path: None,
860 nodes_involved: vec![],
861 message: "category coverage probe".into(),
862 recommendation: Recommendation::Manual {
863 action: "noop".into(),
864 },
865 source: taudit_core::finding::FindingSource::BuiltIn,
866 extras: FindingExtras::default(),
867 }];
868
869 let mut buf = Vec::new();
870 JsonReportSink
871 .emit(&mut buf, &graph, &findings)
872 .expect("sink emits");
873 let report: serde_json::Value =
874 serde_json::from_slice(&buf).expect("output is valid JSON");
875 let errors: Vec<String> = validator
876 .iter_errors(&report)
877 .map(|err| err.to_string())
878 .collect();
879 assert!(
880 errors.is_empty(),
881 "category {category:?} produced a report that fails the published schema:\n{}",
882 errors.join("\n")
883 );
884 }
885 }
886}