Repository: woodruffw/zizmor
Pull request #270 (2024-12-10 13:50:49)

docs: Rename "unsecure" to insecure
Closes  #269


Patch:
diff --git a/docs/audits.md b/docs/audits.md
index fa8c0f6..93dfd15 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -629,7 +629,7 @@ A before/after example is shown below.
 Detects opt-in for executing insecure workflow commands.
 
 Workflow commands (like `::set-env` and `::add-path`)
-[were deprecated by Github] in 2020 due to their inherent weaknesses
+[were deprecated by GitHub] in 2020 due to their inherent weaknesses
 (e.g., allowing any command with the ability to emit to `stdout`
 to inject environment variables and therefore obtain code execution).
 
@@ -643,7 +643,7 @@ Other resources:
 
 ### Remediation
 
-In general, users should use for [Github Actions environment files]
+In general, users should use for [GitHub Actions environment files]
 (like `GITHUB_PATH` and `GITHUB_OUTPUT`) instead of using workflow commands.
 
 === "Before"
@@ -703,8 +703,8 @@ If you need to pass state between steps, consider using `GITHUB_OUTPUT` instead.
 [Trusted Publishing - RubyGems Guides]: https://guides.rubygems.org/trusted-publishing/
 [Trusted publishing: a new benchmark for packaging security]: https://blog.trailofbits.com/2023/05/23/trusted-publishing-a-new-benchmark-for-packaging-security/
 [Trusted Publishers for All Package Repositories]: https://repos.openssf.org/trusted-publishers-for-all-package-repositories.html
-[were deprecated by Github]: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
-[Github Actions environment files]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#environment-files
+[were deprecated by GitHub]: https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
+[GitHub Actions environment files]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#environment-files
 [Semgrep audit]: https://semgrep.dev/r?q=yaml.github-actions.security.allowed-unsecure-commands.allowed-unsecure-commands
 [GitHub Actions exploitation: environment manipulation]: https://www.synacktiv.com/en/publications/github-actions-exploitation-repo-jacking-and-environment-manipulation
 [GHSL-2024-177: Environment Variable injection in an Actions workflow of Litestar]: https://securitylab.github.com/advisories/GHSL-2024-177_Litestar/
diff --git a/docs/development.md b/docs/development.md
index 91349dd..ca76708 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -180,7 +180,7 @@ Some things that can be useful to discuss beforehand:
 - Which criticality should we assign for this new finding?
 - Which confidence should we assign for this new finding?
 - Should this new audit be pedantic at all?
-- Does this new audit require using the Github API, or is it entirely offline?
+- Does this new audit require using the GitHub API, or is it entirely offline?
 
 When developing a new `zizmor` audit, there are a couple of implementation details to be aware of:
 
