Secure GitHub Actions with CodeQL

Use CodeQL to find and fix injection flaws in GitHub Actions workflows.

Introduction

30 Second Summary

Automation often runs in the background, so a dangerous instruction can hide in plain sight until someone triggers it. Catching that instruction before it runs can stop a small mistake from becoming a security problem.

In this project, you will use CodeQL with GitHub code scanning to catch an injection flaw in a GitHub Actions workflow. The full cycle takes you from an unsafe push to a closed alert with the source-to-shell path visible in between.

What You'll Build

You'll open the Security tab to show a CodeQL alert tracing unsafe input into a shell command before pointing to the fixed commit that closed it.

By the end of this project, you'll have:

  • An always-on scan history where every push starts a new check that you can follow in the Actions tab.
  • A source-to-shell alert that pinpoints how an untrusted workflow value reaches a shell command.
  • A repeatable detection cycle where a secure commit closes the first alert before your own planted flaw triggers another.
  • An optional Secret Mission to push your workflow security skills further.

Are there any prerequisites?

You'll need a GitHub account. The project uses a public repository, which keeps code scanning and Actions usage at $0.

Before We Start

Every scan in this project belongs to a repository under your GitHub account. A signed-in browser session gives you the access needed to create that repository in the next step.

This step gets your GitHub account ready in the browser. You'll confirm that GitHub recognizes your signed-in session.

In this step, get ready to:
  • Sign in to an existing GitHub account or create a personal account.
  • Confirm GitHub recognizes your account in the browser.
Sign in to GitHub

Your personal account owns the public repository you'll scan later. Choose the path that matches your current account status.

✔️ I already have an account

Signing in may include an extra verification prompt if your account uses additional security. That extra screen is a normal part of protecting your account.

  • Visit the GitHub sign-in page.
  • Complete the sign-in form using your account credentials.
  • Complete any verification prompt using your usual authentication method.

ⓧ I need an account

New personal accounts need a verified email address before they can create repositories. GitHub sends the verification message to the address used during sign-up.

  • Visit the GitHub sign-up page.
  • Follow the prompts to create your personal account.
  • Verify your email address using the message from GitHub.

Can't complete sign-in?

Help me troubleshoot my GitHub sign-in. GitHub's official authentication guide explains the supported sign-in methods.

Confirm your browser session

A visible account menu confirms that the browser recognizes your signed-in session. This check prevents you from reaching the repository step with the wrong account.

Before you check, do you expect the account menu to show the username you plan to use for this project?

  • Return to GitHub's homepage.
  • Select your profile image in the top-right corner.
  • Confirm the account menu displays the username you expect.

You should see your username in the account menu. This proves the browser is signed in to the account that owns your work.

That access check is complete: GitHub recognizes the account you'll use for the project.

Your GitHub session is ready. Next, you'll create the public repository that CodeQL scans for workflow vulnerabilities.

Create a Repo and Enable Code Scanning

Before a scanner can catch a risky workflow, it needs a repository to watch. Your public GitHub repository becomes the controlled place where every push can be analysed.

In this step, you'll create the repository. You'll enable CodeQL default setup so GitHub Actions can run the first scan in GitHub's own environment.

In this step, get ready to:
  • Create a public repository with an initialized default branch.
  • Enable CodeQL default setup for supported content and future pushes.
  • Confirm the initial CodeQL scan appears in the Actions tab.
Create the public repository

A README gives the repository its first commit. That commit creates the default branch needed for later workflow files.

This demo repository is visible to anyone. You can keep it safe by using it only for this isolated project.

  • Visit the new repository page in your signed-in browser.
  • Select your personal account from the Owner menu.
  • Enter codeql-workflow-vulnerability-demo in the Repository name field.
  • Select Public under Choose visibility.
  • Toggle Add README to On.
  • Click Create repository.

You'll land on the new repository page with README.md in the file list. You'll also see a Public badge beside the repository name.

Good start. Your repository now has a default branch that CodeQL can monitor.

Enable CodeQL default setup

