Skip to content

GitHub Code Scanning: Complete Guide

Lesson 1 of 8Intermediate14 min readGit Security & DevSecOps · Code & Dependency SecurityVerified: GitHub code scanning, SARIF upload and ruleset code scanning rules, September 2026

Code scanning is the platform, not the analyser. It ingests findings, manages their lifecycle, annotates pull requests, and can block a merge. What produces the findings is a separate question: GitHub’s own CodeQL, or any tool that emits SARIF.

That separation is the most useful thing to understand about it, because it means adopting a different analyser does not change your triage workflow, your alert history or your merge gates.

What it does. Runs static analysis against your repository, records findings as alerts, annotates pull requests that introduce them, and optionally blocks merges.

How to enable it. Default setup is a few clicks and GitHub manages the workflow. Advanced setup gives you a workflow file you control.

What it costs. Free and enabled by default for public repositories. On private and internal repositories it requires a GitHub Code Security licence.

What it is not. A guarantee. It finds classes of defect its queries model, in languages its analysers support, on code paths its analysis can reach.

The choice is about who owns the workflow.

GitHub configures and runs CodeQL for you. It detects the languages, chooses a query suite, sets up triggers, and manages the analysis without a workflow file in your repository.

Choose it when you want scanning on and do not need to customise anything. For most repositories, most of the time, this is the correct answer — and the fact that it requires no maintenance is a real security property, because an unmaintained workflow is how scanning silently stops running.

Its limits: you cannot add custom queries, run a third-party analyser alongside, or control the build for a compiled language beyond what GitHub can infer.

A workflow file in .github/workflows/, which you own.

Choose it when you need custom queries, a specific build process, third-party SARIF tools, or control over when analysis runs.

Its cost: it is a workflow, and workflows rot. A repository whose advanced setup broke six months ago has a security tab that looks fine and an analysis that has not run.

A representative advanced setup:

.github/workflows/codeql.yml
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 3 * * 1"
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: java-kotlin
build-mode: none
steps:
- uses: actions/checkout@v7
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

Four details worth understanding rather than copying.

security-events: write is what allows results to be uploaded. Without it the analysis runs and the results go nowhere, which produces a passing job and an empty security tab.

fail-fast: false stops one language’s failure cancelling the others. With the default, a build problem in Java means you get no Python results either.

The schedule trigger matters more than it looks. Analysis on push covers new code; the scheduled run re-analyses existing code against updated queries. A vulnerability class added to CodeQL last month is only found in your existing code by a scheduled run.

category distinguishes results from different analyses so they do not overwrite each other. Omitting it in a matrix means each language’s upload replaces the last.

Code scanning accepts SARIF from any tool. That is what makes it a platform rather than a CodeQL feature.

- name: Run the analyser
run: my-analyser --format sarif --output results.sarif .
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
category: my-analyser

Two details that decide whether this works.

continue-on-error: true on the scan step, so a finding does not fail the job before the upload runs. Gating belongs in a ruleset that evaluates alerts, not in the analyser’s exit code — that is the distinction the next section is about.

A distinct category per tool. Without it, one tool’s upload replaces another’s, and you get whichever ran last.

This is the mechanism by which the whole security ecosystem lands in one interface: linters with security rules, container scanners, infrastructure-as-code analysers, and GitLeaks all emit SARIF. One triage workflow, one alert history, one set of merge gates, regardless of which tool found what.

An alert has a severity, a state and a location.

Every alert carries a standard severityError, Warning or Note — describing the rule’s confidence and importance.

CodeQL alerts additionally carry a security severityCritical, High, Medium or Low — derived from CVSS, calculated from the CVEs associated with that vulnerability type.

The distinction matters when you configure gating. A rule that produces an Error may be a maintainability issue; security severity is the axis to gate on when the gate is about security.

StateMeaning
OpenPresent in the analysed code
FixedNo longer found — closed automatically by a later analysis
DismissedClosed by a person, with a reason

Alerts close themselves when the code changes such that the analysis no longer finds them, which is the right behaviour and has one consequence worth knowing: an alert can also disappear because analysis stopped covering that file. A refactor that moves code into a language your setup does not analyse closes its alerts silently.

Findings appear as annotations on the diff, which is the whole point — the author sees the problem in the change that introduced it, while they still have the context.

One behaviour to know: an alert appears on a pull request only if all the lines it identifies exist in the diff. A finding whose data flow spans changed and unchanged code may not annotate, even though it is genuinely introduced by the change. The pull request view is not a complete list of what the change causes.

