Repository: woodruffw/zizmor (rust)

Issue:
Feature: Manual allowlist/denylist for actions (`forbidden-uses`)
### Pre-submission checks

- [x] I am **not** reporting 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?

For organizations that are worried about supply-chain security, it can be important to only allow actions that have been reviewed and approved.

### Describe the solution you'd like

A new check that outputs a warning if an actions is used that is not on an allowlist.

By default, the check should only allow:
- Official GitHub Actions (`actions/*`)
- First-party actions (hosted on the same user/organization as the repo itself.

Additional actions could be added to an allowlist (via settings). Inversely, actions that have should not be used could be put into a denylist (and thus always result in high-severity finding).
Since most users don't need this, findings for non-allowlisted actions should probably be suppressed by default.

### Additional context

Some ideas for settings:

```yaml
rules:
  forbidden-uses:
    allow:  # Matching actions will not result in a finding
      - "actions/*"
      - "@self/*"  # Something like this might be used to allowlist first-party actions (?)
      - "some-user/trusted-action"
      - "trusted-org/*"
      - "another-user/action-where-only-a-specific-version-is-trusted@e046bf4c08d98a867ad0fce4a27d3c0d06976598"
    deny:  # Matching actions will result in finding of level error (or warning?)
     - "untrusted-user/*"
     - "some-user/untrusted-action"
     - "another-user/action-where-only-a-specific-version-is-untrusted@5790ee9005aa9399722753077ba64e92947a5727"
     - "*"  # Add this if you want to deny everything that is listed under allow
```

Pull request description:
feat: new audit: forbidden-uses
Building up from #574.

TODO:

- [x] Documentation
- [x] Tests, including snapshots

Closes #558.

Patch:
diff --git a/docs/audits.md b/docs/audits.md
index 9edfb22..324badd 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -12,7 +12,7 @@ Legend:
 
 | Type     | Examples         | Introduced in | Works offline  | Enabled by default | Configurable |
 |----------|------------------|---------------|----------------|--------------------|--------------|
-| The kind of audit ("Workflow" or "Action") | Links to vulnerable examples | Added to `zizmor` in this version | The audit works with `--offline` | The audit needs to be explicitly enabled with `--pedantic` | The audit supports custom configuration |
+| The kind of audit ("Workflow" or "Action") | Links to vulnerable examples | Added to `zizmor` in this version | The audit works with `--offline` | The audit needs to be explicitly enabled via configuration or an API token | The audit supports custom configuration |
 
 ## `artipacked`
 
@@ -313,7 +313,7 @@ that exists only in a fork can be referenced via its parent's
 `owner/repo` slug, and vice versa.
 
 GitHub's network-of-forks design can be used to obscure a commit's true origin
-in a fully-pinned `uses:` workflow reference. This can be used by an attacker
+in a fully-pinned `#!yaml uses:` workflow reference. This can be used by an attacker
 to surreptitiously introduce a backdoored action into a victim's workflows(s).
 
 A notable historical example of this is github/dmca@565ece486c7c1652754d7b6d2b5ed9cb4097f9d5,
@@ -373,7 +373,7 @@ Detects actions that are pinned to confusable symbolic refs (i.e. branches
 or tags).
 
 Like with [impostor commits], actions that are used with a symbolic ref
-in their `uses:` are subject to a degree of ambiguity: a ref like
+in their `#!yaml uses:` are subject to a degree of ambiguity: a ref like
 `@v1` might refer to either a branch or tag ref.
 
 An attacker can exploit this ambiguity to publish a branch or tag ref that
@@ -483,7 +483,7 @@ shell quoting/expansion rules.
     default shell on Windows runners) uses `${env:VARNAME}`.
 
     To avoid having to specialize your handling for different runners,
-    you can set `shell: sh` or `shell: bash`.
+    you can set `#!yaml shell: sh` or `#!yaml shell: bash`.
 
 === "Before :warning:"
 
@@ -558,13 +558,13 @@ or @rubygems/release-gem for canonical examples of using it.
 
 [unpinned.yml]: https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/unpinned.yml
 
-Detects "unpinned" `uses:` clauses.
+Detects "unpinned" `#!yaml uses:` clauses.
 
-When a `uses:` clause is not pinned by branch, tag, or SHA reference,
+When a `#!yaml uses:` clause is not pinned by branch, tag, or SHA reference,
 GitHub Actions will use the latest commit on the referenced repository's
 default branch (or, in the case of Docker actions, the `:latest` tag).
 
