Repository: woodruffw/zizmor
Pull request #425 (2025-01-12 17:32:41)

refactor: add StepBodyCommon
~~WIP.~~

This will allow me to remove the `uses()` and `with()` trait methods, which encode unnecessary runtime error states.

More fully, this implies that the `Uses` model needs to be pushed into `github_actions_models` and made into a hard parsing invariant (i.e. we should fail to parse if a `uses:` clause is invalid).

xref https://github.com/woodruffw/github-actions-models/pull/29


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 7a089ca..de73e24 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -616,9 +616,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
 
 [[package]]
 name = "github-actions-models"
-version = "0.18.0"
+version = "0.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "890f2af630615f46ba8240107ef72742ccff7254209bd75678c3896393739371"
+checksum = "1e9576dac15088d565d47b67d09bd9fd9f0d84bda4b0d34af91ab55327f4c2d0"
 dependencies = [
  "indexmap",
  "serde",
diff --git a/Cargo.toml b/Cargo.toml
index 19eef9a..4ec5178 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,7 @@ clap-verbosity-flag = { version = "3.0.2", features = [
 ], default-features = false }
 etcetera = "0.8.0"
 flate2 = "1.0.35"
-github-actions-models = "0.18.0"
+github-actions-models = "0.19.0"
 http-cache-reqwest = "0.15.0"
 human-panic = "2.0.1"
 indexmap = "2.7.0"
diff --git a/src/audit/artipacked.rs b/src/audit/artipacked.rs
index db5efbf..e9291a8 100644
--- a/src/audit/artipacked.rs
+++ b/src/audit/artipacked.rs
@@ -2,7 +2,7 @@ use std::ops::Deref;
 
 use anyhow::Result;
 use github_actions_models::{
-    common::{expr::ExplicitExpr, EnvValue},
+    common::{expr::ExplicitExpr, EnvValue, Uses},
     workflow::{job::StepBody, Job},
 };
 use itertools::Itertools;
@@ -10,6 +10,7 @@ use itertools::Itertools;
 use super::{audit_meta, Audit};
 use crate::{
     finding::{Confidence, Finding, Persona, Severity},
+    models::uses::RepositoryUsesExt as _,
     state::AuditState,
 };
 use crate::{models::Workflow, utils::split_patterns};
@@ -64,11 +65,15 @@ impl Audit for Artipacked {
             let mut vulnerable_checkouts = vec![];
             let mut vulnerable_uploads = vec![];
             for step in job.steps() {
-                let StepBody::Uses { ref uses, ref with } = &step.deref().body else {
+                let StepBody::Uses {
+                    uses: Uses::Repository(ref uses),
+                    ref with,
+                } = &step.deref().body
+                else {
                     continue;
                 };
 
-                if uses.starts_with("actions/checkout") {
+                if uses.matches("actions/checkout") {
                     match with.get("persist-credentials") {
                         Some(EnvValue::Boolean(false)) => continue,
                         Some(EnvValue::Boolean(true)) => {
@@ -80,7 +85,7 @@ impl Audit for Artipacked {
                         // persist-credentials is true by default.
                         _ => vulnerable_checkouts.push((step, Persona::default())),
                     }
-                } else if uses.starts_with("actions/upload-artifact") {
+                } else if uses.matches("actions/upload-artifact") {
                     let Some(EnvValue::String(path)) = with.get("path") else {
                         continue;
                     };
diff --git a/src/audit/cache_poisoning.rs b/src/audit/cache_poisoning.rs
index 376e063..2a5f73d 100644
--- a/src/audit/cache_poisoning.rs
+++ b/src/audit/cache_poisoning.rs
@@ -1,10 +1,12 @@
 use crate::audit::{audit_meta, Audit};
 use crate::finding::{Confidence, Finding, Severity};
 use crate::models::coordinate::{ActionCoordinate, Control, ControlFieldType, Toggle, Usage};
-use crate::models::{Job, Step, StepCommon, Steps, Uses};
+use crate::models::{Job, Step, StepCommon, Steps};
 use crate::state::AuditState;
+use github_actions_models::common::Uses;
 use github_actions_models::workflow::event::{BareEvent, BranchFilters, OptionalBody};
 use github_actions_models::workflow::Trigger;
+use std::str::FromStr;
 use std::sync::LazyLock;
 
 /// The list of know cache-aware actions
@@ -14,113 +16,113 @@ static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::ne
     vec![
         // https://github.com/actions/cache/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/cache").unwrap(),
+            uses: Uses::from_str("actions/cache").unwrap(),
             control: Control::new(Toggle::OptOut, "lookup-only", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/actions/setup-java/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/setup-java").unwrap(),
+            uses: Uses::from_str("actions/setup-java").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
             enabled_by_default: false,
         },
         // https://github.com/actions/setup-go/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/setup-go").unwrap(),
+            uses: Uses::from_str("actions/setup-go").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/actions/setup-node/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/setup-node").unwrap(),
+            uses: Uses::from_str("actions/setup-node").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
             enabled_by_default: false,
         },
         // https://github.com/actions/setup-python/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/setup-python").unwrap(),
+            uses: Uses::from_str("actions/setup-python").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
             enabled_by_default: false,
         },
         // https://github.com/actions/setup-dotnet/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions/setup-dotnet").unwrap(),
+            uses: Uses::from_str("actions/setup-dotnet").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
             enabled_by_default: false,
         },
         // https://github.com/astral-sh/setup-uv/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("astral-sh/setup-uv").unwrap(),
+            uses: Uses::from_str("astral-sh/setup-uv").unwrap(),
             control: Control::new(Toggle::OptOut, "enable-cache", ControlFieldType::String),
             enabled_by_default: true,
         },
         // https://github.com/Swatinem/rust-cache/blob/master/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("Swatinem/rust-cache").unwrap(),
+            uses: Uses::from_str("Swatinem/rust-cache").unwrap(),
             control: Control::new(Toggle::OptOut, "lookup-only", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/ruby/setup-ruby/blob/master/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("ruby/setup-ruby").unwrap(),
+            uses: Uses::from_str("ruby/setup-ruby").unwrap(),
             control: Control::new(Toggle::OptIn, "bundler-cache", ControlFieldType::Boolean),
             enabled_by_default: false,
         },
         // https://github.com/PyO3/maturin-action/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("PyO3/maturin-action").unwrap(),
+            uses: Uses::from_str("PyO3/maturin-action").unwrap(),
             control: Control::new(Toggle::OptIn, "sccache", ControlFieldType::Boolean),
             enabled_by_default: false,
         },
         // https://github.com/mlugg/setup-zig/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("mlugg/setup-zig").unwrap(),
+            uses: Uses::from_str("mlugg/setup-zig").unwrap(),
             control: Control::new(Toggle::OptIn, "use-cache", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/oven-sh/setup-bun/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("oven-sh/setup-bun").unwrap(),
+            uses: Uses::from_str("oven-sh/setup-bun").unwrap(),
             control: Control::new(Toggle::OptOut, "no-cache", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/DeterminateSystems/magic-nix-cache-action/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("DeterminateSystems/magic-nix-cache-action").unwrap(),
+            uses: Uses::from_str("DeterminateSystems/magic-nix-cache-action").unwrap(),
             control: Control::new(Toggle::OptIn, "use-gha-cache", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/graalvm/setup-graalvm/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("graalvm/setup-graalvm").unwrap(),
+            uses: Uses::from_str("graalvm/setup-graalvm").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::String),
             enabled_by_default: false,
         },
         // https://github.com/gradle/actions/blob/main/setup-gradle/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("gradle/actions/setup-gradle").unwrap(),
+            uses: Uses::from_str("gradle/actions/setup-gradle").unwrap(),
             control: Control::new(Toggle::OptOut, "cache-disabled", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/docker/setup-buildx-action/blob/master/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("docker/setup-buildx-action").unwrap(),
+            uses: Uses::from_str("docker/setup-buildx-action").unwrap(),
             control: Control::new(Toggle::OptIn, "cache-binary", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/actions-rust-lang/setup-rust-toolchain/blob/main/action.yml
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("actions-rust-lang/setup-rust-toolchain").unwrap(),
+            uses: Uses::from_str("actions-rust-lang/setup-rust-toolchain").unwrap(),
             control: Control::new(Toggle::OptIn, "cache", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         // https://github.com/Mozilla-Actions/sccache-action/blob/main/action.yml
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("Mozilla-Actions/sccache-action").unwrap(),
+            Uses::from_str("Mozilla-Actions/sccache-action").unwrap(),
         ),
         // https://github.com/nix-community/cache-nix-action/blob/main/action.yml
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("nix-community/cache-nix-action").unwrap(),
+            Uses::from_str("nix-community/cache-nix-action").unwrap(),
         ),
     ]
 });
@@ -130,49 +132,49 @@ static KNOWN_CACHE_AWARE_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::ne
 static KNOWN_PUBLISHER_ACTIONS: LazyLock<Vec<ActionCoordinate>> = LazyLock::new(|| {
     vec![
         // Public packages and/or binary distribution channels
-        ActionCoordinate::NotConfigurable(Uses::from_step("pypa/gh-action-pypi-publish").unwrap()),
-        ActionCoordinate::NotConfigurable(Uses::from_step("rubygems/release-gem").unwrap()),
-        ActionCoordinate::NotConfigurable(Uses::from_step("jreleaser/release-action").unwrap()),
-        ActionCoordinate::NotConfigurable(Uses::from_step("goreleaser/goreleaser-action").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("pypa/gh-action-pypi-publish").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("rubygems/release-gem").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("jreleaser/release-action").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("goreleaser/goreleaser-action").unwrap()),
         // Github releases
-        ActionCoordinate::NotConfigurable(Uses::from_step("softprops/action-gh-release").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("softprops/action-gh-release").unwrap()),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("release-drafter/release-drafter").unwrap(),
+            Uses::from_str("release-drafter/release-drafter").unwrap(),
         ),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("googleapis/release-please-action").unwrap(),
+            Uses::from_str("googleapis/release-please-action").unwrap(),
         ),
         // Container registries
         ActionCoordinate::Configurable {
-            uses: Uses::from_step("docker/build-push-action").unwrap(),
+            uses: Uses::from_str("docker/build-push-action").unwrap(),
             control: Control::new(Toggle::OptIn, "push", ControlFieldType::Boolean),
             enabled_by_default: true,
         },
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("redhat-actions/push-to-registry").unwrap(),
+            Uses::from_str("redhat-actions/push-to-registry").unwrap(),
         ),
         // Cloud + Edge providers
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("aws-actions/amazon-ecs-deploy-task-definition ").unwrap(),
+            Uses::from_str("aws-actions/amazon-ecs-deploy-task-definition ").unwrap(),
         ),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("aws-actions/aws-cloudformation-github-deploy").unwrap(),
+            Uses::from_str("aws-actions/aws-cloudformation-github-deploy").unwrap(),
         ),
-        ActionCoordinate::NotConfigurable(Uses::from_step("Azure/aci-deploy").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("Azure/aci-deploy").unwrap()),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("Azure/container-apps-deploy-action").unwrap(),
+            Uses::from_str("Azure/container-apps-deploy-action").unwrap(),
         ),
