Repository: woodruffw/zizmor
Pull request #345 (2024-12-22 21:56:59)

feat: adds support to non-configurable caching on cache-audit
Another follow-up for #334 

Here we tweak a bit the models for `CacheAwareAction`, moving into to an enum an introducing `Configurable` and `NotConfigurable` variants. 

The not-configurable variant covers Actions like [Mozilla-Actions/sccache-action](https://github.com/Mozilla-Actions/sccache-action), which always cache/restore and does not provide control inputs / control values to disable caching.

I added a snapshot test convering this new use case. Added some docs on the semantics of `CacheControlInput:OptIn` and `CacheControlInput:OptOut` as well.


Patch:
diff --git a/src/audit/cache_poisoning.rs b/src/audit/cache_poisoning.rs
index a603937..fe13fed 100644
--- a/src/audit/cache_poisoning.rs
+++ b/src/audit/cache_poisoning.rs
@@ -10,103 +10,126 @@ use github_actions_models::workflow::Trigger;
 use std::ops::Deref;
 use std::sync::LazyLock;
 
+/// The value type that controls the activation/deactivation of caching
 #[derive(PartialEq)]
-enum ControlValue {
+enum CacheControlValue {
     Boolean,
     String,
 }
 
-enum CacheControl {
+/// The input that controls the behaviour of a configurable caching Action
+enum CacheControlInput {
+    /// Opt-in means that cache becomes **enabled** when the control value matches.
     OptIn(&'static str),
+    /// Opt-out means that cache becomes **disabled** when the control value matches.
     OptOut(&'static str),
 }
 
 /// The general schema for a cache-aware actions
-struct CacheAwareAction<'w> {
+struct ControllableCacheAction<'w> {
     /// The owner/repo part within the Action full coordinate
     uses: Uses<'w>,
     /// The input that controls caching behavior
-    cache_control: CacheControl,
+    control_input: CacheControlInput,
     /// The type of value used to opt-in/opt-out (Boolean, String)
-    control_value: ControlValue,
+    control_value: CacheControlValue,
     /// Whether this Action adopts caching as the default behavior
     caching_by_default: bool,
 }
 
+enum CacheAwareAction<'w> {
+    Configurable(ControllableCacheAction<'w>),
+    NotConfigurable(Uses<'w>),
+}
+
+impl CacheAwareAction<'_> {
+    fn uses(&self) -> Uses {
+        match self {
+            CacheAwareAction::Configurable(inner) => inner.uses,
+            CacheAwareAction::NotConfigurable(inner) => *inner,
+        }
+    }
+}
+
 /// 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 {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/cache").unwrap(),
-            cache_control: CacheControl::OptOut("lookup-only"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptOut("lookup-only"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: true,
-        },
-        CacheAwareAction {
+        }),
+        // https://github.com/actions/setup-java/blob/main/action.yml
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/setup-java").unwrap(),
-            cache_control: CacheControl::OptIn("cache"),
-            control_value: ControlValue::String,
+            control_input: CacheControlInput::OptIn("cache"),
+            control_value: CacheControlValue::String,
             caching_by_default: false,
-        },
+        }),
         // https://github.com/actions/setup-go/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/setup-go").unwrap(),
-            cache_control: CacheControl::OptIn("cache"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptIn("cache"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: true,
-        },
+        }),
         // https://github.com/actions/setup-node/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/setup-node").unwrap(),
-            cache_control: CacheControl::OptIn("cache"),
-            control_value: ControlValue::String,
+            control_input: CacheControlInput::OptIn("cache"),
+            control_value: CacheControlValue::String,
             caching_by_default: false,
-        },
+        }),
         // https://github.com/actions/setup-python/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/setup-python").unwrap(),
-            cache_control: CacheControl::OptIn("cache"),
-            control_value: ControlValue::String,
+            control_input: CacheControlInput::OptIn("cache"),
+            control_value: CacheControlValue::String,
             caching_by_default: false,
-        },
+        }),
         // https://github.com/actions/setup-dotnet/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("actions/setup-dotnet").unwrap(),
-            cache_control: CacheControl::OptIn("cache"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptIn("cache"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: false,
-        },
+        }),
         // https://github.com/astral-sh/setup-uv/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("astral-sh/setup-uv").unwrap(),
-            cache_control: CacheControl::OptOut("enable-cache"),
-            control_value: ControlValue::String,
+            control_input: CacheControlInput::OptOut("enable-cache"),
+            control_value: CacheControlValue::String,
             caching_by_default: true,
-        },
+        }),
         // https://github.com/Swatinem/rust-cache/blob/master/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("Swatinem/rust-cache").unwrap(),
-            cache_control: CacheControl::OptOut("lookup-only"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptOut("lookup-only"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: true,
-        },
+        }),
         // https://github.com/ruby/setup-ruby/blob/master/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("ruby/setup-ruby").unwrap(),
-            cache_control: CacheControl::OptIn("bundler-cache"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptIn("bundler-cache"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: false,
-        },
+        }),
         // https://github.com/PyO3/maturin-action/blob/main/action.yml