diff --git a/src/audit/artipacked.rs b/src/audit/artipacked.rs
index 2c15a9b..d7b5b3d 100644
--- a/src/audit/artipacked.rs
+++ b/src/audit/artipacked.rs
@@ -73,8 +73,8 @@ impl WorkflowAudit for Artipacked {
                         Some(EnvValue::Boolean(false)) => continue,
                         Some(EnvValue::Boolean(true)) => {
                             // If a user explicitly sets `persist-credentials: true`,
-                            // they probably mean it. Only report if being pedantic.
-                            vulnerable_checkouts.push((step, Persona::Pedantic))
+                            // they probably mean it. Only report if in auditor mode.
+                            vulnerable_checkouts.push((step, Persona::Auditor))
                         }
                         // TODO: handle expressions and literal strings here.
                         // persist-credentials is true by default.
diff --git a/src/audit/self_hosted_runner.rs b/src/audit/self_hosted_runner.rs
index 751f372..72a4fae 100644
--- a/src/audit/self_hosted_runner.rs
+++ b/src/audit/self_hosted_runner.rs
@@ -10,14 +10,14 @@ use crate::{
     AuditState,
 };
 
+use super::{audit_meta, WorkflowAudit};
+use crate::models::Matrix;
 use anyhow::Result;
 use github_actions_models::{
     common::expr::{ExplicitExpr, LoE},
     workflow::{job::RunsOn, Job},
 };
 
-use super::{audit_meta, WorkflowAudit};
-
 pub(crate) struct SelfHostedRunner;
 
 audit_meta!(
@@ -106,18 +106,35 @@ impl WorkflowAudit for SelfHostedRunner {
                 ),
                 // The entire `runs-on:` is an expression, which may or may
                 // not be a self-hosted runner when expanded, like above.
-                LoE::Expr(_) => results.push(
-                    Self::finding()
-                        .confidence(Confidence::Low)
-                        .severity(Severity::Unknown)
-                        .persona(Persona::Auditor)
-                        .add_location(
-                            job.location()
-                                .with_keys(&["runs-on".into()])
-                                .annotated("expression may expand into a self-hosted runner"),
+                LoE::Expr(exp) => {
+                    let matrix = Matrix::try_from(&job)?;
+
+                    let expansions = matrix.expand_values();
+
+                    let self_hosted = expansions.iter().any(|(path, expansion)| {
+                        exp.as_bare() == path && expansion.contains("self-hosted")
+                    });
+
+                    if self_hosted {
+                        results.push(
+                            Self::finding()
+                                .confidence(Confidence::High)
+                                .severity(Severity::Unknown)
+                                .persona(Persona::Auditor)
+                                .add_location(
+                                    job.location()
+                                        .with_keys(&["strategy".into()])
+                                        .annotated("matrix declares self-hosted runner"),
+                                )
+                                .add_location(
+                                    job.location().with_keys(&["runs-on".into()]).annotated(
+                                        "expression may expand into a self-hosted runner",
+                                    ),
+                                )
+                                .build(workflow)?,
                         )
-                        .build(workflow)?,
-                ),
+                    }
+                }
             }
         }
 
diff --git a/src/audit/template_injection.rs b/src/audit/template_injection.rs
index c2cc19f..eb0a847 100644
--- a/src/audit/template_injection.rs
+++ b/src/audit/template_injection.rs
@@ -14,13 +14,14 @@ use std::ops::Deref;
 
 use github_actions_models::{
     common::expr::LoE,
-    workflow::job::{Matrix, NormalJob, StepBody, Strategy},
+    workflow::job::{NormalJob, StepBody, Strategy},
 };
 
 use super::{audit_meta, WorkflowAudit};
 use crate::{
     expr::{BinOp, Expr, UnOp},
     finding::{Confidence, Persona, Severity},
+    models,
     state::AuditState,
     utils::extract_expressions,
 };
@@ -118,50 +119,6 @@ impl TemplateInjection {
         }
     }
 