Code scanning uses CodeQL to inspect repository content without executing it. Default setup keeps the scan configuration on GitHub.

  • Click the Settings tab under the repository name.
  • Click Advanced Security in the sidebar's Security section.
  • Find CodeQL analysis in the Code Security section.
  • Click Set up beside CodeQL analysis.
  • Select Default from the menu.

The CodeQL default configuration dialog summarizes the generated scan settings. This setup automatically responds to supported content added on future pushes.

  • Click Edit in the configuration dialog.
  • Set the GitHub Actions workflows checkbox to selected in Languages.
  • Click Enable CodeQL.

The settings page now shows CodeQL analysis enabled with default setup. Your repository is ready to scan supported content on future pushes.

Can't Enable Default Setup?

  • Return to the repository page to confirm the Public badge appears beside its name.
  • Confirm that your personal account owns the repository.

Share what you see in the CodeQL analysis section with help me enable CodeQL default setup.

Confirm the initial scan

Enabling default setup triggers a workflow that tests the generated configuration. Its appearance in the Actions tab proves GitHub accepted the setup.

The first scan can take several minutes. A short wait here is normal.

Before you check, do you think the first run will already be complete?

  • Click the Actions tab under the repository name.
  • Select the newest workflow run that mentions CodeQL.

You'll see an initial default-setup CodeQL analysis workflow run. Its status may be queued, in progress, or completed.

That's the scanner switched on. GitHub Actions has accepted the generated CodeQL configuration.

Don't See the Initial Run?

  • Wait a few minutes for GitHub to schedule the workflow.
  • Refresh the Actions tab.
  • Return to Advanced Security to confirm CodeQL analysis shows default setup as enabled.

Share the state shown in both tabs with help me find my initial CodeQL workflow run.

Your Step Checkpoint

Your browser shows the public codeql-workflow-vulnerability-demo repository with an initialized default branch.

CodeQL analysis uses default setup to scan supported content and future pushes.

The Actions tab lists the initial default-setup CodeQL analysis workflow run.

Your repository now has a scanner watching every supported push. Next up, you'll add a deliberately unsafe workflow and give CodeQL something real to catch.

Add an Unsafe Workflow

Your repository already has CodeQL watching the default branch. The scanner now needs vulnerable content to inspect.

A GitHub Actions workflow can read an issue title when an issue opens. Directly embedding that title in a shell command creates the unsafe path for this demo.

In this step, get ready to:
  • Create .github/workflows/unsafe-issue-title.yml in GitHub's web editor.
  • Commit the unsafe workflow to the default branch.
  • Confirm the commit triggers a new CodeQL analysis run.
Create the unsafe workflow file

A workflow uses YAML to define its trigger. It also defines the job that runs after the trigger fires.

Planting a flaw can feel risky. This workflow uses no repository secrets.

  • Return to the main page of codeql-workflow-vulnerability-demo.
  • Click Add file above the repository file list.
  • Select Create new file.
  • Enter .github/workflows/unsafe-issue-title.yml in the file name field.
  • Add the unsafe workflow by pasting this code into the editor:
# Run when a new issue is opened.
name: Unsafe issue title

on:
  issues:
    types:
      - opened

jobs:
  print-title:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue title
        # Put untrusted issue data directly into the generated shell script.
        run: |
          echo "Issue title: ${{ github.event.issue.title }}"

What does this workflow do?

  • The issues trigger runs the workflow when someone opens an issue.
  • The print-title job uses an ubuntu-latest runner to execute its step.
  • The github.event.issue.title expression contains input supplied by the person opening the issue.
  • The run block places that input directly inside a shell script. This creates a potential script injection path.
  • Click Preview above the editor.

You should see the complete workflow under the .github/workflows/unsafe-issue-title.yml path. The direct github.event.issue.title expression should remain inside the run block.

  • Click Commit changes....
  • Enter Add unsafe issue title workflow in the Commit message field.
  • Keep the option that commits directly to your default branch selected.
  • Click Commit changes.

Good progress. You should return to the committed file with Add unsafe issue title workflow shown as its latest commit message.

