Repository: woodruffw/zizmor (rust)

Issue:
[BUG]: setup-buildx-action false positive for cache poisoning audit
Hi, maintainer of Docker GitHub Actions here.

The following check is not quite right:

https://github.com/woodruffw/zizmor/blob/ccd4178eb5276bd0bd17f2e814a921959ff67517/src/audit/cache_poisoning.rs#L109-L114

Even if `cache-binary` is `true`, it will not write or read from cache if `version` is not set. By default it will use buildx binary available on GitHub runner: https://github.com/actions/runner-images/blame/main/images/ubuntu/Ubuntu2404-Readme.md#L74

I think this should check for both `version` input being set and `cache-binary` set to `true`.

Pull request description:
bugfix: cache-poisoning: fix false positive for docker/setup-buildx-action
This is an initial stab at exposing greater generality when evaluating whether a set of controls are "satisfied" for the purpose of considering an action enabled.

Doing this while also preserving the concept of "default enablement" results in some very wonky 4-valued logic, since there are 4 different states a control can evaluate to:

1. Satisfied by default
2. Satisfied through evaluation
3. Not satisfied through evaluation
4. "Conditional," i.e. we don't know because it's driven by an expression or similar

This is a little hard to follow, so I need to think about this more before actually merging it. However, the tests _do_ pass and I've also added a successful test for the original bug case that spurred this.

Fixes #642.

Patch:
diff --git a/docs/release-notes.md b/docs/release-notes.md
index cef1c08..6368657 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -17,6 +17,8 @@ of `zizmor`.
   to determine whether contexts actually expand in an unsafe manner,
   making it significantly more accurate (#640)
 * The [cache-poisoning] audit is now aware of @jdx/mise-action (#645)
+* The [cache-poisoning] audit is now significantly more accurate
+  when analyzing workflows that use @docker/setup-buildx-action (#644)
 
 ### Bug Fixes 🐛
 
diff --git a/src/audit/cache_poisoning.rs b/src/audit/cache_poisoning.rs
index 3beffe5..d6538df 100644
--- a/src/audit/cache_poisoning.rs
+++ b/src/audit/cache_poisoning.rs
@@ -7,7 +7,7 @@ use github_actions_models::workflow::event::{BareEvent, BranchFilters, OptionalB
 
 use crate::audit::{Audit, audit_meta};
 use crate::finding::{Confidence, Finding, Severity};
-use crate::models::coordinate::{ActionCoordinate, Control, ControlFieldType, Toggle, Usage};
+use crate::models::coordinate::{ActionCoordinate, ControlExpr, ControlFieldType, Toggle, Usage};
 use crate::models::{JobExt as _, NormalJob, Step, StepCommon, Steps};
 use crate::state::AuditState;
 
@@ -19,104 +19,140 @@ static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::ne
         // https://github.com/actions/cache/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/cache").unwrap(),
-            control: Control::new(Toggle::OptOut, "lookup-only", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "lookup-only",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/actions/setup-java/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/setup-java").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::String, false),
         },
         // https://github.com/actions/setup-go/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/setup-go").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::Boolean, true),
         },
         // https://github.com/actions/setup-node/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/setup-node").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::String, false),
         },
         // https://github.com/actions/setup-python/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/setup-python").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::String, false),
         },
         // https://github.com/actions/setup-dotnet/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions/setup-dotnet").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::Boolean, false),
         },
         // https://github.com/astral-sh/setup-uv/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("astral-sh/setup-uv").unwrap(),
-            control: Control::new(Toggle::OptOut, "enable-cache", ControlFieldType::String),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "enable-cache",
+                ControlFieldType::String,
+                true,
+            ),
         },
         // https://github.com/Swatinem/rust-cache/blob/master/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("Swatinem/rust-cache").unwrap(),
-            control: Control::new(Toggle::OptOut, "lookup-only", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "lookup-only",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/ruby/setup-ruby/blob/master/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("ruby/setup-ruby").unwrap(),
-            control: Control::new(Toggle::OptIn, "bundler-cache", ControlFieldType::Boolean),
-            enabled_by_default: false,
+            control: ControlExpr::single(
+                Toggle::OptIn,
+                "bundler-cache",
+                ControlFieldType::Boolean,
+                false,
+            ),
         },
         // https://github.com/PyO3/maturin-action/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("PyO3/maturin-action").unwrap(),