-    /// Checks whether the given `expr` into `matrix` is static.
-    fn matrix_is_static(&self, expr: &str, matrix: &Matrix) -> bool {
-        // If the matrix's dimensions are an expression, then it's not static.
-        let LoE::Literal(dimensions) = &matrix.dimensions else {
-            return false;
-        };
-
-        // Our `expr` should be a literal path of `matrix.foo.bar.baz.etc`,
-        // so we descend through the matrix based on it.
-        let mut keys = expr.split('.').skip(1);
-
-        let Some(key) = keys.next() else {
-            // No path means that we're effectively expanding the entire matrix,
-            // meaning *any* non-static component makes the entire expansion
-            // non-static.
-
-            // HACK: The correct way to do this is to walk `matrix.dimensions`,
-            // but it could be arbitrarily deep. Instead, we YOLO the dimensions
-            // back into YAML and see if the serialized equivalent has
-            // any indicators of expansion (`${{ ... }}`) in it.
-            // NOTE: Safe unwrap since `dimensions` was loaded directly from YAML
-            let dimensions_yaml = serde_yaml::to_string(&dimensions).unwrap();
-            return !(dimensions_yaml.contains("${{") && dimensions_yaml.contains("}}"));
-        };
-
-        match dimensions.get(key) {
-            // This indicates a malformed matrix or matrix ref, which is
-            // static for our purposes.
-            None => true,
-            // If our key is an expression, it's definitely not static.
-            Some(LoE::Expr(_)) => false,
-            Some(LoE::Literal(dim)) => {
-                // TODO: This is imprecise: technically we should walk the
-                // entire set of keys to determine if a specific index is
-                // accessed + whether that index is an expression.
-                // But doing that is hard, so we do the same YOLO reserialize
-                // trick as above and consider this non-static
-                // if it has any hint of a template expansion in it.
-                let dim_yaml = serde_yaml::to_string(&dim).unwrap();
-                !(dim_yaml.contains("${{") && dim_yaml.contains("}}"))
-            }
-        }
-    }
-
     fn injectable_template_expressions(
         &self,
         run: &str,
@@ -223,12 +180,11 @@ impl TemplateInjection {
                 } else if context.starts_with("matrix.") || context == "matrix" {
                     if let Some(Strategy { matrix, .. }) = &job.strategy {
                         let matrix_is_static = match matrix {
-                            // The matrix is statically defined, but one
-                            // or more keys might contain expressions.
-                            Some(LoE::Literal(matrix)) => self.matrix_is_static(context, matrix),
                             // The matrix is generated by an expression, meaning
                             // that it's trivially not static.
                             Some(LoE::Expr(_)) => false,
+
+                            Some(inner) => models::Matrix::new(inner).is_static(),
                             // Context specifies a matrix, but there is no matrix defined.
                             // This is an invalid workflow so there's no point in flagging it.
                             None => continue,
diff --git a/src/models.rs b/src/models.rs
index 9d5465d..32675c4 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -1,21 +1,23 @@
 //! Enriching/context-bearing wrappers over GitHub Actions models
 //! from the `github-actions-models` crate.
 
-use std::fmt::Debug;
-use std::{iter::Enumerate, ops::Deref};
-
 use crate::finding::{Route, SymbolicLocation};
 use crate::registry::WorkflowKey;
-use anyhow::{Context, Result};
+use anyhow::{bail, Context, Result};
 use camino::Utf8Path;
 use github_actions_models::common::expr::LoE;
 use github_actions_models::workflow::event::{BareEvent, OptionalBody};
-use github_actions_models::workflow::job::RunsOn;
+use github_actions_models::workflow::job::{RunsOn, Strategy};
 use github_actions_models::workflow::{
-    self,
+    self, job,
     job::{NormalJob, StepBody},
     Trigger,
 };
+use indexmap::IndexMap;
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::fmt::Debug;
+use std::{iter::Enumerate, ops::Deref};
 use terminal_link::Link;
 
 /// Represents an entire GitHub Actions workflow.
@@ -224,6 +226,162 @@ impl<'w> Iterator for Jobs<'w> {
     }
 }
 
+/// Represents an execution Matrix within a Job.
+///
+/// This type implements [`Deref`] for [job::NormalJob::Strategy`], providing
+/// access to the underlying data model.
+#[derive(Clone)]
+pub(crate) struct Matrix<'w> {
+    inner: &'w LoE<job::Matrix>,
+}
+
+impl<'w> Deref for Matrix<'w> {
+    type Target = &'w LoE<job::Matrix>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.inner
+    }
+}
+
+impl<'w> TryFrom<&'w Job<'w>> for Matrix<'w> {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'w Job<'w>) -> std::result::Result<Self, Self::Error> {
+        let workflow::Job::NormalJob(job) = value.deref() else {
+            bail!("job is not a normal job")
+        };
+
+        let Some(Strategy {
+            matrix: Some(matrix),
+            ..
+        }) = &job.strategy
+        else {
+            bail!("job does not define a strategy or interior matrix")
+        };
+
+        Ok(Matrix { inner: matrix })
+    }
+}
+
+impl<'w> Matrix<'w> {
+    pub(crate) fn new(inner: &'w LoE<job::Matrix>) -> Self {
+        Self { inner }
+    }
+
+    /// Expands the current Matrix into all possible values
+    /// By default, the return is a pair (String, String), in which
+    /// the first component is the expanded path (e.g. 'matrix.os') and
+    /// the second component is the string representation for the expanded value
+    /// (eg ubuntu-latest)
+    ///
+    pub(crate) fn expand_values(&self) -> Vec<(String, String)> {
+        match &self.inner {
+            LoE::Expr(_) => vec![],
+            LoE::Literal(matrix) => {
+                let LoE::Literal(dimensions) = &matrix.dimensions else {
+                    return vec![];
+                };
+
+                let mut expansions = Matrix::expand_dimensions(dimensions);
+
+                if let LoE::Literal(includes) = &matrix.include {
+                    let additional_expansions = includes
+                        .iter()
+                        .flat_map(Matrix::expand_explicit_rows)
+                        .collect::<Vec<_>>();
+
+                    expansions.extend(additional_expansions);
+                };
+
+                let LoE::Literal(excludes) = &matrix.exclude else {
+                    return expansions;
+                };
+
+                let to_exclude = excludes
+                    .iter()
+                    .flat_map(Matrix::expand_explicit_rows)
+                    .collect::<Vec<_>>();
+
+                expansions
+                    .into_iter()
+                    .filter(|expanded| !to_exclude.contains(expanded))
+                    .collect()
+            }
+        }
+    }
+
+    /// Checks whether some expanded path leads to an expression
+    pub(crate) fn is_static(&self) -> bool {
+        let expands_to_expression = self
+            .expand_values()
+            .iter()
+            .any(|(_, expansion)| expansion.starts_with("${{") && expansion.ends_with("}}"));
+
+        !expands_to_expression
+    }
+
+    fn expand_explicit_rows(
+        include: &IndexMap<String, serde_yaml::Value>,
+    ) -> Vec<(String, String)> {
+        let normalized = include
+            .iter()
+            .map(|(k, v)| (k.to_owned(), json!(v)))
+            .collect::<HashMap<_, _>>();
+
+        Matrix::expand(normalized)
+    }
+
+    fn expand_dimensions(
+        dimensions: &IndexMap<String, LoE<Vec<serde_yaml::Value>>>,
+    ) -> Vec<(String, String)> {
+        let normalized = dimensions
+            .iter()
+            .map(|(k, v)| (k.to_owned(), json!(v)))
+            .collect::<HashMap<_, _>>();
+
+        Matrix::expand(normalized)
+    }
+
+    fn expand(values: HashMap<String, serde_json::Value>) -> Vec<(String, String)> {
+        values
+            .iter()
+            .flat_map(|(key, value)| Matrix::walk_path(value, format!("matrix.{}", key)))
+            .collect()
+    }
+
+    // Walks recursively a serde_json::Value tree, expanding it into a Vec<(String, String)>
+    // according to the inner value of each node
+    fn walk_path(tree: &serde_json::Value, current_path: String) -> Vec<(String, String)> {
+        match tree {
+            Value::Null => vec![],
+
+            // In the case of scalars, we just convert the value to a string
+            Value::Bool(inner) => vec![(current_path, inner.to_string())],
+            Value::Number(inner) => vec![(current_path, inner.to_string())],
+            Value::String(inner) => vec![(current_path, inner.to_string())],
+
+            // In the case of an array, we recursively create on expansion pair for each item
+            Value::Array(inner) => inner
+                .iter()
+                .flat_map(|value| Matrix::walk_path(value, current_path.clone()))
+                .collect(),
+
+            // In the case of an object, we recursively create on expansion pair for each
+            // value in the key/value set, using the key to form the expanded path using
+            // the dot notation
+            Value::Object(inner) => inner
+                .iter()
+                .flat_map(|(key, value)| {
+                    let mut new_path = current_path.clone();
+                    new_path.push('.');
+                    new_path.push_str(key);
+                    Matrix::walk_path(value, new_path)
+                })
+                .collect(),
+        }
+    }
+}
+
 /// Represents a single step in a normal workflow job.
 ///
 /// This type implements [`Deref`] for [`workflow::job::Step`], which


