1use crate::graph::{
8 AuthorityGraph, EdgeKind, Node, NodeId, NodeKind, META_GHA_ACTION, META_GHA_WITH_INPUTS,
9 META_JOB_NAME, META_OIDC, META_SCRIPT_BODY,
10};
11use serde::Serialize;
12use std::fmt::Write as _;
13
14#[derive(Debug, Clone, Default)]
15pub struct ExploitGraphOptions<'a> {
16 pub job: Option<&'a str>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct ExploitGraphExport {
21 pub schema_version: &'static str,
22 pub schema_uri: &'static str,
23 pub view: &'static str,
24 pub source: crate::graph::PipelineSource,
25 pub paths: Vec<ExploitPathExport>,
26 pub summary: ExploitGraphSummary,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct ExploitGraphSummary {
31 pub path_count: usize,
32 pub observed_path_count: usize,
33 pub authority_path_count: usize,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ExploitPathExport {
38 pub rule_id: &'static str,
39 pub umbrella_rule_id: &'static str,
40 pub rule_scope: &'static str,
41 pub mutable_channel: &'static str,
42 pub helper: &'static str,
43 pub helper_resolution: &'static str,
44 pub authority_transport: Vec<&'static str>,
45 pub authority_origin: &'static str,
46 pub nodes: Vec<ExploitNodeExport>,
47 pub edges: Vec<ExploitEdgeExport>,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct ExploitNodeExport {
52 pub id: String,
53 pub kind: &'static str,
54 pub label: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub source_node_id: Option<NodeId>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub note: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct ExploitEdgeExport {
63 pub from: String,
64 pub to: String,
65 pub kind: &'static str,
66 pub confidence: &'static str,
67 pub authority_bearing: bool,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub observed: Option<bool>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum MutableChannel {
74 GithubPath,
75}
76
77impl MutableChannel {
78 fn label(self) -> &'static str {
79 match self {
80 Self::GithubPath => "GITHUB_PATH",
81 }
82 }
83}
84
85#[allow(dead_code)]
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum HelperResolution {
88 BareCommand,
89 ShellString,
90 ToolkitWhich,
91 AbsolutePath,
92 ToolcachePath,
93 ActionOwnedPath,
94 UserSuppliedAbsolutePath,
95 AmbientPathByExplicitMode,
96 Unknown,
97}
98
99impl HelperResolution {
100 fn as_str(self) -> &'static str {
101 match self {
102 Self::BareCommand => "bare_command",
103 Self::ShellString => "shell_string",
104 Self::ToolkitWhich => "toolkit_which",
105 Self::AbsolutePath => "absolute_path",
106 Self::ToolcachePath => "toolcache_path",
107 Self::ActionOwnedPath => "action_owned_path",
108 Self::UserSuppliedAbsolutePath => "user_supplied_absolute_path",
109 Self::AmbientPathByExplicitMode => "ambient_path_by_explicit_mode",
110 Self::Unknown => "unknown",
111 }
112 }
113
114 fn is_path_selected(self) -> bool {
115 matches!(
116 self,
117 Self::BareCommand | Self::ShellString | Self::ToolkitWhich
118 )
119 }
120
121 fn is_downgrade_or_suppress(self) -> bool {
122 matches!(
123 self,
124 Self::AbsolutePath
125 | Self::ToolcachePath
126 | Self::ActionOwnedPath
127 | Self::UserSuppliedAbsolutePath
128 | Self::AmbientPathByExplicitMode
129 )
130 }
131}
132
133#[allow(dead_code)]
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum AuthorityTransport {
136 Argv,
137 Stdin,
138 Env,
139 CredentialFilePath,
140 ConfigFilePath,
141 WorkspaceFile,
142 OidcRequestEnv,
143}
144
145impl AuthorityTransport {
146 fn as_str(self) -> &'static str {
147 match self {
148 Self::Argv => "argv",
149 Self::Stdin => "stdin",
150 Self::Env => "env",
151 Self::CredentialFilePath => "credential_file_path",
152 Self::ConfigFilePath => "config_file_path",
153 Self::WorkspaceFile => "workspace_file",
154 Self::OidcRequestEnv => "oidc_request_env",
155 }
156 }
157}
158
159#[allow(dead_code)]
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum AuthorityOrigin {
162 CallerProvidedSecret,
163 ActionInputSecret,
164 GitHubToken,
165 OidcRequestCapability,
166 CloudCredentialMintedByAction,
167 RegistryCredentialMintedByAction,
168 GeneratedCredentialFile,
169 DerivedSecretPayload,
170}
171
172impl AuthorityOrigin {
173 fn as_str(self) -> &'static str {
174 match self {
175 Self::CallerProvidedSecret => "caller_provided_secret",
176 Self::ActionInputSecret => "action_input_secret",
177 Self::GitHubToken => "github_token",
178 Self::OidcRequestCapability => "oidc_request_capability",
179 Self::CloudCredentialMintedByAction => "cloud_credential_minted_by_action",
180 Self::RegistryCredentialMintedByAction => "registry_credential_minted_by_action",
181 Self::GeneratedCredentialFile => "generated_credential_file",
182 Self::DerivedSecretPayload => "derived_secret_payload",
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy)]
188struct ExploitRule {
189 id: &'static str,
190 scope: &'static str,
191 mutable_channel: MutableChannel,
192 require_prior_mutation: bool,
193 require_path_selected_helper: bool,
194 require_authority_materialization: bool,
195}
196
197#[derive(Debug, Clone, Copy)]
198struct ActionCatalogEntry {
199 action: &'static str,
200 action_label: &'static str,
201 helper: &'static str,
202 helper_resolution: HelperResolution,
203 authority_patterns: &'static [AuthorityPattern],
204 mode_gate: Option<ModeGate>,
205 suppress: bool,
206}
207
208#[derive(Debug, Clone, Copy)]
209struct AuthorityPattern {
210 artifact_label: &'static str,
211 env_label: Option<&'static str>,
212 transports: &'static [AuthorityTransport],
213 origin: AuthorityOrigin,
214 input_keys: &'static [&'static str],
215 accepts_graph_authority: bool,
216}
217
218#[derive(Debug, Clone, Copy)]
219struct ModeGate {
220 input_key: &'static str,
221 expected: TruthyMode,
222}
223
224#[derive(Debug, Clone, Copy)]
225enum TruthyMode {
226 Truthy,
227}
228
229#[derive(Debug, Clone, Copy)]
230struct AuthorityMaterialization<'a> {
231 pattern: AuthorityPattern,
232 graph_authority: Option<&'a Node>,
233}
234
235const RULE_SCOPE_EXPLOIT_PATH: &str = "exploit_path";
236const RULE_HELPER_AUTHORITY: ExploitRule = ExploitRule {
237 id: "EXPLOIT_PATH_HELPER_AUTHORITY",
238 scope: RULE_SCOPE_EXPLOIT_PATH,
239 mutable_channel: MutableChannel::GithubPath,
240 require_prior_mutation: true,
241 require_path_selected_helper: true,
242 require_authority_materialization: true,
243};
244
245const EXPLOIT_RULES: &[ExploitRule] = &[RULE_HELPER_AUTHORITY];
246
247const TRANSPORT_CREDENTIAL_FILE_ENV: &[AuthorityTransport] = &[
248 AuthorityTransport::CredentialFilePath,
249 AuthorityTransport::Env,
250];
251const TRANSPORT_ENV_STDIN: &[AuthorityTransport] =
252 &[AuthorityTransport::Env, AuthorityTransport::Stdin];
253const TRANSPORT_STDIN: &[AuthorityTransport] = &[AuthorityTransport::Stdin];
254const TRANSPORT_ARGV: &[AuthorityTransport] = &[AuthorityTransport::Argv];
255const TRANSPORT_ENV: &[AuthorityTransport] = &[AuthorityTransport::Env];
256const TRANSPORT_CONFIG_OIDC_ENV: &[AuthorityTransport] = &[
257 AuthorityTransport::ConfigFilePath,
258 AuthorityTransport::OidcRequestEnv,
259];
260
261const INPUT_FIREBASE_SERVICE_ACCOUNT: &[&str] = &["firebaseServiceAccount"];
262const INPUT_AZURE_CREDS: &[&str] = &["creds"];
263const INPUT_CLOUDFLARE_AUTHORITY: &[&str] = &["apiToken", "secrets", "command"];
264const INPUT_DOCKER_LOGIN_AUTHORITY: &[&str] = &["password", "registry"];
265const INPUT_NPM_PUBLISH_AUTHORITY: &[&str] = &["token", "access"];
266const INPUT_EMPTY: &[&str] = &[];
267
268const FIREBASE_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
269 artifact_label: "service-account credential file",
270 env_label: Some("GOOGLE_APPLICATION_CREDENTIALS"),
271 transports: TRANSPORT_CREDENTIAL_FILE_ENV,
272 origin: AuthorityOrigin::GeneratedCredentialFile,
273 input_keys: INPUT_FIREBASE_SERVICE_ACCOUNT,
274 accepts_graph_authority: true,
275}];
276const AZURE_LOGIN_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
277 artifact_label: "Azure service principal credential payload",
278 env_label: None,
279 transports: TRANSPORT_ARGV,
280 origin: AuthorityOrigin::ActionInputSecret,
281 input_keys: INPUT_AZURE_CREDS,
282 accepts_graph_authority: true,
283}];
284const CLOUDFLARE_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
285 artifact_label: "Wrangler deploy secret payload",
286 env_label: Some("CLOUDFLARE_API_TOKEN / SECRET_ALPHA"),
287 transports: TRANSPORT_ENV_STDIN,
288 origin: AuthorityOrigin::ActionInputSecret,
289 input_keys: INPUT_CLOUDFLARE_AUTHORITY,
290 accepts_graph_authority: true,
291}];
292const DOCKER_LOGIN_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
293 artifact_label: "registry password payload",
294 env_label: None,
295 transports: TRANSPORT_STDIN,
296 origin: AuthorityOrigin::CallerProvidedSecret,
297 input_keys: INPUT_DOCKER_LOGIN_AUTHORITY,
298 accepts_graph_authority: true,
299}];
300const ECR_LOGIN_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
301 artifact_label: "AWS-minted ECR password",
302 env_label: None,
303 transports: TRANSPORT_ARGV,
304 origin: AuthorityOrigin::RegistryCredentialMintedByAction,
305 input_keys: INPUT_EMPTY,
306 accepts_graph_authority: true,
307}];
308const NPM_PUBLISH_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
309 artifact_label: ".npmrc / package publish token",
310 env_label: Some("NODE_AUTH_TOKEN"),
311 transports: TRANSPORT_ENV,
312 origin: AuthorityOrigin::ActionInputSecret,
313 input_keys: INPUT_NPM_PUBLISH_AUTHORITY,
314 accepts_graph_authority: true,
315}];
316const TELEPORT_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
317 artifact_label: "Teleport bot config / identity material",
318 env_label: Some("GitHub OIDC request env"),
319 transports: TRANSPORT_CONFIG_OIDC_ENV,
320 origin: AuthorityOrigin::OidcRequestCapability,
321 input_keys: INPUT_EMPTY,
322 accepts_graph_authority: true,
323}];
324const SETUP_GCLOUD_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
325 artifact_label: "ambient gcloud credential context",
326 env_label: Some("Google Cloud auth env"),
327 transports: TRANSPORT_ARGV,
328 origin: AuthorityOrigin::CloudCredentialMintedByAction,
329 input_keys: INPUT_EMPTY,
330 accepts_graph_authority: true,
331}];
332const GORELEASER_AUTHORITY: &[AuthorityPattern] = &[AuthorityPattern {
333 artifact_label: "GoReleaser token context",
334 env_label: None,
335 transports: TRANSPORT_ENV,
336 origin: AuthorityOrigin::ActionInputSecret,
337 input_keys: INPUT_EMPTY,
338 accepts_graph_authority: true,
339}];
340
341const ACTION_CATALOG: &[ActionCatalogEntry] = &[
342 ActionCatalogEntry {
343 action: "firebaseextended/action-hosting-deploy",
344 action_label: "FirebaseExtended/action-hosting-deploy",
345 helper: "npx",
346 helper_resolution: HelperResolution::BareCommand,
347 authority_patterns: FIREBASE_AUTHORITY,
348 mode_gate: None,
349 suppress: false,
350 },
351 ActionCatalogEntry {
352 action: "azure/login",
353 action_label: "Azure/login",
354 helper: "az",
355 helper_resolution: HelperResolution::BareCommand,
356 authority_patterns: AZURE_LOGIN_AUTHORITY,
357 mode_gate: None,
358 suppress: false,
359 },
360 ActionCatalogEntry {
361 action: "cloudflare/wrangler-action",
362 action_label: "cloudflare/wrangler-action",
363 helper: "npx",
364 helper_resolution: HelperResolution::ShellString,
365 authority_patterns: CLOUDFLARE_AUTHORITY,
366 mode_gate: None,
367 suppress: false,
368 },
369 ActionCatalogEntry {
370 action: "docker/login-action",
371 action_label: "docker/login-action",
372 helper: "docker",
373 helper_resolution: HelperResolution::BareCommand,
374 authority_patterns: DOCKER_LOGIN_AUTHORITY,
375 mode_gate: None,
376 suppress: false,
377 },
378 ActionCatalogEntry {
379 action: "aws-actions/amazon-ecr-login",
380 action_label: "aws-actions/amazon-ecr-login",
381 helper: "docker",
382 helper_resolution: HelperResolution::BareCommand,
383 authority_patterns: ECR_LOGIN_AUTHORITY,
384 mode_gate: None,
385 suppress: false,
386 },
387 ActionCatalogEntry {
388 action: "js-devtools/npm-publish",
389 action_label: "JS-DevTools/npm-publish",
390 helper: "npm",
391 helper_resolution: HelperResolution::BareCommand,
392 authority_patterns: NPM_PUBLISH_AUTHORITY,
393 mode_gate: None,
394 suppress: false,
395 },
396 ActionCatalogEntry {
397 action: "teleport-actions/database-tunnel",
398 action_label: "teleport-actions/database-tunnel",
399 helper: "tbot",
400 helper_resolution: HelperResolution::ToolkitWhich,
401 authority_patterns: TELEPORT_AUTHORITY,
402 mode_gate: None,
403 suppress: false,
404 },
405 ActionCatalogEntry {
406 action: "google-github-actions/setup-gcloud",
407 action_label: "google-github-actions/setup-gcloud",
408 helper: "gcloud",
409 helper_resolution: HelperResolution::AmbientPathByExplicitMode,
410 authority_patterns: SETUP_GCLOUD_AUTHORITY,
411 mode_gate: Some(ModeGate {
412 input_key: "skip_install",
413 expected: TruthyMode::Truthy,
414 }),
415 suppress: false,
416 },
417 ActionCatalogEntry {
418 action: "goreleaser/goreleaser-action",
419 action_label: "goreleaser/goreleaser-action",
420 helper: "goreleaser",
421 helper_resolution: HelperResolution::ToolcachePath,
422 authority_patterns: GORELEASER_AUTHORITY,
423 mode_gate: None,
424 suppress: true,
425 },
426];
427
428#[derive(Debug)]
429struct ExploitPath<'a> {
430 rule: ExploitRule,
431 rule_id: &'static str,
432 writer: &'a Node,
433 action_step: &'a Node,
434 catalog: ActionCatalogEntry,
435 authority: AuthorityMaterialization<'a>,
436}
437
438pub fn render_dot(graph: &AuthorityGraph, options: ExploitGraphOptions<'_>) -> String {
440 let paths = exploit_paths(graph, options.job);
441 let mut out = String::new();
442 out.push_str("digraph taudit_exploit {\n");
443 out.push_str(" rankdir=LR;\n");
444 out.push_str(" graph [fontname=\"Helvetica\"];\n");
445 out.push_str(" node [fontname=\"Helvetica\" style=\"rounded\"];\n");
446 out.push_str(" edge [fontname=\"Helvetica\"];\n");
447 let title = format!("taudit exploit view\nsource: {}", graph.source.file);
448 out.push_str(&format!(" label=\"{}\";\n", dot_escape(&title)));
449 out.push_str(" labelloc=\"t\";\n");
450
451 legend(&mut out);
452
453 for (idx, path) in paths.iter().enumerate() {
454 render_path(&mut out, idx, path);
455 }
456
457 if paths.is_empty() {
458 out.push_str(
459 " \"empty\" [label=\"No exploit path found\\nrequires prior mutable state and later PATH-resolved helper authority\" shape=note color=gray];\n",
460 );
461 }
462
463 out.push_str("}\n");
464 out
465}
466
467pub fn render_mermaid(graph: &AuthorityGraph, options: ExploitGraphOptions<'_>) -> String {
468 let paths = exploit_paths(graph, options.job);
469 let mut out = String::new();
470 out.push_str("flowchart LR\n");
471
472 if paths.is_empty() {
473 out.push_str(" empty[\"No exploit path found\"]\n");
474 return out;
475 }
476
477 for (idx, path) in paths.iter().enumerate() {
478 let nodes = path_nodes(idx, path);
479 for node in &nodes {
480 let label = mermaid_escape(&format!("{}: {}", node.kind, node.label));
481 match node.kind {
482 "Step" => {
483 let _ = writeln!(out, " {}(\"{}\")", node.id, label);
484 }
485 "ThirdPartyAction" => {
486 let _ = writeln!(out, " {}[\"{}\"]", node.id, label);
487 }
488 "MutableState" | "AuthorityEnv" => {
489 let _ = writeln!(out, " {}[[\"{}\"]]", node.id, label);
490 }
491 "ResolvedHelper" | "ObservedSink" => {
492 let _ = writeln!(out, " {}{{\"{}\"}}", node.id, label);
493 }
494 _ => {
495 let _ = writeln!(out, " {}[\"{}\"]", node.id, label);
496 }
497 }
498 }
499 for edge in path_edges(idx, path) {
500 let label = mermaid_escape(edge.kind);
501 let arrow = if edge.confidence == "observed" {
502 "==>"
503 } else if edge.confidence == "inferred" {
504 "-.->"
505 } else {
506 "-->"
507 };
508 let _ = writeln!(out, " {} {}|{}| {}", edge.from, arrow, label, edge.to);
509 }
510 }
511
512 out
513}
514
515pub fn build_export(
516 graph: &AuthorityGraph,
517 options: ExploitGraphOptions<'_>,
518) -> ExploitGraphExport {
519 let paths: Vec<ExploitPathExport> = exploit_paths(graph, options.job)
520 .iter()
521 .enumerate()
522 .map(|(idx, path)| ExploitPathExport {
523 rule_id: path.rule_id,
524 umbrella_rule_id: path.rule.id,
525 rule_scope: path.rule.scope,
526 mutable_channel: path.rule.mutable_channel.label(),
527 helper: path.catalog.helper,
528 helper_resolution: path.catalog.helper_resolution.as_str(),
529 authority_transport: path
530 .authority
531 .pattern
532 .transports
533 .iter()
534 .map(|transport| transport.as_str())
535 .collect(),
536 authority_origin: path.authority.pattern.origin.as_str(),
537 nodes: path_nodes(idx, path),
538 edges: path_edges(idx, path),
539 })
540 .collect();
541 let observed_path_count = paths
542 .iter()
543 .filter(|path| path.edges.iter().any(|edge| edge.observed == Some(true)))
544 .count();
545 let authority_path_count = paths
546 .iter()
547 .filter(|path| path.edges.iter().any(|edge| edge.authority_bearing))
548 .count();
549 ExploitGraphExport {
550 schema_version: "1.0.0",
551 schema_uri: "https://taudit.dev/schemas/exploit-graph.v1.json",
552 view: "exploit",
553 source: graph.source.clone(),
554 summary: ExploitGraphSummary {
555 path_count: paths.len(),
556 observed_path_count,
557 authority_path_count,
558 },
559 paths,
560 }
561}
562
563pub fn render_json_pretty(
564 graph: &AuthorityGraph,
565 options: ExploitGraphOptions<'_>,
566) -> Result<String, serde_json::Error> {
567 serde_json::to_string_pretty(&build_export(graph, options))
568}
569
570pub fn render_summary_pretty(
571 graph: &AuthorityGraph,
572 options: ExploitGraphOptions<'_>,
573) -> Result<String, serde_json::Error> {
574 let export = build_export(graph, options);
575 serde_json::to_string_pretty(&serde_json::json!({
576 "schema_version": export.schema_version,
577 "schema_uri": export.schema_uri,
578 "view": export.view,
579 "source": export.source,
580 "summary": export.summary,
581 }))
582}
583
584fn exploit_paths<'a>(graph: &'a AuthorityGraph, job_filter: Option<&str>) -> Vec<ExploitPath<'a>> {
585 if graph
586 .metadata
587 .get("platform")
588 .map(String::as_str)
589 .is_some_and(|p| p != "github-actions")
590 {
591 return Vec::new();
592 }
593
594 graph
595 .nodes_of_kind(NodeKind::Step)
596 .filter(|step| {
597 job_filter.is_none()
598 || step.metadata.get(META_JOB_NAME).map(String::as_str) == job_filter
599 })
600 .flat_map(|step| {
601 EXPLOIT_RULES
602 .iter()
603 .copied()
604 .filter_map(move |rule| match_exploit_rule(graph, step, rule))
605 })
606 .collect()
607}
608
609fn match_exploit_rule<'a>(
610 graph: &'a AuthorityGraph,
611 step: &'a Node,
612 rule: ExploitRule,
613) -> Option<ExploitPath<'a>> {
614 let catalog = catalog_entry(step)?;
615 if catalog.suppress || catalog.helper_resolution.is_downgrade_or_suppress() {
616 return None;
617 }
618 if rule.require_path_selected_helper && !catalog.helper_resolution.is_path_selected() {
619 return None;
620 }
621 let writer = if rule.require_prior_mutation {
622 prior_mutable_writer(graph, step, rule.mutable_channel)?
623 } else {
624 step
625 };
626 let authority = if rule.require_authority_materialization {
627 authority_materialization(graph, step, catalog)?
628 } else {
629 AuthorityMaterialization {
630 pattern: catalog.authority_patterns.first().copied()?,
631 graph_authority: None,
632 }
633 };
634 Some(ExploitPath {
635 rule,
636 rule_id: transport_rule_id(authority.pattern.transports),
637 writer,
638 action_step: step,
639 catalog,
640 authority,
641 })
642}
643
644fn catalog_entry(step: &Node) -> Option<ActionCatalogEntry> {
645 let action = step.metadata.get(META_GHA_ACTION)?.to_ascii_lowercase();
646 let entry = ACTION_CATALOG
647 .iter()
648 .copied()
649 .find(|entry| entry.action == action)?;
650 if !mode_gate_matches(step, entry.mode_gate) {
651 return None;
652 }
653 Some(entry)
654}
655
656fn authority_materialization<'a>(
657 graph: &'a AuthorityGraph,
658 step: &Node,
659 catalog: ActionCatalogEntry,
660) -> Option<AuthorityMaterialization<'a>> {
661 let graph_authority = first_authority_node(graph, step.id);
662 catalog
663 .authority_patterns
664 .iter()
665 .copied()
666 .find(|pattern| {
667 (pattern.accepts_graph_authority && graph_authority.is_some())
668 || pattern_input_signal(step, *pattern)
669 || origin_materializes_without_input(pattern.origin)
670 })
671 .map(|pattern| AuthorityMaterialization {
672 pattern,
673 graph_authority,
674 })
675}
676
677fn pattern_input_signal(step: &Node, pattern: AuthorityPattern) -> bool {
678 let inputs = step
679 .metadata
680 .get(META_GHA_WITH_INPUTS)
681 .map(String::as_str)
682 .unwrap_or("");
683 pattern
684 .input_keys
685 .iter()
686 .any(|key| contains_input_key(inputs, key))
687}
688
689fn origin_materializes_without_input(origin: AuthorityOrigin) -> bool {
690 matches!(
691 origin,
692 AuthorityOrigin::CloudCredentialMintedByAction
693 | AuthorityOrigin::RegistryCredentialMintedByAction
694 | AuthorityOrigin::OidcRequestCapability
695 | AuthorityOrigin::GeneratedCredentialFile
696 | AuthorityOrigin::DerivedSecretPayload
697 )
698}
699
700fn mode_gate_matches(step: &Node, gate: Option<ModeGate>) -> bool {
701 match gate {
702 None => true,
703 Some(ModeGate {
704 input_key,
705 expected: TruthyMode::Truthy,
706 }) => with_truthy(step, input_key),
707 }
708}
709
710fn contains_input_key(inputs: &str, key: &str) -> bool {
711 inputs.lines().any(|line| {
712 line.split_once('=')
713 .map(|(k, v)| k.eq_ignore_ascii_case(key) && !v.trim().is_empty())
714 .unwrap_or(false)
715 })
716}
717
718fn with_truthy(step: &Node, key: &str) -> bool {
719 let Some(inputs) = step.metadata.get(META_GHA_WITH_INPUTS) else {
720 return false;
721 };
722 inputs.lines().any(|line| {
723 let Some((k, v)) = line.split_once('=') else {
724 return false;
725 };
726 k.eq_ignore_ascii_case(key)
727 && matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "yes" | "1")
728 })
729}
730
731fn prior_mutable_writer<'a>(
732 graph: &'a AuthorityGraph,
733 step: &Node,
734 channel: MutableChannel,
735) -> Option<&'a Node> {
736 let job = step.metadata.get(META_JOB_NAME)?;
737 graph
738 .nodes_of_kind(NodeKind::Step)
739 .filter(|candidate| candidate.id < step.id)
740 .filter(|candidate| candidate.metadata.get(META_JOB_NAME) == Some(job))
741 .filter(|candidate| {
742 candidate
743 .metadata
744 .get(META_SCRIPT_BODY)
745 .map(|body| writes_mutable_channel(body, channel.label()))
746 .unwrap_or(false)
747 })
748 .last()
749}
750
751fn writes_mutable_channel(body: &str, channel: &str) -> bool {
752 body.contains(channel)
753 && (body.contains(">>")
754 || body.contains("tee -a")
755 || body.contains("Out-File")
756 || body.contains("Add-Content"))
757}
758
759fn first_authority_node(graph: &AuthorityGraph, step_id: NodeId) -> Option<&Node> {
760 graph
761 .edges_from(step_id)
762 .filter(|e| e.kind == EdgeKind::HasAccessTo)
763 .filter_map(|e| graph.node(e.to))
764 .find(|n| match n.kind {
765 NodeKind::Secret => true,
766 NodeKind::Identity => {
767 n.metadata
768 .get(META_OIDC)
769 .map(|v| v == "true")
770 .unwrap_or(false)
771 || n.name != "GITHUB_TOKEN"
772 }
773 _ => false,
774 })
775}
776
777fn render_path(out: &mut String, idx: usize, path: &ExploitPath<'_>) {
778 for node_export in path_nodes(idx, path) {
779 let (shape, color) = dot_node_style(node_export.kind);
780 node(
781 out,
782 &node_export.id,
783 node_export.kind,
784 &node_export.label,
785 shape,
786 color,
787 node_export.note.as_deref(),
788 );
789 }
790 for edge_export in path_edges(idx, path) {
791 let (style, color, pen) = dot_edge_style(&edge_export);
792 edge(
793 out,
794 &edge_export.from,
795 &edge_export.to,
796 edge_export.kind,
797 style,
798 color,
799 pen,
800 );
801 }
802}
803
804fn path_nodes(idx: usize, path: &ExploitPath<'_>) -> Vec<ExploitNodeExport> {
805 let ids = PathIds::new(idx);
806 let mut nodes = vec![
807 ExploitNodeExport {
808 id: ids.step,
809 kind: "Step",
810 label: path.writer.name.clone(),
811 source_node_id: Some(path.writer.id),
812 note: None,
813 },
814 ExploitNodeExport {
815 id: ids.state,
816 kind: "MutableState",
817 label: path.rule.mutable_channel.label().into(),
818 source_node_id: None,
819 note: None,
820 },
821 ExploitNodeExport {
822 id: ids.helper,
823 kind: "ResolvedHelper",
824 label: format!("PATH-selected {}", path.catalog.helper),
825 source_node_id: None,
826 note: None,
827 },
828 ExploitNodeExport {
829 id: ids.boundary,
830 kind: "ThirdPartyAction",
831 label: path.catalog.action_label.into(),
832 source_node_id: Some(path.action_step.id),
833 note: Some(path.action_step.name.clone()),
834 },
835 ExploitNodeExport {
836 id: ids.artifact,
837 kind: "AuthorityArtifact",
838 label: authority_artifact_label(path),
839 source_node_id: path.authority.graph_authority.map(|n| n.id),
840 note: Some(authority_note(path.authority.pattern)),
841 },
842 ];
843 if let Some(env_name) = path.authority.pattern.env_label {
844 nodes.push(ExploitNodeExport {
845 id: ids.env,
846 kind: "AuthorityEnv",
847 label: env_name.into(),
848 source_node_id: path.authority.graph_authority.map(|n| n.id),
849 note: None,
850 });
851 }
852 nodes
853}
854
855fn path_edges(idx: usize, path: &ExploitPath<'_>) -> Vec<ExploitEdgeExport> {
856 let ids = PathIds::new(idx);
857 let mut edges = vec![
858 edge_export(
859 &ids.step,
860 &ids.state,
861 "mutates_state",
862 "static",
863 false,
864 None,
865 ),
866 edge_export(
867 &ids.state,
868 &ids.helper,
869 "influences_resolution",
870 "inferred",
871 false,
872 None,
873 ),
874 edge_export(
875 &ids.step,
876 &ids.boundary,
877 "uses_action",
878 "static",
879 false,
880 None,
881 ),
882 edge_export(
883 &ids.boundary,
884 &ids.artifact,
885 "creates_authority_artifact",
886 "static",
887 true,
888 None,
889 ),
890 ];
891 if path.authority.pattern.env_label.is_some() {
892 edges.push(edge_export(
893 &ids.artifact,
894 &ids.env,
895 "exposes_env",
896 "static",
897 true,
898 None,
899 ));
900 }
901 edges.push(edge_export(
902 &ids.boundary,
903 &ids.helper,
904 "invokes_helper",
905 "static",
906 true,
907 None,
908 ));
909 edges.push(edge_export(
910 &ids.helper,
911 &ids.artifact,
912 helper_authority_edge_kind(path.authority.pattern.transports),
913 "inferred",
914 true,
915 None,
916 ));
917 edges
918}
919
920fn helper_authority_edge_kind(transports: &[AuthorityTransport]) -> &'static str {
921 if transports.contains(&AuthorityTransport::CredentialFilePath)
922 || transports.contains(&AuthorityTransport::ConfigFilePath)
923 {
924 "reads_artifact"
925 } else if transports.contains(&AuthorityTransport::Stdin) {
926 "receives_stdin"
927 } else if transports.contains(&AuthorityTransport::Argv) {
928 "receives_argv"
929 } else if transports.contains(&AuthorityTransport::Env)
930 || transports.contains(&AuthorityTransport::OidcRequestEnv)
931 {
932 "inherits_env"
933 } else {
934 "receives_authority"
935 }
936}
937
938fn transport_rule_id(transports: &[AuthorityTransport]) -> &'static str {
939 if transports.contains(&AuthorityTransport::CredentialFilePath)
940 || transports.contains(&AuthorityTransport::ConfigFilePath)
941 {
942 "EXPLOIT_PATH_HELPER_CREDENTIAL_FILE"
943 } else if transports.contains(&AuthorityTransport::Stdin) {
944 "EXPLOIT_PATH_HELPER_STDIN_AUTHORITY"
945 } else if transports.contains(&AuthorityTransport::Argv) {
946 "EXPLOIT_PATH_HELPER_ARGV_AUTHORITY"
947 } else if transports.contains(&AuthorityTransport::Env)
948 || transports.contains(&AuthorityTransport::OidcRequestEnv)
949 {
950 "EXPLOIT_PATH_HELPER_ENV_AUTHORITY"
951 } else {
952 "EXPLOIT_PATH_HELPER_AUTHORITY"
953 }
954}
955
956fn edge_export(
957 from: &str,
958 to: &str,
959 kind: &'static str,
960 confidence: &'static str,
961 authority_bearing: bool,
962 observed: Option<bool>,
963) -> ExploitEdgeExport {
964 ExploitEdgeExport {
965 from: from.into(),
966 to: to.into(),
967 kind,
968 confidence,
969 authority_bearing,
970 observed,
971 }
972}
973
974struct PathIds {
975 step: String,
976 state: String,
977 helper: String,
978 boundary: String,
979 artifact: String,
980 env: String,
981}
982
983impl PathIds {
984 fn new(idx: usize) -> Self {
985 let p = format!("p{idx}");
986 Self {
987 step: format!("{p}_step"),
988 state: format!("{p}_state"),
989 helper: format!("{p}_helper"),
990 boundary: format!("{p}_action"),
991 artifact: format!("{p}_artifact"),
992 env: format!("{p}_env"),
993 }
994 }
995}
996
997fn dot_node_style(kind: &str) -> (&'static str, &'static str) {
998 match kind {
999 "Step" => ("ellipse", "green"),
1000 "MutableState" => ("folder", "orange"),
1001 "ResolvedHelper" => ("component", "red"),
1002 "ThirdPartyAction" => ("box3d", "goldenrod"),
1003 "AuthorityArtifact" => ("hexagon", "red"),
1004 "AuthorityEnv" => ("note", "red"),
1005 "ObservedSink" => ("doubleoctagon", "red"),
1006 _ => ("box", "gray"),
1007 }
1008}
1009
1010fn dot_edge_style(edge: &ExploitEdgeExport) -> (&'static str, &'static str, u8) {
1011 match edge.confidence {
1012 "observed" => ("bold", "red", 3),
1013 "inferred" if edge.authority_bearing => ("dashed", "red", 2),
1014 "inferred" => ("dashed", "black", 1),
1015 _ if edge.authority_bearing => ("solid", "red", 2),
1016 _ => ("solid", "black", 1),
1017 }
1018}
1019
1020fn authority_artifact_label(path: &ExploitPath<'_>) -> String {
1021 path.authority.pattern.artifact_label.to_string()
1022}
1023
1024fn authority_note(pattern: AuthorityPattern) -> String {
1025 let transport_labels: Vec<_> = pattern
1026 .transports
1027 .iter()
1028 .map(|transport| transport.as_str())
1029 .collect();
1030 format!(
1031 "transport: {}; origin: {}",
1032 transport_labels.join(", "),
1033 pattern.origin.as_str()
1034 )
1035}
1036
1037fn node(
1038 out: &mut String,
1039 id: &str,
1040 kind: &str,
1041 label: &str,
1042 shape: &str,
1043 color: &str,
1044 note: Option<&str>,
1045) {
1046 let rendered = match note {
1047 Some(note) => format!("{kind}\n{label}\n{note}"),
1048 None => format!("{kind}\n{label}"),
1049 };
1050 let _ = writeln!(
1051 out,
1052 " \"{}\" [label=\"{}\" shape={} color={}];",
1053 dot_escape(id),
1054 dot_escape(&rendered),
1055 shape,
1056 color
1057 );
1058}
1059
1060fn edge(out: &mut String, from: &str, to: &str, label: &str, style: &str, color: &str, pen: u8) {
1061 let _ = writeln!(
1062 out,
1063 " \"{}\" -> \"{}\" [label=\"{}\" style={} color={} penwidth={}];",
1064 dot_escape(from),
1065 dot_escape(to),
1066 dot_escape(label),
1067 style,
1068 color,
1069 pen
1070 );
1071}
1072
1073fn legend(out: &mut String) {
1074 out.push_str(" subgraph cluster_legend {\n");
1075 out.push_str(" label=\"edge confidence\";\n");
1076 out.push_str(" color=gray;\n");
1077 out.push_str(" \"legend_static_a\" [label=\"static/source\" shape=plaintext];\n");
1078 out.push_str(" \"legend_static_b\" [label=\"solid\" shape=plaintext];\n");
1079 out.push_str(
1080 " \"legend_static_a\" -> \"legend_static_b\" [style=solid label=\"static\"];\n",
1081 );
1082 out.push_str(" \"legend_inferred_a\" [label=\"inferred taint\" shape=plaintext];\n");
1083 out.push_str(" \"legend_inferred_b\" [label=\"dashed\" shape=plaintext];\n");
1084 out.push_str(" \"legend_inferred_a\" -> \"legend_inferred_b\" [style=dashed label=\"inferred\"];\n");
1085 out.push_str(" \"legend_observed_a\" [label=\"hosted witness\" shape=plaintext];\n");
1086 out.push_str(" \"legend_observed_b\" [label=\"bold red\" shape=plaintext];\n");
1087 out.push_str(" \"legend_observed_a\" -> \"legend_observed_b\" [style=bold color=red penwidth=3 label=\"observed\"];\n");
1088 out.push_str(" }\n");
1089}
1090
1091fn dot_escape(s: &str) -> String {
1092 let mut out = String::with_capacity(s.len());
1093 for c in s.chars() {
1094 match c {
1095 '\\' => out.push_str("\\\\"),
1096 '"' => out.push_str("\\\""),
1097 '\n' | '\r' => out.push_str("\\n"),
1098 _ => out.push(c),
1099 }
1100 }
1101 out
1102}
1103
1104fn mermaid_escape(s: &str) -> String {
1105 let mut out = String::with_capacity(s.len());
1106 for c in s.chars() {
1107 match c {
1108 '&' => out.push_str("&"),
1109 '<' => out.push_str("<"),
1110 '>' => out.push_str(">"),
1111 '"' => out.push_str("""),
1112 '\n' | '\r' => out.push(' '),
1113 '|' => out.push_str("|"),
1114 '[' => out.push_str("["),
1115 ']' => out.push_str("]"),
1116 '{' | '}' => out.push('·'),
1117 _ => out.push(c),
1118 }
1119 }
1120 out
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::graph::PipelineSource;
1127 use crate::graph::*;
1128 use std::collections::HashMap;
1129
1130 fn source(file: &str) -> PipelineSource {
1131 PipelineSource {
1132 file: file.into(),
1133 repo: None,
1134 git_ref: None,
1135 commit_sha: None,
1136 }
1137 }
1138
1139 #[test]
1140 fn firebase_exploit_path_dot_shows_red_authority_path() {
1141 let mut g = AuthorityGraph::new(source("deploy.yml"));
1142 g.metadata
1143 .insert(META_PLATFORM.into(), "github-actions".into());
1144
1145 let mut writer_meta = HashMap::new();
1146 writer_meta.insert(META_JOB_NAME.into(), "deploy".into());
1147 writer_meta.insert(
1148 META_SCRIPT_BODY.into(),
1149 "mkdir -p /tmp/fake\necho /tmp/fake >> $GITHUB_PATH".into(),
1150 );
1151 g.add_node_with_metadata(
1152 NodeKind::Step,
1153 "Create fake npx and persist PATH mutation",
1154 TrustZone::FirstParty,
1155 writer_meta,
1156 );
1157
1158 let mut action_meta = HashMap::new();
1159 action_meta.insert(META_JOB_NAME.into(), "deploy".into());
1160 action_meta.insert(
1161 META_GHA_ACTION.into(),
1162 "FirebaseExtended/action-hosting-deploy".into(),
1163 );
1164 action_meta.insert(
1165 META_GHA_WITH_INPUTS.into(),
1166 "firebaseServiceAccount=${{ secrets.FIREBASE_SERVICE_ACCOUNT }}".into(),
1167 );
1168 let action = g.add_node_with_metadata(
1169 NodeKind::Step,
1170 "Run Firebase Hosting witness",
1171 TrustZone::ThirdParty,
1172 action_meta,
1173 );
1174 let secret = g.add_node(
1175 NodeKind::Secret,
1176 "FIREBASE_SERVICE_ACCOUNT",
1177 TrustZone::FirstParty,
1178 );
1179 g.add_edge(action, secret, EdgeKind::HasAccessTo);
1180
1181 let dot = render_dot(&g, ExploitGraphOptions { job: None });
1182
1183 assert!(dot.contains("MutableState\\nGITHUB_PATH"));
1184 assert!(dot.contains("ResolvedHelper\\nPATH-selected npx"));
1185 assert!(dot.contains("ThirdPartyAction\\nFirebaseExtended/action-hosting-deploy"));
1186 assert!(dot.contains("AuthorityArtifact\\nservice-account credential file"));
1187 assert!(dot.contains("AuthorityEnv\\nGOOGLE_APPLICATION_CREDENTIALS"));
1188 assert!(dot.contains("label=\"influences_resolution\" style=dashed"));
1189 assert!(dot.contains("label=\"creates_authority_artifact\" style=solid color=red"));
1190 assert!(!dot.contains("ObservedSink"));
1191 assert!(!dot.contains("observed_by_witness"));
1192
1193 let json = render_json_pretty(&g, ExploitGraphOptions { job: None }).unwrap();
1194 assert!(json.contains("\"view\": \"exploit\""));
1195 assert!(json.contains("\"kind\": \"MutableState\""));
1196 assert!(json.contains("\"authority_bearing\": true"));
1197 }
1198}