-        ActionCoordinate::NotConfigurable(Uses::from_step("Azure/functions-action").unwrap()),
-        ActionCoordinate::NotConfigurable(Uses::from_step("Azure/sql-action").unwrap()),
-        ActionCoordinate::NotConfigurable(Uses::from_step("cloudflare/wrangler-action").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("Azure/functions-action").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("Azure/sql-action").unwrap()),
+        ActionCoordinate::NotConfigurable(Uses::from_str("cloudflare/wrangler-action").unwrap()),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("google-github-actions/deploy-appengine").unwrap(),
+            Uses::from_str("google-github-actions/deploy-appengine").unwrap(),
         ),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("google-github-actions/deploy-cloudrun").unwrap(),
+            Uses::from_str("google-github-actions/deploy-cloudrun").unwrap(),
         ),
         ActionCoordinate::NotConfigurable(
-            Uses::from_step("google-github-actions/deploy-cloud-functions").unwrap(),
+            Uses::from_str("google-github-actions/deploy-cloud-functions").unwrap(),
         ),
     ]
 });
@@ -240,7 +242,7 @@ impl CachePoisoning {
         ))
     }
 
-    fn evaluate_cache_usage(&self, step: &impl StepCommon) -> Option<Usage> {
+    fn evaluate_cache_usage<'s>(&self, step: &impl StepCommon<'s>) -> Option<Usage> {
         KNOWN_CACHE_AWARE_ACTIONS
             .iter()
             .find_map(|coord| coord.usage(step))
diff --git a/src/audit/impostor_commit.rs b/src/audit/impostor_commit.rs
index 966edd2..f9af732 100644
--- a/src/audit/impostor_commit.rs
+++ b/src/audit/impostor_commit.rs
@@ -6,13 +6,16 @@
 //! [`clank`]: https://github.com/chainguard-dev/clank
 
 use anyhow::{anyhow, Result};
-use github_actions_models::workflow::Job;
+use github_actions_models::{
+    common::{RepositoryUses, Uses},
+    workflow::Job,
+};
 
 use super::{audit_meta, Audit};
 use crate::{
     finding::{Confidence, Finding, Severity},
     github_api::{self, ComparisonStatus},
-    models::{RepositoryUses, Uses, Workflow},
+    models::{uses::RepositoryUsesExt as _, Workflow},
     state::AuditState,
 };
 
@@ -31,14 +34,14 @@ audit_meta!(
 impl ImpostorCommit {
     fn named_ref_contains_commit(
         &self,
-        uses: &RepositoryUses<'_>,
+        uses: &RepositoryUses,
         base_ref: &str,
         head_ref: &str,
     ) -> Result<bool> {
         Ok(
             match self
                 .client
-                .compare_commits(uses.owner, uses.repo, base_ref, head_ref)?
+                .compare_commits(&uses.owner, &uses.repo, base_ref, head_ref)?
             {
                 // A base ref "contains" a commit if the base is either identical
                 // to the head ("identical") or the target is behind the base ("behind").
@@ -55,7 +58,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: RepositoryUses<'_>) -> 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);
@@ -65,7 +68,7 @@ impl ImpostorCommit {
         // the branch or tag's history, so check those first.
         // Check tags before branches, since in practice version tags
         // are more commonly pinned.
-        let tags = self.client.list_tags(uses.owner, uses.repo)?;
+        let tags = self.client.list_tags(&uses.owner, &uses.repo)?;
 
         for tag in &tags {
             if tag.commit.sha == head_ref {
@@ -73,7 +76,7 @@ impl ImpostorCommit {
             }
         }
 
-        let branches = self.client.list_branches(uses.owner, uses.repo)?;
+        let branches = self.client.list_branches(&uses.owner, &uses.repo)?;
 
         for branch in &branches {
             if branch.commit.sha == head_ref {
@@ -83,7 +86,7 @@ impl ImpostorCommit {
 
         for branch in &branches {
             if self.named_ref_contains_commit(
-                &uses,
+                uses,
                 &format!("refs/heads/{}", &branch.name),
                 head_ref,
             )? {
@@ -93,7 +96,7 @@ impl ImpostorCommit {
 
         for tag in &tags {
             if self.named_ref_contains_commit(
-                &uses,
+                uses,
                 &format!("refs/tags/{}", &tag.name),
                 head_ref,
             )? {
@@ -152,7 +155,7 @@ impl Audit for ImpostorCommit {
                 Job::ReusableWorkflowCallJob(reusable) => {
                     // Reusable workflows can also be commit pinned, meaning
                     // they can also be impersonated.
-                    let Some(uses) = Uses::from_reusable(&reusable.uses) else {
+                    let Uses::Repository(uses) = &reusable.uses else {
                         continue;
                     };
 
diff --git a/src/audit/known_vulnerable_actions.rs b/src/audit/known_vulnerable_actions.rs
index b643372..e2f6c85 100644
--- a/src/audit/known_vulnerable_actions.rs
+++ b/src/audit/known_vulnerable_actions.rs
@@ -6,6 +6,7 @@
 //! See: <https://docs.github.com/en/rest/security-advisories/global-advisories?apiVersion=2022-11-28>
 
 use anyhow::{anyhow, Context, Result};
+use github_actions_models::common::{RepositoryUses, Uses};
 
 use super::{audit_meta, Audit};
 use crate::finding::Finding;
@@ -13,7 +14,7 @@ use crate::models::CompositeStep;
 use crate::{
     finding::{Confidence, Severity},
     github_api,
-    models::{RepositoryUses, Uses},
+    models::uses::RepositoryUsesExt as _,
     state::AuditState,
 };
 
@@ -30,9 +31,9 @@ audit_meta!(
 impl KnownVulnerableActions {
     fn action_known_vulnerabilities(
         &self,
-        uses: &RepositoryUses<'_>,
+        uses: &RepositoryUses,
     ) -> Result<Vec<(Severity, String)>> {
-        let version = match uses.git_ref {
+        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.
             // In the "happy" case the symbolic ref is an exact version tag,
@@ -51,7 +52,8 @@ impl KnownVulnerableActions {
             // and then find the longest tag for that commit.
             Some(version) if !uses.ref_is_commit() => {
                 let Some(commit_ref) =
-                    self.client.commit_for_ref(uses.owner, uses.repo, version)?
+                    self.client
+                        .commit_for_ref(&uses.owner, &uses.repo, version)?
                 else {
                     // No `ref -> commit` means that the action's version
                     // is probably just outright invalid.
@@ -60,7 +62,7 @@ impl KnownVulnerableActions {
 
                 match self
                     .client
-                    .longest_tag_for_commit(uses.owner, uses.repo, &commit_ref)?
+                    .longest_tag_for_commit(&uses.owner, &uses.repo, &commit_ref)?
                 {
                     Some(tag) => tag.name,
                     // Somehow we've round-tripped through a commit and ended
@@ -76,7 +78,7 @@ impl KnownVulnerableActions {
             // which we should also probably support.
             Some(commit_ref) => match self
                 .client
-                .longest_tag_for_commit(uses.owner, uses.repo, commit_ref)
+                .longest_tag_for_commit(&uses.owner, &uses.repo, commit_ref)
                 .with_context(|| {
                     format!(
                         "couldn't retrieve tag for {owner}/{repo}@{commit_ref}",
@@ -101,7 +103,7 @@ impl KnownVulnerableActions {
 
         let vulns = self
             .client
-            .gha_advisories(uses.owner, uses.repo, &version)?;
+            .gha_advisories(&uses.owner, &uses.repo, &version)?;
 
         let mut results = vec![];
 
@@ -144,7 +146,7 @@ impl Audit for KnownVulnerableActions {
             return Ok(findings);
         };
 
-        for (severity, id) in self.action_known_vulnerabilities(&uses)? {
+        for (severity, id) in self.action_known_vulnerabilities(uses)? {
             findings.push(
                 Self::finding()
                     .confidence(Confidence::High)
@@ -170,7 +172,7 @@ impl Audit for KnownVulnerableActions {
             return Ok(findings);
         };
 
-        for (severity, id) in self.action_known_vulnerabilities(&uses)? {
+        for (severity, id) in self.action_known_vulnerabilities(uses)? {
             findings.push(
                 Self::finding()
                     .confidence(Confidence::High)
diff --git a/src/audit/ref_confusion.rs b/src/audit/ref_confusion.rs
index cfb2b08..8a8d782 100644
--- a/src/audit/ref_confusion.rs
+++ b/src/audit/ref_confusion.rs
@@ -9,6 +9,7 @@
 use std::ops::Deref;
 
 use anyhow::{anyhow, Result};
+use github_actions_models::common::{RepositoryUses, Uses};
 use github_actions_models::workflow::Job;
 
 use super::{audit_meta, Audit};
@@ -17,7 +18,7 @@ use crate::models::CompositeStep;
 use crate::{
     finding::{Confidence, Severity},
     github_api,
-    models::{RepositoryUses, Uses},
+    models::uses::RepositoryUsesExt as _,
     state::AuditState,
 };
 
@@ -40,8 +41,8 @@ impl RefConfusion {
             return Ok(false);
         };
 
-        let branches_match = self.client.has_branch(uses.owner, uses.repo, sym_ref)?;
-        let tags_match = self.client.has_tag(uses.owner, uses.repo, sym_ref)?;
+        let branches_match = self.client.has_branch(&uses.owner, &uses.repo, sym_ref)?;
+        let tags_match = self.client.has_tag(&uses.owner, &uses.repo, sym_ref)?;
 
         // If both the branch and tag namespaces have a match, we have a
         // confusable ref.
@@ -79,7 +80,7 @@ impl Audit for RefConfusion {
                             continue;
                         };
 
-                        if self.confusable(&uses)? {
+                        if self.confusable(uses)? {
                             findings.push(
                                 Self::finding()
                                     .severity(Severity::Medium)
@@ -96,11 +97,11 @@ impl Audit for RefConfusion {
                     }
                 }
                 Job::ReusableWorkflowCallJob(reusable) => {
-                    let Some(uses) = Uses::from_reusable(&reusable.uses) else {
+                    let Uses::Repository(uses) = &reusable.uses else {
                         continue;
                     };
 
-                    if self.confusable(&uses)? {
+                    if self.confusable(uses)? {
                         findings.push(
                             Self::finding()
                                 .severity(Severity::Medium)
@@ -125,7 +126,7 @@ impl Audit for RefConfusion {
             return Ok(findings);
         };
 
-        if self.confusable(&uses)? {
+        if self.confusable(uses)? {
             findings.push(
                 Self::finding()
                     .severity(Severity::Medium)
diff --git a/src/audit/template_injection.rs b/src/audit/template_injection.rs
index 86cdc9c..54e87f2 100644
--- a/src/audit/template_injection.rs
+++ b/src/audit/template_injection.rs
@@ -10,15 +10,16 @@
 //! A small amount of additional processing is done to remove template
 //! expressions that an attacker can't control.
 
-use std::ops::Deref;
-
-use github_actions_models::{action, common::expr::LoE, workflow::job, workflow::job::Strategy};
+use github_actions_models::{
+    common::{expr::LoE, Uses},
+    workflow::job::Strategy,
+};
 
 use super::{audit_meta, Audit};
 use crate::{
     expr::{BinOp, Expr, UnOp},
-    finding::{Confidence, Persona, Severity},
-    models::{self, StepCommon},
+    finding::{Confidence, Persona, Severity, SymbolicLocation},
+    models::{self, uses::RepositoryUsesExt as _, StepCommon},
     state::AuditState,
     utils::extract_expressions,
 };
@@ -79,6 +80,43 @@ const SAFE_CONTEXTS: &[&str] = &[
 ];
 
 impl TemplateInjection {
+    fn script_with_location<'s>(
+        step: &impl StepCommon<'s>,
+    ) -> Option<(String, SymbolicLocation<'s>)> {
+        match step.body() {
+            models::StepBodyCommon::Uses {
+                uses: Uses::Repository(uses),
+                with,
+            } => {
+                if uses.matches("actions/github-script") {
+                    with.get("script").map(|script| {
+                        (
+                            script.to_string(),
+                            step.location().with_keys(&["with".into(), "script".into()]),
+                        )
+                    })
+                } else if uses.matches("azure/powershell") || uses.matches("azure/cli") {
+                    // Both `azure/powershell` and `azure/cli` uses the same `inlineScript`
+                    // option to feed arbitrary code.
+
+                    with.get("inlineScript").map(|script| {
+                        (
+                            script.to_string(),
+                            step.location()
+                                .with_keys(&["with".into(), "inlineScript".into()]),
+                        )
+                    })
+                } else {
+                    None
+                }
+            }
+            models::StepBodyCommon::Run { run, .. } => {
+                Some((run.to_string(), step.location().with_keys(&["run".into()])))
+            }
+            _ => None,
+        }
+    }
+
     /// Checks whether an expression is "safe" for the purposes of template
     /// injection.
     ///
@@ -123,10 +161,10 @@ impl TemplateInjection {
         }
     }
 
-    fn injectable_template_expressions(
+    fn injectable_template_expressions<'s>(
         &self,
         run: &str,
-        step: &impl StepCommon,
+        step: &impl StepCommon<'s>,
     ) -> Vec<(String, Severity, Confidence, Persona)> {
         let mut bad_expressions = vec![];
         for expr in extract_expressions(run) {
@@ -240,25 +278,12 @@ impl Audit for TemplateInjection {
     ) -> anyhow::Result<Vec<super::Finding<'a>>> {
         let mut findings = vec![];
 
-        let (script, script_loc) = match &step.deref().body {
-            action::StepBody::Uses { uses, with } => {
-                if uses.starts_with("actions/github-script") {
-                    match with.get("script") {
-                        Some(script) => (
-                            &script.to_string(),
-                            step.location().with_keys(&["with".into(), "script".into()]),
-                        ),
-                        None => return Ok(findings),
-                    }
-                } else {
-                    return Ok(findings);
-                }
-            }
-            action::StepBody::Run { run, .. } => (run, step.location().with_keys(&["run".into()])),
+        let Some((script, script_loc)) = Self::script_with_location(step) else {
+            return Ok(findings);
         };
 
         for (expr, severity, confidence, persona) in
-            self.injectable_template_expressions(script, step)
+            self.injectable_template_expressions(&script, step)
         {
             findings.push(
                 Self::finding()
@@ -281,37 +306,12 @@ impl Audit for TemplateInjection {
     fn audit_step<'w>(&self, step: &super::Step<'w>) -> anyhow::Result<Vec<super::Finding<'w>>> {
         let mut findings = vec![];
 
-        let (script, script_loc) = match &step.deref().body {
-            job::StepBody::Uses { uses, with } => {
-                if uses.starts_with("actions/github-script") {
-                    match with.get("script") {
-                        Some(script) => (
-                            &script.to_string(),
-                            step.location().with_keys(&["with".into(), "script".into()]),
-                        ),
-                        None => return Ok(findings),
-                    }
-                } else if uses.starts_with("azure/powershell") || uses.starts_with("azure/cli") {
-                    // Both `azure/powershell` and `azure/cli` uses the same `inlineScript`
-                    // option to feed arbitrary code.
-                    if let Some(script) = with.get("inlineScript") {
-                        (
-                            &script.to_string(),
-                            step.location()
-                                .with_keys(&["with".into(), "inlineScript".into()]),
-                        )
-                    } else {
-                        return Ok(findings);
-                    }
-                } else {
-                    return Ok(findings);
-                }
-            }
-            job::StepBody::Run { run, .. } => (run, step.location().with_keys(&["run".into()])),
+        let Some((script, script_loc)) = Self::script_with_location(step) else {
+            return Ok(findings);
         };
 
         for (expr, severity, confidence, persona) in
-            self.injectable_template_expressions(script, step)
+            self.injectable_template_expressions(&script, step)
         {
             findings.push(
                 Self::finding()
diff --git a/src/audit/unpinned_uses.rs b/src/audit/unpinned_uses.rs
index 25e8e86..bcbfdf2 100644
--- a/src/audit/unpinned_uses.rs
+++ b/src/audit/unpinned_uses.rs
@@ -1,6 +1,8 @@
+use github_actions_models::common::Uses;
+
 use super::{audit_meta, Audit, AuditState, Finding, Step};
 use crate::finding::{Confidence, Persona, Severity};
-use crate::models::{CompositeStep, Uses};
+use crate::models::{uses::UsesExt as _, CompositeStep};
 
 pub(crate) struct UnpinnedUses;
 
@@ -41,7 +43,7 @@ impl Audit for UnpinnedUses {
             return Ok(vec![]);
         };
 
-        let Some((annotation, severity, persona)) = self.evaluate_pinning(&uses) else {
+        let Some((annotation, severity, persona)) = self.evaluate_pinning(uses) else {
             return Ok(vec![]);
         };
 
@@ -72,7 +74,7 @@ impl Audit for UnpinnedUses {
             return Ok(vec![]);
         };
 
-        let Some((annotation, severity, persona)) = self.evaluate_pinning(&uses) else {
+        let Some((annotation, severity, persona)) = self.evaluate_pinning(uses) else {
             return Ok(vec![]);
         };
 
diff --git a/src/audit/use_trusted_publishing.rs b/src/audit/use_trusted_publishing.rs
index 0d134b0..ca62332 100644
--- a/src/audit/use_trusted_publishing.rs
+++ b/src/audit/use_trusted_publishing.rs
@@ -1,13 +1,16 @@
 use std::ops::Deref;
 
 use anyhow::Ok;
-use github_actions_models::{common::EnvValue, workflow::job::StepBody};
+use github_actions_models::{
+    common::{EnvValue, Uses},
+    workflow::job::StepBody,
+};
 use indexmap::IndexMap;
 
 use super::{audit_meta, Audit};
 use crate::{
     finding::{Confidence, Severity},
-    models::Uses,
+    models::uses::RepositoryUsesExt as _,
     state::AuditState,
 };
 
@@ -74,11 +77,11 @@ impl Audit for UseTrustedPublishing {
     fn audit_step<'w>(&self, step: &super::Step<'w>) -> anyhow::Result<Vec<super::Finding<'w>>> {
         let mut findings = vec![];
 
-        let StepBody::Uses { uses, with } = &step.deref().body else {
-            return Ok(findings);
-        };
-
-        let Some(Uses::Repository(uses)) = Uses::from_step(uses) else {
+        let StepBody::Uses {
+            uses: Uses::Repository(uses),
+            with,
+        } = &step.deref().body
+        else {
             return Ok(findings);
         };
 
diff --git a/src/github_api.rs b/src/github_api.rs
index adb2248..84755ee 100644
--- a/src/github_api.rs
+++ b/src/github_api.rs
@@ -8,6 +8,7 @@ use std::{io::Read, ops::Deref, path::Path};
 use anyhow::{anyhow, Result};
 use camino::Utf8Path;
 use flate2::read::GzDecoder;
+use github_actions_models::common::RepositoryUses;
 use http_cache_reqwest::{
     CACacheManager, Cache, CacheMode, CacheOptions, HttpCache, HttpCacheOptions,
 };
@@ -22,7 +23,7 @@ use tracing::instrument;
 
 use crate::{
     audit::AuditInput,
-    models::{Action, RepositoryUses, Workflow},
+    models::{Action, Workflow},
     registry::InputKey,
     utils::PipeSelf,
 };
@@ -315,9 +316,9 @@ impl Client {
     #[instrument(skip(self))]
     #[tokio::main]
     pub(crate) async fn fetch_workflows(&self, slug: &RepositoryUses) -> Result<Vec<Workflow>> {
-        let owner = slug.owner;
-        let repo = slug.repo;
-        let git_ref = slug.git_ref;
+        let owner = &slug.owner;
+        let repo = &slug.repo;
+        let git_ref = &slug.git_ref;
 
         tracing::debug!("fetching workflows for {owner}/{repo}");
 
@@ -354,7 +355,7 @@ impl Client {
                 .http
                 .get(file_url)
                 .header(ACCEPT, "application/vnd.github.raw+json")
-                .pipe(|req| match git_ref {
+                .pipe(|req| match git_ref.as_ref() {
                     Some(g) => req.query(&[("ref", g)]),
                     None => req,
                 })
@@ -391,7 +392,7 @@ impl Client {
             api_base = self.api_base,
             owner = slug.owner,
             repo = slug.repo,
-            git_ref = slug.git_ref.unwrap_or("HEAD")
+            git_ref = slug.git_ref.as_deref().unwrap_or("HEAD")
         );
         tracing::debug!("fetching repo: {url}");
 
diff --git a/src/main.rs b/src/main.rs
index cdeba71..fac1e19 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,4 @@
-use std::{io::stdout, process::ExitCode};
+use std::{io::stdout, process::ExitCode, str::FromStr};
 
 use annotate_snippets::{Level, Renderer};
 use anstream::{eprintln, stream::IsTerminal};
@@ -9,9 +9,10 @@ use clap::{Parser, ValueEnum};
 use clap_verbosity_flag::InfoLevel;
 use config::Config;
 use finding::{Confidence, Persona, Severity};
+use github_actions_models::common::Uses;
 use github_api::GitHubHost;
 use indicatif::ProgressStyle;
-use models::{Action, Uses};
+use models::Action;
 use owo_colors::OwoColorize;
 use registry::{AuditRegistry, FindingRegistry, InputRegistry};
 use state::AuditState;
@@ -217,7 +218,7 @@ fn collect_from_repo_slug(
     registry: &mut InputRegistry,
 ) -> Result<()> {
     // Our pre-existing `uses: <slug>` parser does 90% of the work for us.
-    let Some(Uses::Repository(slug)) = Uses::from_step(input) else {
+    let Ok(Uses::Repository(slug)) = Uses::from_str(input) else {
         return Err(anyhow!(tip(
             format!("invalid input: {input}"),
             format!(
diff --git a/src/models.rs b/src/models.rs
index f193114..4e6b890 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -4,9 +4,8 @@
 use crate::finding::{Route, SymbolicLocation};
 use crate::registry::InputKey;
 use crate::utils::{self, extract_expressions};
-use anyhow::{anyhow, bail, Context, Result};
+use anyhow::{bail, Context, Result};
 use camino::Utf8Path;
-use github_actions_models::action;
 use github_actions_models::common::expr::LoE;
 use github_actions_models::common::Env;
 use github_actions_models::workflow::event::{BareEvent, OptionalBody};
@@ -16,6 +15,7 @@ use github_actions_models::workflow::{
     job::{NormalJob, StepBody},
     Trigger,
 };
+use github_actions_models::{action, common};
 use indexmap::IndexMap;
 use serde_json::{json, Value};
 use std::collections::HashMap;
@@ -24,9 +24,24 @@ use std::{iter::Enumerate, ops::Deref};
 use terminal_link::Link;
 
 pub(crate) mod coordinate;
+pub(crate) mod uses;
+
+/// Common fields between workflow and action step bodies.
+pub(crate) enum StepBodyCommon<'s> {
+    Uses {
+        uses: &'s common::Uses,
+        with: &'s Env,
+    },
+    Run {
+        run: &'s str,
+        _working_directory: Option<&'s str>,
+        _shell: Option<&'s str>,
+        _env: &'s LoE<Env>,
+    },
+}
 
 /// Common interfaces between workflow and action steps.
-pub(crate) trait StepCommon {
+pub(crate) trait StepCommon<'s> {
     /// Returns whether the given `env.name` environment access is "static,"
     /// i.e. is not influenced by another expression.
     fn env_is_static(&self, name: &str) -> bool;
@@ -36,11 +51,11 @@ pub(crate) trait StepCommon {
     /// Composite action steps have no strategy.
     fn strategy(&self) -> Option<&Strategy>;
 
-    /// Returns this step's `uses:` clause, if present.
-    fn uses(&self) -> Option<Uses>;
+    /// Returns a [`StepBodyCommon`] for this step.
+    fn body(&self) -> StepBodyCommon;
 
-    /// Returns the `with:` clause for this step.
-    fn with(&self) -> Option<&Env>;
+    /// Returns a [`SymbolicLocation`] for this step.
+    fn location(&self) -> SymbolicLocation<'s>;
 }
 
 /// Represents an entire GitHub Actions workflow.
@@ -437,7 +452,7 @@ impl<'w> Deref for Step<'w> {
     }
 }
 
-impl StepCommon for Step<'_> {
+impl<'s> StepCommon<'s> for Step<'s> {
     fn env_is_static(&self, name: &str) -> bool {
         // Collect each of the step, job, and workflow-level `env` blocks
         // and check each.
@@ -465,16 +480,26 @@ impl StepCommon for Step<'_> {
         self.job().strategy.as_ref()
     }
 
-    fn uses(&self) -> Option<Uses> {
-        self.uses()
-    }
-
-    fn with(&self) -> Option<&Env> {
+    fn body(&self) -> StepBodyCommon {
         match &self.body {
-            StepBody::Uses { uses: _, with } => Some(with),
-            StepBody::Run { .. } => None,
+            StepBody::Uses { uses, with } => StepBodyCommon::Uses { uses, with },
+            StepBody::Run {
+                run,
+                working_directory,
+                shell,
+                env,
+            } => StepBodyCommon::Run {
+                run,
+                _working_directory: working_directory.as_deref(),
+                _shell: shell.as_deref(),
+                _env: env,
+            },
         }
     }
+
+    fn location(&self) -> SymbolicLocation<'s> {
+        self.location()
+    }
 }
 
 impl<'w> Step<'w> {
@@ -502,13 +527,13 @@ impl<'w> Step<'w> {
         self.parent.parent()
     }
 
-    /// Returns a [`Uses`] for this [`Step`], if it has one.
-    pub(crate) fn uses(&self) -> Option<Uses<'w>> {
+    /// Returns a [`common::Uses`] for this [`Step`], if it has one.
+    pub(crate) fn uses(&self) -> Option<&common::Uses> {
         let StepBody::Uses { uses, .. } = &self.inner.body else {
             return None;
         };
 
-        Uses::from_step(uses)
+        Some(uses)
     }
 
     /// Returns the the shell used by this step, or `None`
@@ -601,208 +626,6 @@ impl<'w> Iterator for Steps<'w> {
     }
 }
 
-/// The contents of a `uses: docker://` step stanza.
-#[derive(Copy, Clone, Debug, PartialEq)]
-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>,
-}
-
-/// The contents of a `uses: some/repo` step stanza.
-#[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> TryFrom<&'a str> for RepositoryUses<'a> {
-    type Error = anyhow::Error;
-
-    fn try_from(value: &'a str) -> std::result::Result<Self, Self::Error> {
-        let Some(Uses::Repository(uses)) = Uses::from_common(value) else {
-            return Err(anyhow!("invalid repository uses: {value}"));
-        };
-
-        Ok(uses)
-    }
-}
-
-impl RepositoryUses<'_> {
-    /// Returns whether this `uses:` clause "matches" the given template.
-    /// The template is itself formatted like a normal `uses:` clause.
-    ///
-    /// This is an asymmetrical match: `actions/checkout@v3` "matches"
-    /// the `actions/checkout` template but not vice versa.
-    ///
-    /// Comparisons consider that GitHub does not adopt
-    /// case-sensitive URLs, hence templates like
-    /// `Actions/Checkout` and `actions/checkout`
-    /// resolve to the same Action
-    pub(crate) fn matches<'a>(&self, template: impl TryInto<RepositoryUses<'a>>) -> bool {
-        let Ok(other) = template.try_into() else {
-            return false;
-        };
-
-        self.owner.eq_ignore_ascii_case(other.owner)
-            && self.repo.eq_ignore_ascii_case(other.repo)
-            && self.subpath.map(|s| s.to_lowercase()) == other.subpath.map(|s| s.to_lowercase())
-            && other.git_ref.map_or(true, |git_ref| {
-                Some(git_ref.to_lowercase()) == self.git_ref.map(|r| r.to_lowercase())
-            })
-    }
-
-    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,
-        }
-    }
-}
-
-/// 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),
-        };
-
-        // 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,
-            }))
-        }
-    }
-
-    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 {
-                tracing::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)
-    }
-
-    /// 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<'a>> {
-        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::Repository(RepositoryUses {
-                owner: _,
-                repo: _,
-                subpath: _,
-                git_ref,
-            })) if git_ref.is_none() => None,
-            Some(Uses::Repository(repo)) => Some(repo),
-            None => 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(),
-        }
-    }
-
-    pub(crate) fn unhashed(&self) -> bool {
-        match self {
-            Uses::Docker(docker) => docker.hash.is_some(),
-            Uses::Repository(repo) => !repo.ref_is_commit(),
-        }
-    }
-}
-
 /// Represents an entire (composite) action.
 ///
 /// This type implements [`Deref`] for [`action::Action`], providing
@@ -937,7 +760,7 @@ impl<'a> Deref for CompositeStep<'a> {
     }
 }
 
-impl StepCommon for CompositeStep<'_> {
+impl<'s> StepCommon<'s> for CompositeStep<'s> {
     fn env_is_static(&self, name: &str) -> bool {
         let env = match &self.body {
             action::StepBody::Uses { .. } => {
@@ -958,16 +781,26 @@ impl StepCommon for CompositeStep<'_> {
         None
     }
 
-    fn uses(&self) -> Option<Uses> {
-        self.uses()
-    }
-
-    fn with(&self) -> Option<&Env> {
+    fn body(&self) -> StepBodyCommon {
         match &self.body {
-            action::StepBody::Uses { uses: _, with } => Some(with),
-            action::StepBody::Run { .. } => None,
+            action::StepBody::Uses { uses, with } => StepBodyCommon::Uses { uses, with },
+            action::StepBody::Run {
+                run,
+                working_directory,
+                shell,
+                env,
+            } => StepBodyCommon::Run {
+                run,
+                _working_directory: working_directory.as_deref(),
+                _shell: Some(shell),
+                _env: env,
+            },
         }
     }
+
+    fn location(&self) -> SymbolicLocation<'s> {
+        self.location()
+    }
 }
 
 impl<'a> CompositeStep<'a> {
@@ -999,243 +832,23 @@ impl<'a> CompositeStep<'a> {
         self.parent
     }
 
-    /// Returns a [`Uses`] for this [`Step`], if it has one.
-    pub(crate) fn uses(&self) -> Option<Uses<'a>> {
+    /// Returns a [`common::Uses`] for this [`Step`], if it has one.
+    pub(crate) fn uses(&self) -> Option<&common::Uses> {
         let action::StepBody::Uses { uses, .. } = &self.inner.body else {
             return None;
         };
 
-        Uses::from_step(uses)
+        Some(uses)
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use super::{DockerUses, RepositoryUses, Uses};
-
-    #[test]
-    fn uses_from_step() {
-        let vectors = [
-            (
-                // Valid: fully pinned.
-                "actions/checkout@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::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::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::Repository(RepositoryUses {
-                    owner: "actions",
-                    repo: "checkout",
-                    subpath: None,
-                    git_ref: Some("v4"),
-                })),
-            ),
-            (
-                "actions/checkout@abcd",
-                Some(Uses::Repository(RepositoryUses {
-                    owner: "actions",
-                    repo: "checkout",
-                    subpath: None,
-                    git_ref: Some("abcd"),
-                })),
-            ),
-            (
-                // Valid: unpinned
-                "actions/checkout",
-                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),
-            // Invalid: local action refs not supported
-            (
-                "./.github/actions/hello-world-action@172239021f7ba04fe7327647b213799853a9eb89",
-                None,
-            ),
-        ];
-
-        for (input, expected) in vectors {
-            assert_eq!(Uses::from_step(input), expected);
-        }
-    }
+    use std::str::FromStr;
 
-    #[test]
-    fn uses_from_reusable() {
-        let vectors = [
-            // Valid, as expected.
-            (
-                "octo-org/this-repo/.github/workflows/workflow-1.yml@\
-                 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(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(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),
-            // Invalid: missing user/repo
-            (
-                "workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
-                None,
-            ),
-            // Invalid: local reusable workflow refs not supported
-            (
-                "./.github/workflows/workflow-1.yml@172239021f7ba04fe7327647b213799853a9eb89",
-                None,
-            ),
-        ];
-
-        for (input, expected) in vectors {
-            assert_eq!(Uses::from_reusable(input), expected);
-        }
-    }
+    use github_actions_models::common::Uses;
 
-    #[test]
-    fn uses_ref_is_commit() {
-        assert!(
-            Uses::from_reusable("actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3")
-                .unwrap()
-                .ref_is_commit()
-        );
-
-        assert!(!Uses::from_reusable("actions/checkout@v4")
-            .unwrap()
-            .ref_is_commit());
-
-        assert!(!Uses::from_reusable("actions/checkout@abcd")
-            .unwrap()
-            .ref_is_commit());
-    }
+    use crate::models::uses::RepositoryUsesExt;
 
     #[test]
     fn test_repositoryuses_matches() {
@@ -1263,7 +876,7 @@ mod tests {
             ("actions/checkout", "actions/checkout@v3", false),
             ("actions/checkout/foo", "actions/checkout/foo@v3", false),
         ] {
-            let Some(Uses::Repository(uses)) = Uses::from_common(uses) else {
+            let Ok(Uses::Repository(uses)) = Uses::from_str(uses) else {
                 panic!();
             };
 
diff --git a/src/models/coordinate.rs b/src/models/coordinate.rs
index f830fac..7a2a85c 100644
--- a/src/models/coordinate.rs
+++ b/src/models/coordinate.rs
@@ -13,27 +13,28 @@
 // able to express things like
 // "match foo/bar if foo: A and not bar: B and baz: /abcd/"
 
-use github_actions_models::common::{expr::ExplicitExpr, EnvValue};
+use github_actions_models::common::{expr::ExplicitExpr, EnvValue, Uses};
 
-use super::{StepCommon, Uses};
+use super::{StepBodyCommon, StepCommon};
+use crate::models::uses::RepositoryUsesExt as _;
 
-pub(crate) enum ActionCoordinate<'w> {
+pub(crate) enum ActionCoordinate {
     Configurable {
         /// The `uses:` clause of the coordinate
-        uses: Uses<'w>,
+        uses: Uses,
         /// The input that controls the coordinate
         control: Control,
         /// Whether or not the behavior is the default
         enabled_by_default: bool,
     },
-    NotConfigurable(Uses<'w>),
+    NotConfigurable(Uses),
 }
 
-impl ActionCoordinate<'_> {
-    pub(crate) fn uses(&self) -> Uses {
+impl ActionCoordinate {
+    pub(crate) fn uses(&self) -> &Uses {
         match self {
-            ActionCoordinate::Configurable { uses, .. } => *uses,
-            ActionCoordinate::NotConfigurable(inner) => *inner,
+            ActionCoordinate::Configurable { uses, .. } => uses,
+            ActionCoordinate::NotConfigurable(inner) => inner,
         }
     }
 
@@ -71,17 +72,21 @@ impl ActionCoordinate<'_> {
     /// while the `Some(_)` variants indicate various (potential) usages (such as being implicitly
     /// enabled, or explicitly enabled, or potentially enabled by a template expansion that
     /// can't be directly analyzed).
-    pub(crate) fn usage(&self, step: &impl StepCommon) -> Option<Usage> {
+    pub(crate) fn usage<'s>(&self, step: &impl StepCommon<'s>) -> Option<Usage> {
         let Uses::Repository(template) = self.uses() else {
             return None;
         };
-        let Uses::Repository(uses) = step.uses()? else {
+        let StepBodyCommon::Uses {
+            uses: Uses::Repository(uses),
+            with,
+        } = step.body()
+        else {
             return None;
         };
 
         // If our coordinate's `uses:` template doesn't match the step's `uses:`,
         // then no usage semantics are possible.
-        if !uses.matches(template) {
+        if !uses.matches_uses(template) {
             return None;
         }
 
@@ -92,7 +97,6 @@ impl ActionCoordinate<'_> {
                 enabled_by_default,
             } => {
                 // We need to inspect this `uses:`'s configuration to determine its semantics.
-                let with = step.with()?;
                 match with.get(control.field_name) {
                     Some(field_value) => {
                         // The declared usage is whatever the user explicitly configured,
@@ -165,14 +169,16 @@ pub(crate) enum Usage {
 
 #[cfg(test)]
 mod tests {
-    use github_actions_models::workflow::job::Step;
+    use std::str::FromStr;
+
+    use github_actions_models::{common::Uses, workflow::job::Step};
 
     use crate::models::coordinate::{Control, ControlFieldType, Toggle, Usage};
 
-    use super::{ActionCoordinate, StepCommon, Uses};
+    use super::{ActionCoordinate, StepCommon};
 
     // Test-only trait impl.
-    impl StepCommon for Step {
+    impl<'s> StepCommon<'s> for Step {
         fn env_is_static(&self, _name: &str) -> bool {
             unimplemented!()
         }
@@ -181,22 +187,27 @@ mod tests {
             unimplemented!()
         }
 
-        fn uses(&self) -> Option<Uses> {
+        fn body(&self) -> super::StepBodyCommon {
             match &self.body {
-                github_actions_models::workflow::job::StepBody::Uses { uses, .. } => {
-                    Some(Uses::from_step(uses).unwrap())
+                github_actions_models::workflow::job::StepBody::Uses { uses, with } => {
+                    super::StepBodyCommon::Uses { uses, with }
                 }
-                github_actions_models::workflow::job::StepBody::Run { .. } => None,
+                github_actions_models::workflow::job::StepBody::Run {
+                    run,
+                    working_directory,
+                    shell,
+                    env,
+                } => super::StepBodyCommon::Run {
+                    run,
+                    _working_directory: working_directory.as_deref(),
+                    _shell: shell.as_deref(),
+                    _env: env,
+                },
             }
         }
 
-        fn with(&self) -> Option<&github_actions_models::common::Env> {
-            match &self.body {
-                github_actions_models::workflow::job::StepBody::Uses { uses: _, with } => {
-                    Some(with)
-                }
-                github_actions_models::workflow::job::StepBody::Run { .. } => None,
-            }
+        fn location(&self) -> crate::models::SymbolicLocation<'s> {
+            unimplemented!()
         }
     }
 
@@ -204,7 +215,7 @@ mod tests {
     fn test_usage() {
         // Trivial case: no usage is possible, since the coordinate's `uses:`
         // does not match the step.
-        let coord = ActionCoordinate::NotConfigurable(Uses::from_common("foo/bar").unwrap());
+        let coord = ActionCoordinate::NotConfigurable(Uses::from_str("foo/bar").unwrap());
         let step: Step = serde_yaml::from_str("uses: not/thesame").unwrap();
         assert_eq!(coord.usage(&step), None);
 
@@ -217,7 +228,7 @@ mod tests {
         // Coordinate `uses:` matches but is not enabled by default and is
         // missing the needed control.
         let coord = ActionCoordinate::Configurable {
-            uses: Uses::from_common("foo/bar").unwrap(),
+            uses: Uses::from_str("foo/bar").unwrap(),
             control: Control::new(Toggle::OptIn, "set-me", ControlFieldType::Boolean),
             enabled_by_default: false,
         };
@@ -234,7 +245,7 @@ mod tests {
 
         // Coordinate `uses:` matches and is enabled by default.
         let coord = ActionCoordinate::Configurable {
-            uses: Uses::from_common("foo/bar").unwrap(),
+            uses: Uses::from_str("foo/bar").unwrap(),
             control: Control::new(Toggle::OptIn, "set-me", ControlFieldType::Boolean),
             enabled_by_default: true,
         };
@@ -252,7 +263,7 @@ mod tests {
         // Coordinate `uses:` matches and has an opt-out toggle, which does not affect
         // the default.
         let coord = ActionCoordinate::Configurable {
-            uses: Uses::from_common("foo/bar").unwrap(),
+            uses: Uses::from_str("foo/bar").unwrap(),
             control: Control::new(Toggle::OptOut, "disable-cache", ControlFieldType::Boolean),
             enabled_by_default: false,
         };
diff --git a/src/models/uses.rs b/src/models/uses.rs
new file mode 100644
index 0000000..866420b
--- /dev/null
+++ b/src/models/uses.rs
@@ -0,0 +1,104 @@
+//! Extension traits for the `Uses` APIs.
+
+use github_actions_models::common::{RepositoryUses, Uses};
+
+/// Useful APIs for interacting with `uses: org/repo` clauses.
+pub(crate) trait RepositoryUsesExt {
+    /// Returns whether this `uses:` clause "matches" the given template.
+    /// The template is itself formatted like a normal `uses:` clause.
+    ///
+    /// This is an asymmetrical match: `actions/checkout@v3` "matches"
+    /// the `actions/checkout` template but not vice versa.
+    ///
+    /// Comparisons are case-insensitive, since GitHub's own APIs are insensitive.
+    fn matches(&self, template: &str) -> bool;
+
+    /// Like [`RepositoryUsesExt::matches`].
+    fn matches_uses(&self, template: &RepositoryUses) -> bool;
+
+    /// Returns whether this `uses:` clause has a `git` ref and, if so,
+    /// whether that ref is a commit ref.
+    ///
+    /// For example, `foo/bar@baz` returns false while `foo/bar@1234...`
+    /// returns true.
+    fn ref_is_commit(&self) -> bool;
+
+    /// Returns the `git` ref for this `uses:`, if present.
+    fn commit_ref(&self) -> Option<&str>;
+
+    /// Returns the *symbolic* `git` ref for this `uses`, if present.
+    ///
+    /// Commit refs (i.e. SHA refs) are not returned.
+    fn symbolic_ref(&self) -> Option<&str>;
+}
+
+impl RepositoryUsesExt for RepositoryUses {
+    fn matches(&self, template: &str) -> bool {
+        let Ok(other) = template.parse::<RepositoryUses>() else {
+            return false;
+        };
+
+        self.matches_uses(&other)
+    }
+
+    fn matches_uses(&self, template: &RepositoryUses) -> bool {
+        self.owner.eq_ignore_ascii_case(&template.owner)
+            && self.repo.eq_ignore_ascii_case(&template.repo)
+            && self.subpath.as_ref().map(|s| s.to_lowercase())
+                == template.subpath.as_ref().map(|s| s.to_lowercase())
+            && template.git_ref.as_ref().map_or(true, |git_ref| {
+                Some(git_ref.to_lowercase()) == self.git_ref.as_ref().map(|r| r.to_lowercase())
+            })
+    }
+
+    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 commit_ref(&self) -> Option<&str> {
+        match &self.git_ref {
+            Some(git_ref) if self.ref_is_commit() => Some(git_ref),
+            _ => None,
+        }
+    }
+
+    fn symbolic_ref(&self) -> Option<&str> {
+        match &self.git_ref {
+            Some(git_ref) if !self.ref_is_commit() => Some(git_ref),
+            _ => None,
+        }
+    }
+}
+
+/// Useful APIs for interacting with all kinds of `uses:` clauses.
+pub(crate) trait UsesExt {
+    fn unpinned(&self) -> bool;
+    fn unhashed(&self) -> bool;
+}
+
+impl UsesExt for Uses {
+    /// Whether the `uses:` is unpinned.
+    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(),
+            Uses::Local(local) => local.git_ref.is_none(),
+        }
+    }
+
+    /// Whether the `uses:` is unhashed (but potentially pinned with a non-hash),
+    fn unhashed(&self) -> bool {
+        match self {
+            // TODO: Handle this case. Right now it's not very important,
+            // since we don't really analyze local action uses at all,
+            // and the "hashedness" of a local action is mostly moot anyways
+            // (since it's fully contained within the calling repo),
+            Uses::Local(_) => false,
+            Uses::Repository(repo) => !repo.ref_is_commit(),
+            Uses::Docker(docker) => docker.hash.is_none(),
+        }
+    }
+}
diff --git a/src/registry.rs b/src/registry.rs
index ba4f1de..fab07f0 100644
--- a/src/registry.rs
+++ b/src/registry.rs
@@ -7,11 +7,12 @@ use crate::{
     audit::{Audit, AuditInput},
     config::Config,
     finding::{Confidence, Finding, Persona, Severity},
-    models::{Action, RepositoryUses, Workflow},
+    models::{Action, Workflow},
     App,
 };
 use anyhow::{anyhow, Context, Result};
 use camino::{Utf8Path, Utf8PathBuf};
+use github_actions_models::common::RepositoryUses;
 use indexmap::IndexMap;
 use serde::Serialize;
 use tracing::instrument;
@@ -75,9 +76,9 @@ impl InputKey {
         }
 
         Ok(Self::Remote(RemoteKey {
-            owner: slug.owner.into(),
-            repo: slug.repo.into(),
-            git_ref: slug.git_ref.map(Into::into),
+            owner: slug.owner.clone(),
+            repo: slug.repo.clone(),
+            git_ref: slug.git_ref.clone(),
             path: path.into(),
         }))
     }
@@ -300,7 +301,9 @@ impl From<FindingRegistry<'_>> for ExitCode {
 
 #[cfg(test)]
 mod tests {
-    use crate::models::Uses;
+    use std::str::FromStr;
+
+    use github_actions_models::common::Uses;
 
     use super::InputKey;
 
@@ -310,7 +313,7 @@ mod tests {
         assert_eq!(local.to_string(), "file:///foo/bar/baz.yml");
 
         // No ref
-        let Uses::Repository(slug) = Uses::from_step("foo/bar").unwrap() else {
+        let Uses::Repository(slug) = Uses::from_str("foo/bar").unwrap() else {
             panic!()
         };
         let remote = InputKey::remote(&slug, ".github/workflows/baz.yml".into()).unwrap();
@@ -320,7 +323,7 @@ mod tests {
         );
 
         // With a git ref
-        let Uses::Repository(slug) = Uses::from_step("foo/bar@v1").unwrap() else {
+        let Uses::Repository(slug) = Uses::from_str("foo/bar@v1").unwrap() else {
             panic!()
         };
         let remote = InputKey::remote(&slug, ".github/workflows/baz.yml".into()).unwrap();


Test patch:
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index 4060b24..ed12e79 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -279,6 +279,12 @@ fn template_injection() -> Result<()> {
         ))
         .run()?);
 
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "template-injection/pr-425-backstop/action.yml"
+        ))
+        .run()?);
+
     Ok(())
 }
 
diff --git a/tests/snapshots/snapshot__template_injection-8.snap b/tests/snapshots/snapshot__template_injection-8.snap
new file mode 100644
index 0000000..17a98cd
--- /dev/null
+++ b/tests/snapshots/snapshot__template_injection-8.snap
@@ -0,0 +1,62 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"template-injection/pr-425-backstop/action.yml\")).run()?"
+snapshot_kind: text
+---
+error[template-injection]: code injection via template expansion
+  --> @@INPUT@@:12:7
+   |
+12 |       - name: case1
+   |         ^^^^^^^^^^^ this step
+13 | /       run: |
+14 | |         hello ${{ inputs.expandme }}
+   | |____________________________________^ inputs.expandme may expand into attacker-controllable code
+   |
+   = note: audit confidence → Low
+
+error[template-injection]: code injection via template expansion
+  --> @@INPUT@@:17:7
+   |
+17 |     - name: case2
+   |       ^^^^^^^^^^^ this step
+18 |       uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
+19 |       with:
+20 |         script: return "${{ inputs.expandme }}"
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ inputs.expandme may expand into attacker-controllable code
+   |
+   = note: audit confidence → Low
+
+error[template-injection]: code injection via template expansion
+  --> @@INPUT@@:22:7
+   |
+22 |       - name: case3
+   |         ^^^^^^^^^^^ this step
+23 |         uses: azure/cli@089eac9d8cc39f5d003e94f8b65efc51076c9cbd
+24 |         with:
+25 | /         inlineScript: |
+26 | |           echo "hello ${{ inputs.expandme }}"
+   | |_____________________________________________^ inputs.expandme may expand into attacker-controllable code
+   |
+   = note: audit confidence → Low
+
+error[template-injection]: code injection via template expansion
+  --> @@INPUT@@:28:7
+   |
+28 |     - name: case4
+   |       ^^^^^^^^^^^ this step
+29 |       uses: azure/powershell
+30 |       with:
+31 |         inlineScript: Get-AzVM -ResourceGroupName "${{ inputs.expandme }}"
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ inputs.expandme may expand into attacker-controllable code
+   |
+   = note: audit confidence → Low
+
+warning[unpinned-uses]: unpinned action reference
+  --> @@INPUT@@:29:7
+   |
+29 |       uses: azure/powershell
+   |       ---------------------- action is not pinned to a tag, branch, or hash ref
+   |
+   = note: audit confidence → High
+
+5 findings: 0 unknown, 0 informational, 0 low, 1 medium, 4 high
diff --git a/tests/test-data/template-injection/pr-425-backstop/action.yml b/tests/test-data/template-injection/pr-425-backstop/action.yml
new file mode 100644
index 0000000..9862d76
--- /dev/null
+++ b/tests/test-data/template-injection/pr-425-backstop/action.yml
@@ -0,0 +1,31 @@
+name: pr-425-backstop
+description: Functional test for changes in PR#425
+
+inputs:
+  expandme:
+    required: true
+    description: expand me
+
+runs:
+  using: composite
+  steps:
+    - name: case1
+      run: |
+        hello ${{ inputs.expandme }}
+      shell: bash
+
+    - name: case2
+      uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
+      with:
+        script: return "${{ inputs.expandme }}"
+
+    - name: case3
+      uses: azure/cli@089eac9d8cc39f5d003e94f8b65efc51076c9cbd
+      with:
+        inlineScript: |
+          echo "hello ${{ inputs.expandme }}"
+
+    - name: case4
+      uses: azure/powershell
+      with:
+        inlineScript: Get-AzVM -ResourceGroupName "${{ inputs.expandme }}"