-Similarly, if a `uses:` clause is pinned via branch or tag (i.e. a "symbolic
+Similarly, if a `#!yaml uses:` clause is pinned via branch or tag (i.e. a "symbolic
 reference") instead of a SHA reference, GitHub Actions will use whatever
 commit is at the tip of that branch or tag. GitHub does not have immutable
 branches or tags, meaning that the action can change without the symbolic
@@ -613,34 +613,34 @@ _Type_: `object`
 The `rules.unpinned-uses.config.policies` object defines your `unpinned-uses`
 policies.
 
-Each member is a `pattern: policy` rule, where `pattern` describes which
-`uses:` clauses to match and `policy` describes how to treat them.
+Each member is a `#!yaml pattern: policy` rule, where `pattern` describes which
+`#!yaml uses:` clauses to match and `policy` describes how to treat them.
 
 The valid patterns are (in order of specificity):
 
-* `owner/repo`: match all `uses:` clauses that are exact matches for the
+* `owner/repo`: match all `#!yaml uses:` clauses that are exact matches for the
   `owner/repo` pattern.
 
     For example, `actions/checkout` matches only @actions/checkout.
 
-* `owner/*`: match all `uses:` clauses that have the given `owner`.
+* `owner/*`: match all `#!yaml uses:` clauses that have the given `owner`.
 
     For example, `actions/*` matches both @actions/checkout and @actions/setup-node.
 
-* `*`: match all `uses:` clauses.
+* `*`: match all `#!yaml uses:` clauses.
 
     For example, `*` matches @actions/checkout and @pypa/gh-action-pypi-publish.
 
 The valid policies are:
 
-* `hash-pin`: any `uses:` clauses that match the associated pattern must be
+* `hash-pin`: any `#!yaml uses:` clauses that match the associated pattern must be
   fully pinned by SHA reference.
-* `ref-pin`: any `uses:` clauses that match the associated pattern must be
+* `ref-pin`: any `#!yaml uses:` clauses that match the associated pattern must be
   pinned either symbolic or SHA reference.
-* `any`: no pinning is required for any `uses:` clauses that match the associated
+* `any`: no pinning is required for any `#!yaml uses:` clauses that match the associated
   pattern.
 
-If a `uses:` clauses matches multiple rules, the most specific one is used
+If a `#!yaml uses:` clauses matches multiple rules, the most specific one is used
 regardless of definition order. For example, the following
 configuration contains two rules that could match @actions/checkout,
 but the first one is more specific and therefore gets applied:
@@ -654,10 +654,10 @@ rules:
         actions/*: ref-pin
 ```
 
-In plain English, this policy set says "anything that `uses: actions/*` must
+In plain English, this policy set says "anything that `#!yaml uses: actions/*` must
 be at least ref-pinned, but @actions/checkout in particular must be hash-pinned."
 
-If a `uses:` clause does not match any rules, then an implicit `"*": hash-pin`
+If a `#!yaml uses:` clause does not match any rules, then an implicit `"*": hash-pin`
 rule is applied. Users can override this implicit rule by adding their
 own `*` rule. For example:
 
@@ -670,7 +670,7 @@ rules:
         "*": ref-pin
 ```
 
-In plain English, this policy set says "anything that `uses: example/*` must
+In plain English, this policy set says "anything that `#!yaml uses: example/*` must
 be hash-pinned, and anything else must be at least ref-pinned."
 
 ### Remediation
@@ -856,7 +856,7 @@ intended to publish build artifacts:
 
 * Remove cache-aware actions like @actions/cache from workflows that produce
   releases, *or*
-* Disable cache-aware actions with an `if:` condition based on the trigger at
+* Disable cache-aware actions with an `#!yaml if:` condition based on the trigger at
   the step level, *or*
 * Set an action-specific input to disable cache restoration when appropriate,
   such as `lookup-only` in @Swatinem/rust-cache.
@@ -873,14 +873,14 @@ Detects excessive secret inheritance between calling workflows and reusable
 (called) workflows.
 
 [Reusable workflows] can be given secrets by their calling workflow either
-explicitly, or in a blanket fashion with `secrets: inherit`. The latter
+explicitly, or in a blanket fashion with `#!yaml secrets: inherit`. The latter
 should almost never be used, as it makes it violates the
 [Principle of Least Authority] and makes it impossible to determine which exact
 secrets a reusable workflow was executed with.
 
 ### Remediation
 
-In general, `secrets: inherit` should be replaced with a `secrets:` block
+In general, `#!yaml secrets: inherit` should be replaced with a `#!yaml secrets:` block
 that explicitly forwards each secret actually needed by the reusable workflow.
 
 === "Before :warning:"
@@ -1103,6 +1103,87 @@ individual fields as separate secrets.
               PASSWORD: ${{ secrets.MY_SECRET_PASSWORD }}
     ```
 
+## `forbidden-uses`
+
+| Type     | Examples                | Introduced in | Works offline  | Enabled by default | Configurable |
+|----------|-------------------------|---------------|----------------|--------------------| ---------------|
+| Workflow, Action  | N/A            | v1.6.0        | ✅             | ❌                |  ✅ |
+
+An *opt-in* audit for denylisting/allowlisting specific `#!yaml uses:` clauses.
+This is not enabled by default; you must
+[configure it](#forbidden-uses-configuration) to use it.
+
+!!! warning
+
+    This audit comes with several limitations that are important to understand:
+
+    * This audit is *opt-in*. You must configure it to use it; it
+      **does nothing** by default.
+    * This audit (currently) operates on *repository* `#!yaml uses:` clauses,
+      e.g. `#!yaml uses: actions/checkout@v4`. It does not operate on Docker
+      `#!yaml uses:` clauses, e.g. `#!yaml uses: docker://ubuntu:24.04`. This limitation
+      may be lifted in the future.
+    * This audit operates on `#!yaml uses:` clauses *as they appear* in the workflow
+      and action files. In other words, in *cannot* detect
+      [impostor commits](#impostor-commit) or indirect usage of actions
+      via manual `git clone` and local path usage.
+    * This audit's configuration operates on patterns, just like
+      [unpinned-uses](#unpinned-uses). That means that you can't (yet)
+      define *exact* matches. For example, you can't forbid `actions/checkout@v4`,
+      you have to forbid `actions/checkout`, which forbids all versions.
+
+### Configuration { #forbidden-uses-configuration }
+
+#### `rules.forbidden-uses.config.<allow|deny>`
+
+_Type_: `list`
+
+The `forbidden-uses` audit operates on either an allowlist or denylist
+basis:
+
+* In allowlist mode, only the listed `#!yaml uses:` patterns are allowed. All
+  non-matching `#!yaml uses:` clauses result in a finding.
+
+    Intended use case: only allowing "known good" actions to be used,
+    and forbidding everything else.
+
+* In denylist mode, only the listed `#!yaml uses:` patterns are disallowed. All
+  matching `#!yaml uses:` clauses result in a finding.
+
+    Intended use case: permitting all `#!yaml uses:` by default, but explicitly
+    forbidding "known bad" actions.
+
+Regardless of the mode used, the patterns allowed are the same as those
+in [unpinned-uses](#unpinned-uses-configuration).
+
+For example, the following configuration would allow only actions owned by
+the @actions organization, plus any actions defined in @github/codeql-action:
+
+```yaml title="zizmor.yml"
+rules:
+  forbidden-uses:
+    config:
+      allow:
+        - actions/*
+        - github/codeql-action
+```
+
+Whereas the following would allow all actions except for those in the
+@actions organization or defined in @github/codeql-action:
+
+```yaml title="zizmor.yml"
+rules:
+  forbidden-uses:
+    config:
+      deny:
+        - actions/*
+        - github/codeql-action
+```
+
+### Remediation
+
+Either remove the offending `#!yaml uses:` clause or, if intended, add it to
+your [configuration](#forbidden-uses-configuration).
 
 [ArtiPACKED: Hacking Giants Through a Race Condition in GitHub Actions Artifacts]: https://unit42.paloaltonetworks.com/github-repo-artifacts-leak-tokens/
 [Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests]: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
@@ -1128,3 +1209,5 @@ individual fields as separate secrets.
 [GitHub Actions exploitations: Dependabot]: https://www.synacktiv.com/publications/github-actions-exploitation-dependabot
 [Using secrets in GitHub Actions]: https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions
 [Palo Alto Networks Unit42: tj-actions/changed-files incident]: https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/
+
+
diff --git a/src/audit/forbidden_uses.rs b/src/audit/forbidden_uses.rs
new file mode 100644
index 0000000..19227d5
--- /dev/null
+++ b/src/audit/forbidden_uses.rs
@@ -0,0 +1,118 @@
+use anyhow::{Context, anyhow};
+use github_actions_models::common::Uses;
+
+use super::{Audit, AuditLoadError, AuditState, Finding, Step, audit_meta};
+use crate::finding::{Confidence, Persona, Severity};
+use crate::models::CompositeStep;
+use crate::models::uses::RepositoryUsesPattern;
+use serde::Deserialize;
+
+pub(crate) struct ForbiddenUses {
+    config: ForbiddenUsesConfig,
+}
+
+audit_meta!(ForbiddenUses, "forbidden-uses", "forbidden action used");
+
+impl ForbiddenUses {
+    pub fn use_denied(&self, uses: &Uses) -> bool {
+        match uses {
+            // Local uses are never denied.
+            Uses::Local(_) => false,
+            // TODO: Support Docker uses here?
+            // We'd need some equivalent to RepositoryUsesPattern
+            // but for Docker uses, which will be slightly annoying.
+            Uses::Docker(_) => {
+                tracing::warn!("can't evaluate direct Docker uses");
+                false
+            }
+            Uses::Repository(uses) => match &self.config {
+                ForbiddenUsesConfig::Allow { allow } => {
+                    !allow.iter().any(|pattern| pattern.matches(uses))
+                }
+                ForbiddenUsesConfig::Deny { deny } => {
+                    deny.iter().any(|pattern| pattern.matches(uses))
+                }
+            },
+        }
+    }
+}
+
+impl Audit for ForbiddenUses {
+    fn new(state: &AuditState<'_>) -> Result<Self, AuditLoadError>
+    where
+        Self: Sized,
+    {
+        let Some(config) = state
+            .config
+            .rule_config(Self::ident())
+            .context("invalid configuration")
+            .map_err(AuditLoadError::Fail)?
+        else {
+            return Err(AuditLoadError::Skip(anyhow!("audit not configured")));
+        };
+
+        Ok(Self { config })
+    }
+
+    fn audit_step<'w>(&self, step: &Step<'w>) -> anyhow::Result<Vec<Finding<'w>>> {
+        let mut findings = vec![];
+
+        let Some(uses) = step.uses() else {
+            return Ok(vec![]);
+        };
+
+        if self.use_denied(uses) {
+            findings.push(
+                Self::finding()
+                    .confidence(Confidence::High)
+                    .severity(Severity::High)
+                    .persona(Persona::Regular)
+                    .add_location(
+                        step.location()
+                            .primary()
+                            .with_keys(&["uses".into()])
+                            .annotated("use of this action is forbidden"),
+                    )
+                    .build(step.workflow())?,
+            );
+        };
+
+        Ok(findings)
+    }
+
+    fn audit_composite_step<'a>(
+        &self,
+        step: &CompositeStep<'a>,
+    ) -> anyhow::Result<Vec<Finding<'a>>> {
+        let mut findings = vec![];
+
+        let Some(uses) = step.uses() else {
+            return Ok(vec![]);
+        };
+
+        if self.use_denied(uses) {
+            findings.push(
+                Self::finding()
+                    .confidence(Confidence::High)
+                    .severity(Severity::High)
+                    .persona(Persona::Regular)
+                    .add_location(
+                        step.location()
+                            .primary()
+                            .with_keys(&["uses".into()])
+                            .annotated("use of this action is forbidden"),
+                    )
+                    .build(step.action())?,
+            );
+        };
+
+        Ok(findings)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "kebab-case", untagged)]
+enum ForbiddenUsesConfig {
+    Allow { allow: Vec<RepositoryUsesPattern> },
+    Deny { deny: Vec<RepositoryUsesPattern> },
+}
diff --git a/src/audit/mod.rs b/src/audit/mod.rs
index aaa92c2..04a2269 100644
--- a/src/audit/mod.rs
+++ b/src/audit/mod.rs
@@ -18,6 +18,7 @@ pub(crate) mod bot_conditions;
 pub(crate) mod cache_poisoning;
 pub(crate) mod dangerous_triggers;
 pub(crate) mod excessive_permissions;
+pub(crate) mod forbidden_uses;
 pub(crate) mod github_env;
 pub(crate) mod hardcoded_container_credentials;
 pub(crate) mod impostor_commit;
diff --git a/src/audit/unpinned_uses.rs b/src/audit/unpinned_uses.rs
index 886a811..434b617 100644
--- a/src/audit/unpinned_uses.rs
+++ b/src/audit/unpinned_uses.rs
@@ -1,13 +1,12 @@
 use std::collections::HashMap;
-use std::sync::LazyLock;
 
 use anyhow::Context;
 use github_actions_models::common::{RepositoryUses, Uses};
-use regex::Regex;
-use serde::{Deserialize, Deserializer};
+use serde::Deserialize;
 
 use super::{Audit, AuditLoadError, AuditState, Finding, Step, audit_meta};
 use crate::finding::{Confidence, Persona, Severity};
+use crate::models::uses::RepositoryUsesPattern;
 use crate::models::{CompositeStep, uses::UsesExt as _};
 
 pub(crate) struct UnpinnedUses {
@@ -50,9 +49,11 @@ impl UnpinnedUses {
                 let (pattern, policy) = self.policies.get_policy(repo_uses);
 
                 let pat_desc = match pattern {
-                    Some(UsesPattern::Any) | None => "blanket".into(),
-                    Some(UsesPattern::InOwner(owner)) => format!("{owner}/*"),
-                    Some(UsesPattern::InRepo { owner, repo }) => format!("{owner}/{repo}"),
+                    Some(RepositoryUsesPattern::Any) | None => "blanket".into(),
+                    Some(RepositoryUsesPattern::InOwner(owner)) => format!("{owner}/*"),
+                    Some(RepositoryUsesPattern::InRepo { owner, repo }) => {
+                        format!("{owner}/{repo}")
+                    }
                 };
 
                 match policy {
@@ -148,10 +149,6 @@ impl Audit for UnpinnedUses {
     }
 }
 
-// Matches patterns like `owner/repo` and `owner/*`.
-static USES_PATTERN: LazyLock<Regex> =
-    LazyLock::new(|| Regex::new(r#"(?mi)^[\w-]+/([\w\.-]+|\*)$"#).unwrap());
-
 /// Config for the `unpinned-uses` rule.
 ///
 /// This configuration is reified into an `UnpinnedUsesPolicies`.
@@ -159,71 +156,32 @@ static USES_PATTERN: LazyLock<Regex> =
 #[serde(rename_all = "kebab-case", deny_unknown_fields)]
 struct UnpinnedUsesConfig {
     /// A mapping of `uses:` patterns to policies.
-    policies: HashMap<UsesPattern, UsesPolicy>,
+    policies: HashMap<RepositoryUsesPattern, UsesPolicy>,
 }
 
 impl Default for UnpinnedUsesConfig {
     fn default() -> Self {
         Self {
             policies: [
-                (UsesPattern::InOwner("actions".into()), UsesPolicy::RefPin),
-                (UsesPattern::InOwner("github".into()), UsesPolicy::RefPin),
                 (
-                    UsesPattern::InOwner("dependabot".into()),
+                    RepositoryUsesPattern::InOwner("actions".into()),
+                    UsesPolicy::RefPin,
+                ),
+                (
+                    RepositoryUsesPattern::InOwner("github".into()),
+                    UsesPolicy::RefPin,
+                ),
+                (
+                    RepositoryUsesPattern::InOwner("dependabot".into()),
                     UsesPolicy::RefPin,
                 ),
-                (UsesPattern::Any, UsesPolicy::HashPin),
+                (RepositoryUsesPattern::Any, UsesPolicy::HashPin),
             ]
             .into(),
         }
     }
 }
 
-/// Represents a pattern for matching `uses` references.
-/// These patterns are ordered by specificity; more specific patterns
-/// should be listed first.
-#[derive(Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
-enum UsesPattern {
-    /// `owner/repo`: matches all actions in the given repository.
-    InRepo { owner: String, repo: String },
-    /// `owner/*`: matches all actions in repositories owned by the given owner.
-    InOwner(String),
-    /// `*`: matches all actions in all repositories.
-    Any,
-}
-
-impl<'de> Deserialize<'de> for UsesPattern {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let raw = String::deserialize(deserializer)?;
-
-        if raw == "*" {
-            return Ok(UsesPattern::Any);
-        }
-
-        if !USES_PATTERN.is_match(&raw) {
-            return Err(serde::de::Error::custom(format!(
-                "invalid uses pattern: {raw}"
-            )));
-        }
-
-        let (owner, repo) = raw
-            .split_once('/')
-            .ok_or_else(|| serde::de::Error::custom(format!("invalid uses pattern: {raw}")))?;
-
-        Ok(if repo == "*" {
-            UsesPattern::InOwner(owner.into())
-        } else {
-            UsesPattern::InRepo {
-                owner: owner.into(),
-                repo: repo.into(),
-            }
-        })
-    }
-}
-
 /// A singular policy for a `uses:` reference.
 #[derive(Copy, Clone, Debug, Deserialize)]
 #[serde(rename_all = "kebab-case")]
@@ -253,7 +211,7 @@ struct UnpinnedUsesPolicies {
     /// This is done for performance reasons: a two-level structure here
     /// means that checking a `uses:` is a linear scan of the policies
     /// for that owner, rather than a full scan of all policies.
-    policy_tree: HashMap<String, Vec<(UsesPattern, UsesPolicy)>>,
+    policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>>,
 
     /// This is the policy that's applied if nothing in the policy tree matches.
     ///
@@ -266,24 +224,24 @@ struct UnpinnedUsesPolicies {
 impl UnpinnedUsesPolicies {
     /// Returns the most specific policy for the given repository `uses` reference,
     /// or the default policy if none match.
-    fn get_policy(&self, uses: &RepositoryUses) -> (Option<&UsesPattern>, UsesPolicy) {
+    fn get_policy(&self, uses: &RepositoryUses) -> (Option<&RepositoryUsesPattern>, UsesPolicy) {
         match self.policy_tree.get(&uses.owner) {
             Some(policies) => {
                 // Policies are ordered by specificity, so we can
                 // iterate and return eagerly.
                 for (uses_pattern, policy) in policies {
                     match uses_pattern {
-                        UsesPattern::InRepo { owner: _, repo } => {
+                        RepositoryUsesPattern::InRepo { owner: _, repo } => {
                             if repo == &uses.repo {
                                 return (Some(uses_pattern), *policy);
                             } else {
                                 continue;
                             }
                         }
-                        UsesPattern::InOwner(_) => return (Some(uses_pattern), *policy),
+                        RepositoryUsesPattern::InOwner(_) => return (Some(uses_pattern), *policy),
                         // NOTE: Unreachable because we only
                         // allow `*` to configure the default policy.
-                        UsesPattern::Any => unreachable!(),
+                        RepositoryUsesPattern::Any => unreachable!(),
                     }
                 }
                 // The policies under `owner/` might be fully divergent
@@ -298,24 +256,25 @@ impl UnpinnedUsesPolicies {
 
 impl From<UnpinnedUsesConfig> for UnpinnedUsesPolicies {
     fn from(config: UnpinnedUsesConfig) -> Self {
-        let mut policy_tree: HashMap<String, Vec<(UsesPattern, UsesPolicy)>> = HashMap::new();
+        let mut policy_tree: HashMap<String, Vec<(RepositoryUsesPattern, UsesPolicy)>> =
+            HashMap::new();
         let mut default_policy = UsesPolicy::HashPin;
 
         for (pattern, policy) in config.policies {
             match pattern {
-                UsesPattern::InRepo { owner, repo } => {
+                RepositoryUsesPattern::InRepo { owner, repo } => {
                     policy_tree
                         .entry(owner.clone())
                         .or_default()
-                        .push((UsesPattern::InRepo { owner, repo }, policy));
+                        .push((RepositoryUsesPattern::InRepo { owner, repo }, policy));
                 }
-                UsesPattern::InOwner(owner) => {
+                RepositoryUsesPattern::InOwner(owner) => {
                     policy_tree
                         .entry(owner.clone())
                         .or_default()
-                        .push((UsesPattern::InOwner(owner), policy));
+                        .push((RepositoryUsesPattern::InOwner(owner), policy));
                 }
-                UsesPattern::Any => {
+                RepositoryUsesPattern::Any => {
                     default_policy = policy;
                 }
             }
@@ -335,17 +294,17 @@ impl From<UnpinnedUsesConfig> for UnpinnedUsesPolicies {
 
 #[cfg(test)]
 mod tests {
-    use super::UsesPattern;
+    use crate::models::uses::RepositoryUsesPattern;
 
     #[test]
     fn test_uses_pattern_ord() {
         let mut patterns = vec![
-            UsesPattern::Any,
-            UsesPattern::InRepo {
+            RepositoryUsesPattern::Any,
+            RepositoryUsesPattern::InRepo {
                 owner: "owner".into(),
                 repo: "repo".into(),
             },
-            UsesPattern::InOwner("owner/*".into()),
+            RepositoryUsesPattern::InOwner("owner/*".into()),
         ];
 
         patterns.sort();
@@ -353,12 +312,12 @@ mod tests {
         assert_eq!(
             patterns,
             vec![
-                UsesPattern::InRepo {
+                RepositoryUsesPattern::InRepo {
                     owner: "owner".into(),
                     repo: "repo".into()
                 },
-                UsesPattern::InOwner("owner/*".into()),
-                UsesPattern::Any,
+                RepositoryUsesPattern::InOwner("owner/*".into()),
+                RepositoryUsesPattern::Any,
             ]
         );
     }
diff --git a/src/main.rs b/src/main.rs
index 9626467..d343f9b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -529,6 +529,7 @@ fn run() -> Result<ExitCode> {
     register_audit!(audit::bot_conditions::BotConditions);
     register_audit!(audit::overprovisioned_secrets::OverprovisionedSecrets);
     register_audit!(audit::unredacted_secrets::UnredactedSecrets);
+    register_audit!(audit::forbidden_uses::ForbiddenUses);
 
     let mut results = FindingRegistry::new(&app, &config);
     {
diff --git a/src/models/uses.rs b/src/models/uses.rs
index f3bc3fe..6fcd3c7 100644
--- a/src/models/uses.rs
+++ b/src/models/uses.rs
@@ -1,8 +1,75 @@
 //! Extension traits for the `Uses` APIs.
 
+use std::{str::FromStr, sync::LazyLock};
+
 use github_actions_models::common::{RepositoryUses, Uses};
+use regex::Regex;
+use serde::Deserialize;
+
+// Matches patterns like `owner/repo` and `owner/*`.
+static REPOSITORY_USES_PATTERN: LazyLock<Regex> =
+    LazyLock::new(|| Regex::new(r#"(?mi)^([\w-]+)/([\w\.-]+|\*)$"#).unwrap());
+
+/// Represents a pattern for matching repository `uses` references.
+/// These patterns are ordered by specificity; more specific patterns
+/// should be listed first.
+#[derive(Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
+pub(crate) enum RepositoryUsesPattern {
+    // TODO: InRepoPath for `owner/repo/path`?
+    InRepo { owner: String, repo: String },
+    InOwner(String),
+    Any,
+}
+
+impl RepositoryUsesPattern {
+    pub(crate) fn matches(&self, uses: &RepositoryUses) -> bool {
+        match self {
+            RepositoryUsesPattern::InRepo { owner, repo } => {
+                uses.owner.eq_ignore_ascii_case(owner) && uses.repo.eq_ignore_ascii_case(repo)
+            }
+            RepositoryUsesPattern::InOwner(owner) => uses.owner.eq_ignore_ascii_case(owner),
+            RepositoryUsesPattern::Any => true,
+        }
+    }
+}
+
+impl FromStr for RepositoryUsesPattern {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s == "*" {
+            return Ok(RepositoryUsesPattern::Any);
+        }
+
+        let caps = REPOSITORY_USES_PATTERN.captures(s).ok_or_else(|| {
+            anyhow::anyhow!("invalid repository pattern: {s} (expected owner/repo or owner/*)")
+        })?;
+
+        let owner = &caps[1];
+        let repo = &caps[2];
+
+        Ok(if repo == "*" {
+            RepositoryUsesPattern::InOwner(owner.into())
+        } else {
+            RepositoryUsesPattern::InRepo {
+                owner: owner.into(),
+                repo: repo.into(),
+            }
+        })
+    }
+}
+
+impl<'de> Deserialize<'de> for RepositoryUsesPattern {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        RepositoryUsesPattern::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
 
-/// Useful APIs for interacting with `uses: org/repo` clauses.
+/// Useful APIs for interacting with `uses: owner/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.
@@ -11,6 +78,8 @@ pub(crate) trait RepositoryUsesExt {
     /// the `actions/checkout` template but not vice versa.
     ///
     /// Comparisons are case-insensitive, since GitHub's own APIs are insensitive.
+    ///
+    /// TODO: Remove this API and replace it with [`RepositoryUsesPattern`].
     fn matches(&self, template: &str) -> bool;
 
     /// Like [`RepositoryUsesExt::matches`].
@@ -107,10 +176,13 @@ impl UsesExt for Uses {
 mod tests {
     use std::str::FromStr;
 
+    use anyhow::anyhow;
     use github_actions_models::common::Uses;
 
     use crate::models::uses::RepositoryUsesExt;
 
+    use super::RepositoryUsesPattern;
+
     #[test]
     fn test_repositoryuses_matches() {
         for (uses, template, matches) in [
@@ -144,4 +216,40 @@ mod tests {
             assert_eq!(uses.matches(template), matches)
         }
     }
+
+    #[test]
+    fn test_repositoryusespattern_matches() -> anyhow::Result<()> {
+        for (uses, pattern, matches) in [
+            // owner/repo matches regardless of ref, casing, and subpath
+            // but rejects when owner/repo diverges
+            ("actions/checkout", "actions/checkout", true),
+            ("ACTIONS/CHECKOUT", "actions/checkout", true),
+            ("actions/checkout@v3", "actions/checkout", true),
+            ("actions/checkout/foo@v3", "actions/checkout", true),
+            ("actions/somethingelse", "actions/checkout", false),
+            ("whatever/checkout", "actions/checkout", false),
+            // owner/* matches of ref, casing, and subpath
+            // but rejects when owner diverges
+            ("actions/checkout", "actions/*", true),
+            ("ACTIONS/CHECKOUT", "actions/*", true),
+            ("actions/checkout@v3", "actions/*", true),
+            ("actions/checkout/foo@v3", "actions/*", true),
+            ("someoneelse/checkout", "actions/*", false),
+            // * matches everything
+            ("actions/checkout", "*", true),
+            ("actions/checkout@v3", "*", true),
+            ("actions/checkout/foo@v3", "*", true),
+            ("whatever/checkout", "*", true),
+        ] {
+            let Ok(Uses::Repository(uses)) = Uses::from_str(uses) else {
+                return Err(anyhow!("invalid uses: {uses}"));
+            };
+
+            let pattern = RepositoryUsesPattern::from_str(pattern)?;
+
+            assert_eq!(pattern.matches(&uses), matches);
+        }
+
+        Ok(())
+    }
 }


Test patch:
diff --git a/tests/integration/snapshot.rs b/tests/integration/snapshot.rs
index 77bd560..d290bcf 100644
--- a/tests/integration/snapshot.rs
+++ b/tests/integration/snapshot.rs
@@ -632,3 +632,21 @@ fn unredacted_secrets() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn forbidden_uses() -> Result<()> {
+    for config in ["allow-all", "deny-all", "allow-some", "deny-some"] {
+        insta::assert_snapshot!(
+            zizmor()
+                .config(input_under_test(&format!(
+                    "forbidden-uses/configs/{config}.yml"
+                )))
+                .input(input_under_test(
+                    "forbidden-uses/forbidden-uses-menagerie.yml"
+                ))
+                .run()?
+        );
+    }
+
+    Ok(())
+}
diff --git a/tests/integration/snapshots/integration__e2e__gha_hazmat.snap b/tests/integration/snapshots/integration__e2e__gha_hazmat.snap
index c9b1acc..84c2896 100644
--- a/tests/integration/snapshots/integration__e2e__gha_hazmat.snap
+++ b/tests/integration/snapshots/integration__e2e__gha_hazmat.snap
@@ -7,6 +7,7 @@ snapshot_kind: text
  INFO zizmor: skipping impostor-commit: offline audits only requested
  INFO zizmor: skipping ref-confusion: offline audits only requested
  INFO zizmor: skipping known-vulnerable-actions: offline audits only requested
+ INFO zizmor: skipping forbidden-uses: audit not configured
  INFO audit: zizmor: 🌈 completed .github/workflows/artipacked.yml
  INFO audit: zizmor: 🌈 completed .github/workflows/bot-conditions.yml
  INFO audit: zizmor: 🌈 completed .github/workflows/cache-poisoning.yml
diff --git a/tests/integration/snapshots/integration__e2e__issue_569.snap b/tests/integration/snapshots/integration__e2e__issue_569.snap
index b5f90fe..32886ce 100644
--- a/tests/integration/snapshots/integration__e2e__issue_569.snap
+++ b/tests/integration/snapshots/integration__e2e__issue_569.snap
@@ -6,6 +6,7 @@ snapshot_kind: text
  INFO zizmor: skipping impostor-commit: offline audits only requested
  INFO zizmor: skipping ref-confusion: offline audits only requested
  INFO zizmor: skipping known-vulnerable-actions: offline audits only requested
+ INFO zizmor: skipping forbidden-uses: audit not configured
  INFO audit: zizmor: 🌈 completed .github/workflows/add-issue-header.yml
  INFO audit: zizmor: 🌈 completed .github/workflows/build.yml
  INFO audit: zizmor: 🌈 completed .github/workflows/documentation-links.yml
diff --git a/tests/integration/snapshots/integration__e2e__menagerie-2.snap b/tests/integration/snapshots/integration__e2e__menagerie-2.snap
index 7e73b3a..5bf93d5 100644
--- a/tests/integration/snapshots/integration__e2e__menagerie-2.snap
+++ b/tests/integration/snapshots/integration__e2e__menagerie-2.snap
@@ -6,6 +6,7 @@ snapshot_kind: text
  INFO zizmor: skipping impostor-commit: can't run without a GitHub API token
  INFO zizmor: skipping ref-confusion: can't run without a GitHub API token
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
+ INFO zizmor: skipping forbidden-uses: audit not configured
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/dummy-action-2/action.yml
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/another-dummy.yml
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/dummy.yml
diff --git a/tests/integration/snapshots/integration__e2e__menagerie.snap b/tests/integration/snapshots/integration__e2e__menagerie.snap
index edc256d..5403045 100644
--- a/tests/integration/snapshots/integration__e2e__menagerie.snap
+++ b/tests/integration/snapshots/integration__e2e__menagerie.snap
@@ -6,6 +6,7 @@ snapshot_kind: text
  INFO zizmor: skipping impostor-commit: can't run without a GitHub API token
  INFO zizmor: skipping ref-confusion: can't run without a GitHub API token
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
+ INFO zizmor: skipping forbidden-uses: audit not configured
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/dummy-action-2/action.yml
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/another-dummy.yml
  INFO audit: zizmor: 🌈 completed @@INPUT@@/.github/workflows/dummy.yml
diff --git a/tests/integration/snapshots/integration__snapshot__forbidden_uses-2.snap b/tests/integration/snapshots/integration__snapshot__forbidden_uses-2.snap
new file mode 100644
index 0000000..a3507c5
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__forbidden_uses-2.snap
@@ -0,0 +1,30 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
+snapshot_kind: text
+---
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:12:9
+   |
+12 |       - uses: actions/setup-python@v4
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:13:9
+   |
+13 |       - uses: pypa/gh-action-pypi-publish@release/v1
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:14:9
+   |
+14 |       - uses: actions/checkout@v4
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+3 findings: 0 unknown, 0 informational, 0 low, 0 medium, 3 high
diff --git a/tests/integration/snapshots/integration__snapshot__forbidden_uses-3.snap b/tests/integration/snapshots/integration__snapshot__forbidden_uses-3.snap
new file mode 100644
index 0000000..bbc6097
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__forbidden_uses-3.snap
@@ -0,0 +1,14 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
+snapshot_kind: text
+---
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:12:9
+   |
+12 |       - uses: actions/setup-python@v4
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+1 finding: 0 unknown, 0 informational, 0 low, 0 medium, 1 high
diff --git a/tests/integration/snapshots/integration__snapshot__forbidden_uses-4.snap b/tests/integration/snapshots/integration__snapshot__forbidden_uses-4.snap
new file mode 100644
index 0000000..d705f92
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__forbidden_uses-4.snap
@@ -0,0 +1,22 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
+snapshot_kind: text
+---
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:13:9
+   |
+13 |       - uses: pypa/gh-action-pypi-publish@release/v1
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+error[forbidden-uses]: forbidden action used
+  --> @@INPUT@@:14:9
+   |
+14 |       - uses: actions/checkout@v4
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^ use of this action is forbidden
+   |
+   = note: audit confidence → High
+
+2 findings: 0 unknown, 0 informational, 0 low, 0 medium, 2 high
diff --git a/tests/integration/snapshots/integration__snapshot__forbidden_uses.snap b/tests/integration/snapshots/integration__snapshot__forbidden_uses.snap
new file mode 100644
index 0000000..6f2da70
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__forbidden_uses.snap
@@ -0,0 +1,6 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().config(input_under_test(&format!(\"forbidden-uses/configs/{config}.yml\"))).input(input_under_test(\"forbidden-uses/forbidden-uses-menagerie.yml\")).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/integration/snapshots/integration__snapshot__unpinned_uses-10.snap b/tests/integration/snapshots/integration__snapshot__unpinned_uses-10.snap
index d6eabe9..151aa5d 100644
--- a/tests/integration/snapshots/integration__snapshot__unpinned_uses-10.snap
+++ b/tests/integration/snapshots/integration__snapshot__unpinned_uses-10.snap
@@ -8,5 +8,5 @@ snapshot_kind: text
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
 fatal: no audit was performed
 error: failed to load audit: unpinned-uses
- = note: invalid configuration: invalid uses pattern: foo/b*r
+ = note: invalid configuration: invalid repository pattern: foo/b*r (expected owner/repo or owner/*)
  = note: see: https://woodruffw.github.io/zizmor/audits/#unpinned-uses
diff --git a/tests/integration/snapshots/integration__snapshot__unpinned_uses-7.snap b/tests/integration/snapshots/integration__snapshot__unpinned_uses-7.snap
index 8e64f45..c0eac44 100644
--- a/tests/integration/snapshots/integration__snapshot__unpinned_uses-7.snap
+++ b/tests/integration/snapshots/integration__snapshot__unpinned_uses-7.snap
@@ -8,5 +8,5 @@ snapshot_kind: text
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
 fatal: no audit was performed
 error: failed to load audit: unpinned-uses
- = note: invalid configuration: invalid uses pattern: lol
+ = note: invalid configuration: invalid repository pattern: lol (expected owner/repo or owner/*)
  = note: see: https://woodruffw.github.io/zizmor/audits/#unpinned-uses
diff --git a/tests/integration/snapshots/integration__snapshot__unpinned_uses-8.snap b/tests/integration/snapshots/integration__snapshot__unpinned_uses-8.snap
index e42185a..ba1c8da 100644
--- a/tests/integration/snapshots/integration__snapshot__unpinned_uses-8.snap
+++ b/tests/integration/snapshots/integration__snapshot__unpinned_uses-8.snap
@@ -8,5 +8,5 @@ snapshot_kind: text
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
 fatal: no audit was performed
 error: failed to load audit: unpinned-uses
- = note: invalid configuration: invalid uses pattern: foo/
+ = note: invalid configuration: invalid repository pattern: foo/ (expected owner/repo or owner/*)
  = note: see: https://woodruffw.github.io/zizmor/audits/#unpinned-uses
diff --git a/tests/integration/snapshots/integration__snapshot__unpinned_uses-9.snap b/tests/integration/snapshots/integration__snapshot__unpinned_uses-9.snap
index f38f680..7a5ef7f 100644
--- a/tests/integration/snapshots/integration__snapshot__unpinned_uses-9.snap
+++ b/tests/integration/snapshots/integration__snapshot__unpinned_uses-9.snap
@@ -8,5 +8,5 @@ snapshot_kind: text
  INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
 fatal: no audit was performed
 error: failed to load audit: unpinned-uses
- = note: invalid configuration: invalid uses pattern: */foo
+ = note: invalid configuration: invalid repository pattern: */foo (expected owner/repo or owner/*)
  = note: see: https://woodruffw.github.io/zizmor/audits/#unpinned-uses
diff --git a/tests/integration/test-data/forbidden-uses/configs/allow-all.yml b/tests/integration/test-data/forbidden-uses/configs/allow-all.yml
new file mode 100644
index 0000000..e9fe48b
--- /dev/null
+++ b/tests/integration/test-data/forbidden-uses/configs/allow-all.yml
@@ -0,0 +1,11 @@
+rules:
+  unpinned-uses:
+    config:
+      policies:
+        # just to make unrelated findings go away
+        "*": ref-pin
+
+  forbidden-uses:
+    config:
+      allow:
+        - "*"
diff --git a/tests/integration/test-data/forbidden-uses/configs/allow-some.yml b/tests/integration/test-data/forbidden-uses/configs/allow-some.yml
new file mode 100644
index 0000000..9c77023
--- /dev/null
+++ b/tests/integration/test-data/forbidden-uses/configs/allow-some.yml
@@ -0,0 +1,12 @@
+rules:
+  unpinned-uses:
+    config:
+      policies:
+        # just to make unrelated findings go away
+        "*": ref-pin
+
+  forbidden-uses:
+    config:
+      allow:
+        - "actions/checkout"
+        - "pypa/gh-action-pypi-publish"
diff --git a/tests/integration/test-data/forbidden-uses/configs/deny-all.yml b/tests/integration/test-data/forbidden-uses/configs/deny-all.yml
new file mode 100644
index 0000000..eb7fe6a
--- /dev/null
+++ b/tests/integration/test-data/forbidden-uses/configs/deny-all.yml
@@ -0,0 +1,11 @@
+rules:
+  unpinned-uses:
+    config:
+      policies:
+        # just to make unrelated findings go away
+        "*": ref-pin
+
+  forbidden-uses:
+    config:
+      deny:
+        - "*"
diff --git a/tests/integration/test-data/forbidden-uses/configs/deny-some.yml b/tests/integration/test-data/forbidden-uses/configs/deny-some.yml
new file mode 100644
index 0000000..c861d41
--- /dev/null
+++ b/tests/integration/test-data/forbidden-uses/configs/deny-some.yml
@@ -0,0 +1,12 @@
+rules:
+  unpinned-uses:
+    config:
+      policies:
+        # just to make unrelated findings go away
+        "*": ref-pin
+
+  forbidden-uses:
+    config:
+      deny:
+        - "actions/checkout"
+        - "pypa/gh-action-pypi-publish"
diff --git a/tests/integration/test-data/forbidden-uses/forbidden-uses-menagerie.yml b/tests/integration/test-data/forbidden-uses/forbidden-uses-menagerie.yml
new file mode 100644
index 0000000..0fe130a
--- /dev/null
+++ b/tests/integration/test-data/forbidden-uses/forbidden-uses-menagerie.yml
@@ -0,0 +1,16 @@
+name: forbidden-uses-menagerie
+
+on: [push]
+
+permissions: {}
+
+jobs:
+  menagerie:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/setup-python@v4
+      - uses: pypa/gh-action-pypi-publish@release/v1
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false