-            control: Control::new(Toggle::OptIn, "sccache", ControlFieldType::Boolean),
-            enabled_by_default: false,
+            control: ControlExpr::single(
+                Toggle::OptIn,
+                "sccache",
+                ControlFieldType::Boolean,
+                false,
+            ),
         },
         // https://github.com/mlugg/setup-zig/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("mlugg/setup-zig").unwrap(),
-            control: Control::new(Toggle::OptIn, "use-cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptIn,
+                "use-cache",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/oven-sh/setup-bun/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("oven-sh/setup-bun").unwrap(),
-            control: Control::new(Toggle::OptOut, "no-cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "no-cache",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/DeterminateSystems/magic-nix-cache-action/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("DeterminateSystems/magic-nix-cache-action").unwrap(),
-            control: Control::new(Toggle::OptIn, "use-gha-cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptIn,
+                "use-gha-cache",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/graalvm/setup-graalvm/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("graalvm/setup-graalvm").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::String, false),
         },
         // https://github.com/gradle/actions/blob/main/setup-gradle/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("gradle/actions/setup-gradle").unwrap(),
-            control: Control::new(Toggle::OptOut, "cache-disabled", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "cache-disabled",
+                ControlFieldType::Boolean,
+                true,
+            ),
         },
         // https://github.com/docker/setup-buildx-action/blob/master/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("docker/setup-buildx-action").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache-binary", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::all([
+                ControlExpr::single(
+                    Toggle::OptIn,
+                    "cache-binary",
+                    ControlFieldType::Boolean,
+                    true,
+                ),
+                ControlExpr::single(Toggle::OptIn, "version", ControlFieldType::String, false),
+            ]),
         },
         // https://github.com/actions-rust-lang/setup-rust-toolchain/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("actions-rust-lang/setup-rust-toolchain").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::Boolean, true),
         },
         // https://github.com/Mozilla-Actions/sccache-action/blob/main/action.yml
         ActionCoordinate::NotConfigurable(
@@ -129,8 +165,7 @@ static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::ne
         // https://github.com/jdx/mise-action/blob/main/action.yml
         ActionCoordinate::Configurable {
             uses: Uses::from_str("jdx/mise-action").unwrap(),
-            control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(Toggle::OptIn, "cache", ControlFieldType::Boolean, true),
         },
     ]
 });
