skip to content

What can a malicious third-party GitHub Action do inside your job, and how do you contain it?

level: seniorimportance: should knowfreq 47%

answer

  1. It is code, not configuration
  2. Masking only filters the log
  3. Which context does the step inherit?
  4. Separate jobs mean separate tokens
  5. Nothing static left to steal

basics

~20 s

An action runs as arbitrary code in your job: it can read the workspace, every environment variable including secrets you expose, use the GITHUB_TOKEN against your repository, and send data anywhere the runner can reach. Containment means least-privilege token, minimal secret exposure, pinned refs, and job isolation.

solid answer

~50 s

A `uses:` step is not configuration — it is code executing with your job's full context. It can read the checked-out source, every environment variable in the process (log masking hides secrets in output, but a base64 or DNS exfiltration defeats masking), the `GITHUB_TOKEN`, and any credential a previous step wrote to disk; and it can open outbound connections. Containment is layered: set `permissions:` explicitly, ideally `contents: read` at workflow level and widen only in the job that needs it; pass secrets to the specific step that needs them rather than declaring them job-wide; pin third-party actions by commit SHA; split privileged work (publishing, deploying) into a separate job that runs no third-party code and is gated by an environment with required reviewers; and prefer OIDC over long-lived cloud keys so there is no static credential to steal.

code

yaml · 20 lines
yaml
permissions:
  contents: read          # default for every job in this workflow

jobs:
  lint:                   # third-party actions live here: no secrets
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: some-org/community-linter@<full 40-character commit sha> # v3.1.0

  publish:                # privileged, first-party steps only, human-gated
    needs: lint
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/publish.sh

go deeper

for a junior

Know that a third-party action is executable code with access to your job's environment, not a passive configuration block.

for a middle

Explain what the step can actually read — workspace, environment variables, GITHUB_TOKEN — and why log masking does not prevent exfiltration.

for a senior

Lay out the containment ladder and defend the structural control: privileged work in its own job, environment-gated, with no third-party steps and no static credentials.

for a principal

Own the organisational posture: allow-listing and pinning policy, a standard workflow shape that separates untrusted from privileged jobs, and reducing third-party action count as a deliberate program.

## What the action actually has When a step runs `uses: some/action@ref`, GitHub fetches that code and executes it on the runner in the job's process context. Concretely it can reach: - **The workspace** — your source, plus anything earlier steps built or downloaded. - **The process environment** — every variable set by `env:` at workflow, job or step level, and any `secrets.*` value interpolated into one. GitHub masks registered secrets in the log, but masking is an output filter: encoding the value, splitting it, or sending it over the network bypasses it entirely. - **The `GITHUB_TOKEN`** — whatever scopes the job granted it. With `contents: write` that includes pushing to the repository; with `packages: write`, publishing. - **The filesystem and network** — credential files a `setup-*` action wrote (cloud CLI profiles, npm or Maven auth files), plus arbitrary outbound egress. - **On a persistent self-hosted runner**, anything left on the machine and everything the runner's network position can reach. A JavaScript action's `post:` entry runs even after a failure, so "the job failed" does not mean the action stopped. ## The containment ladder **1. Least-privilege token.** Set `permissions:` explicitly. A workflow-level `permissions: contents: read` makes every job's token read-only, and a specific job can widen to exactly what it needs. Without an explicit block you inherit the repository default, which may be permissive. ```yaml permissions: contents: read ``` **2. Narrow secret exposure.** Do not declare secrets in workflow-level `env:` where every step of every job sees them. Attach a secret to the one step that needs it. A third-party linter running in a job with a deploy key is a preventable mistake. **3. Pin the reference.** SHA-pin third-party actions so a compromised tag cannot silently substitute new code, and keep the version in a trailing comment for automated bumps. **4. Split trust into jobs.** The strongest structural control: run untrusted or third-party-heavy work (linting, formatting, community actions) in a job with a read-only token and no secrets, and do the privileged work — publish, deploy, sign — in a separate job that uses only first-party or vendored steps. Jobs get separate runners and separate token instantiations, so the two never share a process. **5. Gate the privileged job.** Attach an `environment:` with required reviewers or a wait timer so a human approves before deployment credentials are issued. **6. Remove the static credential.** With OIDC there is no long-lived cloud key in the environment to steal; a stolen short-lived token expires, and the cloud-side trust policy can require specific claims. **7. Reduce the count.** Every third-party action is a dependency with its own transitive dependencies. Replacing a trivial action with three lines of `run:` shell is often a net security win and one less thing to update. ## What does *not* contain it - Secret masking. It is a log filter, not an access control. - "We reviewed the action once." Without a SHA pin, the code can change under the same ref. - `continue-on-error` or a short `timeout-minutes`. Exfiltration takes milliseconds. ## The interview-ready framing Treat every `uses:` as running an untrusted contractor inside your build: decide what they can see (secrets), what they can do (token permissions), where they can go (network and runner isolation), and whether the code they run today is the code you approved (pinning).

  • Secrets are masked in the logs, so why is an untrusted action still a problem?
    Masking only redacts known secret strings from log output. The action holds the real value in memory and can encode it, chunk it, or POST it to any host the runner can reach — none of which touches the log. Masking is a defence against accidental printing, not against a hostile step.
  • Why does splitting work into two jobs help when both run in the same workflow?
    Each job runs on its own runner with its own freshly minted GITHUB_TOKEN and its own permissions block, and only the secrets that job references. Code in the lint job never shares a process, filesystem or token with the publish job, so compromising the third-party linter yields nothing the deploy job holds.
  • Does using OIDC instead of a stored cloud key remove the risk entirely?
    It removes the durable credential: there is no long-lived key sitting in the environment to steal, and the token the job receives is short-lived and audience-scoped. A hostile step in the same job can still use that token while it is valid, which is why the privileged job should not be the one running third-party code.

saying these in an interview costs you the question

  • Calling secret masking a security control
  • Declaring all secrets in workflow-level env
  • Reviewing an action once but referencing a moving tag
  • Running third-party actions in the deploy job
  • Assuming a failed job stops the action's post step

context