Repository: zizmorcore/zizmor (Rust)

Issue:
[BUG]: How to address "opt-in for caching here"?
### Pre-submission checks

- [X] I am **not** filing a feature request. These should be filed via the feature request form instead.
- [X] I have looked through the [open issues](https://github.com/woodruffw/zizmor/issues?q=is%3Aissue+is%3Aopen+) for a duplicate report.

### Expected behavior

When Docker images are built (but not pushed), cached artifacts are harmless.
How do I disable https://woodruffw.github.io/zizmor/audits/#cache-poisoning for those cases?

### Actual behavior

```
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
  --> ...
   |
34 | /         with:
35 | |           cache-binary: true
   | |____________________________^ opt-in for caching here
36 |
37 |         - name: Build docker
38 |           uses: docker/build-push-action@v6
   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ runtime artifacts usually published here
   |
```

### Reproduction steps

Create a new action

```
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          cache-binary: true

      - name: Build docker
        uses: docker/build-push-action@v6
        with:
          cache-from: type=gha
          cache-to: type=gha,mode=max
          push: false
```

### Logs

_No response_

### Additional context

_No response_


Discussion:
Thanks for the report @ashish!

To make sure I understand: is the false positive happening because `push: false` disables the publish, but we don't detect that?

If that sounds right to you, I think what we need to do here is better filter the publish actions in a manner similar to the cache-aware actions. CC @ubiratansoares for thoughts 🙂
> To make sure I understand: is the false positive happening because push: false disables the publish, but we don't detect that?

Yes, I am not an expert on caching but I do believe that `push: false` does kill this attack.
@ashishb Thanks for reporting! 

I agree with @woodruffw, this looks like a false positive, and to proper handle it, we need to enhance what we have for publisher-like Actions (like `docker/build-push-action`), so we model whether publishing can be disabled or ran in some sort of dry-run mode.

For now, I think the easiest way to disable this check is using zizmor's support to [ignoring results](https://woodruffw.github.io/zizmor/usage/#ignoring-results).
I ran into a very similar error- I think is a false positive for the same reason. The underlying problem appears to be not taking context into account for the cache poisoning. In my example I have configured the build-push-action to use the unique github action run ID to benefit from using the cache. However this means it should not be vulnerable to cache poisoning across different runs/branches

error:
```
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
34 |           uses: docker/setup-buildx-action@v3
   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cache enabled by default here
```

Reproduction steps
```
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push to registries
  uses: docker/build-push-action@v6
  with:
    cache-from: type=gha,ref=${{ github.run_id }}
    cache-to: type=gha,ref=${{ github.run_id }},mode=max
```
Ah yeah, that's a challenging case: we likely won't ever have the fidelity needed to fully inspect that kind of cache key configuration. But I'll think some more about that as well 🙂
And I believe that's ok. The goal isn't to handle all edge cases but to reduce obvious false positives.
Yep. One possibility there is that we can encode personas with each selector, so we'll only flag on that more complicated case when the user selects an "auditor" persona.

I'll have time to work on this (first the simple cases, then the more nuanced ones) in the coming days.
Thought about this a bit more:

I think the solution here is going to be to generalize `CacheAwareAction` into a `UsesCoordinate` or similar, which could then be used whenever we need "this `uses:` step satisfies a set of conditions." That would allow us to use it for both cache-aware steps and publishing steps.

#389 is the final piece here. I'll open up a separate follow-up for https://github.com/woodruffw/zizmor/issues/378#issuecomment-2569449218, since that requires a yet-higher layer of generality.



Patch:
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 747a6f1c1..b7b1b6da2 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -12,6 +12,8 @@ of `zizmor`.
 ### Fixed
 
 * `workflow_call.secrets` keys with missing values are now parsed correctly (#388)
+* The [cache-poisoning] audit no longer incorrectly treats `docker/build-push-action` as
+  a publishing workflow is `push: false` is explicitly set (#389)
 
 ## v1.0.0
 
diff --git a/src/audit/cache_poisoning.rs b/src/audit/cache_poisoning.rs
index 10402b0fe..52dde0b46 100644
--- a/src/audit/cache_poisoning.rs
+++ b/src/audit/cache_poisoning.rs
@@ -123,31 +123,53 @@ static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<UsesCoordinate>> = LazyLock::new(
 
 /// A list of well-know publisher actions
 /// In the future we can retrieve this list from the static API
-static KNOWN_PUBLISHER_ACTIONS: LazyLock<Vec<Uses>> = LazyLock::new(|| {
+static KNOWN_PUBLISHER_ACTIONS: LazyLock<Vec<UsesCoordinate>> = LazyLock::new(|| {
     vec![
         // Public packages and/or binary distribution channels
-        Uses::from_step("pypa/gh-action-pypi-publish").unwrap(),
-        Uses::from_step("rubygems/release-gem").unwrap(),
-        Uses::from_step("jreleaser/release-action").unwrap(),
-        Uses::from_step("goreleaser/goreleaser-action").unwrap(),
+        UsesCoordinate::NotConfigurable(Uses::from_step("pypa/gh-action-pypi-publish").unwrap()),
+        UsesCoordinate::NotConfigurable(Uses::from_step("rubygems/release-gem").unwrap()),
+        UsesCoordinate::NotConfigurable(Uses::from_step("jreleaser/release-action").unwrap()),
+        UsesCoordinate::NotConfigurable(Uses::from_step("goreleaser/goreleaser-action").unwrap()),
         // Github releases
-        Uses::from_step("softprops/action-gh-release").unwrap(),
-        Uses::from_step("release-drafter/release-drafter").unwrap(),
-        Uses::from_step("googleapis/release-please-action").unwrap(),
+        UsesCoordinate::NotConfigurable(Uses::from_step("softprops/action-gh-release").unwrap()),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("release-drafter/release-drafter").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("googleapis/release-please-action").unwrap(),
+        ),
         // Container registries
-        Uses::from_step("docker/build-push-action").unwrap(),
-        Uses::from_step("redhat-actions/push-to-registry").unwrap(),
+        UsesCoordinate::Configurable {
+            uses: Uses::from_step("docker/build-push-action").unwrap(),
+            control: Control::new(Toggle::OptIn, "push", ControlFieldType::Boolean),
+            enabled_by_default: true,
+        },
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("redhat-actions/push-to-registry").unwrap(),
+        ),
         // Cloud + Edge providers
-        Uses::from_step("aws-actions/amazon-ecs-deploy-task-definition ").unwrap(),
-        Uses::from_step("aws-actions/aws-cloudformation-github-deploy").unwrap(),
-        Uses::from_step("Azure/aci-deploy").unwrap(),
-        Uses::from_step("Azure/container-apps-deploy-action").unwrap(),
-        Uses::from_step("Azure/functions-action").unwrap(),
-        Uses::from_step("Azure/sql-action").unwrap(),
-        Uses::from_step("cloudflare/wrangler-action").unwrap(),
-        Uses::from_step("google-github-actions/deploy-appengine").unwrap(),
-        Uses::from_step("google-github-actions/deploy-cloudrun").unwrap(),
-        Uses::from_step("google-github-actions/deploy-cloud-functions").unwrap(),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("aws-actions/amazon-ecs-deploy-task-definition ").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("aws-actions/aws-cloudformation-github-deploy").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(Uses::from_step("Azure/aci-deploy").unwrap()),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("Azure/container-apps-deploy-action").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(Uses::from_step("Azure/functions-action").unwrap()),
+        UsesCoordinate::NotConfigurable(Uses::from_step("Azure/sql-action").unwrap()),
+        UsesCoordinate::NotConfigurable(Uses::from_step("cloudflare/wrangler-action").unwrap()),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("google-github-actions/deploy-appengine").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("google-github-actions/deploy-cloudrun").unwrap(),
+        ),
+        UsesCoordinate::NotConfigurable(
+            Uses::from_step("google-github-actions/deploy-cloud-functions").unwrap(),
+        ),
     ]
 });
 
@@ -190,17 +212,11 @@ impl CachePoisoning {
 
     fn detected_well_known_publisher_step(steps: Steps) -> Option<Step> {
         steps.into_iter().find(|step| {
-            let Some(Uses::Repository(target_uses)) = step.uses() else {
-                return false;
-            };
-
-            KNOWN_PUBLISHER_ACTIONS.iter().any(|publisher| {
-                let Uses::Repository(well_known_uses) = publisher else {
-                    return false;
-                };
-
-                target_uses.matches(*well_known_uses)
-            })
+            // TODO: Specialize further here, and produce an appropriate
+            // confidence/persona setting if the usage is conditional.
+            KNOWN_PUBLISHER_ACTIONS
+                .iter()
+                .any(|publisher| publisher.usage(step).is_some())
         })
     }
 


Test patch:
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index f993bf710..c8b3fab08 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -350,6 +350,10 @@ fn cache_poisoning() -> Result<()> {
         ))
         .run()?);
 
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test("cache-poisoning/issue-378-repro.yml"))
+        .run()?);
+
     Ok(())
 }
 
diff --git a/tests/snapshots/snapshot__cache_poisoning-14.snap b/tests/snapshots/snapshot__cache_poisoning-14.snap
new file mode 100644
index 000000000..98097edc6
--- /dev/null
+++ b/tests/snapshots/snapshot__cache_poisoning-14.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"cache-poisoning/issue-378-repro.yml\")).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/test-data/cache-poisoning/issue-378-repro.yml b/tests/test-data/cache-poisoning/issue-378-repro.yml
new file mode 100644
index 000000000..67768771e
--- /dev/null
+++ b/tests/test-data/cache-poisoning/issue-378-repro.yml
@@ -0,0 +1,23 @@
+# minimized from https://github.com/woodruffw/zizmor/issues/378
+
+name: issue-378
+
+on: push
+
+jobs:
+  issue-378:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
+        with:
+          cache-binary: true
+
+      - name: Build docker
+        uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
+        with:
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+          # push is explicitly disabled, so this is not a publishing
+          # workflow and therefore no cache-poisoning finding is emitted
+          push: false