Test patch:
diff --git a/tests/acceptance.rs b/tests/acceptance.rs
index 70386c7..11ad78c 100644
--- a/tests/acceptance.rs
+++ b/tests/acceptance.rs
@@ -6,7 +6,7 @@ use serde_json_path::JsonPath;
 mod common;
 
 // Acceptance tests for zizmor, on top of Json output
-// For now we don't cover tests that depends on Github API under the hood
+// For now we don't cover tests that depends on GitHub API under the hood
 
 fn zizmor() -> Command {
     let mut cmd = Command::cargo_bin("zizmor").expect("Cannot create executable command");
@@ -210,7 +210,7 @@ fn audit_unpinned_uses() -> anyhow::Result<()> {
 }
 
 #[test]
-fn audit_unsecure_commands_allowed() -> anyhow::Result<()> {
+fn audit_insecure_commands_allowed() -> anyhow::Result<()> {
     let auditable = workflow_under_test("insecure-commands.yml");
 
     let cli_args = [&auditable];
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index b04a081..8318a26 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -138,6 +138,11 @@ fn artipacked() -> Result<()> {
         .workflow(workflow_under_test("artipacked.yml"))
         .run()?);
 
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test("artipacked.yml"))
+        .args(["--persona=auditor"])
+        .run()?);
+
     Ok(())
 }
 