-        CacheAwareAction {
+        CacheAwareAction::Configurable(ControllableCacheAction {
             uses: Uses::from_step("PyO3/maturin-action").unwrap(),
-            cache_control: CacheControl::OptIn("sccache"),
-            control_value: ControlValue::Boolean,
+            control_input: CacheControlInput::OptIn("sccache"),
+            control_value: CacheControlValue::Boolean,
             caching_by_default: false,
-        },
+        }),
+        // https://github.com/Mozilla-Actions/sccache-action/blob/main/action.yml
+        CacheAwareAction::NotConfigurable(
+            Uses::from_step("Mozilla-Actions/sccache-action").unwrap(),
+        ),
     ]
 });
 
@@ -145,6 +168,7 @@ enum CacheUsage {
     ConditionalOptIn,
     DirectOptIn,
     DefaultActionBehaviour,
+    AlwaysCache,
 }
 
 enum PublishingArtifactsScenario<'w> {
@@ -204,7 +228,7 @@ impl CachePoisoning {
         ))
     }
 
-    fn evaluate_default_action_behaviour(action: &CacheAwareAction) -> Option<CacheUsage> {
+    fn evaluate_default_action_behaviour(action: &ControllableCacheAction) -> Option<CacheUsage> {
         if action.caching_by_default {
             Some(CacheUsage::DefaultActionBehaviour)
         } else {
@@ -215,20 +239,20 @@ impl CachePoisoning {
     fn evaluate_user_defined_opt_in(
         cache_control_input: &str,
         env: &Env,
-        action: &CacheAwareAction,
+        action: &ControllableCacheAction,
     ) -> 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) => {
+                "true" if matches!(action.control_value, CacheControlValue::Boolean) => {
                     Some(CacheUsage::DirectOptIn)
                 }
-                "false" if matches!(action.control_value, ControlValue::Boolean) => {
+                "false" if matches!(action.control_value, CacheControlValue::Boolean) => {
                     // Explicitly opts out from caching
                     None
                 }
                 other => match ExplicitExpr::from_curly(other) {
-                    None if matches!(action.control_value, ControlValue::String) => {
+                    None if matches!(action.control_value, CacheControlValue::String) => {
                         Some(CacheUsage::DirectOptIn)
                     }
                     None => None,
@@ -238,44 +262,36 @@ impl CachePoisoning {
         }
     }
 
-    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,
+    fn usage_of_controllable_caching(
+        &self,
+        env: &Env,
+        controllable: &ControllableCacheAction,
+    ) -> Option<CacheUsage> {
+        let cache_control_input = env.keys().find(|k| match controllable.control_input {
+            CacheControlInput::OptIn(inner) => *k == inner,
+            CacheControlInput::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),
+            None => CachePoisoning::evaluate_default_action_behaviour(controllable),
 
             // 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);
+                    CachePoisoning::evaluate_user_defined_opt_in(key, env, controllable);
 
                 // we now evaluate the extracted value against the opt-in semantics
                 match &declared_usage {
                     Some(CacheUsage::DirectOptIn) => {
-                        match known_action.cache_control {
+                        match controllable.control_input {
                             // in this case, we just follow the opt-in
-                            CacheControl::OptIn(_) => declared_usage,
+                            CacheControlInput::OptIn(_) => declared_usage,
                             // otherwise, the user opted for disabling the cache
                             // hence we don't return a CacheUsage
-                            CacheControl::OptOut(_) => None,
+                            CacheControlInput::OptOut(_) => None,
                         }
                     }
                     // Because we can't evaluate expressions, there is nothing to do
@@ -286,6 +302,27 @@ impl CachePoisoning {
         }
     }
 
+    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)
+        })?;
+
+        match known_action {
+            CacheAwareAction::Configurable(action) => {
+                self.usage_of_controllable_caching(env, action)
+            }
+            CacheAwareAction::NotConfigurable(_) => Some(CacheUsage::AlwaysCache),
+        }
+    }
+
     fn uses_cache_aware_step<'w>(
         &self,
         step: &Step<'w>,
@@ -298,6 +335,7 @@ impl CachePoisoning {
         let cache_usage = self.evaluate_cache_usage(uses, with)?;
 
         let (yaml_key, annotation) = match cache_usage {
+            CacheUsage::AlwaysCache => ("uses", "caching always restored here"),
             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"),


Test patch:
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index 1d62312..291ca88 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -328,6 +328,12 @@ fn cache_poisoning() -> Result<()> {
         .workflow(workflow_under_test("cache-poisoning/issue-343-repro.yml"))
         .run()?);
 
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "cache-poisoning/caching-not-configurable.yml"
+        ))
+        .run()?);
+
     Ok(())
 }
 
diff --git a/tests/snapshots/snapshot__cache_poisoning-12.snap b/tests/snapshots/snapshot__cache_poisoning-12.snap
new file mode 100644
index 0000000..ccda5cf
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-12.snap
@@ -0,0 +1,22 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/caching-not-configurable.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: Mozilla-Actions/sccache-action@054db53350805f83040bf3e6e9b8cf5a139aa7c9
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ caching always restored here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/test-data/cache-poisoning/caching-not-configurable.yml b/tests/test-data/cache-poisoning/caching-not-configurable.yml
new file mode 100644
index 0000000..811be96
--- /dev/null
+++ b/tests/test-data/cache-poisoning/caching-not-configurable.yml
@@ -0,0 +1,19 @@
+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: Mozilla-Actions/sccache-action@054db53350805f83040bf3e6e9b8cf5a139aa7c9
+
+      - name: Publish on crates.io
+        run: cargo publish --token ${{ secrets.CRATESIO_PUBLISH_TOKEN }}