Repository: woodruffw/zizmor
Pull request #294 (2024-12-13 22:20:17)

feat: initial cache-poisoning audit
A first iteration on #261 

There is a lot to unpack in this PR already, so I added several inline comments, and I'll go for docs as soon as we are good with the overal design 🙂

First consideration : for now, I'm matching as triggers for this audit only `release` and `push` (with tags filters).
I'm aware that more scenarios exist : I myself use another pattern (on top of `workflow_dispatch`), happy to get input and iterate, or eventually tackle them as a follow-up. I miss a check for the default branch of the repo, but since it requires querying the Github API, I've skipped it for now.

Second consideration: because of the multitude of caching-aware Actions that exist (and how they behave), and because we should aim to consume a static API at some point in the future, I've modelled a `CacheAwareAction` struct, which is the very core of this PR.

In general, I tried to capture with this data holder:

- that some actions will run caching behavior by default, both for saving and restoring (eg `actions/setup-go`)
- that some actions have an input that allows either with opt-in or opt-out from **_restoring cache_** (eg `actions/cache`)
- that some actions define opt-in not by specifying a boolean input, but actually a set of values (eg `actions/setup-node`)
- eventually the user opts-in for caching on top of an expression: there is not much to do, but I keep track of that

There is a static list of `CacheAwareAction`, covering all Actions provided by Github and a few others. Happy to add as many as we want to have 🔥