Can't find the committed workflow?

  • Return to the repository's main page.
  • Confirm .github/workflows/unsafe-issue-title.yml appears in the file tree.
  • Repeat the commit flow if the file is missing from the default branch.

Help me check why my committed workflow file is missing.

✔️ Awesome, I've got everything!

Great. Double check that .github/workflows/unsafe-issue-title.yml is committed to your default branch.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/unsafe-issue-title.yml file. Make sure yours matches exactly:

# Run when a new issue is opened.
name: Unsafe issue title

on:
  issues:
    types:
      - opened

jobs:
  print-title:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue title
        # Put untrusted issue data directly into the generated shell script.
        run: |
          echo "Issue title: ${{ github.event.issue.title }}"
Watch CodeQL analyze the commit

Default setup watches future pushes to the repository. Committing the workflow gives CodeQL a fresh change to analyze.

Before you check, do you think this commit starts a new scan or leaves the original run unchanged?

  • Click Actions in the repository navigation.
  • Locate the newest CodeQL analysis run in the workflow run list.

You should see a new CodeQL analysis run associated with the unsafe workflow commit. Its presence proves that the push started a fresh scan.

The analysis can take a few minutes to finish. Seeing the new run is the signal you need for this step.

Don't see a new analysis run?

  • Refresh the Actions tab after a minute.
  • Return to the repository's main page.
  • Confirm the unsafe workflow commit appears on the default branch.

Help me find the missing CodeQL analysis run.

Your unsafe workflow is committed. Next, you'll inspect the alert that traces the issue title into the shell command.

Investigate the CodeQL Alert

Your unsafe workflow has triggered a fresh scan in GitHub. The next job is to turn that result into evidence you can explain.

CodeQL can trace the issue title as it moves into the shell command. In this step, you'll inspect the alert's path from untrusted input to executable shell input.

In this step, get ready to:
  • Confirm the newest CodeQL analysis has finished.
  • Open the potential script injection alert for .github/workflows/unsafe-issue-title.yml.
  • Follow the source-to-sink path from github.event.issue.title to the shell run command.
Confirm the analysis completed

The alert becomes available after the analysis completes. A scan can take several minutes, so a running status during this wait is expected.

  • Switch back to the Actions tab from earlier.
  • Select the newest CodeQL analysis run triggered by the unsafe workflow commit.
  • Wait for the analysis run to finish.

You'll see that the latest analysis run has completed. The repository is ready to show the finding produced by that scan.

Is the analysis still running?

  • Give the run several minutes to complete.
  • Refresh the run page to check its latest status.

Help me work out why my CodeQL analysis has not finished.

Open the CodeQL alert

The repository's Security area gathers code scanning findings. Each alert connects a detected security rule to the affected file.

  • Return to the codeql-workflow-vulnerability-demo repository using the repository name link.
  • Click the Security tab in the repository navigation.
  • Select Code scanning in the security sidebar.
  • Select the open alert that references .github/workflows/unsafe-issue-title.yml.

Good catch. You now have an open potential script-injection alert tied directly to the unsafe workflow file.

Why Is Direct Interpolation Dangerous?

The workflow places the issue title directly inside a shell command. The shell can interpret parts of that title as executable syntax.

An attacker can shape an issue title to change what the workflow runs. The alert identifies the route that makes this possible.

Trace the input to the shell

The alert's data-flow path is the evidence behind the warning. It shows where the untrusted value enters the workflow before reaching an operation that can execute it.

How Does Source-to-Sink Tracing Work?

A source is where untrusted data enters the workflow. In this alert, the source is github.event.issue.title.

A sink is the operation that can execute the data. In this alert, the sink is the shell run command.

Before you follow the trace, where do you think the issue title stops being ordinary text and becomes executable shell input?

  • Scroll to the displayed path in the alert details.
  • Follow the trace from github.event.issue.title.
  • Continue through the displayed locations until the trace reaches the shell run command.

You'll see the path begin at github.event.issue.title and end at the shell run command. This proves that untrusted issue data can reach an executable command.