There are two mechanisms, and choosing the wrong one is the most common configuration error in this area.

A required status check gates on whether the analysis workflow succeeded. If the workflow finds twelve problems and exits zero, the check is green.

A ruleset’s “require code scanning results” rule gates on the alert state. A pull request introducing an alert at or above a configured severity cannot merge, regardless of the workflow’s exit code.

The second is what you want for a security gate. See Repository rulesets for security.

An alert is a claim that a code path is dangerous. Evaluating it is a skill, and the sequence is consistent enough to write down.

  1. Read the data flow, not the summary. The alert names a source and a sink. The question is whether that path is genuinely reachable with data an attacker controls.

  2. Check the source. Is it actually attacker-controlled? A “user-controlled input” that comes from a configuration file only an administrator writes is a different risk from a request parameter.

  3. Check the sink. What happens with the value? A shell command is different from a log line.

  4. Check what is between them. A validation or escaping step the analyser did not model is the most common reason a genuine-looking finding is a false positive — and “the analyser did not model it” is worth confirming rather than assuming.

  5. Decide, and record why. Fix, dismiss with a reason, or accept with a note.

Dismissing is a judgement that becomes the permanent record, so the reason matters more than the choice.

False positive — the code path is not what the alert describes. Verify before choosing this; “I do not think this is exploitable” is not the same as “the analysis is wrong”.

Used in tests — the pattern is in test code where the risk does not apply. Reasonable, and worth checking that the file really is test-only.

Won’t fix — a real finding you are accepting. This needs the most detail, because it is the dismissal most likely to be revisited during an incident.

Which queries run determines both what you find and how much noise you get. The suites, from narrowest to widest:

SuiteContents
defaultHigh-precision security queries. What default setup runs
security-extendedMore security queries, including lower-precision ones
security-and-qualityEverything in extended, plus maintainability and correctness queries

The trade is precision against recall, and the right choice depends on whether anyone is going to read the output.

default is the correct starting point for essentially every repository. It is tuned so that findings are usually real.

security-extended is worth adopting once the default suite’s findings are being worked. It will find things the default missed and produce more that need judgement.

security-and-quality includes non-security queries. It is useful for code health and it dilutes the security signal, so it is a poor fit for a repository where the security tab is the security team’s queue.

Configured in advanced setup:

- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
queries: security-extended

Turning code scanning on across an established organisation produces a backlog measured in thousands. A backlog nobody can triage teaches everyone that the security tab is noise, and that lesson outlasts the backlog.

  1. Start with the default query suite on a few active repositories. Not security-extended, and not everything at once.

  2. Read what comes back, and separate genuinely exploitable findings from the rest. This is the step that calibrates everything after it.

  3. Gate on new alerts only, at a high severity threshold. The existing backlog is a separate project from keeping new problems out.

  4. Work the backlog deliberately, as a security campaign with an owner and a deadline — not as a queue somebody will get to.

  5. Widen the query suite once the volume is manageable.

  6. Enforce through an organisation ruleset, so a repository cannot opt out quietly.

Step 3 is the one that makes adoption work. Separating “stop the bleeding” from “clear the backlog” lets the first happen this week.

Threat. Code containing an exploitable defect reaches production, and an attacker reaches it.

Attack surface. Every path from input an attacker influences to an operation with consequences — a query, a command, a file path, a deserialisation, a redirect, a template.

Impact. Ranges from nothing (the path is unreachable in practice) to full compromise. Static analysis reports the pattern; only you know the deployment.

Control. Analysis on every pull request, so the defect is caught in the change that introduced it; scheduled analysis so existing code is re-examined against new queries; a merge gate at a severity threshold you have chosen deliberately.

Verification. Introduce a known-vulnerable pattern in a branch and confirm it is flagged, annotated on the pull request, and blocked by the gate. Then confirm what is not flagged — the gaps are the specification for review.

That last instruction is the one worth acting on. A team that knows code scanning does not model their authorisation logic reviews authorisation logic. A team that assumes coverage does not.

At scale, individual repositories are the wrong unit.

Security configurations apply a consistent set of code security settings across many repositories, rather than each being enabled by hand. This is the mechanism that makes coverage measurable — an organisation where scanning is on for sixty percent of repositories has a gap no triage discipline closes.