Third consideration: I defined a strategy for this audit [on top of a chat I had with Adnan](https://github.com/AdnaneKhan/ActionsCacheBlasting/issues/3), focusing on the unzip part of cache-poisoning attacks. This means I'm not considering `actions/cache/save` in the Actions we inspect for. 

Most of the Actions I've listed bring both saving/restoring out-of-the-box, but some of them define a `lookup-only` input (as per `actions/cache` and `actions/cache/restore` do), that only queries the Github API for cache entries but [do not download any caches](https://github.com/actions/cache/blob/main/restore/action.yml). Maybe this is incomplete/insufficient, and I'd love to get inputs here! 

Last but not least : I added several examples of vulnerable Workflows and snapshot test all of them.

Most likely I missed other things, but hopefully this PR sesa a place to get started 🙂

woodruffw: Thanks @ubiratansoares! I'll try and do an initial pass on this today or tomorrow.
ubiratansoares: @woodruffw Addressed your suggestions, let me know whether I missed something! Docs to follow 🙂
woodruffw: Thanks! I think that's everything -- just undraft and give me a ping when you're ready for a final review! 
ubiratansoares: @woodruffw Added some docs [here](https://github.com/woodruffw/zizmor/pull/294/commits/f7071625108b19b1665f4c96e0b61029c080063e), let me know what you think :) 

Some follow-ups from this PR, so we can keep it size manageable for the review: 

- Add more Actions in the list of well-know cache-aware Actions 😄
- Evaluate the presence of well-know publish-like Actions (e.g. `gh-action-pypi-publish`) in addition to the trigger-based evaluation
- Improve evaluation for `push` trigger with well-know branch paths (eg, `release-*` branches) ?
ubiratansoares: Added [this additional commit](https://github.com/woodruffw/zizmor/pull/294/commits/6866b1891840246dc3712ac29df4d20585955973) to highlight another (potential) follow-up : we should evaluate `step.if` to avoid some false positives, since the user can avoid cache restoration by disabling the a cache-aware step
woodruffw: Thanks! I'll give this a final pass tomorrow, but this is looking great to me!

Let's track the follow-ups in https://github.com/woodruffw/zizmor/pull/294#issuecomment-2547938847 with a new issue 🙂 
woodruffw: [`ee7d227` (#294)](https://github.com/woodruffw/zizmor/pull/294/commits/ee7d227ae7b7cf225e4dd7bdca410084935df5d4) tweaks the audit slightly so that it catches our own problematic `pypi.yml` workflow. I need to think about how best to fix that, plus how best to ensure this audit sees our fx.

Patch:
diff --git a/docs/audits.md b/docs/audits.md
index 93dfd15..3cb0fbe 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -693,6 +693,54 @@ like `pull_request_target`.
 
 If you need to pass state between steps, consider using `GITHUB_OUTPUT` instead.
 
+## `cache-poisoning`
+
+| Type     | Examples                | Introduced in | Works offline  | Enabled by default |
+|----------|-------------------------|---------------|----------------|--------------------|
+| Workflow  | [cache-poisoning.yml]   | v0.10.0       | ✅             | ✅                 |
+
+[cache-poisoning.yml]: https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/cache-poisoning.yml
+
+Detects potential cache-poisoning scenarios in release workflows.
+
+Caching and restoring build state is a process eased by utilities provided
+by GitHub, in particular @actions/cache and its "save" and "restore"
+sub-actions. In addition, many of the setup-like actions provided
+by GitHub come with built-in caching functionality, like @actions/setup-node,
+@actions/setup-java and others.
+
+Furthermore, there are many examples of community-driven Actions with built-in
+caching functionality, like @ruby/setup-ruby, @astral-sh/setup-uv,
+@Swatinem/rust-cache. In general, most of them build on top of @actions/tookit
+for the sake of easily integrate with GitHub cache server at Workflow runtime.
+
+This vulnerability happens when release workflows leverage build state cached
+from previous workflow executions, in general on top of the aforementioned
+actions or  similar ones. The publication of artifacts usually happens driven
+by trigger events like `release` or events with path filters like `push`
+(e.g. for tags).
+
+In such scenarios, an attacker with access to a valid `GITHUB_TOKEN` can use it
+poison the repository's GitHub Actions caches. That compounds with the default
+behavior of @actions/tookit during cache restorations, allowing an attacker to
+retrieve payloads from poisoned cache entries, hence achieving code execution at
+Workflow runtime, potentially compromising ready-to-publish artifacts.
+
+Other resources:
+
+* [The Monsters in Your Build Cache – GitHub Actions Cache Poisoning]
+
+### Remediation
+
+In general, you should avoid using previously cached CI state within workflows
+intended to publish build artifacts:
+
+* Remove cache-aware actions like @actions/cache from workflows that produce
+  releases, *or*
+* Disable cache-aware actions with an `if:` condition based on the trigger at
+  the step level, *or*
+* Set an action-specific input to disable cache restoration when appropriate,
+  such as `lookup-only` in @Swatinem/rust-cache.
 
 [ArtiPACKED: Hacking Giants Through a Race Condition in GitHub Actions Artifacts]: https://unit42.paloaltonetworks.com/github-repo-artifacts-leak-tokens/
 [Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests]: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
@@ -711,3 +759,4 @@ If you need to pass state between steps, consider using `GITHUB_OUTPUT` instead.
 [Vulnerable GitHub Actions Workflows Part 1: Privilege Escalation Inside Your CI/CD Pipeline]: https://www.legitsecurity.com/blog/github-privilege-escalation-vulnerability
 [Google & Apache Found Vulnerable to GitHub Environment Injection]: https://www.legitsecurity.com/blog/github-privilege-escalation-vulnerability-0
 [Hacking with Environment Variables]: https://www.elttam.com/blog/env/
+[The Monsters in Your Build Cache – GitHub Actions Cache Poisoning]: https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/
diff --git a/src/audit/cache_poisoning.rs b/src/audit/cache_poisoning.rs
new file mode 100644
index 0000000..1eb92b1
--- /dev/null
+++ b/src/audit/cache_poisoning.rs
@@ -0,0 +1,275 @@
+use crate::audit::{audit_meta, WorkflowAudit};
+use crate::finding::{Confidence, Finding, Severity};
+use crate::models::{Step, Uses};
+use crate::state::AuditState;
+use github_actions_models::common::expr::ExplicitExpr;
+use github_actions_models::common::Env;
+use github_actions_models::workflow::event::{BareEvent, OptionalBody};
+use github_actions_models::workflow::job::StepBody;
+use github_actions_models::workflow::Trigger;
+use std::ops::Deref;
+use std::sync::LazyLock;
+
+#[derive(PartialEq)]
+enum ControlValue {
+    Boolean,
+    String,
+}
+
+enum CacheControl {
+    OptIn(&'static str),
+    OptOut(&'static str),
+}
+
+/// The general schema for a cache-aware actions
+struct CacheAwareAction<'w> {
+    /// The owner/repo part within the Action full coordinate
+    uses: Uses<'w>,
+    /// The input that controls caching behavior
+    cache_control: CacheControl,
+    /// The type of value used to opt-in/opt-out (Boolean, String)
+    control_value: ControlValue,
+    /// Whether this Action adopts caching as the default behavior
+    caching_by_default: bool,
+}
+
+/// The list of know cache-aware actions
+/// In the future we can easily retrieve this list from the static API,
+/// since it should be easily serializable
+static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<CacheAwareAction>> = LazyLock::new(|| {
+    vec![
+        // https://github.com/actions/cache/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("actions/cache").unwrap(),
+            cache_control: CacheControl::OptOut("lookup-only"),
+            control_value: ControlValue::Boolean,
+            caching_by_default: true,
+        },
+        CacheAwareAction {
+            uses: Uses::from_step("actions/setup-java").unwrap(),
+            cache_control: CacheControl::OptIn("cache"),
+            control_value: ControlValue::String,
+            caching_by_default: false,
+        },
+        // https://github.com/actions/setup-go/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("actions/setup-go").unwrap(),
+            cache_control: CacheControl::OptIn("cache"),
+            control_value: ControlValue::String,
+            caching_by_default: true,
+        },
+        // https://github.com/actions/setup-node/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("actions/setup-node").unwrap(),
+            cache_control: CacheControl::OptIn("cache"),
+            control_value: ControlValue::String,
+            caching_by_default: false,
+        },
+        // https://github.com/actions/setup-python/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("actions/setup-python").unwrap(),
+            cache_control: CacheControl::OptIn("cache"),
+            control_value: ControlValue::String,
+            caching_by_default: false,
+        },
+        // https://github.com/actions/setup-dotnet/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("actions/setup-dotnet").unwrap(),
+            cache_control: CacheControl::OptIn("cache"),
+            control_value: ControlValue::Boolean,
+            caching_by_default: false,
+        },
+        // https://github.com/astral-sh/setup-uv/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("astral-sh/setup-uv").unwrap(),
+            cache_control: CacheControl::OptOut("enable-cache"),
+            control_value: ControlValue::String,
+            caching_by_default: true,
+        },
+        // https://github.com/Swatinem/rust-cache/blob/master/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("Swatinem/rust-cache").unwrap(),
+            cache_control: CacheControl::OptOut("lookup-only"),
+            control_value: ControlValue::Boolean,
+            caching_by_default: true,
+        },
+        // https://github.com/ruby/setup-ruby/blob/master/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("ruby/setup-ruby").unwrap(),
+            cache_control: CacheControl::OptIn("bundler-cache"),
+            control_value: ControlValue::Boolean,
+            caching_by_default: false,
+        },
+        // https://github.com/PyO3/maturin-action/blob/main/action.yml
+        CacheAwareAction {
+            uses: Uses::from_step("PyO3/maturin-action").unwrap(),
+            cache_control: CacheControl::OptIn("sccache"),
+            control_value: ControlValue::Boolean,
+            caching_by_default: false,
+        },
+    ]
+});
+
+#[derive(PartialEq)]
+enum CacheUsage {
+    ConditionalOptIn,
+    DirectOptIn,
+    DefaultActionBehaviour,
+}
+
+pub(crate) struct CachePoisoning;
+
+audit_meta!(
+    CachePoisoning,
+    "cache-poisoning",
+    "runtime artifacts potentially vulnerable to a cache poisoning attack"
+);
+
+impl CachePoisoning {
+    fn trigger_used_when_publishing_artifacts(&self, trigger: &Trigger) -> bool {
+        match trigger {
+            Trigger::BareEvent(event) => *event == BareEvent::Release,
+            Trigger::BareEvents(events) => events.contains(&BareEvent::Release),
+            Trigger::Events(events) => match &events.push {
+                OptionalBody::Body(body) => body.tag_filters.is_some(),
+                _ => false,
+            },
+        }
+    }
+
+    fn evaluate_default_action_behaviour(action: &CacheAwareAction) -> Option<CacheUsage> {
+        if action.caching_by_default {
+            Some(CacheUsage::DefaultActionBehaviour)
+        } else {
+            None
+        }
+    }
+
+    fn evaluate_user_defined_opt_in(
+        cache_control_input: &str,
+        env: &Env,
+        action: &CacheAwareAction,
+    ) -> Option<CacheUsage> {
+        match env.get(cache_control_input) {
+            None => None,
+            Some(value) => match value.to_string().as_str() {
+                "true" if matches!(action.control_value, ControlValue::Boolean) => {
+                    Some(CacheUsage::DirectOptIn)
+                }
+                "false" if matches!(action.control_value, ControlValue::Boolean) => {
+                    // Explicitly opts out from caching
+                    None
+                }
+                other => match ExplicitExpr::from_curly(other) {
+                    None if matches!(action.control_value, ControlValue::String) => {
+                        Some(CacheUsage::DirectOptIn)
+                    }
+                    None => None,
+                    Some(_) => Some(CacheUsage::ConditionalOptIn),
+                },
+            },
+        }
+    }
+
+    fn evaluate_cache_usage(&self, target_step: &str, env: &Env) -> Option<CacheUsage> {
+        let known_action = KNOWN_CACHE_AWARE_ACTIONS.iter().find(|action| {
+            let Uses::Repository(well_known_uses) = action.uses else {
+                return false;
+            };
+
+            let Some(Uses::Repository(target_uses)) = Uses::from_step(target_step) else {
+                return false;
+            };
+
+            target_uses.matches(well_known_uses)
+        })?;
+
+        let cache_control_input = env.keys().find(|k| match known_action.cache_control {
+            CacheControl::OptIn(inner) => *k == inner,
+            CacheControl::OptOut(inner) => *k == inner,
+        });
+
+        match cache_control_input {
+            // when not using the specific Action input to control caching behaviour,
+            // we evaluate whether it uses caching by default
+            None => CachePoisoning::evaluate_default_action_behaviour(known_action),
+
+            // otherwise, we infer from the value assigned to the cache control input
+            Some(key) => {
+                // first, we extract the value assigned to that input
+                let declared_usage =
+                    CachePoisoning::evaluate_user_defined_opt_in(key, env, known_action);
+
+                // we now evaluate the extracted value against the opt-in semantics
+                match &declared_usage {
+                    Some(CacheUsage::DirectOptIn) => {
+                        match known_action.cache_control {
+                            // in this case, we just follow the opt-in
+                            CacheControl::OptIn(_) => declared_usage,
+                            // otherwise, the user opted for disabling the cache
+                            // hence we don't return a CacheUsage
+                            CacheControl::OptOut(_) => None,
+                        }
+                    }
+                    // Because we can't evaluate expressions, there is nothing to do
+                    // regarding CacheUsage::ConditionalOptIn
+                    _ => declared_usage,
+                }
+            }
+        }
+    }
+}
+
+impl WorkflowAudit for CachePoisoning {
+    fn new(_: AuditState) -> anyhow::Result<Self>
+    where
+        Self: Sized,
+    {
+        Ok(Self)
+    }
+
+    fn audit_step<'w>(&self, step: &Step<'w>) -> anyhow::Result<Vec<Finding<'w>>> {
+        let mut findings = vec![];
+
+        let trigger = &step.workflow().on;
+
+        if !self.trigger_used_when_publishing_artifacts(trigger) {
+            return Ok(findings);
+        }
+
+        let StepBody::Uses { ref uses, ref with } = &step.deref().body else {
+            return Ok(findings);
+        };
+
+        let Some(cache_usage) = self.evaluate_cache_usage(uses, with) else {
+            return Ok(findings);
+        };
+
+        let (yaml_key, annotation) = match cache_usage {
+            CacheUsage::DefaultActionBehaviour => ("uses", "cache enabled by default here"),
+            CacheUsage::DirectOptIn => ("with", "opt-in for caching here"),
+            CacheUsage::ConditionalOptIn => ("with", "opt-in for caching might happen here"),
+        };
+
+        findings.push(
+            Self::finding()
+                .confidence(Confidence::Low)
+                .severity(Severity::High)
+                .add_location(
+                    step.workflow()
+                        .location()
+                        .with_keys(&["on".into()])
+                        .annotated("generally used when publishing artifacts generated at runtime"),
+                )
+                .add_location(
+                    step.location()
+                        .primary()
+                        .with_keys(&[yaml_key.into()])
+                        .annotated(annotation),
+                )
+                .build(step.workflow())?,
+        );
+
+        Ok(findings)
+    }
+}
diff --git a/src/audit/mod.rs b/src/audit/mod.rs
index be7bda5..acfb6ab 100644
--- a/src/audit/mod.rs
+++ b/src/audit/mod.rs
@@ -10,6 +10,7 @@ use crate::{
 };
 
 pub(crate) mod artipacked;