Can't Find the Displayed Path?

  • Confirm that the alert references .github/workflows/unsafe-issue-title.yml.
  • Return to the alert details.
  • Look for the connected locations that link the issue title to the shell command.

Help me locate the source-to-sink path in my CodeQL alert.

You have found the exact route that turns an issue title into executable shell input. Next, you'll break that route with an environment variable and confirm the alert closes.

Fix the Workflow Injection

You already used CodeQL to trace an untrusted issue title into a GitHub Actions shell command. That alert stays open until the workflow stops placing the title inside the generated script.

An intermediate environment variable carries the title into the step as data. The shell command reads the quoted variable after the script has been created.

In this step, get ready to:
  • Replace the direct issue-title interpolation with an environment variable.
  • Commit the remediation to the repository's default branch.
  • Confirm the new CodeQL analysis closes the original alert.
Replace direct expression interpolation

GitHub Actions evaluates ${{ }} expressions before the shell starts. Moving the issue title into a step-level environment variable keeps the untrusted value out of the generated script.

  • Select the repository name in the page breadcrumb to return to its file list.
  • Select the .github folder.
  • Select the workflows folder.
  • Select unsafe-issue-title.yml.
  • Select the pencil icon in the upper-right corner of the file view.

How does the fix work?

The source remains github.event.issue.title. The environment mapping stores its value under ISSUE_TITLE.

The run block reads "$ISSUE_TITLE" when the shell executes. Quotation prevents the shell from splitting the title into separate words.

  • Add an env map to the affected workflow step that assigns ${{ github.event.issue.title }} to ISSUE_TITLE.
  • Replace the direct expression inside the existing shell command with "$ISSUE_TITLE".
  • Click Preview above the editor.

You should see an env block added to the step. You should also see the direct expression replaced inside the run block.

✔️ Awesome, I've got everything!

Great. Your preview shows the issue title flowing through ISSUE_TITLE before the shell command reads it.

ⓧ I'd like to double check the full code

Compare the complete .github/workflows/unsafe-issue-title.yml file against these checks.

  • Keep the existing issue-open trigger unchanged.
  • Keep the existing job unchanged.
  • Keep the existing runner unchanged.
  • Keep the existing step name unchanged.
  • Confirm that env sits in the same workflow step as run.
  • Confirm that ISSUE_TITLE receives ${{ github.event.issue.title }}.
  • Confirm that the shell command references "$ISSUE_TITLE".
  • Confirm that ${{ github.event.issue.title }} no longer appears inside the run block.

Committing a workflow change can feel risky. This change removes the direct source-to-shell path that CodeQL identified.

  • Click Commit changes....
  • Enter Fix workflow injection in the Commit message field.
  • Choose the option that commits to the current default branch.
  • Click Commit changes.

GitHub records the remediation commit on the default branch. Code scanning default setup starts evaluating the updated workflow.

Can't commit the workflow?

Check that you are signed in with the account that owns codeql-workflow-vulnerability-demo. A protected default branch can also prevent edits through the file editor.

Help me fix a GitHub workflow editing or commit problem.

Watch CodeQL analyze the fix

The remediation only counts after CodeQL evaluates the committed file. Default setup reacts to the repository change by starting another analysis run.

This run can take a few minutes. A queued or in-progress status means GitHub is still analyzing the updated workflow.

  • Select the Actions tab in the repository navigation.
  • Open the newest CodeQL analysis run triggered by the remediation commit.
  • Wait for the analysis run to finish.

You should see the latest CodeQL analysis complete successfully. This confirms that the scanner evaluated the remediated workflow.

Don't see a new analysis run?

  • Refresh the Actions page after a short wait.
  • Confirm that the remediation commit appears on the repository's default branch.
  • Confirm that code scanning default setup remains enabled.

Help me find why CodeQL did not start after my workflow remediation commit.

Confirm the alert is closed

A completed analysis proves that the scan ran. The alert lifecycle provides the evidence that the vulnerable data flow disappeared.

