Repository: woodruffw/zizmor (language: python)

Issue:
New audit: un-pinned action audit
### Pre-submission checks

- [X] I am **not** a bug (crash, false positive/negative, etc). These must be filed via the bug report template.
- [X] I have looked through the open issues for a duplicate request.

### What's the problem this feature will solve?

GitHub Actions may become compromised due to account takeover, social engineering, etc. It's a good idea to pin actions to known-good versions so that a workflow isn't silently compromised by a change in an action.

### Describe the solution you'd like

An audit that checks for actions without a version. For example, it would flag `actions/checkout`. For extra security, it could require a commit ID, since tags could be overwritten by an attacker.

### Additional context

_No response_


Discussion / hints:
Big +1 to this audit idea! This is mentioned in the audit roadmap (#1), but it's good to have this issue as an independent breakout for it as well 🙂 
I'm going to take a stab at this later today.

Fix patch:
diff --git a/docs/audits.md b/docs/audits.md
index 44c2e7f..c36fdc5 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -259,3 +259,69 @@ and possible.
 * <https://docs.pypi.org/trusted-publishers/>
 * <https://guides.rubygems.org/trusted-publishing/>
 * <https://blog.trailofbits.com/2023/05/23/trusted-publishing-a-new-benchmark-for-packaging-security/>
+
+## `unpinned-uses`
+
+| Type     | Examples                     | Introduced in | Works offline  | Enabled by default |
+|----------|------------------------------|---------------|----------------|--------------------|
+| Workflow  | [unpinned.yml]              | v0.4.0        | ✅             | ✅                 |
+
+[unpinned.yml]: https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/unpinned.yml
+
+Detects "unpinned" `uses:` clauses.
+
+When a `uses:` clause is not pinned by branch, tag, or SHA reference,
+GitHub Actions will use the latest commit on the referenced repository
+(or, in the case of Docker actions, the `:latest` tag).
+
+### Remediation
+
+For repository actions (like @actions/checkout): add a branch, tag, or SHA
+reference.
+
+For Docker actions (like `docker://ubuntu`): add an appropriate
+`:{version}` suffix.
+
+A before/after example is shown below.
+
+=== "Before"
+
+    ```yaml title="unpinned-uses.yml" hl_lines="8 12"
+    name: unpinned-uses
+    on: [push]
+
+    jobs:
+    unpinned-uses:
+        runs-on: ubuntu-latest
+        steps:
+        - uses: actions/checkout
+          with:
+          persist-credentials: false
+
+        - uses: docker://ubuntu
+          with:
+          entrypoint: /bin/echo
+          args: hello!
+    ```
+
+=== "After"
+
+    ```yaml title="unpinned-uses.yml" hl_lines="8 12"
+    name: unpinned-uses
+    on: [push]
+
+    jobs:
+    unpinned-uses:
+        runs-on: ubuntu-latest
+        steps:
+        - uses: actions/checkout@v4 # (1)!
+          with:
+          persist-credentials: false
+
+        - uses: docker://ubuntu:24.04
+          with:
+          entrypoint: /bin/echo
+          args: hello!
+    ```
+
+    1. Or `actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683` for a SHA-pinned action.
diff --git a/mkdocs.yml b/mkdocs.yml
index ec83097..042fb1a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -67,6 +67,11 @@ markdown_extensions:
   - admonition
   - pymdownx.details
   - pymdownx.superfences
+  - pymdownx.tabbed:
+      alternate_style: true
+      slugify: !!python/object/apply:pymdownx.slugs.slugify
+        kwds:
+          case: lower
   - pymdownx.snippets:
       base_path:
         - "docs/snippets"
@@ -75,6 +80,10 @@ markdown_extensions:
   - attr_list
   - toc:
       permalink: 🔗
+  - pymdownx.highlight:
+      anchor_linenums: true
+      line_spans: __span
+      pygments_lang_class: true
 
 validation:
   omitted_files: warn
diff --git a/src/audit/impostor_commit.rs b/src/audit/impostor_commit.rs
index 7c806d7..d48f148 100644
--- a/src/audit/impostor_commit.rs
+++ b/src/audit/impostor_commit.rs
@@ -5,16 +5,14 @@
 //!
 //! [`clank`]: https://github.com/chainguard-dev/clank
 
-use std::ops::Deref;
-
 use anyhow::{anyhow, Result};
-use github_actions_models::workflow::{job::StepBody, Job};
+use github_actions_models::workflow::Job;
 
 use super::WorkflowAudit;
 use crate::{
     finding::{Confidence, Finding, Severity},
     github_api::{self, Branch, ComparisonStatus, Tag},
-    models::{Uses, Workflow},
+    models::{RepositoryUses, Uses, Workflow},
     state::AuditState,
 };
 
@@ -25,7 +23,7 @@ pub(crate) struct ImpostorCommit {
 }
 
 impl ImpostorCommit {
-    fn named_refs(&self, uses: Uses<'_>) -> Result<(Vec<Branch>, Vec<Tag>)> {
+    fn named_refs(&self, uses: RepositoryUses<'_>) -> Result<(Vec<Branch>, Vec<Tag>)> {
         let branches = self.client.list_branches(uses.owner, uses.repo)?;
         let tags = self.client.list_tags(uses.owner, uses.repo)?;
         Ok((branches, tags))
@@ -33,7 +31,7 @@ impl ImpostorCommit {
 
     fn named_ref_contains_commit(
         &self,
-        uses: &Uses<'_>,
+        uses: &RepositoryUses<'_>,
         base_ref: &str,
         head_ref: &str,
     ) -> Result<bool> {
@@ -57,7 +55,7 @@ impl ImpostorCommit {
     /// Returns a boolean indicating whether or not this commit is an "impostor",
     /// i.e. resolves due to presence in GitHub's fork network but is not actually
     /// present in any of the specified `owner/repo`'s tags or branches.
-    fn impostor(&self, uses: Uses<'_>) -> Result<bool> {
+    fn impostor(&self, uses: RepositoryUses<'_>) -> Result<bool> {
         // If there's no ref or the ref is not a commit, there's nothing to impersonate.
         let Some(head_ref) = uses.commit_ref() else {
             return Ok(false);
@@ -141,11 +139,7 @@ impl WorkflowAudit for ImpostorCommit {
             match *job {
                 Job::NormalJob(_) => {
                     for step in job.steps() {
-                        let StepBody::Uses { uses, .. } = &step.deref().body else {
-                            continue;
-                        };
-
-                        let Some(uses) = Uses::from_step(uses) else {
+                        let Some(Uses::Repository(uses)) = step.uses() else {
                             continue;
                         };
 
diff --git a/src/audit/known_vulnerable_actions.rs b/src/audit/known_vulnerable_actions.rs
index e5edcc3..c21953e 100644
--- a/src/audit/known_vulnerable_actions.rs
+++ b/src/audit/known_vulnerable_actions.rs
@@ -6,12 +6,12 @@
 //! See: <https://docs.github.com/en/rest/security-advisories/global-advisories?apiVersion=2022-11-28>
 
 use anyhow::{anyhow, Context, Result};
-use github_actions_models::workflow::{job::StepBody, Job};
+use github_actions_models::workflow::Job;
 
 use crate::{
     finding::{Confidence, Severity},
     github_api,
-    models::Uses,
+    models::{RepositoryUses, Uses},
     state::AuditState,
 };
 
@@ -22,7 +22,10 @@ pub(crate) struct KnownVulnerableActions {
 }
 
 impl KnownVulnerableActions {
-    fn action_known_vulnerabilities(&self, uses: &Uses<'_>) -> Result<Vec<(Severity, String)>> {
+    fn action_known_vulnerabilities(
+        &self,
+        uses: &RepositoryUses<'_>,
+    ) -> Result<Vec<(Severity, String)>> {
         let version = match uses.git_ref {
             // If `uses` is pinned to a symbolic ref, we need to perform
             // feats of heroism to figure out what's going on.
@@ -154,11 +157,7 @@ impl WorkflowAudit for KnownVulnerableActions {
             };
 
             for step in job.steps() {
-                let StepBody::Uses { uses, .. } = &step.body else {
-                    continue;
-                };
-
-                let Some(uses) = Uses::from_step(uses) else {
+                let Some(Uses::Repository(uses)) = step.uses() else {
                     continue;
                 };
 
diff --git a/src/audit/mod.rs b/src/audit/mod.rs
index d0e6445..9fca985 100644
--- a/src/audit/mod.rs
+++ b/src/audit/mod.rs
@@ -17,6 +17,7 @@ pub(crate) mod known_vulnerable_actions;
 pub(crate) mod ref_confusion;
 pub(crate) mod self_hosted_runner;
 pub(crate) mod template_injection;
+pub(crate) mod unpinned_uses;
 pub(crate) mod use_trusted_publishing;
 
 pub(crate) trait WorkflowAudit {
diff --git a/src/audit/ref_confusion.rs b/src/audit/ref_confusion.rs
index 68e8036..c961e37 100644
--- a/src/audit/ref_confusion.rs
+++ b/src/audit/ref_confusion.rs
@@ -9,13 +9,13 @@
 use std::ops::Deref;
 
 use anyhow::{anyhow, Result};
-use github_actions_models::workflow::{job::StepBody, Job};
+use github_actions_models::workflow::Job;
 
 use super::WorkflowAudit;
 use crate::{
     finding::{Confidence, Severity},
     github_api,
-    models::Uses,
+    models::{RepositoryUses, Uses},
     state::AuditState,
 };
 
@@ -27,7 +27,7 @@ pub(crate) struct RefConfusion {
 }
 
 impl RefConfusion {
-    fn confusable(&self, uses: &Uses) -> Result<bool> {
+    fn confusable(&self, uses: &RepositoryUses) -> Result<bool> {
         let Some(sym_ref) = uses.symbolic_ref() else {
             return Ok(false);
         };
@@ -90,11 +90,7 @@ impl WorkflowAudit for RefConfusion {
             match job.deref() {
                 Job::NormalJob(_) => {
                     for step in job.steps() {
-                        let StepBody::Uses { uses, .. } = &step.deref().body else {
-                            continue;
-                        };
-
-                        let Some(uses) = Uses::from_step(uses) else {
+                        let Some(Uses::Repository(uses)) = step.uses() else {
                             continue;
                         };
 
diff --git a/src/audit/unpinned_uses.rs b/src/audit/unpinned_uses.rs
new file mode 100644
index 0000000..5a159ef
--- /dev/null
+++ b/src/audit/unpinned_uses.rs
@@ -0,0 +1,64 @@
+use github_actions_models::workflow::Job;
+
+use crate::finding::{Confidence, Severity};
+
+use super::{AuditState, Finding, Workflow, WorkflowAudit};
+
+pub(crate) struct UnpinnedUses {}
+
+impl WorkflowAudit for UnpinnedUses {
+    fn ident() -> &'static str
+    where
+        Self: Sized,
+    {
+        "unpinned-uses"
+    }
+
+    fn desc() -> &'static str
+    where
+        Self: Sized,
+    {
+        "unpinned action reference"
+    }
+
+    fn new(_state: AuditState) -> anyhow::Result<Self>
+    where
+        Self: Sized,
+    {
+        Ok(Self {})
+    }
+
+    fn audit<'w>(&self, workflow: &'w Workflow) -> anyhow::Result<Vec<Finding<'w>>> {
+        let mut findings = vec![];
+
+        for job in workflow.jobs() {
+            // No point in checking reusable workflows, since they
+            // require a ref pin when used outside of the local repo.
+            let Job::NormalJob(_) = *job else {
+                continue;
+            };
+
+            for step in job.steps() {
+                let Some(uses) = step.uses() else {
+                    continue;
+                };
+
+                if uses.unpinned() {
+                    findings.push(
+                        Self::finding()
+                            .confidence(Confidence::High)
+                            .severity(Severity::Informational)
+                            .add_location(
+                                step.location().with_keys(&["uses".into()]).annotated(
+                                    "action is not pinned to a tag, branch, or hash ref",
+                                ),
+                            )
+                            .build(workflow)?,
+                    );
+                }
+            }
+        }
+
+        Ok(findings)
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 2986a2a..6a9878b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -153,6 +153,7 @@ fn run() -> Result<ExitCode> {
     register_audit!(audit::hardcoded_container_credentials::HardcodedContainerCredentials);
     register_audit!(audit::self_hosted_runner::SelfHostedRunner);
     register_audit!(audit::known_vulnerable_actions::KnownVulnerableActions);
+    register_audit!(audit::unpinned_uses::UnpinnedUses);
 
     let bar = ProgressBar::new((workflow_registry.len() * audit_registry.len()) as u64);
 
diff --git a/src/models.rs b/src/models.rs
index 445b4a9..12031f5 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -4,7 +4,7 @@
 use std::{collections::hash_map, iter::Enumerate, ops::Deref, path::Path};
 
 use anyhow::{anyhow, Context, Result};
-use github_actions_models::workflow;
+use github_actions_models::workflow::{self, job::StepBody};
 
 use crate::finding::{Route, SymbolicLocation};
 
@@ -145,6 +145,15 @@ impl<'w> Step<'w> {
         }
     }
 
+    /// Returns a [`Uses`] for this [`Step`], if it has one.
+    pub(crate) fn uses(&self) -> Option<Uses<'w>> {
+        let StepBody::Uses { uses, .. } = &self.inner.body else {
+            return None;
+        };
+
+        Uses::from_step(uses)
+    }
+
     /// Returns a symbolic location for this [`Step`].
     pub(crate) fn location(&self) -> SymbolicLocation<'w> {
         self.parent.with_step(self)
@@ -194,104 +203,167 @@ impl<'w> Iterator for Steps<'w> {
     }
 }
 
-/// Represents the components of an "action ref", i.e. the value
-/// of a `uses:` clause in a normal job step or a reusable workflow job.
-/// Does not support `docker://` refs, or "local" (i.e. `./`) refs.
 #[derive(Copy, Clone, Debug, PartialEq)]
-pub(crate) struct Uses<'a> {
+pub(crate) struct DockerUses<'a> {
+    pub(crate) registry: Option<&'a str>,
+    pub(crate) image: &'a str,
+    pub(crate) tag: Option<&'a str>,
+    pub(crate) hash: Option<&'a str>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub(crate) struct RepositoryUses<'a> {
     pub(crate) owner: &'a str,
     pub(crate) repo: &'a str,
     pub(crate) subpath: Option<&'a str>,
     pub(crate) git_ref: Option<&'a str>,
 }
 
-impl<'a> Uses<'a> {
-    /// Create a new `Uses` manually. No validation of the constituent
-    /// parts is performed.
-    fn new(
-        owner: &'a str,
-        repo: &'a str,
-        subpath: Option<&'a str>,
-        git_ref: Option<&'a str>,
-    ) -> Self {
-        Self {
-            owner,
-            repo,
-            subpath,
-            git_ref,
+impl<'a> RepositoryUses<'a> {
+    pub(crate) fn ref_is_commit(&self) -> bool {
+        match self.git_ref {
+            Some(git_ref) => git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()),
+            None => false,
         }
     }
 
-    fn from_common(uses: &'a str) -> Option<Self> {
-        // We don't currently have enough context to resolve local actions.
-        if uses.starts_with("./") {
-            return None;
+    pub(crate) fn commit_ref(&self) -> Option<&str> {
+        match self.git_ref {
+            Some(git_ref) if self.ref_is_commit() => Some(git_ref),
+            _ => None,
         }
+    }
+
+    pub(crate) fn symbolic_ref(&self) -> Option<&str> {
+        match self.git_ref {
+            Some(git_ref) if !self.ref_is_commit() => Some(git_ref),
+            _ => None,
+        }
+    }
+}
 
-        // NOTE: Technically both git refs and action paths can contain `@`,
-        // so this isn't guaranteed to be correct. In practice, however,
-        // splitting on the last `@` is mostly reliable.
-        let (path, git_ref) = match uses.rsplit_once('@') {
-            Some((path, git_ref)) => (path, Some(git_ref)),
-            None => (uses, None),
+/// Represents the components of an "action ref", i.e. the value
+/// of a `uses:` clause in a normal job step or a reusable workflow job.
+/// Supports Docker (`docker://`) and repository (`actions/checkout`)
+/// style references, but not local (`./foo`) references.
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub(crate) enum Uses<'a> {
+    Docker(DockerUses<'a>),
+    Repository(RepositoryUses<'a>),
+}
+
+impl<'a> Uses<'a> {
+    fn is_registry(registry: &str) -> bool {
+        // https://stackoverflow.com/a/42116190
+        registry == "localhost" || registry.contains('.') || registry.contains(':')
+    }
+
+    /// Parses a Docker image reference.
+    /// See: <https://docs.docker.com/reference/cli/docker/image/tag/>
+    fn from_image_ref(image: &'a str) -> Option<Self> {
+        let (registry, image) = match image.split_once('/') {
+            Some((registry, image)) if Self::is_registry(registry) => (Some(registry), image),
+            _ => (None, image),
         };
 
-        let components = path.splitn(3, '/').collect::<Vec<_>>();
-        if components.len() < 2 {
-            log::debug!("malformed `uses:` ref: {uses}");
-            return None;
+        // NOTE(ww): hashes aren't mentioned anywhere in Docker's own docs,
+        // but appear to be an OCI thing. GitHub doesn't support them
+        // yet either, but we expect them to soon (with "immutable actions").
+        if let Some(at_pos) = image.find('@') {
+            let (image, hash) = image.split_at(at_pos);
+
+            let hash = if hash.is_empty() {
+                None
+            } else {
+                Some(&hash[1..])
+            };
+
+            Some(Self::Docker(DockerUses {
+                registry,
+                image,
+                tag: None,
+                hash,
+            }))
+        } else {
+            let (image, tag) = match image.split_once(':') {
+                Some((image, "")) => (image, None),
+                Some((image, tag)) => (image, Some(tag)),
+                _ => (image, None),
+            };
+
+            Some(Self::Docker(DockerUses {
+                registry,
+                image,
+                tag,
+                hash: None,
+            }))
         }
-
-        Some(Self::new(
-            components[0],
-            components[1],
-            components.get(2).copied(),
-            git_ref,
-        ))
     }
 
-    pub(crate) fn from_step(uses: &'a str) -> Option<Self> {
-        if uses.starts_with("docker://") {
-            return None;
+    fn from_common(uses: &'a str) -> Option<Self> {
+        if uses.starts_with("./") {
+            None
+        } else if let Some(image) = uses.strip_prefix("docker://") {
+            Self::from_image_ref(image)
+        } else {
+            // NOTE: Technically both git refs and action paths can contain `@`,
+            // so this isn't guaranteed to be correct. In practice, however,
+            // splitting on the last `@` is mostly reliable.
+            let (path, git_ref) = match uses.rsplit_once('@') {
+                Some((path, git_ref)) => (path, Some(git_ref)),
+                None => (uses, None),
+            };
+
+            let components = path.splitn(3, '/').collect::<Vec<_>>();
+            if components.len() < 2 {
+                log::debug!("malformed `uses:` ref: {uses}");
+                return None;
+            }
+
+            Some(Self::Repository(RepositoryUses {
+                owner: components[0],
+                repo: components[1],
+                subpath: components.get(2).copied(),
+                git_ref,
+            }))
         }
+    }
 
+    pub(crate) fn from_step(uses: &'a str) -> Option<Self> {
         Self::from_common(uses)
     }
 
-    pub(crate) fn from_reusable(uses: &'a str) -> Option<Self> {
+    /// Parse a [`Uses`] from a reusable workflow `uses:` clause.
+    ///
+    /// Returns only the [`RepositoryUses`] variant since Docker actions
+    /// can't be used in reusable workflows.
+    pub(crate) fn from_reusable(uses: &'a str) -> Option<RepositoryUses> {
         match Self::from_common(uses) {
+            // Reusable workflows don't support Docker actions.
+            Some(Uses::Docker(DockerUses { .. })) => None,
             // Reusable workflows require a git ref.
-            Some(uses) if uses.git_ref.is_none() => None,
-            Some(uses) => Some(uses),
+            Some(Uses::Repository(RepositoryUses {
+                owner: _,
+                repo: _,
+                subpath: _,
+                git_ref,
+            })) if git_ref.is_none() => None,
+            Some(Uses::Repository(repo)) => Some(repo),
             None => None,
         }
     }
 
-    pub(crate) fn ref_is_commit(&self) -> bool {
-        match self.git_ref {
-            Some(git_ref) => git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()),
-            None => false,
-        }
-    }
-
-    pub(crate) fn commit_ref(&self) -> Option<&str> {
-        match self.git_ref {
-            Some(git_ref) if self.ref_is_commit() => Some(git_ref),
-            _ => None,
-        }
-    }
-
-    pub(crate) fn symbolic_ref(&self) -> Option<&str> {
-        match self.git_ref {
-            Some(git_ref) if !self.ref_is_commit() => Some(git_ref),
-            _ => None,
+    pub(crate) fn unpinned(&self) -> bool {
+        match self {
+            Uses::Docker(docker) => docker.hash.is_none() && docker.tag.is_none(),
+            Uses::Repository(repo) => repo.git_ref.is_none(),
         }
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use super::Uses;
+    use super::{DockerUses, RepositoryUses, Uses};
 
     #[test]
     fn uses_from_step() {
@@ -299,46 +371,141 @@ mod tests {
             (
                 // Valid: fully pinned.
                 "actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
-                Some(Uses::new(
-                    "actions",
-                    "checkout",
-                    None,
-                    Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
-                )),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "actions",
+                    repo: "checkout",
+                    subpath: None,
+                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
+                })),
             ),
             (
                 // Valid: fully pinned, subpath
                 "actions/aws/ec2@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
-                Some(Uses::new(
-                    "actions",
-                    "aws",
-                    Some("ec2"),
-                    Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
-                )),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "actions",
+                    repo: "aws",
+                    subpath: Some("ec2"),
+                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
+                })),
             ),
             (
                 // Valid: fully pinned, complex subpath
                 "example/foo/bar/baz/quux@8f4b7f84864484a7bf31766abe9204da3cbe65b3",
-                Some(Uses::new(
-                    "example",
-                    "foo",
-                    Some("bar/baz/quux"),
-                    Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
-                )),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "example",
+                    repo: "foo",
+                    subpath: Some("bar/baz/quux"),
+                    git_ref: Some("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
+                })),
             ),
             (
                 // Valid: pinned with branch/tag
                 "actions/checkout@v4",
-                Some(Uses::new("actions", "checkout", None, Some("v4"))),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "actions",
+                    repo: "checkout",
+                    subpath: None,
+                    git_ref: Some("v4"),
+                })),
             ),
             (
                 "actions/checkout@abcd",
-                Some(Uses::new("actions", "checkout", None, Some("abcd"))),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "actions",
+                    repo: "checkout",
+                    subpath: None,
+                    git_ref: Some("abcd"),
+                })),
             ),
             (
                 // Valid: unpinned
                 "actions/checkout",
-                Some(Uses::new("actions", "checkout", None, None)),
+                Some(Uses::Repository(RepositoryUses {
+                    owner: "actions",
+                    repo: "checkout",
+                    subpath: None,
+                    git_ref: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, implicit registry
+                "docker://alpine:3.8",
+                Some(Uses::Docker(DockerUses {
+                    registry: None,
+                    image: "alpine",
+                    tag: Some("3.8"),
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, localhost
+                "docker://localhost/alpine:3.8",
+                Some(Uses::Docker(DockerUses {
+                    registry: Some("localhost"),
+                    image: "alpine",
+                    tag: Some("3.8"),
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, localhost w/ port
+                "docker://localhost:1337/alpine:3.8",
+                Some(Uses::Docker(DockerUses {
+                    registry: Some("localhost:1337"),
+                    image: "alpine",
+                    tag: Some("3.8"),
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, custom registry
+                "docker://ghcr.io/foo/alpine:3.8",
+                Some(Uses::Docker(DockerUses {
+                    registry: Some("ghcr.io"),
+                    image: "foo/alpine",
+                    tag: Some("3.8"),
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, missing tag
+                "docker://ghcr.io/foo/alpine",
+                Some(Uses::Docker(DockerUses {
+                    registry: Some("ghcr.io"),
+                    image: "foo/alpine",
+                    tag: None,
+                    hash: None,
+                })),
+            ),
+            (
+                // Invalid, but allowed: Docker ref, empty tag
+                "docker://ghcr.io/foo/alpine:",
+                Some(Uses::Docker(DockerUses {
+                    registry: Some("ghcr.io"),
+                    image: "foo/alpine",
+                    tag: None,
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, bare
+                "docker://alpine",
+                Some(Uses::Docker(DockerUses {
+                    registry: None,
+                    image: "alpine",
+                    tag: None,
+                    hash: None,
+                })),
+            ),
+            (
+                // Valid: Docker ref, hash
+                "docker://alpine@hash",
+                Some(Uses::Docker(DockerUses {
+                    registry: None,
+                    image: "alpine",
+                    tag: None,
+                    hash: Some("hash"),
+                })),
             ),
             // Invalid: missing user/repo
             ("checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3", None),
@@ -347,8 +514,6 @@ mod tests {
                 "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
                 None,
             ),
-            // Invalid: Docker refs not supported
-            ("docker://alpine:3.8", None),
         ];
 
         for (input, expected) in vectors {
@@ -363,30 +528,30 @@ mod tests {
             (
                 "octo-org/this-repo/.github/workflows/workflow-1.yml@\
                  172239021f7ba04fe7327647b213799853a9eb89",
-                Some(Uses::new(
-                    "octo-org",
-                    "this-repo",
-                    Some(".github/workflows/workflow-1.yml"),
-                    Some("172239021f7ba04fe7327647b213799853a9eb89"),
-                )),
+                Some(RepositoryUses {
+                    owner: "octo-org",
+                    repo: "this-repo",
+                    subpath: Some(".github/workflows/workflow-1.yml"),
+                    git_ref: Some("172239021f7ba04fe7327647b213799853a9eb89"),
+                }),
             ),
             (
                 "octo-org/this-repo/.github/workflows/workflow-1.yml@notahash",
-                Some(Uses::new(
-                    "octo-org",
-                    "this-repo",
-                    Some(".github/workflows/workflow-1.yml"),
-                    Some("notahash"),
-                )),
+                Some(RepositoryUses {
+                    owner: "octo-org",
+                    repo: "this-repo",
+                    subpath: Some(".github/workflows/workflow-1.yml"),
+                    git_ref: Some("notahash"),
+                }),
             ),
             (
                 "octo-org/this-repo/.github/workflows/workflow-1.yml@abcd",
-                Some(Uses::new(
-                    "octo-org",
-                    "this-repo",
-                    Some(".github/workflows/workflow-1.yml"),
-                    Some("abcd"),
-                )),
+                Some(RepositoryUses {
+                    owner: "octo-org",
+                    repo: "this-repo",
+                    subpath: Some(".github/workflows/workflow-1.yml"),
+                    git_ref: Some("abcd"),
+                }),
             ),
             // Invalid: no ref at all
             ("octo-org/this-repo/.github/workflows/workflow-1.yml", None),
@@ -410,16 +575,16 @@ mod tests {
     #[test]
     fn uses_ref_is_commit() {
         assert!(
-            Uses::from_step("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3")
+            Uses::from_reusable("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3")
                 .unwrap()
                 .ref_is_commit()
         );
 
-        assert!(!Uses::from_step("actions/checkout@v4")
+        assert!(!Uses::from_reusable("actions/checkout@v4")
             .unwrap()
             .ref_is_commit());
 
-        assert!(!Uses::from_step("actions/checkout@abcd")
+        assert!(!Uses::from_reusable("actions/checkout@abcd")
             .unwrap()
             .ref_is_commit());
     }


Test patch:
diff --git a/tests/acceptance.rs b/tests/acceptance.rs
index 0e125c0..23d6c63 100644
--- a/tests/acceptance.rs
+++ b/tests/acceptance.rs
@@ -162,3 +162,41 @@ fn audit_self_hosted() -> anyhow::Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn audit_unpinned_uses() -> anyhow::Result<()> {
+    let auditable = workflow_under_test("unpinned-uses.yml");
+
+    let cli_args = [&auditable];
+
+    let execution = zizmor().args(cli_args).output()?;
+
+    assert_eq!(execution.status.code(), Some(11));
+
+    let findings = serde_json::from_slice(&execution.stdout)?;
+
+    assert_value_match(&findings, "$[0].determinations.confidence", "High");
+    assert_value_match(&findings, "$[0].determinations.severity", "Informational");
+    assert_value_match(
+        &findings,
+        "$[0].locations[0].concrete.feature",
+        "uses: actions/checkout",
+    );
+    assert_value_match(
+        &findings,
+        "$[1].locations[0].concrete.feature",
+        "uses: github/codeql-action/upload-sarif",
+    );
+    assert_value_match(
+        &findings,
+        "$[2].locations[0].concrete.feature",
+        "uses: docker://ubuntu",
+    );
+    assert_value_match(
+        &findings,
+        "$[3].locations[0].concrete.feature",
+        "uses: docker://ghcr.io/pypa/gh-action-pypi-publish",
+    );
+
+    Ok(())
+}
diff --git a/tests/test-data/unpinned-uses.yml b/tests/test-data/unpinned-uses.yml
new file mode 100644
index 0000000..37ada56
--- /dev/null
+++ b/tests/test-data/unpinned-uses.yml
@@ -0,0 +1,26 @@
+name: example
+on: [push]
+
+jobs:
+  unpinned-0:
+    runs-on: ubuntu-latest
+    steps:
+      # NOT OK: unpinned
+      - uses: actions/checkout
+        with:
+          persist-credentials: false
+
+      # NOT OK: unpinned
+      - uses: github/codeql-action/upload-sarif
+
+      # NOT OK: unpinned
+      - uses: docker://ubuntu
+        with:
+          entrypoint: /bin/echo
+          args: hello!
+
+      # NOT OK: unpinned
+      - uses: docker://ghcr.io/pypa/gh-action-pypi-publish
+        with:
+          entrypoint: /bin/echo
+          args: hello!