Code Scanning with CodeQL
CodeQL is GitHub’s semantic code analysis engine. Instead of matching text patterns like a linter, it treats your source code as a queryable database and runs security-focused queries against it to find real vulnerabilities: SQL injection, path traversal, hardcoded credentials, unsafe deserialization, and dozens of other classes of bug. When CodeQL runs inside GitHub Actions, results show up as annotated alerts in the repository’s Security tab and as inline review comments on pull requests, without you having to run anything locally.
This lesson assumes you already know how to write a basic workflow file and trigger it on push and pull_request events. Here we focus specifically on the CodeQL workflow: how the analysis pipeline works, how to configure it for one or many languages, how to scope it safely for untrusted contributions, and how to keep the signal-to-noise ratio high enough that people actually act on the alerts.
Overview / How it works
A CodeQL analysis has three stages, each mapped to a step in the workflow:
- Init — the
github/codeql-action/initaction downloads the CodeQL CLI and query packs for the languages you specify and prepares an empty database. - Build — for compiled languages (Java, C/C++, C#, Go, Swift), CodeQL needs to observe a real compilation to build its database. Interpreted languages (JavaScript/TypeScript, Python, Ruby) don’t need a build step. This is controlled by
build-mode. - Analyze — the
github/codeql-action/analyzeaction runs the query suite against the finished database and uploads the results as a SARIF (Static Analysis Results Interchange Format) file to GitHub’s code scanning API.
Once uploaded, GitHub renders the SARIF as alerts. A alert has a rule ID, a severity, a file and line range, and often a full data-flow path showing how untrusted input reaches a dangerous operation. Alerts persist across runs: if the same issue is found again, it’s the same alert; if a fix removes it, GitHub marks it fixed automatically.
Syntax or workflow structure
A minimal advanced-setup CodeQL workflow needs a trigger, a permissions block, and a job with three steps: checkout, init, analyze. The permissions block matters more here than in most workflows, because uploading SARIF requires an elevated scope that the default GITHUB_TOKEN does not always have.
| Permission | Why it’s needed |
|---|---|
contents: read |
Check out the repository to build the CodeQL database |
security-events: write |
Upload SARIF results and create/update code scanning alerts |
actions: read |
Required on private repositories so the analyze step can read workflow run details |
Set permissions explicitly at the job level even if your organization already grants broader default permissions — an explicit block documents intent and survives an org-wide tightening of defaults without silently breaking the scan.
Examples
Example 1: single-language advanced setup. This is the workflow GitHub generates for a JavaScript/TypeScript repository, trimmed to the essentials.
name: CodeQL
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '30 3 * * 1'
permissions:
contents: read
security-events: write
actions: read
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:javascript-typescript"
Expected behavior: on every push to main and every pull request targeting main, a check named “CodeQL” (or “Analyze (javascript-typescript)”) runs and reports pass/fail. The weekly cron catches issues introduced by new CodeQL query releases even when the code itself hasn’t changed. Alerts appear under Security → Code scanning alerts, and new alerts on a pull request also show as review annotations on the changed lines.
Example 2: multiple languages with different build modes. Most real repositories mix languages. Use a matrix so each language gets its own database and its own category in the Security tab.
name: CodeQL Matrix
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '15 4 * * 3'
permissions:
contents: read
security-events: write
actions: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: java-kotlin
build-mode: autobuild
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
Expected behavior: three parallel jobs run, one per language. build-mode: none skips compilation for the interpreted languages; build-mode: autobuild lets CodeQL attempt to detect and run the Java/Kotlin build itself (Gradle or Maven). If autobuild can’t figure out your build, switch that entry to build-mode: manual and add your own build commands as a step between init and analyze.
| build-mode | Use when |
|---|---|
none |
Interpreted languages (JS/TS, Python, Ruby) — no compilation exists |
autobuild |
Compiled languages with a standard, single build tool CodeQL can detect |
manual |
Compiled languages with custom, multi-step, or containerized build processes |
Example 3: a custom query suite and noise reduction. The default query suite is deliberately conservative to minimize false positives. Once a team has burned down its backlog, broadening the suite and excluding known-noisy paths keeps the signal useful.
# .github/codeql/codeql-config.yml
name: "Custom CodeQL config"
queries:
- uses: security-extended
- uses: security-and-quality
paths-ignore:
- "**/*.test.js"
- "**/vendor/**"
- "**/dist/**"
paths:
- "src"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
build-mode: none
config-file: ./.github/codeql/codeql-config.yml
Expected behavior: the scan now includes the broader security-extended and security-and-quality query packs (more coverage, more scan time, and a few more low-confidence findings to triage), while skipping test fixtures and vendored/build output that would otherwise generate irrelevant alerts.
Step by step
- Confirm code scanning is available (public repos get it free; private repos need GitHub Advanced Security enabled).
- Add a workflow under
.github/workflows/codeql.ymlwith an explicitpermissionsblock containingcontents: readandsecurity-events: write. - List the languages actually present in the repository — scanning a language with no source files wastes runner time and adds an empty category to the Security tab.
- Choose a
build-modeper compiled language; start withautobuildand fall back tomanualonly if the build step fails. - Trigger on
pushto your default branch, onpull_request, and on a weeklyscheduleso query-pack updates are picked up even during quiet periods. - Run the workflow, open the Security → Code scanning alerts tab, and triage: fix true positives, dismiss false positives with a documented reason (not silently).
- Once the backlog is under control, add a
codeql-config.ymlto widen the query suite and exclude generated or vendored paths. - In branch protection rules, require the CodeQL check to pass, and optionally require code scanning results at a chosen severity threshold before merge is allowed.
Common Mistakes
Mistake 1: omitting security-events: write. If your organization or repository has tightened default workflow permissions to read-only, the analyze step fails with a permissions error when it tries to upload SARIF, even though the scan itself completed successfully.
# Wrong — no explicit permissions block, relies on org default which may be read-only
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- uses: github/codeql-action/analyze@v3
# Correct — permissions declared explicitly at job level
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- uses: github/codeql-action/analyze@v3
Mistake 2: analyzing fork pull requests with pull_request_target. pull_request_target runs with the base repository’s permissions and secrets, not the fork’s, and it does not automatically avoid checking out the pull request’s head commit. If a CodeQL workflow triggers on pull_request_target and then checks out and builds github.event.pull_request.head.sha, an external contributor’s build scripts execute with access to your repository’s secrets and a token that may have write permissions — a direct path to secret exfiltration or a poisoned build.
# Risky — attacker-controlled PR code runs with base-repo privileges and secrets
on:
pull_request_target:
jobs:
analyze:
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- uses: github/codeql-action/analyze@v3
# Correct — trigger on pull_request instead; forked-PR runs get a read-only
# token and no repository secrets by default, so it is safe to build and scan
# attacker-controlled code automatically
on:
pull_request:
branches: [ "main" ]
jobs:
analyze:
permissions:
contents: read
security-events: write
actions: read
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- uses: github/codeql-action/analyze@v3
If you need pull_request_target for something else in the same repository (for example, to post a comment using a token with write access), keep that job separate from the CodeQL build-and-analyze job, and never check out or execute the pull request’s head commit inside a pull_request_target job.
Best Practices
- Scope permissions per job. Even inside a workflow that has broader top-level permissions, repeat the minimal set at the job level for the CodeQL job so it’s self-documenting and resilient to future changes elsewhere in the file.
- Pin action versions deliberately.
github/codeql-action@v3tracks a maintained major version and receives query-pack updates automatically, which is usually what you want for a security scanner. Pinning to a commit SHA instead gives you full reproducibility and immunity to a compromised tag, at the cost of manually updating to get new vulnerability queries — a real trade-off, not a default “always pin” rule. - Scan only the languages you have. Extra matrix entries cost runner minutes and add empty or near-empty categories to the Security tab that dilute attention from real findings.
- Start report-only, then gate. Turn on scanning and let the team triage the existing backlog before making the check required in branch protection. Requiring a check that already has 200 unresolved alerts trains people to ignore it.
- Exclude noise, not risk. Use
paths-ignorefor vendored dependencies, generated code, and test fixtures — never for application source just because it’s inconvenient to fix. - Triage with reasons. Dismissing an alert without a reason (false positive, won’t fix, used in tests) loses the context the next person needs when the same pattern reappears elsewhere.
- Trust the built-in fork protections. The
pull_requesttrigger already gives forked-PR runs a read-only token and withholds secrets — that’s precisely what makes it safe to run CodeQL automatically on external contributions. Don’t “fix” a perceived permissions problem by switching topull_request_target.
Practice Exercises
- Add an advanced-setup CodeQL workflow to a JavaScript or Python repository with an explicit
permissionsblock, apush/pull_request/weeklyscheduletrigger, and confirm a check run appears on your next pull request. - Extend the workflow to a matrix covering a second language present in the repository, choosing the correct
build-modefor each, and confirm both languages appear as separate categories under Security → Code scanning alerts. - Add a
codeql-config.ymlthat excludes your test or vendor directories and enables thesecurity-and-qualityquery pack, then compare the alert count before and after. - In branch protection settings, require the CodeQL check to pass before merging, and open a pull request that intentionally introduces an unsafe pattern (such as string-concatenated SQL) to confirm the alert blocks the merge.
Summary
CodeQL turns your source code into a queryable database and runs targeted security queries against it inside a normal GitHub Actions workflow: init, build (if compiled), analyze. Get the permissions block right — contents: read, security-events: write, and actions: read on private repos — or SARIF upload silently fails. Match build-mode to each language, scan on push, pull request, and a weekly schedule, and keep pull request scanning on the safe pull_request trigger rather than pull_request_target, since forked contributions must never run with your repository’s secrets or write-level token. Once the alert backlog is triaged, tighten the query suite, exclude generated paths, and require the check in branch protection so CodeQL becomes a real merge gate instead of a tab nobody opens.