Before you check, where do you expect the original alert to appear after CodeQL accepts the fix?

  • Return to the Security tab from earlier.
  • Select Code scanning in the sidebar.
  • Select the Closed alerts list.
  • Open the original potential script-injection alert for .github/workflows/unsafe-issue-title.yml.

You should find the original alert in the Closed list. Its timeline should include the remediation commit that replaced direct expression interpolation.

That's the full security loop complete. CodeQL found the source-to-sink path. The later analysis closed the alert automatically after your remediation.

Your first workflow flaw is fixed with evidence from a fresh scan. Next, you'll plant a second flaw and test whether CodeQL catches it too.

Test a Second Workflow Flaw

Your first alert is closed because the issue title now reaches the shell through an environment variable. One successful detection could still be a coincidence.

You’ll test CodeQL again with a second GitHub Actions workflow that sends an issue body directly into a shell command. If the same analysis finds this new path, you have repeatable evidence that the scanner protects future pushes.

In this step, get ready to:
  • Create a second workflow that interpolates an untrusted issue body inside a shell command.
  • Commit the workflow to trigger another CodeQL analysis.
  • Confirm a new alert appears while the original alert remains closed.
Create the second unsafe workflow

An issue body is controlled by the person who opens the issue. Direct expression interpolation places that untrusted text inside the generated shell script.

This workflow deliberately recreates the input-to-shell pattern with a different issue field. The new file gives CodeQL an independent test case.

  • Return to the main page of the codeql-workflow-vulnerability-demo repository by selecting its name near the top of the page.
  • Select Add file above the repository file list.
  • Select Create new file.
  • Enter .github/workflows/unsafe-issue-body.yml in the file name field.
  • Add the unsafe issue-body workflow by pasting this code into the editor:
name: Unsafe issue body

# Run this workflow when a new issue is opened.
on:
  issues:
    types:
      - opened

jobs:
  print-issue-body:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue body
        run: |
          # Direct interpolation lets issue content reach the shell.
          echo "Issue body: ${{ github.event.issue.body }}"

What does this workflow do?

  • The issues trigger limits this workflow to issue events.
  • The opened activity type starts the workflow when someone opens an issue.
  • The github.event.issue.body context supplies text controlled by the issue author.
  • The run block places that text directly inside a temporary shell script.
  • Select Preview above the workflow editor.

You should see a workflow triggered by opened issues. Its shell command should contain ${{ github.event.issue.body }} directly.

  • Click Commit changes....
  • Enter Add unsafe issue body workflow in the Commit message field.
  • Keep the option for committing to the default branch selected.
  • Click Commit changes.

Good. Your second test case is now committed at .github/workflows/unsafe-issue-body.yml.

Workflow file missing after the commit?

Check that the file path starts with .github/workflows/. Confirm that the file ends with .yml.

If GitHub rejects the workflow, compare the YAML indentation with the reference below. Each nested level uses two spaces.

Help me check my unsafe issue-body workflow.

✔️ Awesome, I've got everything!

Your second workflow is committed on the default branch with the issue body interpolated directly inside its shell command.

ⓧ I'd like to double check the full code

Compare your complete .github/workflows/unsafe-issue-body.yml file with this reference.

name: Unsafe issue body

# Run this workflow when a new issue is opened.
on:
  issues:
    types:
      - opened

jobs:
  print-issue-body:
    runs-on: ubuntu-latest
    steps:
      - name: Print issue body
        run: |
          # Direct interpolation lets issue content reach the shell.
          echo "Issue body: ${{ github.event.issue.body }}"
Watch CodeQL analyse the commit

The unsafe workflow itself waits for an issue-open event. The push still prompts code scanning default setup to inspect the new workflow file.

This scan can take several minutes. A queued or in-progress status means GitHub’s runner is still analysing the commit.

  • Select the Actions tab from the repository navigation.

You should see a new analysis run associated with the Add unsafe issue body workflow commit.

  • Select the newest analysis run tied to your commit.
  • Wait for the analysis run to reach a completed state.

The completed run confirms that CodeQL evaluated the newly committed workflow.

New analysis run not appearing?