+pub(crate) mod cache_poisoning;
 pub(crate) mod dangerous_triggers;
 pub(crate) mod excessive_permissions;
 pub(crate) mod github_env;
diff --git a/src/main.rs b/src/main.rs
index d349876..2aa76f7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -259,6 +259,7 @@ fn run() -> Result<ExitCode> {
     register_audit!(audit::unpinned_uses::UnpinnedUses);
     register_audit!(audit::insecure_commands::InsecureCommands);
     register_audit!(audit::github_env::GitHubEnv);
+    register_audit!(audit::cache_poisoning::CachePoisoning);
 
     let mut results = FindingRegistry::new(&app, &config);
     {
diff --git a/src/models.rs b/src/models.rs
index b5e5879..55b43d6 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -4,7 +4,7 @@
 use crate::finding::{Route, SymbolicLocation};
 use crate::registry::WorkflowKey;
 use crate::utils::extract_expressions;
-use anyhow::{bail, Context, Result};
+use anyhow::{anyhow, bail, Context, Result};
 use camino::Utf8Path;
 use github_actions_models::common::expr::LoE;
 use github_actions_models::workflow::event::{BareEvent, OptionalBody};
@@ -599,14 +599,26 @@ pub(crate) struct RepositoryUses<'a> {
     pub(crate) git_ref: Option<&'a str>,
 }
 
+impl<'a> TryFrom<&'a str> for RepositoryUses<'a> {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'a str) -> std::result::Result<Self, Self::Error> {
+        let Some(Uses::Repository(uses)) = Uses::from_common(value) else {
+            return Err(anyhow!("invalid repository uses: {value}"));
+        };
+
+        Ok(uses)
+    }
+}
+
 impl RepositoryUses<'_> {
     /// Returns whether this `uses:` clause "matches" the given template.
     /// The template is itself formatted like a normal `uses:` clause.
     ///
     /// This is an asymmetrical match: `actions/checkout@v3` "matches"
     /// the `actions/checkout` template but not vice versa.