@@ -152,6 +157,41 @@ fn self_hosted() -> Result<()> {
         .workflow(workflow_under_test("self-hosted.yml"))
         .run()?);
 
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "self-hosted/self-hosted-runner-label.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "self-hosted/self-hosted-runner-group.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "self-hosted/self-hosted-matrix-dimension.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "self-hosted/self-hosted-matrix-inclusion.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "self-hosted/self-hosted-matrix-exclusion.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
     Ok(())
 }
 
@@ -182,3 +222,22 @@ fn insecure_commands() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn template_injection() -> Result<()> {
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "template-injection/template-injection-static-matrix.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .workflow(workflow_under_test(
+            "template-injection/template-injection-dynamic-matrix.yml"
+        ))
+        .args(["--persona=auditor"])
+        .run()?);
+
+    Ok(())
+}
diff --git a/tests/snapshots/snapshot__artipacked-3.snap b/tests/snapshots/snapshot__artipacked-3.snap
new file mode 100644
index 0000000..563a9f4
--- /dev/null
+++ b/tests/snapshots/snapshot__artipacked-3.snap
@@ -0,0 +1,25 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"artipacked.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+warning[artipacked]: credential persistence through GitHub Actions artifacts
+  --> @@INPUT@@:13:9
+   |
+13 |       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+   |         ---------------------------------------------------------------------------- does not set persist-credentials: false
+   |
+   = note: audit confidence → Low
+
+warning[artipacked]: credential persistence through GitHub Actions artifacts
+  --> @@INPUT@@:18:9
+   |
+18 |         - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+   |  _________-
+19 | |         with:
+20 | |           persist-credentials: true
+   | |____________________________________- does not set persist-credentials: false
+   |
+   = note: audit confidence → Low
+
+2 findings: 0 unknown, 0 informational, 0 low, 2 medium, 0 high
diff --git a/tests/snapshots/snapshot__artipacked.snap b/tests/snapshots/snapshot__artipacked.snap
index b5b0308..de16173 100644
--- a/tests/snapshots/snapshot__artipacked.snap
+++ b/tests/snapshots/snapshot__artipacked.snap
@@ -11,15 +11,4 @@ warning[artipacked]: credential persistence through GitHub Actions artifacts
    |
    = note: audit confidence → Low
 