Security overview aggregates alerts, which is where the useful comparative questions get answered: which repositories have the most high-severity findings, which have scanning enabled but no analysis in the last month, and which teams are accumulating rather than resolving.

Security campaigns turn a filtered set of alerts into tracked remediation with an owner and a deadline. This is the right instrument for backlog work, and it is covered in Security campaigns.

The metric worth tracking is not open alert count. It is time from alert to resolution for new alerts, split from the historical backlog. The first measures whether the process works; the second measures a project.

It finds classes of defect its queries model: injection, unsafe deserialisation, path traversal, hardcoded credentials, unsafe cryptographic use, and similar patterns with recognisable shapes.

It does not find:

  • Business logic flaws. An authorisation check that permits one customer to read another’s data is correct code doing the wrong thing. No static analyser has the specification.
  • Design problems. A system that logs personal data everywhere is working as written.
  • Anything in a language it does not support, or that its configuration did not analyse.
  • Problems in your dependencies. That is a different system — dependency review and Dependabot.
  • Runtime and configuration issues. The analysis reads source, not a deployment.

That list is not a criticism. It is the specification for what else you need: review for logic, threat modelling for design, dependency tooling for third-party code, and runtime controls for everything analysis cannot see from source.

A public repository receives pull requests from forks, and analysing them safely has a constraint worth knowing before you meet it.

Code scanning on a pull_request event runs with a read-only token and no access to secrets, which is exactly right — the code being analysed is untrusted. Results still upload, because GitHub treats code scanning results from pull requests specially.

What does not work is any pattern that gives a fork’s code access to credentials in order to analyse it. If your advanced setup needs a secret to build the project, analysis of fork pull requests will fail, and the temptation is to reach for pull_request_target — which runs with the base repository’s permissions and would execute the contributor’s build with your credentials.

Do not. See Workflow security for why that pattern is the origin of a whole class of incident. The correct resolutions are to make the analysis build not require secrets, or to accept that fork pull requests get reduced analysis and rely on review plus the scan that runs after merge.

The second option is more common than people expect, and it is a reasonable position provided it is a decision rather than a surprise: state in the contributing guide that fork pull requests receive reduced automated analysis, so reviewers know they are the primary control on that path.

Omitting security-events: write. The analysis runs, results go nowhere, and the job passes.

No category with multiple analyses. Uploads overwrite each other and you keep whichever ran last.

No scheduled run. Existing code is never re-analysed against new queries.

Gating on the workflow’s exit code. Conflates “did not run” with “found nothing”.

Enabling security-extended on day one. Volume that nobody triages, and a security tab everybody learns to ignore.

fail-fast left at its default in a language matrix. One language’s build failure discards the others’ results.

Assuming a green pull request means no findings. An alert only annotates when every line it identifies is in the diff.

Treating an advanced setup as maintenance-free. A broken workflow produces no alerts, which looks exactly like a clean repository.

Code scanning is an alert database with a pull request interface and a merge gate. Analysers produce findings; the platform decides what happens to them. Changing the analyser changes what is found — not how it is handled.

  • Code scanning is the platform; CodeQL and third-party SARIF tools are the analysers
  • Default setup is maintenance-free and less flexible; advanced setup is yours to own and to break
  • security-events: write is required to upload results, and its absence produces a silent pass
  • A distinct category per analysis stops uploads overwriting each other
  • Scheduled runs re-analyse existing code against updated queries, which push triggers never do
  • Alerts carry both a standard severity and, for CodeQL, a CVSS-derived security severity
  • An alert annotates a pull request only when all its lines are in the diff
  • Gate on alert state through a ruleset, not on the analyser’s exit code
  • Static analysis cannot find business logic flaws, design problems or dependency vulnerabilities

Use a disposable public repository so scanning is free.

  1. Enable default setup and let the first analysis run. Note how long it takes and what it found.

  2. Add a file with an obvious injection pattern in a supported language — string concatenation into a shell command from a function parameter. Predict: does the next analysis flag it?

  3. Switch to advanced setup and read the generated workflow. Identify the permissions block, the matrix and the category.

  4. Remove security-events: write and re-run. Predict: does the job fail, or pass with no results?

  5. Restore it. Add a second analysis with the same category and observe what happens to the first one’s results.

  6. Add a ruleset requiring code scanning results at high or above. Open a pull request introducing a flagged pattern. Predict: which blocks the merge — the workflow check or the ruleset rule?

  7. Fix the code and confirm the alert closes itself.

  8. Delete the repository.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.