Skip to main content

taudit_core/
exploit_path.rs

1//! Exploit graph view rendering.
2//!
3//! This is intentionally a view over [`AuthorityGraph`], not a new persisted
4//! graph schema. The canonical graph remains the source model; this module
5//! extracts cross-step mutable-state to helper-authority paths.
6
7use 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
438/// Render the exploit graph view as Graphviz DOT.
439pub 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("&amp;"),
1109            '<' => out.push_str("&lt;"),
1110            '>' => out.push_str("&gt;"),
1111            '"' => out.push_str("&quot;"),
1112            '\n' | '\r' => out.push(' '),
1113            '|' => out.push_str("&#124;"),
1114            '[' => out.push_str("&#91;"),
1115            ']' => out.push_str("&#93;"),
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}