-warning[artipacked]: credential persistence through GitHub Actions artifacts
-  --> @@INPUT@@:18:9
-   |
-18 |         - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
-   |  _________-
-19 | |         with:
-20 | |           persist-credentials: true
-   | |____________________________________- does not set persist-credentials: false
-   |
-   = note: audit confidence → Low
-
-2 findings: 0 unknown, 0 informational, 0 low, 2 medium, 0 high
+2 findings (1 suppressed): 0 unknown, 0 informational, 0 low, 1 medium, 0 high
diff --git a/tests/snapshots/snapshot__self_hosted-3.snap b/tests/snapshots/snapshot__self_hosted-3.snap
new file mode 100644
index 0000000..8589957
--- /dev/null
+++ b/tests/snapshots/snapshot__self_hosted-3.snap
@@ -0,0 +1,14 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"self-hosted/self-hosted-runner-label.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+note[self-hosted-runner]: runs on a self-hosted runner
+ --> @@INPUT@@:6:5
+  |
+6 |     runs-on: [self-hosted, linux, arm64]
+  |     ------------------------------------ note: self-hosted runner used here
+  |
+  = note: audit confidence → High
+
+1 finding: 1 unknown, 0 informational, 0 low, 0 medium, 0 high
diff --git a/tests/snapshots/snapshot__self_hosted-4.snap b/tests/snapshots/snapshot__self_hosted-4.snap
new file mode 100644
index 0000000..411fa3a
--- /dev/null
+++ b/tests/snapshots/snapshot__self_hosted-4.snap
@@ -0,0 +1,15 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"self-hosted/self-hosted-runner-group.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+note[self-hosted-runner]: runs on a self-hosted runner
+ --> @@INPUT@@:6:5
+  |
+6 | /     runs-on:
+7 | |       group: ubuntu-runners
+  | |___________________________- note: runner group implies self-hosted runner
+  |
+  = note: audit confidence → Low
+
+1 finding: 1 unknown, 0 informational, 0 low, 0 medium, 0 high
diff --git a/tests/snapshots/snapshot__self_hosted-5.snap b/tests/snapshots/snapshot__self_hosted-5.snap
new file mode 100644
index 0000000..53b161f
--- /dev/null
+++ b/tests/snapshots/snapshot__self_hosted-5.snap
@@ -0,0 +1,19 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"self-hosted/self-hosted-matrix-dimension.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+note[self-hosted-runner]: runs on a self-hosted runner
+  --> @@INPUT@@:8:5
+   |
+ 6 |       runs-on: ${{ matrix.os }}
+   |       ------------------------- note: expression may expand into a self-hosted runner
+ 7 |
+ 8 | /     strategy:
+ 9 | |       matrix:
+10 | |         os: [self-hosted, ubuntu-latest]
+   | |________________________________________- note: matrix declares self-hosted runner
+   |
+   = note: audit confidence → High
+
+1 finding: 1 unknown, 0 informational, 0 low, 0 medium, 0 high
diff --git a/tests/snapshots/snapshot__self_hosted-6.snap b/tests/snapshots/snapshot__self_hosted-6.snap
new file mode 100644
index 0000000..0cdcaed
--- /dev/null
+++ b/tests/snapshots/snapshot__self_hosted-6.snap
@@ -0,0 +1,21 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"self-hosted/self-hosted-matrix-inclusion.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+note[self-hosted-runner]: runs on a self-hosted runner
+  --> @@INPUT@@:8:5
+   |
+ 6 |       runs-on: ${{ matrix.os }}
+   |       ------------------------- note: expression may expand into a self-hosted runner
+ 7 |
+ 8 | /     strategy:
+ 9 | |       matrix:
+10 | |         os: [macOS-latest, ubuntu-latest]
+11 | |         include:
+12 | |           - os: self-hosted
+   | |___________________________- note: matrix declares self-hosted runner
+   |
+   = note: audit confidence → High
+
+1 finding: 1 unknown, 0 informational, 0 low, 0 medium, 0 high
diff --git a/tests/snapshots/snapshot__self_hosted-7.snap b/tests/snapshots/snapshot__self_hosted-7.snap
new file mode 100644
index 0000000..e460dc7
--- /dev/null
+++ b/tests/snapshots/snapshot__self_hosted-7.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"self-hosted/self-hosted-matrix-exclusion.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/snapshots/snapshot__template_injection-2.snap b/tests/snapshots/snapshot__template_injection-2.snap
new file mode 100644
index 0000000..a058f62
--- /dev/null
+++ b/tests/snapshots/snapshot__template_injection-2.snap
@@ -0,0 +1,17 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"template-injection/template-injection-dynamic-matrix.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+warning[template-injection]: code injection via template expansion
+  --> @@INPUT@@:17:9
+   |
+17 |         - name: Please dont
+   |           ----------------- this step
+18 | /         run: |
+19 | |           echo "doing a thing: ${{ matrix.dynamic }}"
+   | |______________________________________________________- matrix.dynamic may expand into attacker-controllable code
+   |
+   = note: audit confidence → Medium
+
+1 finding: 0 unknown, 0 informational, 0 low, 1 medium, 0 high
diff --git a/tests/snapshots/snapshot__template_injection.snap b/tests/snapshots/snapshot__template_injection.snap
new file mode 100644
index 0000000..6ea4e0c
--- /dev/null
+++ b/tests/snapshots/snapshot__template_injection.snap
@@ -0,0 +1,6 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().workflow(workflow_under_test(\"template-injection/template-injection-static-matrix.yml\")).args([\"--persona=auditor\"]).run()?"
+snapshot_kind: text
+---
+No findings to report. Good job!
diff --git a/tests/test-data/self-hosted/self-hosted-matrix-dimension.yml b/tests/test-data/self-hosted/self-hosted-matrix-dimension.yml
new file mode 100644
index 0000000..1e7f4bd
--- /dev/null
+++ b/tests/test-data/self-hosted/self-hosted-matrix-dimension.yml
@@ -0,0 +1,12 @@
+on:
+  push:
+
+jobs:
+  whops:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        os: [self-hosted, ubuntu-latest]
+    steps:
+      - run: echo \"hello from a self-hosted runner\"
diff --git a/tests/test-data/self-hosted/self-hosted-matrix-exclusion.yml b/tests/test-data/self-hosted/self-hosted-matrix-exclusion.yml
new file mode 100644
index 0000000..a15b4e8
--- /dev/null
+++ b/tests/test-data/self-hosted/self-hosted-matrix-exclusion.yml
@@ -0,0 +1,16 @@
+# Adapted from
+# https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/self-hosted.yml
+on:
+  push:
+
+jobs:
+  ok:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        os: [self-hosted, macOS-latest, ubuntu-latest]
+        exclude:
+          - os: self-hosted # Wont trigger a finding
+    steps:
+      - run: echo \"hello from a Github runner\"
diff --git a/tests/test-data/self-hosted/self-hosted-matrix-inclusion.yml b/tests/test-data/self-hosted/self-hosted-matrix-inclusion.yml
new file mode 100644
index 0000000..83b7c62
--- /dev/null
+++ b/tests/test-data/self-hosted/self-hosted-matrix-inclusion.yml
@@ -0,0 +1,14 @@
+on:
+  push:
+
+jobs:
+  whops:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        os: [macOS-latest, ubuntu-latest]
+        include:
+          - os: self-hosted
+    steps:
+      - run: echo \"hello from a self-hosted runner\"
diff --git a/tests/test-data/self-hosted/self-hosted-runner-group.yml b/tests/test-data/self-hosted/self-hosted-runner-group.yml
new file mode 100644
index 0000000..c9567f4
--- /dev/null
+++ b/tests/test-data/self-hosted/self-hosted-runner-group.yml
@@ -0,0 +1,10 @@
+on:
+  push:
+
+jobs:
+  whops:
+    runs-on:
+      group: ubuntu-runners
+
+    steps:
+      - run: echo \"hello from a self-hosted runner\"
diff --git a/tests/test-data/self-hosted/self-hosted-runner-label.yml b/tests/test-data/self-hosted/self-hosted-runner-label.yml
new file mode 100644
index 0000000..7879c06
--- /dev/null
+++ b/tests/test-data/self-hosted/self-hosted-runner-label.yml
@@ -0,0 +1,9 @@
+on:
+  push:
+
+jobs:
+  whops:
+    runs-on: [self-hosted, linux, arm64]
+
+    steps:
+      - run: echo \"hello from a self-hosted runner\"
diff --git a/tests/test-data/template-injection/template-injection-dynamic-matrix.yml b/tests/test-data/template-injection/template-injection-dynamic-matrix.yml
new file mode 100644
index 0000000..12c848f
--- /dev/null
+++ b/tests/test-data/template-injection/template-injection-dynamic-matrix.yml
@@ -0,0 +1,19 @@
+# Adapted from
+# https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/template-injection.yml
+name: paw-me
+on:
+  issues:
+
+jobs:
+  not-ok:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        frob: ["nothing", "special"]
+        dynamic: ${{ github.event.client_payload.mystery_meat }}
+
+    steps:
+      - name: Please dont
+        run: |
+          echo "doing a thing: ${{ matrix.dynamic }}"
diff --git a/tests/test-data/template-injection/template-injection-static-matrix.yml b/tests/test-data/template-injection/template-injection-static-matrix.yml
new file mode 100644
index 0000000..fc755c1
--- /dev/null
+++ b/tests/test-data/template-injection/template-injection-static-matrix.yml
@@ -0,0 +1,18 @@
+# Adapted from
+# https://github.com/woodruffw/gha-hazmat/blob/main/.github/workflows/template-injection.yml
+name: safe
+on:
+  issues:
+
+jobs:
+  ok-ish:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        frob: ["nothing", "special"]
+
+    steps:
+      - name: Nothing to fear
+        run: |
+          echo "issue created: ${{ matrix.frob }}"