-    pub(crate) fn matches(&self, template: &str) -> bool {
-        let Some(Uses::Repository(other)) = Uses::from_step(template) else {
+    pub(crate) fn matches<'a>(&self, template: impl TryInto<RepositoryUses<'a>>) -> bool {
+        let Ok(other) = template.try_into() else {
             return false;
         };
 


Test patch:
diff --git a/tests/acceptance.rs b/tests/acceptance.rs
index 11ad78c..9bb4ec9 100644
--- a/tests/acceptance.rs
+++ b/tests/acceptance.rs
@@ -252,3 +252,21 @@ fn audit_github_env_injection() -> anyhow::Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn audit_cache_poisoning() -> anyhow::Result<()> {
+    let auditable = workflow_under_test("cache-poisoning.yml");
+
+    let cli_args = [&auditable];
+
+    let execution = zizmor().args(cli_args).output()?;
+
+    assert_eq!(execution.status.code(), Some(14));
+
+    let findings = serde_json::from_slice(&execution.stdout)?;
+
+    assert_value_match(&findings, "$[0].determinations.confidence", "Low");
+    assert_value_match(&findings, "$[0].locations[0].concrete.feature", "release");
+
+    Ok(())
+}
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index 5a285ba..5dd30d0 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -259,3 +259,60 @@ fn template_injection() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn cache_poisoning() -> Result<()> {
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-disabled-by-default.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-enabled-by-default.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-opt-in-boolean-toggle.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-opt-in-expression.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-opt-in-multi-value-toggle.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test("cache-poisoning/caching-opt-out.yml"))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/no-cache-aware-steps.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/workflow-tag-trigger.yml"
+        ))
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-opt-in-boolish-toggle.yml"
+        ))
+        .run()?);
+
+    Ok(())
+}
diff --git a/tests/snapshots/snapshot__cache_poisoning-2.snap b/tests/snapshots/snapshot__cache_poisoning-2.snap
new file mode 100644
index 0000000..7df2b60
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-2.snap
@@ -0,0 +1,20 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-enabled-by-default.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:1:1
+   |
+ 1 | / on:
+ 2 | |   release
+   | |_________^ generally used when publishing artifacts generated at runtime
+ 3 |
+...
+14 |         - name: Setup CI caching
+15 |           uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cache enabled by default here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning-3.snap b/tests/snapshots/snapshot__cache_poisoning-3.snap
new file mode 100644
index 0000000..aa81a94
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-3.snap
@@ -0,0 +1,22 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-opt-in-boolean-toggle.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:1:1
+   |
+ 1 | / on:
+ 2 | |   release
+   | |_________^ generally used when publishing artifacts generated at runtime
+ 3 |
+...
+14 |           uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25
+15 | /         with:
+16 | |           dotnet-version: '5.0.x'
+17 | |           cache: true
+   | |_____________________^ opt-in for caching here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning-4.snap b/tests/snapshots/snapshot__cache_poisoning-4.snap
new file mode 100644
index 0000000..33f5eee
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-4.snap
@@ -0,0 +1,22 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-opt-in-expression.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:1:1
+   |
+ 1 | / on:
+ 2 | |   release
+   | |_________^ generally used when publishing artifacts generated at runtime
+ 3 |
+...
+14 |           uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
+15 | /         with:
+16 | |           python-version: "3.12"
+17 | |           enable-cache: ${{ github.ref == 'refs/heads/main' }}
+   | |______________________________________________________________^ opt-in for caching might happen here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning-5.snap b/tests/snapshots/snapshot__cache_poisoning-5.snap
new file mode 100644
index 0000000..82388e9
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-5.snap
@@ -0,0 +1,23 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-opt-in-multi-value-toggle.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:1:1
+   |
+ 1 | / on:
+ 2 | |   release
+   | |_________^ generally used when publishing artifacts generated at runtime
+ 3 |
+...
+14 |           uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b
+15 | /         with:
+16 | |           distribution: 'zulu'
+17 | |           cache: 'gradle'
+18 | |           java-version: '17'
+   | |____________________________^ opt-in for caching here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning-6.snap b/tests/snapshots/snapshot__cache_poisoning-6.snap
new file mode 100644
index 0000000..72bbfbb
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-6.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-opt-out.yml\")).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/snapshots/snapshot__cache_poisoning-7.snap b/tests/snapshots/snapshot__cache_poisoning-7.snap
new file mode 100644
index 0000000..c659bea
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-7.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/no-cache-aware-steps.yml\")).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/snapshots/snapshot__cache_poisoning-8.snap b/tests/snapshots/snapshot__cache_poisoning-8.snap
new file mode 100644
index 0000000..2c44396
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-8.snap
@@ -0,0 +1,22 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/workflow-tag-trigger.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:1:1
+   |
+ 1 | / on:
+ 2 | |   push:
+ 3 | |     tags:
+ 4 | |       - '**'
+   | |____________^ generally used when publishing artifacts generated at runtime
+ 5 |
+...
+15 |         - name: Setup CI caching
+16 |           uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cache enabled by default here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning-9.snap b/tests/snapshots/snapshot__cache_poisoning-9.snap
new file mode 100644
index 0000000..e115073
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-9.snap
@@ -0,0 +1,22 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-opt-in-boolish-toggle.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:4:1
+   |
+ 4 |   on: release
+   |   ^^^^^^^^^^^ generally used when publishing artifacts generated at runtime
+ 5 |
+...
+12 |           uses: PyO3/maturin-action@ea5bac0f1ccd0ab11c805e2b804bfcb65dac2eab # v1
+13 | /         with:
+14 | |           target: ${{ matrix.platform.target }}
+15 | |           args: --release --out dist
+16 | |           sccache: "true"
+   | |__________________________^ opt-in for caching here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/snapshots/snapshot__cache_poisoning.snap b/tests/snapshots/snapshot__cache_poisoning.snap
new file mode 100644
index 0000000..73ba2da
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-disabled-by-default.yml\")).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/test-data/cache-poisoning.yml b/tests/test-data/cache-poisoning.yml
new file mode 100644
index 0000000..0665ac9
--- /dev/null
+++ b/tests/test-data/cache-poisoning.yml
@@ -0,0 +1,16 @@
+on:
+  release
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Setup uv
+        uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
+        with:
+          python-version: "3.12"
+
+      - name: Publish on Pypi
+        run: uv build publish
+        env:
+          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_PUBLISH_TOKEN }}
diff --git a/tests/test-data/cache-poisoning/caching-disabled-by-default.yml b/tests/test-data/cache-poisoning/caching-disabled-by-default.yml
new file mode 100644
index 0000000..8aeaf30
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-disabled-by-default.yml
@@ -0,0 +1,20 @@
+on:
+  release
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup JDK
+        uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b
+        with:
+          distribution: 'zulu'
+          java-version: '17'
+
+      - name: Publish to Maven Central
+        run: ./gradlew publishToMavenCentral --no-configuration-cache
diff --git a/tests/test-data/cache-poisoning/caching-enabled-by-default.yml b/tests/test-data/cache-poisoning/caching-enabled-by-default.yml
new file mode 100644
index 0000000..decc4e3
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-enabled-by-default.yml
@@ -0,0 +1,19 @@
+on:
+  release
+
+jobs:
+  publish-crate:
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup CI caching
+        uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab
+
+      - name: Publish on crates.io
+        if: false
+        run: cargo publish --token ${{ secrets.CRATESIO_PUBLISH_TOKEN }}
diff --git a/tests/test-data/cache-poisoning/caching-opt-in-boolean-toggle.yml b/tests/test-data/cache-poisoning/caching-opt-in-boolean-toggle.yml
new file mode 100644
index 0000000..8411c19
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-opt-in-boolean-toggle.yml
@@ -0,0 +1,22 @@
+on:
+  release
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup DotNet
+        uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25
+        with:
+          dotnet-version: '5.0.x'
+          cache: true
+
+      - name: Publish on NuGet
+        run: |
+          dotnet build
+          dotnet nuget push My.Tool.1.0.0.nupkg --api-key ${{ secrets.NUGET_PUBLISH_KEY }} --source https://api.nuget.org/v3/index.json
diff --git a/tests/test-data/cache-poisoning/caching-opt-in-boolish-toggle.yml b/tests/test-data/cache-poisoning/caching-opt-in-boolish-toggle.yml
new file mode 100644
index 0000000..749d0ba
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-opt-in-boolish-toggle.yml
@@ -0,0 +1,16 @@
+# ensures that boolish toggles are treated like booleans,
+# i.e. the `"true"` string is treated like `true`
+
+on: release
+
+jobs:
+  publish-crate:
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Build wheels
+        uses: PyO3/maturin-action@ea5bac0f1ccd0ab11c805e2b804bfcb65dac2eab # v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist
+          sccache: "true"
diff --git a/tests/test-data/cache-poisoning/caching-opt-in-expression.yml b/tests/test-data/cache-poisoning/caching-opt-in-expression.yml
new file mode 100644
index 0000000..ce37bf0
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-opt-in-expression.yml
@@ -0,0 +1,22 @@
+on:
+  release
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup uv
+        uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a
+        with:
+          python-version: "3.12"
+          enable-cache: ${{ github.ref == 'refs/heads/main' }}
+
+      - name: Publish on Pypi
+        run: uv build publish
+        env:
+          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_PUBLISH_TOKEN }}
diff --git a/tests/test-data/cache-poisoning/caching-opt-in-multi-value-toggle.yml b/tests/test-data/cache-poisoning/caching-opt-in-multi-value-toggle.yml
new file mode 100644
index 0000000..ee50518
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-opt-in-multi-value-toggle.yml
@@ -0,0 +1,21 @@
+on:
+  release
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup JDK
+        uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b
+        with:
+          distribution: 'zulu'
+          cache: 'gradle'
+          java-version: '17'
+
+      - name: Publish to Maven Central
+        run: ./gradlew publishToMavenCentral --no-configuration-cache
diff --git a/tests/test-data/cache-poisoning/caching-opt-out.yml b/tests/test-data/cache-poisoning/caching-opt-out.yml
new file mode 100644
index 0000000..239d7af
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-opt-out.yml
@@ -0,0 +1,21 @@
+on:
+  release
+
+jobs:
+  publish-crate:
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup CI caching
+        uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab
+        with:
+          lookup-only: true
+
+      - name: Publish on crates.io
+        if: false
+        run: cargo publish --token ${{ secrets.CRATESIO_PUBLISH_TOKEN }}
diff --git a/tests/test-data/cache-poisoning/no-cache-aware-steps.yml b/tests/test-data/cache-poisoning/no-cache-aware-steps.yml
new file mode 100644
index 0000000..818af94
--- /dev/null
+++ b/tests/test-data/cache-poisoning/no-cache-aware-steps.yml
@@ -0,0 +1,15 @@
+on:
+  release
+
+jobs:
+  publish-crate:
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Publish on crates.io
+        run: cargo publish --token ${{ secrets.CRATESIO_PUBLISH_TOKEN }}
diff --git a/tests/test-data/cache-poisoning/workflow-tag-trigger.yml b/tests/test-data/cache-poisoning/workflow-tag-trigger.yml
new file mode 100644
index 0000000..dc98732
--- /dev/null
+++ b/tests/test-data/cache-poisoning/workflow-tag-trigger.yml
@@ -0,0 +1,20 @@
+on:
+  push:
+    tags:
+      - '**'
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Project Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+        with:
+          persist-credentials: false
+
+      - name: Setup CI caching
+        uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab
+
+      - name: Publish on crates.io
+        if: true # Todo: We should handle that too!
+        run: cargo publish --token ${{ secrets.CRATESIO_PUBLISH_TOKEN }}