Confirm that .github/workflows/unsafe-issue-body.yml appears on the repository’s default branch. Refresh the Actions tab after confirming the commit.

Help me find the missing CodeQL analysis run.

Confirm the second alert

The completed analysis is your signal to inspect the scanner’s verdict. Before you check, do you expect the different issue field to produce another open alert?

  • Return to the repository’s Security tab from earlier.
  • Select Code scanning in the left sidebar.
  • Locate the open alert that points to .github/workflows/unsafe-issue-body.yml.
  • Select the new alert to inspect its data flow.

You’ll see a new open potential script-injection alert for .github/workflows/unsafe-issue-body.yml.

The alert traces github.event.issue.body into the shell run block. Your original issue-title alert remains closed.

You have now reproduced the detection with a different input. That turns the first result into repeatable evidence that CodeQL catches this workflow pattern.

Second alert not visible?

Confirm that the latest analysis run completed after the second workflow commit. An alert appears only after CodeQL finishes evaluating that commit.

Check that .github/workflows/unsafe-issue-body.yml contains ${{ github.event.issue.body }} directly inside the run block.

Help me troubleshoot the missing second alert.

Secret mission

Explain the Second Injection Path

The scanner has caught a second workflow flaw. Your challenge is to trace the issue body from its untrusted source into the shell command. You will compare that path with the fixed workflow and describe a remediation without changing the repository.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep this public GitHub repository available, leave it untouched for now, or remove it. This public repository has no ongoing project cost.

Resources you used:

  • Your public codeql-workflow-vulnerability-demo repository on GitHub. It contains the two workflow files. It also stores the CodeQL default setup. Its Security tab keeps the open second alert plus the closed original alert.

Keep everything running

No action is needed. Choose this option while you are actively using the repository as a controlled security demonstration.

  • Leave code scanning default setup enabled so future pushes trigger CodeQL analysis through GitHub Actions.
  • Keep .github/workflows/unsafe-issue-title.yml as the remediated example.
  • Keep .github/workflows/unsafe-issue-body.yml only while you need the intentionally unsafe comparison.
  • Delete the repository when the planted flaw is no longer useful for your demonstration.

Pause - I'll come back to this later

There is no local process to stop because scans run on GitHub's runners. The intentionally unsafe issue-body workflow remains public while the repository is paused.

  • Leave both committed workflow files unchanged if you plan to return soon.
  • Avoid pushing new commits while you pause.
  • Return to the Security tab when you are ready to continue investigating the second alert.

Delete - I don't want to use this again

Deleting the repository is permanent. The first action below preserves the remediated workflow before anything is removed.

  • Save a copy of .github/workflows/unsafe-issue-title.yml through your browser.
  • Open the repository settings area from the top navigation.
  • Select the general repository settings page.
  • Scroll to the permanent repository deletion controls at the bottom of the page.
  • Choose the permanent repository deletion option.
  • Enter codeql-workflow-vulnerability-demo in the confirmation prompt.
  • Confirm the permanent deletion.
  • Return to the repository URL to verify the deletion.

The repository URL should no longer show the project. That confirms the repository plus its workflows, alerts, and scanning configuration are gone.

Nice Work!

Nice Work!

You did it! Your public repository now uses CodeQL to scan GitHub Actions workflows for script-injection risks. Your commit history shows the full security loop from a vulnerable workflow to a closed alert.

You've learned how to:

  • Enabled default code scanning so CodeQL analyzes supported content on future pushes to codeql-workflow-vulnerability-demo.
  • Created a deliberately unsafe workflow in .github/workflows/unsafe-issue-title.yml. You used the alert's source-to-shell path to understand how untrusted issue input reached a shell command.
  • Fixed direct expression interpolation by passing the issue title through the ISSUE_TITLE environment variable. The next completed scan moved the original alert to closed.
  • Secret Mission: Proved the detection was repeatable with .github/workflows/unsafe-issue-body.yml. A new open alert confirmed that CodeQL also catches the second untrusted-input-to-shell pattern.

Ready to quiz yourself?