Get unstuck

Hint 1: One expression is not the rule

The vulnerability is any attacker-controlled context in a script, not one field. Pull the expression out of every ${{ ... }} and look for the untrusted prefixes anywhere in it: toJSON(github.event.pull_request) does not start with one, but its value is still the attacker’s text.

Hint 2: env is the cure, not a sink

Putting the untrusted value in env and reading $NAME in run is the safe pattern. Scan run and script for interpolation; leave env values alone.

Reveal the reference patch and explanation
Fixedaudit-step.mjsJavaScript
export function auditStep(step, untrustedPrefixes) {
  const scripts = [step.run, step.script].filter(s => typeof s === 'string');
  for (const body of scripts)
    // Each expression runs to its closing }}; it can contain a single }.
    for (const match of body.matchAll(/\$\{\{(.*?)\}\}/gs)) {
      const expr = match[1];
      if (untrustedPrefixes.some(prefix => expr.includes(prefix))) return 'injectable';
    }
  return 'safe';
}

The linter scans run and the github-script body for ${{ }} expressions and flags any that mention an untrusted prefix, so github.event.issue.title, github.event.pull_request.title, github.event.comment.body and github.head_ref in a shell step are all injectable, including inside a github-script, and so are toJSON(github.event.pull_request), format() with a title, and head_ref chosen by && and ||. github.event_name, without the dot, is the trigger’s name and is safe. A run that echoes github.repository or matrix.os is safe, because those are not attacker-controlled. A step that sets env TITLE to github.event.issue.title and runs echo "$TITLE" is safe: the untrusted value is an environment variable, not part of the script text. The env-variable form, quoted, is the fix the guide recommends.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download cases.mjs
How the browser sandbox runs your code

Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.

The same as in current Node.js:

  • ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag

Added to the sandbox so that it behaves like Node.js:

  • structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
  • URL and URLSearchParams, parsed by your browser’s own URL implementation
  • TextEncoder and TextDecoder for UTF-8
  • Intl.Collator and a locale-aware String.prototype.localeCompare
  • console.log and related methods, whose output appears with your results

Not available in the browser sandbox:

  • Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
  • Node.js modules and globals: import, require, process and Buffer
  • Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
  • Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
  • using declarations, Symbol.dispose and DisposableStack (explicit resource management)
  • Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace

Use the lesson in your own system

This is a teaching linter over a simplified step, not actionlint or zizmor. It checks for untrusted context in run and github-script, and it flags any expression that mentions one, even one such as contains(github.event.issue.title, 'x') that can only produce true or false; a precise tool has to work out what each expression returns. It does not model composite-action inputs, ${{ }} inside an env value that a later unquoted $NAME expands, bash quoting bugs, or the GITHUB_ENV and set-output sinks. Use a maintained scanner in CI, pin and review third-party actions, and set least-privilege GITHUB_TOKEN permissions.

Your next task: Which of your workflows interpolate github.event fields or github.head_ref into a run step, and could each move to an env variable?

GitHub: security hardening for GitHub Actions — untrusted input

Related guide: Keep untrusted CI work outside the release boundary