Repository: woodruffw/zizmor (rust)

Issue:
New audit: obfuscation
Obfuscation isn't a security issue per se, but often suggests that the author is trying to hide their intent. Obfuscated workflows are also harder for us to analyze, so producing findings for some common cases will help us nudge users towards things we can analyze.

Some patterns we want to detect:

* Unnecessary use of `format(...)`: `format(fstring, ...args)` is unnecessary when all `args` are literals
* Unnecessary wrappings of `fromJSON(toJSON(...))` or vice versa
* Double negation
* `contains(haystack, needle)` where both are literals
  * Same for `startsWith` and `endsWith`

Pull request:
feat: new audit: obfuscation
~~WIP; needs to be fleshed out more.~~

TODO:

- [x] Documentation
- [x] Add expression obfuscation checks per #458
- [x] More tests

Closes #458

Patch:
diff --git a/docs/audits.md b/docs/audits.md
index cb961dd..a869449 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -1242,6 +1242,58 @@ in [unpinned-uses](#unpinned-uses-configuration).
 Either remove the offending `#!yaml uses:` clause or, if intended, add it to
 your [configuration](#forbidden-uses-configuration).
 
+## `obfuscation`
+
+| Type     | Examples                | Introduced in | Works offline  | Enabled by default | Configurable |
+|----------|-------------------------|---------------|----------------|--------------------| ---------------|
+| Workflow, Action  | N/A   | v1.7.0        | ✅             | ✅                 | ❌  |
+
+Checks for obfuscated usages of GitHub Actions features.
+
+This audit primarily serves to "unstick" other audits, which may fail to detect
+functioning but obfuscated usages of GitHub Actions features.
+
+This audit detects a variety of obfuscated usages, including:
+
+* Obfuscated paths within `#!yaml uses:` clauses, including redundant `/`
+  separators and uses of `.` or `..` in path segments.
+* Obfuscated GitHub expressions, including no-op patterns like
+  `fromJSON(toJSON(...))` and calls to `format(...)` where all
+  arguments are literal values.
+
+### Remediation
+
+Address the source of obfuscation by simplifying the expression,
+`#!yaml uses:` clause, or other obfuscated feature.
+
+!!! example
+
+    === "Before :warning:"
+
+        ```yaml title="obfuscation.yml" hl_lines="8"
+        jobs:
+          build:
+            runs-on: ubuntu-latest
+            steps:
+              - name: Checkout
+                uses: actions/checkout@v4
+                with:
+                  repository: ${{ format('{0}/{1}', 'octocat', 'hello-world') }}
+        ```
+
+    === "After :white_check_mark:"
+
+        ```yaml title="obfuscation.yml" hl_lines="8"
+        jobs:
+          build:
+            runs-on: ubuntu-latest
+            steps:
+              - name: Checkout
+                uses: actions/checkout@v4
+                with:
+                  repository: octocat/hello-world
+        ```
+
 [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/
 [What the fork? Imposter commits in GitHub Actions and CI/CD]: https://www.chainguard.dev/unchained/what-the-fork-imposter-commits-in-github-actions-and-ci-cd
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 03a8d5b..8162b1c 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -9,6 +9,12 @@ of `zizmor`.
 
 ## Next (UNRELEASED)
 
+### New Features 🌈
+
+* **New audit**: The [obfuscation] audit detects obfuscatory patterns in
+  GitHub Actions usages. These patterns are not themselves dangerous,
+  but may indicate an attempt to obscure malicious behavior (#683)
+
 ## v1.6.0
 
 ### New Features 🌈
diff --git a/src/audit/mod.rs b/src/audit/mod.rs
index 04a2269..b0aec32 100644
--- a/src/audit/mod.rs
+++ b/src/audit/mod.rs
@@ -24,6 +24,7 @@ pub(crate) mod hardcoded_container_credentials;
 pub(crate) mod impostor_commit;
 pub(crate) mod insecure_commands;
 pub(crate) mod known_vulnerable_actions;
+pub(crate) mod obfuscation;
 pub(crate) mod overprovisioned_secrets;
 pub(crate) mod ref_confusion;
 pub(crate) mod secrets_inherit;
diff --git a/src/audit/obfuscation.rs b/src/audit/obfuscation.rs
new file mode 100644
index 0000000..17a6b3a
--- /dev/null
+++ b/src/audit/obfuscation.rs
@@ -0,0 +1,163 @@
+use github_actions_models::common::{RepositoryUses, Uses};
+
+use crate::{
+    Confidence, Severity,
+    expr::Expr,
+    finding::{self, Feature, Location},
+    utils::parse_expressions_from_input,
+};
+
+use super::{Audit, audit_meta};
+
+pub(crate) struct Obfuscation;
+
+audit_meta!(
+    Obfuscation,
+    "obfuscation",
+    "obfuscated usage of GitHub Actions features"
+);
+
+impl Obfuscation {
+    fn obfuscated_repo_uses(&self, uses: &RepositoryUses) -> Vec<&str> {
+        let mut annotations = vec![];
+
+        // Users can put all kinds of nonsense in `uses:` clauses, which
+        // GitHub happily interprets but otherwise gums up pattern matching
+        // in audits like unpinned-uses, forbidden-uses, and cache-poisoning.
+        // We check for some of these forms of nonsense here and report them.
+        if let Some(subpath) = uses.subpath.as_deref() {
+            for component in subpath.split('/') {
+                match component {
+                    // . and .. are valid in uses subpaths, but are impossible to
+                    // analyze or match with full generality.
+                    "." => {
+                        annotations.push("actions reference contains '.'");
+                    }
+                    ".." => {
+                        annotations.push("actions reference contains '..'");
+                    }
+                    // `uses: foo/bar////baz` and similar is valid, but
+                    // only serves to mess up pattern matching.
+                    // This also catches `uses: foo/bar/@v1`.
+                    _ if component.is_empty() => {
+                        annotations.push("actions reference contains empty component");
+                    }
+                    _ => {}
+                }
+            }
+        }
+
+        annotations
+    }
+
+    fn obfuscated_exprs(&self, expr: &Expr) -> Vec<&str> {
+        let mut annotations = vec![];
+
+        // Check for some common expression obfuscation patterns.
+
+        // Expressions that can be constant folded should be simplified to
+        // their evaluated form.
+        if expr.constant_foldable() {
+            annotations.push("expression can be simplified via constant folding");
+        } else if expr.has_constant_foldable_subexpr() {
+            annotations.push("expression contains constant foldable subexpression");
+        }
+
+        // TODO: calculate call breadth/depth and flag above thresholds.
+
+        annotations
+    }
+}
+
+impl Audit for Obfuscation {
+    fn new(_state: &crate::AuditState<'_>) -> Result<Self, super::AuditLoadError>
+    where
+        Self: Sized,
+    {
+        Ok(Self)
+    }
+
+    fn audit_raw<'w>(
+        &self,
+        input: &'w super::AuditInput,
+    ) -> anyhow::Result<Vec<finding::Finding<'w>>> {
+        let mut findings = vec![];
+
+        for (expr, span) in parse_expressions_from_input(input) {
+            let Ok(parsed) = Expr::parse(expr.as_bare()) else {
+                tracing::warn!("couldn't parse expression: {expr}", expr = expr.as_bare());
+                continue;
+            };
+
+            for annotation in self.obfuscated_exprs(&parsed) {
+                findings.push(
+                    Self::finding()
+                        .confidence(Confidence::High)
+                        .severity(Severity::Low)
+                        .add_raw_location(Location::new(
+                            input.location().annotated(annotation).primary(),
+                            Feature::from_span(&span, input),
+                        ))
+                        .build(input)?,
+                );
+            }
+        }
+
+        Ok(findings)
+    }
+
+    fn audit_step<'w>(
+        &self,
+        step: &crate::models::Step<'w>,
+    ) -> anyhow::Result<Vec<finding::Finding<'w>>> {
+        let mut findings = vec![];
+
+        if let Some(Uses::Repository(uses)) = step.uses() {
+            for annotation in self.obfuscated_repo_uses(uses) {
+                findings.push(
+                    Self::finding()
+                        .confidence(Confidence::High)
+                        .severity(Severity::Low)
+                        .add_location(
+                            step.location()
+                                .primary()
+                                .with_keys(&["uses".into()])
+                                .annotated(annotation),
+                        )
+                        .build(step.workflow())?,
+                );
+            }
+        }
+
+        Ok(findings)
+    }
+
+    fn audit_composite_step<'a>(
+        &self,
+        step: &crate::models::CompositeStep<'a>,
+    ) -> anyhow::Result<Vec<finding::Finding<'a>>> {
+        let mut findings = vec![];
+
+        if let Some(Uses::Repository(uses)) = step.uses() {
+            for annotation in self.obfuscated_repo_uses(uses) {
+                findings.push(
+                    Self::finding()
+                        .confidence(Confidence::High)
+                        .severity(Severity::Low)
+                        .add_location(
+                            step.location()
+                                .primary()
+                                .with_keys(&["uses".into()])
+                                .annotated(annotation),
+                        )
+                        .build(step.action())?,
+                );
+            }
+        }
+
+        Ok(findings)
+    }
+
+    // TODO: Implement obfuscation checks for expressions via audit_raw.
+    // See: https://github.com/woodruffw/zizmor/issues/458
+}
diff --git a/src/audit/overprovisioned_secrets.rs b/src/audit/overprovisioned_secrets.rs
index 335273d..ef9b460 100644
--- a/src/audit/overprovisioned_secrets.rs
+++ b/src/audit/overprovisioned_secrets.rs
@@ -31,8 +31,6 @@ impl Audit for OverprovisionedSecrets {
                 continue;
             };
 