@@ -155,8 +190,7 @@ static KNOWN_PUBLISHER_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::new(
         // Container registries
         ActionCoordinate::Configurable {
             uses: Uses::from_str("docker/build-push-action").unwrap(),
-            control: Control::new(Toggle::OptIn, "push", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(Toggle::OptIn, "push", ControlFieldType::Boolean, true),
         },
         ActionCoordinate::NotConfigurable(
             Uses::from_str("redhat-actions/push-to-registry").unwrap(),
diff --git a/src/models.rs b/src/models.rs
index 2203133..ee9a736 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -600,7 +600,7 @@ impl<'w> Step<'w> {
     }
 
     /// Returns this step's parent [`NormalJob`].
-    pub(crate) fn job(&self) -> &'w NormalJob {
+    pub(crate) fn job(&self) -> &NormalJob<'w> {
         &self.parent
     }
 
diff --git a/src/models/coordinate.rs b/src/models/coordinate.rs
index 73d6a02..98900f8 100644
--- a/src/models/coordinate.rs
+++ b/src/models/coordinate.rs
@@ -13,7 +13,10 @@
 // able to express things like
 // "match foo/bar if foo: A and not bar: B and baz: /abcd/"
 
+use std::ops::{BitAnd, BitOr};
+
 use github_actions_models::common::{EnvValue, Uses, expr::ExplicitExpr};
+use indexmap::IndexMap;
 
 use super::{StepBodyCommon, StepCommon};
 use crate::models::uses::RepositoryUsesExt as _;
@@ -22,10 +25,8 @@ pub(crate) enum ActionCoordinate {
     Configurable {
         /// The `uses:` clause of the coordinate
         uses: Uses,
-        /// The input that controls the coordinate
-        control: Control,
-        /// Whether or not the behavior is the default
-        enabled_by_default: bool,
+        /// The expression of fields that controls the coordinate
+        control: ControlExpr,
     },
     NotConfigurable(Uses),
 }
@@ -38,34 +39,6 @@ impl ActionCoordinate {
         }
     }
 
-    /// Returns the "declared" usage from a `with:` field, modulo a toggle.
-    fn declared_usage(
-        &self,
-        value: &EnvValue,
-        toggle: &Toggle,
-        field_type: &ControlFieldType,
-    ) -> Option<Usage> {
-        match value.to_string().as_str() {
-            // Handle `false` specially, since we need to invert the toggle.
-            "false" if matches!(field_type, ControlFieldType::Boolean) => match toggle {
-                Toggle::OptIn => None,
-                Toggle::OptOut => Some(Usage::DirectOptIn),
-            },
-            // NOTE: We don't bother checking for string or other-typed fields here,
-            // since it's all stringly-typed under the hood.
-            other => match ExplicitExpr::from_curly(other) {
-                // If it's not an expression, all we know is that it's likely a
-                // fixed sentinel value.
-                // This catches the `true` boolean case as well.
-                None => match toggle {
-                    Toggle::OptIn => Some(Usage::DirectOptIn),
-                    Toggle::OptOut => None,
-                },
-                Some(_) => Some(Usage::ConditionalOptIn),
-            },
-        }
-    }
-
     /// Returns the semantic "usage" of the given step relative to the current coordinate.
     ///
     /// `None` indicates that the step is "unused" from the perspective of the coordinate,
@@ -91,28 +64,12 @@ impl ActionCoordinate {
         }
 
         match self {
-            ActionCoordinate::Configurable {
-                uses: _,
-                control,
-                enabled_by_default,
-            } => {
-                // We need to inspect this `uses:`'s configuration to determine its semantics.
-                match with.get(control.field_name) {
-                    Some(field_value) => {
-                        // The declared usage is whatever the user explicitly configured,
-                        // which might be inverted if the toggle semantics are opt-out instead.
-                        self.declared_usage(field_value, &control.toggle, &control.field_type)
-                    }
-                    None => {
-                        // If the controlling field is not present, the default dictates the semantics.
-                        if *enabled_by_default {
-                            Some(Usage::DefaultActionBehaviour)
-                        } else {
-                            None
-                        }
-                    }
-                }
-            }
+            ActionCoordinate::Configurable { uses: _, control } => match control.eval(with) {
+                ControlEvaluation::DefaultSatisfied => Some(Usage::DefaultActionBehaviour),
+                ControlEvaluation::Satisfied => Some(Usage::DirectOptIn),
+                ControlEvaluation::NotSatisfied => None,
+                ControlEvaluation::Conditional => Some(Usage::ConditionalOptIn),
+            },
             // The mere presence of this `uses:` implies the expected usage semantics.
             ActionCoordinate::NotConfigurable(_) => Some(Usage::Always),
         }
@@ -135,26 +92,221 @@ pub(crate) enum ControlFieldType {
     String,
 }
 
-/// The input that controls the behavior of a configurable action.
-pub(crate) struct Control {
-    /// What kind of toggle the input is.
-    pub(crate) toggle: Toggle,
-    /// The field that controls the action's behavior.
-    pub(crate) field_name: &'static str,
-    /// The type of the field that controls the action's behavior.
-    pub(crate) field_type: ControlFieldType,
+/// The result of evaluating a control expression.
+#[derive(Copy, Clone, PartialEq)]
+pub(crate) enum ControlEvaluation {
+    /// The control expression is satisfied by default.
+    DefaultSatisfied,
+    /// The control expression is satisfied.
+    Satisfied,
+    /// The control expression is not satisfied.
+    NotSatisfied,
+    /// The control expression is conditionally satisfied,
+    /// i.e. depends on an actions expression or similar.
+    Conditional,
+}
+
+impl BitAnd for ControlEvaluation {
+    type Output = Self;
+
+    fn bitand(self, rhs: Self) -> Self::Output {
+        // NOTE: This could be done less literally, but I find it easier to read.
+        match (self, rhs) {
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Conditional
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Conditional
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Conditional
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Conditional
+            }
+        }
+    }
 }
 
-impl Control {
-    pub(crate) fn new(
+impl BitOr for ControlEvaluation {
+    type Output = Self;
+
+    fn bitor(self, rhs: Self) -> Self::Output {
+        // TODO: Does this mapping make sense?
+        match (self, rhs) {
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::DefaultSatisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Satisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::NotSatisfied
+            }
+            (ControlEvaluation::NotSatisfied, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Conditional
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::DefaultSatisfied) => {
+                ControlEvaluation::DefaultSatisfied
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::Satisfied) => {
+                ControlEvaluation::Satisfied
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::NotSatisfied) => {
+                ControlEvaluation::Conditional
+            }
+            (ControlEvaluation::Conditional, ControlEvaluation::Conditional) => {
+                ControlEvaluation::Conditional
+            }
+        }
+    }
+}
+
+/// An "expression" of control fields.
+///
+/// This allows us to express basic quantified logic, such as
+/// "all/any of these fields must be satisfied".
+///
+/// This is made slightly more complicated by the fact that our logic is
+/// four-valued: control fields can be default-satisfied, explicitly satisfied,
+/// not satisfied, or conditionally satisfied.
+pub(crate) enum ControlExpr {
+    /// A single control field.
+    Single {
+        /// What kind of toggle the input is.
         toggle: Toggle,
+        /// The field that controls the action's behavior.
         field_name: &'static str,
+        /// The type of the field that controls the action's behavior.
         field_type: ControlFieldType,
+        /// Whether this control is satisfied by default, if not present.
+        satisfied_by_default: bool,
+    },
+    /// Universal quantification: all of the fields must be satisfied.
+    All(Vec<ControlExpr>),
+    /// Existential quantification: any of the fields must be satisfied.
+    #[allow(dead_code)]
+    Any(Vec<ControlExpr>),
+}
+
+impl ControlExpr {
+    pub(crate) fn single(
+        toggle: Toggle,
+        field_name: &'static str,
+        field_type: ControlFieldType,
+        enabled_by_default: bool,
     ) -> Self {
-        Self {
+        Self::Single {
             toggle,
             field_name,
             field_type,
+            satisfied_by_default: enabled_by_default,
+        }
+    }
+
+    pub(crate) fn all(exprs: impl IntoIterator<Item = ControlExpr>) -> Self {
+        Self::All(exprs.into_iter().collect())
+    }
+
+    pub(crate) fn eval(&self, with: &IndexMap<String, EnvValue>) -> ControlEvaluation {
+        match self {
+            ControlExpr::Single {
+                toggle,
+                field_name,
+                field_type,
+                satisfied_by_default: enabled_by_default,
+            } => {
+                // If the controlling field is not present, the default dictates the semantics.
+                if let Some(field_value) = with.get(*field_name) {
+                    match field_value.to_string().as_str() {
+                        "false" if matches!(field_type, ControlFieldType::Boolean) => {
+                            match toggle {
+                                Toggle::OptIn => ControlEvaluation::NotSatisfied,
+                                Toggle::OptOut => ControlEvaluation::Satisfied,
+                            }
+                        }
+                        other => match ExplicitExpr::from_curly(other) {
+                            None => match toggle {
+                                Toggle::OptIn => ControlEvaluation::Satisfied,
+                                Toggle::OptOut => ControlEvaluation::NotSatisfied,
+                            },
+                            Some(_) => ControlEvaluation::Conditional,
+                        },
+                    }
+                } else if *enabled_by_default {
+                    ControlEvaluation::DefaultSatisfied
+                } else {
+                    ControlEvaluation::NotSatisfied
+                }
+            }
+            ControlExpr::All(exprs) => exprs
+                .iter()
+                .map(|expr| expr.eval(with))
+                .fold(ControlEvaluation::Satisfied, |acc, expr| acc & expr),
+            ControlExpr::Any(exprs) => exprs
+                .iter()
+                .map(|expr| expr.eval(with))
+                .fold(ControlEvaluation::NotSatisfied, |acc, expr| acc | expr),
         }
     }
 }
@@ -174,7 +326,7 @@ mod tests {
     use github_actions_models::{common::Uses, workflow::job::Step};
 
     use super::{ActionCoordinate, StepCommon};
-    use crate::models::coordinate::{Control, ControlFieldType, Toggle, Usage};
+    use crate::models::coordinate::{ControlExpr, ControlFieldType, Toggle, Usage};
 
     // Test-only trait impl.
     impl<'s> StepCommon<'s> for Step {
@@ -228,8 +380,7 @@ mod tests {
         // missing the needed control.
         let coord = ActionCoordinate::Configurable {
             uses: Uses::from_str("foo/bar").unwrap(),
-            control: Control::new(Toggle::OptIn, "set-me", ControlFieldType::Boolean),
-            enabled_by_default: false,
+            control: ControlExpr::single(Toggle::OptIn, "set-me", ControlFieldType::Boolean, false),
         };
         let step: Step = serde_yaml::from_str("uses: foo/bar").unwrap();
         assert_eq!(coord.usage(&step), None);
@@ -245,8 +396,7 @@ mod tests {
         // Coordinate `uses:` matches and is enabled by default.
         let coord = ActionCoordinate::Configurable {
             uses: Uses::from_str("foo/bar").unwrap(),
-            control: Control::new(Toggle::OptIn, "set-me", ControlFieldType::Boolean),
-            enabled_by_default: true,
+            control: ControlExpr::single(Toggle::OptIn, "set-me", ControlFieldType::Boolean, true),
         };
         let step: Step = serde_yaml::from_str("uses: foo/bar").unwrap();
         assert_eq!(coord.usage(&step), Some(Usage::DefaultActionBehaviour));
@@ -263,8 +413,12 @@ mod tests {
         // the default.
         let coord = ActionCoordinate::Configurable {
             uses: Uses::from_str("foo/bar").unwrap(),
-            control: Control::new(Toggle::OptOut, "disable-cache", ControlFieldType::Boolean),
-            enabled_by_default: false,
+            control: ControlExpr::single(
+                Toggle::OptOut,
+                "disable-cache",
+                ControlFieldType::Boolean,
+                false,
+            ),
         };
         let step: Step = serde_yaml::from_str("uses: foo/bar").unwrap();
         assert_eq!(coord.usage(&step), None);


Test patch:
diff --git a/tests/integration/snapshot.rs b/tests/integration/snapshot.rs
index 90264f2..629e9a8 100644
--- a/tests/integration/snapshot.rs
+++ b/tests/integration/snapshot.rs
@@ -341,6 +341,12 @@ fn cache_poisoning() -> Result<()> {
             .run()?
     );
 
+    insta::assert_snapshot!(
+        zizmor()
+            .input(input_under_test("cache-poisoning/issue-642-repro.yml"))
+            .run()?
+    );
+
     Ok(())
 }
 
diff --git a/tests/integration/snapshots/integration__snapshot__cache_poisoning-15.snap b/tests/integration/snapshots/integration__snapshot__cache_poisoning-15.snap
new file mode 100644
index 0000000..0e5fc1d
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__cache_poisoning-15.snap
@@ -0,0 +1,20 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().input(input_under_test(\"cache-poisoning/issue-642-repro.yml\")).run()?"
+snapshot_kind: text
+---
+error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
+  --> @@INPUT@@:20:9
+   |
+15 | /         with:
+16 | |           cache-binary: true
+17 | |           version: latest
+   | |_________________________^ opt-in for caching here
+18 |
+19 |         - name: Build docker
+20 |           uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
+   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ runtime artifacts usually published here
+   |
+   = note: audit confidence → Low
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/integration/test-data/cache-poisoning/issue-378-repro.yml b/tests/integration/test-data/cache-poisoning/issue-378-repro.yml
index 5e52a3e..b40112b 100644
--- a/tests/integration/test-data/cache-poisoning/issue-378-repro.yml
+++ b/tests/integration/test-data/cache-poisoning/issue-378-repro.yml
@@ -14,6 +14,7 @@ jobs:
         uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
         with:
           cache-binary: true
+          version: latest
 
       - name: Build docker
         uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
diff --git a/tests/integration/test-data/cache-poisoning/issue-642-repro.yml b/tests/integration/test-data/cache-poisoning/issue-642-repro.yml
new file mode 100644
index 0000000..3d4bcab
--- /dev/null
+++ b/tests/integration/test-data/cache-poisoning/issue-642-repro.yml
@@ -0,0 +1,39 @@
+# repro case for https://github.com/woodruffw/zizmor/issues/642
+
+name: issue-642
+
+on: push
+
+permissions: {}
+
+jobs:
+  issue-642-true-positive:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
+        with:
+          cache-binary: true
+          version: latest
+
+      - name: Build docker
+        uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
+        with:
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+
+  issue-642-true-negative:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
+        with:
+          # cache-binary is set but version is not, meaning that
+          # caching is not actually used.
+          cache-binary: true
+
+      - name: Build docker
+        uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
+        with:
+          cache-from: type=gha
+          cache-to: type=gha,mode=max