-            expr.as_curly();
-
             for _ in Self::secrets_expansions(&parsed) {
                 findings.push(
                     Self::finding()
diff --git a/src/audit/unredacted_secrets.rs b/src/audit/unredacted_secrets.rs
index e8d6a59..8477461 100644
--- a/src/audit/unredacted_secrets.rs
+++ b/src/audit/unredacted_secrets.rs
@@ -35,8 +35,6 @@ impl Audit for UnredactedSecrets {
                 continue;
             };
 
-            expr.as_curly();
-
             for _ in Self::secret_leakages(&parsed) {
                 findings.push(
                     Self::finding()
diff --git a/src/expr/mod.rs b/src/expr/mod.rs
index f0e7fa4..9a1e0a8 100644
--- a/src/expr/mod.rs
+++ b/src/expr/mod.rs
@@ -187,6 +187,76 @@ impl<'src> Expr<'src> {
         Self::Context(Context::new(r, components))
     }
 
+    fn is_literal(&self) -> bool {
+        matches!(
+            self,
+            Expr::Number(_) | Expr::String(_) | Expr::Boolean(_) | Expr::Null
+        )
+    }
+
+    /// Returns whether the expression is constant foldable.
+    ///
+    /// There are three kinds of foldable expressions:
+    ///
+    /// 1. Literals, which fold to their literal value;
+    /// 2. Binops/unops with foldable subexpressions, which fold
+    ///    to their evaluation;
+    /// 3. Select function calls where the semantics of the function
+    ///    mean that foldable arguments make the call itself foldable.
+    ///
+    /// NOTE: This implementation is sound but not complete.
+    pub(crate) fn constant_foldable(&self) -> bool {
+        match self {
+            // Literals are always foldable.
+            Expr::Number(_) | Expr::String(_) | Expr::Boolean(_) | Expr::Null => true,
+            // Binops are foldable if their LHS and RHS are foldable.
+            Expr::BinOp { lhs, op: _, rhs } => lhs.constant_foldable() && rhs.constant_foldable(),
+            // Unops are foldable if their interior expression is foldable.
+            Expr::UnOp { op: _, expr } => expr.constant_foldable(),
+            Expr::Call { func, args } => {
+                // These functions are foldable if their arguments are foldable.
+                if func == "format"
+                    || func == "contains"
+                    || func == "startsWith"
+                    || func == "endsWith"
+                {
+                    args.iter().all(Expr::constant_foldable)
+                } else {
+                    // TODO: fromJSON(toJSON(...)) and vice versa.
+                    false
+                }
+            }
+            // Everything else is presumed non-foldable.
+            _ => false,
+        }
+    }
+
+    /// Like [`Self::constant_foldable`], but for all subexpressions
+    /// rather than the top-level expression.
+    ///
+    /// This has slightly different semantics than `constant_foldable`:
+    /// it doesn't include "trivially" foldable expressions like literals,
+    /// since flagging these as foldable within a larger expression
+    /// would be misleading.
+    pub(crate) fn has_constant_foldable_subexpr(&self) -> bool {
+        if !self.is_literal() && self.constant_foldable() {
+            return true;
+        }
+
+        match self {
+            Expr::Call { func: _, args } => args.iter().any(|a| a.has_constant_foldable_subexpr()),
+            Expr::Context(context) => {
+                let head = &context.components()[0];
+                head.has_constant_foldable_subexpr()
+            }
+            Expr::BinOp { lhs, op: _, rhs } => {
+                lhs.has_constant_foldable_subexpr() || rhs.has_constant_foldable_subexpr()
+            }
+            Expr::UnOp { op: _, expr } => expr.has_constant_foldable_subexpr(),
+            _ => false,
+        }
+    }
+
     /// Returns the contexts in this expression that directly flow into the
     /// expression's evaluation.
     ///
@@ -723,6 +793,68 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_expr_constant_foldable() -> Result<()> {
+        for (expr, foldable) in &[
+            ("'foo'", true),
+            ("1", true),
+            ("true", true),
+            ("null", true),
+            // boolean and unary expressions of all literals are
+            // always foldable.
+            ("!true", true),
+            ("!null", true),
+            ("true && false", true),
+            ("true || false", true),
+            ("null && !null && true", true),
+            // formats/contains/startsWith/endsWith are foldable
+            // if all of their arguments are foldable.
+            ("format('{0} {1}', 'foo', 'bar')", true),
+            ("format('{0} {1}', 1, 2)", true),
+            ("format('{0} {1}', 1, '2')", true),
+            ("contains('foo', 'bar')", true),
+            ("startsWith('foo', 'bar')", true),
+            ("endsWith('foo', 'bar')", true),
+            ("startsWith(some.context, 'bar')", false),
+            ("endsWith(some.context, 'bar')", false),
+            // Nesting works as long as the nested call is also foldable.
+            ("format('{0} {1}', '1', format('{0}', null))", true),
+            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
+            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
+            ("foo", false),
+            ("foo.bar", false),
+            ("foo.bar[1]", false),
+            ("foo.bar == 'bar'", false),
+            ("foo.bar || bar || baz", false),
+            ("foo.bar && bar && baz", false),
+        ] {
+            let expr = Expr::parse(expr)?;
+            assert_eq!(expr.constant_foldable(), *foldable);
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_expr_has_constant_foldable_subexpr() -> Result<()> {
+        for (expr, foldable) in &[
+            // Literals are not considered foldable subexpressions.
+            ("'foo'", false),
+            ("1", false),
+            ("true", false),
+            ("null", false),
+            // Non-foldable expressions with foldable subexpressions
+            (
+                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
+                true,
+            ),
+        ] {
+            let expr = Expr::parse(expr)?;
+            assert_eq!(expr.has_constant_foldable_subexpr(), *foldable);
+        }
+        Ok(())
+    }
+
     #[test]
     fn test_expr_dataflow_contexts() -> Result<()> {
         // Trivial cases.
diff --git a/src/main.rs b/src/main.rs
index d343f9b..b648536 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -530,6 +530,7 @@ fn run() -> Result<ExitCode> {
     register_audit!(audit::overprovisioned_secrets::OverprovisionedSecrets);
     register_audit!(audit::unredacted_secrets::UnredactedSecrets);
     register_audit!(audit::forbidden_uses::ForbiddenUses);
+    register_audit!(audit::obfuscation::Obfuscation);
 
     let mut results = FindingRegistry::new(&app, &config);
     {


Test patch:
diff --git a/tests/integration/snapshot.rs b/tests/integration/snapshot.rs
index def211a..332b09d 100644
--- a/tests/integration/snapshot.rs
+++ b/tests/integration/snapshot.rs
@@ -666,3 +666,10 @@ fn forbidden_uses() -> Result<()> {
 
     Ok(())
 }
+
+#[test]
+fn obfuscation() -> Result<()> {
+    insta::assert_snapshot!(zizmor().input(input_under_test("obfuscation.yml")).run()?);
+
+    Ok(())
+}
diff --git a/tests/integration/snapshots/integration__e2e__issue_569.snap b/tests/integration/snapshots/integration__e2e__issue_569.snap
index 32886ce..cad4a2b 100644
--- a/tests/integration/snapshots/integration__e2e__issue_569.snap
+++ b/tests/integration/snapshots/integration__e2e__issue_569.snap
@@ -1,7 +1,6 @@
 ---
 source: tests/integration/e2e.rs
 expression: "zizmor().offline(false).output(OutputMode::Both).args([\"--no-online-audits\",\n\"--collect=workflows-only\"]).input(\"python/cpython@f963239ff1f986742d4c6bab2ab7b73f5a4047f6\").run()?"
-snapshot_kind: text
 ---
  INFO zizmor: skipping impostor-commit: offline audits only requested
  INFO zizmor: skipping ref-confusion: offline audits only requested
@@ -83,6 +82,14 @@ error[unpinned-uses]: unpinned action reference
     |
     = note: audit confidence → High
 
+help[obfuscation]: obfuscated usage of GitHub Actions features
+   --> .github/workflows/build.yml:184:18
+    |
+184 |       Windows MSI${{ '' }}
+    |                  --------- help: expression can be simplified via constant folding
+    |
+    = note: audit confidence → High
+
 error[dangerous-triggers]: use of fundamentally insecure workflow trigger
   --> .github/workflows/documentation-links.yml:5:1
    |
@@ -175,4 +182,4 @@ error[unpinned-uses]: unpinned action reference
    |
    = note: audit confidence → High
 
-46 findings (28 suppressed): 0 unknown, 0 informational, 0 low, 0 medium, 18 high
+47 findings (28 suppressed): 0 unknown, 0 informational, 1 low, 0 medium, 18 high
diff --git a/tests/integration/snapshots/integration__snapshot__obfuscation.snap b/tests/integration/snapshots/integration__snapshot__obfuscation.snap
new file mode 100644
index 0000000..4e6f504
--- /dev/null
+++ b/tests/integration/snapshots/integration__snapshot__obfuscation.snap
@@ -0,0 +1,181 @@
+---
+source: tests/integration/snapshot.rs
+expression: "zizmor().input(input_under_test(\"obfuscation.yml\")).run()?"
+---
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:12:9
+   |
+12 |       - uses: actions/checkout/@v4
+   |         -------------------------- help: actions reference contains empty component
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:15:9
+   |
+15 |       - uses: actions/checkout////@v4
+   |         ----------------------------- help: actions reference contains empty component
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:15:9
+   |
+15 |       - uses: actions/checkout////@v4
+   |         ----------------------------- help: actions reference contains empty component
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:15:9
+   |
+15 |       - uses: actions/checkout////@v4
+   |         ----------------------------- help: actions reference contains empty component
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:15:9
+   |
+15 |       - uses: actions/checkout////@v4
+   |         ----------------------------- help: actions reference contains empty component
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:18:9
+   |
+18 |       - uses: github/codeql-action/./init@v2
+   |         ------------------------------------ help: actions reference contains '.'
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:19:9
+   |
+19 |       - uses: actions/checkout/.@v4
+   |         --------------------------- help: actions reference contains '.'
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:22:9
+   |
+22 |       - uses: actions/cache/save/../save@v4
+   |         ----------------------------------- help: actions reference contains '..'
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:29:16
+   |
+29 |           echo ${{ '' }}
+   |                --------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:30:16
+   |
+30 |           echo ${{ 'a' }}
+   |                ---------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:31:16
+   |
+31 |           echo ${{ true }}
+   |                ----------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:32:16
+   |
+32 |           echo ${{ true && false }}
+   |                -------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:33:16
+   |
+33 |           echo ${{ true || false }}
+   |                -------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:34:16
+   |
+34 |           echo ${{ 1 > 2 || true }}
+   |                -------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:35:16
+   |
+35 |           echo ${{ 1 != 2}}
+   |                ------------ help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:39:16
+   |
+39 |           echo ${{ format('{0}', 'abc') }}
+   |                --------------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:40:16
+   |
+40 |           echo ${{ format('{0} {1}', 'abc', 'def') }}
+   |                -------------------------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:41:16
+   |
+41 |           echo ${{ format('{0} {1}', 'abc', format('{0}', 'def')) }}
+   |                ----------------------------------------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:42:16
+   |
+42 |           echo ${{ startsWith('abc', 'a') }}
+   |                ----------------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:43:16
+   |
+43 |           echo ${{ ENDSWITH('abc', 'c') }}
+   |                --------------------------- help: expression can be simplified via constant folding
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:48:16
+   |
+48 |           echo ${{ format('{0}, {1}', github.event.number, format('{0}', 'abc')) }}
+   |                -------------------------------------------------------------------- help: expression contains constant foldable subexpression
+   |
+   = note: audit confidence → High
+
+help[obfuscation]: obfuscated usage of GitHub Actions features
+  --> @@INPUT@@:50:16
+   |
+50 |           echo ${{ format('{0}, {1}', github.event.number, format('{0} {1}', github.event.number, true || false)) }}
+   |                ----------------------------------------------------------------------------------------------------- help: expression contains constant foldable subexpression
+   |
+   = note: audit confidence → High
+
+29 findings (7 suppressed): 0 unknown, 0 informational, 22 low, 0 medium, 0 high
diff --git a/tests/integration/test-data/obfuscation.yml b/tests/integration/test-data/obfuscation.yml
new file mode 100644
index 0000000..ae2faaf
--- /dev/null
+++ b/tests/integration/test-data/obfuscation.yml
@@ -0,0 +1,50 @@
+on: pull_request
+
+name: obfuscation
+
+permissions: {}
+
+jobs:
+  obfuscated-uses:
+    runs-on: ubuntu-latest
+    steps:
+      # trailing slash
+      - uses: actions/checkout/@v4
+
+      # many trailing slashes
+      - uses: actions/checkout////@v4
+
+      # pointless use of .
+      - uses: github/codeql-action/./init@v2
+      - uses: actions/checkout/.@v4
+
+      # pointless use of ..
+      - uses: actions/cache/save/../save@v4
+
+  obfuscated-exprs:
+    runs-on: ubuntu-latest
+    steps:
+      # trivially foldable expressions
+      - run: |
+          echo ${{ '' }}
+          echo ${{ 'a' }}
+          echo ${{ true }}
+          echo ${{ true && false }}
+          echo ${{ true || false }}
+          echo ${{ 1 > 2 || true }}
+          echo ${{ 1 != 2}}
+
+      # trivially foldable functions
+      - run: |
+          echo ${{ format('{0}', 'abc') }}
+          echo ${{ format('{0} {1}', 'abc', 'def') }}
+          echo ${{ format('{0} {1}', 'abc', format('{0}', 'def')) }}
+          echo ${{ startsWith('abc', 'a') }}
+          echo ${{ ENDSWITH('abc', 'c') }}
+
+      # foldable subexpressions
+      - run: |
+          # inner format(...) is foldable
+          echo ${{ format('{0}, {1}', github.event.number, format('{0}', 'abc')) }}
+          # inner || is foldable
+          echo ${{ format('{0}, {1}', github.event.number, format('{0} {1}', github.event.number, true || false)) }}