Tools Used in DevSecOps · CodeQL

CodeQL

CodeQL is GitHub's semantic code-analysis engine, and its whole pitch rests on one deceptively simple move: don't scan source code as text, compile it into a database and query it like any other structured data. Every function call, every assignment, every path a value can take from user input to a dangerous sink becomes a row a purpose-built query language — QL — can search directly. "Find every place untrusted input reaches a SQL call without passing through this sanitizer" stops being a description of a bug in a ticket and becomes an actual query: written once, tested, versioned, and re-run against every future commit. That power has a real cost, which is the tradeoff this whole page keeps circling back to: building that database takes real time and, for compiled languages, a real build — exactly why CodeQL earns its keep on a schedule and inside GitHub's own pull-request checks, rather than on literally every keystroke the way Semgrep does.

☺ Explain it like I'm 10

Imagine two ways to answer a question about a huge library. One way: walk every aisle yourself, reading page after page, hoping to spot what you need. The other way: first build a complete card catalog that already knows which book cites which, which chapter follows which, who borrowed what and when — then you just ask the catalog a real question, like "show me every book written after 1990 that quotes a book that was banned before 1950," and get an exact answer back instantly. Building that catalog takes real work up front. CodeQL builds that catalog for your code once, and then lets you ask genuinely deep questions of it, over and over, as many times as you want.

🐢Your host for this topic: Timmy the Turtle — Timmy runs Semgrep on every single pull request because it's fast enough that nobody notices the wait. CodeQL is the much deeper scan he schedules for nights and merges to the default branch, because building its whole-program database isn't something he's willing to make anyone wait on for every commit.

What CodeQL is, and the problem it solves

☺ Like you're 10: It doesn't read your code once and forget it — it builds a whole searchable model first, so any question about how data moves through your program has a real, exact answer.

CodeQL began as the query language and engine behind Semmle, a company spun out of research on large-scale, queryable program analysis; GitHub acquired Semmle in 2019 and made CodeQL free to run against public repositories, folding it into GitHub Advanced Security and the code scanning feature built into every GitHub repository's Security tab. (The exact founding history is worth checking against GitHub's own writeup if you need it precisely — what matters for this page is the engine it produced, not the corporate history.) The name is a fair description of the product: Code analyzed via QL, a genuine query language, not a configuration format bolted onto a scanner.

The gap CodeQL fills sits one level deeper than the gap Semgrep fills. Semgrep parses one file into an AST and matches a rule's shape against it, independently, file by file — fast, because it never has to know what a file three directories away contains. CodeQL instead performs extraction: it compiles or parses your entire codebase into a relational-style database that encodes the abstract syntax tree, the control-flow graph, the data-flow graph, and — for most supported languages — a call graph, all at once, with every one of those edges already resolved between files, between functions, sometimes across your whole monorepo. A QL query doesn't reconstruct that structure at query time; it just searches it. That's what lets a single query trace a tainted value from an HTTP handler in one file, through two intermediate functions in two other files, into a database call in a fourth — the exact kind of interprocedural chain Semgrep's per-file model was never built to follow.

◆ Key idea

CodeQL's core trade is the mirror image of Semgrep's. It spends real time, and for compiled languages a real build, constructing a queryable model of the whole program — in exchange for queries that can trace a vulnerability across function and file boundaries, not just within one file. Depth costs time; that's the whole shape of every gotcha and every scheduling decision later on this page.

Architecture: from source to a queryable database

☺ Like you're 10: First it builds the catalog (once, and it costs time), then it answers questions against that catalog (fast, and you can ask as many as you like).

Producing a CodeQL result is always two separate phases, and keeping them separate in your head is most of what makes the tool's behavior — and its failure modes — predictable.

Source repository every file, every commit you point the CLI at codeql database create compiled langs: runs the REAL build interpreted langs: parse only phase 1 · once per run CodeQL database AST · control-flow graph data-flow graph · call graph a relational-style snapshot of exactly what got compiled Query pack (.ql) default suite, or your own custom .ql query codeql database analyze evaluates the query against phase 2 · run it as often as you like SARIF results file, line, message, and the full data-flow path GitHub code scanning Security-tab alert, PLUS an inline annotation on the PR's changed lines Extraction hooks the real compiler invocations for compiled languages — a guessed build is never enough.

Phase one, extraction, is codeql database create. For interpreted languages (Python, JavaScript/TypeScript, Ruby) this is essentially a parse — no build needed. For compiled languages (Java/Kotlin, C/C++, C#, Go, Swift), CodeQL has to watch your actual build happen: it wraps or intercepts every compiler invocation your build system makes, so the database reflects exactly what was compiled, with exactly which flags and which conditionally-included files — not a best-effort guess. That's why --command or CodeQL's autobuild step matters so much for compiled projects, and it's the source of most of the gotchas later on this page. Phase two, evaluation, is codeql database analyze: internally, QL compiles down toward relational-algebra-style operations (the standard library and query evaluator both lean on ideas from Datalog), which is what makes exhaustive, whole-program queries tractable rather than a brute-force search. The output is SARIF — the same interchange format most modern SAST tools emit — which is what lets a single result flow into GitHub's code scanning UI, a dashboard like DefectDojo, or any other SARIF consumer.

Default query suites vs. a custom QL query for a homegrown pattern

☺ Like you're 10: The built-in suites already know the dangerous calls every public framework ships with; only you know the dangerous call your own team built and gave a friendly name.

Each language's CodeQL query pack ships three built-in suites, layered by how much you're willing to trade noise for coverage:

SuiteWhat it includesTypical use
code-scanningA curated, high-confidence set tuned for low false-positive ratesThe default the Action runs when you don't override queries: — safe to treat as merge-blocking
security-extendedAdds lower-confidence and lower-severity security queries the default set leaves outBroader advisory coverage, often on a schedule rather than as a hard PR gate
security-and-qualitysecurity-extended plus maintainability and code-quality queriesTeams that want CodeQL to double as a quality tool, not just a security one

(GitHub periodically renames and re-scopes these suites as its query packs evolve — treat the exact names and boundaries above as the shape to expect, and verify the current set against GitHub's own CodeQL documentation before you rely on precise wording in an audit or a report.) You select one in a config file:

# .github/codeql/codeql-config.yml
name: "Acme CodeQL config"
queries:
  - uses: security-extended
  - uses: ./custom-queries/db-run-sql-injection.ql   # your own query, alongside the suite
paths-ignore:
  - "**/*_test.py"
  - "vendor/**"
  - "generated/**"

The same limitation that pushed Semgrep past its off-the-shelf ruleset shows up here too, for the same underlying reason: a built-in suite is written to be roughly right across every public codebase that might install it, so it knows a raw database driver's own execute() call as a sink — but has no idea your team wraps that call in an internal db.run() helper three layers down. This course has followed that exact gap before: Secure Coding Patterns and the Semgrep page both watch Benny build a query through Acme's own db.run() wrapper, and a generic ruleset never flags it because it was trained on the driver's own call, not a wrapper three layers removed from it. Semgrep's answer was a taint-mode YAML rule scoped to one file at a time. CodeQL's answer is a real QL query that can trace the same tainted value across function and file boundaries, using the modern module-based data-flow API:

/**
 * @name Unsanitized input reaches db.run()
 * @description Untrusted HTTP input reaching Acme's db.run() wrapper without
 *              going through its own bind-parameter form is a SQL injection risk.
 * @kind path-problem
 * @problem.severity error
 * @security-severity 8.6
 * @precision high
 * @id acme/db-run-sql-injection
 * @tags security
 *       external/cwe/cwe-089
 */

import python
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.TaintTracking
import semmle.python.ApiGraphs
import DataFlow::PathGraph

// A module implementing DataFlow::ConfigSig — the current idiomatic shape.
// Older CodeQL queries you'll find in the wild instead extend a
// `TaintTracking::Configuration` class directly; both compile today, but
// the module form is what GitHub's own docs teach now.
module DbRunConfig implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) {
    // request.args.get(...) / request.form.get(...) — untrusted by definition
    source = API::moduleImport("flask").getMember("request")
               .getMember(["args", "form"]).getMember("get").getACall()
  }

  predicate isSink(DataFlow::Node sink) {
    exists(API::CallNode run |
      run = API::moduleImport("acme_db").getMember("db").getMember("run").getACall() and
      sink = run.getArg(0)
    )
  }

  predicate isBarrier(DataFlow::Node node) {
    // Acme's own bind-parameter form is the sanitizer — same role the
    // Semgrep rule's pattern-sanitizers block played for this exact wrapper.
    exists(API::CallNode run |
      run = API::moduleImport("acme_db").getMember("db").getMember("run").getACall() and
      node = run.getArgByName("params")
    )
  }
}

module DbRunFlow = TaintTracking::Global<DbRunConfig>;

from DbRunFlow::PathNode source, DbRunFlow::PathNode sink
where DbRunFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
  "This query depends on a $@ reaching db.run() without a bind parameter.",
  source.getNode(), "user-provided value"

Read the shape, not just the syntax: isSource names where untrusted data enters, isSink names where it becomes dangerous, isBarrier names what neutralizes it in between — the exact same source/sink/sanitizer vocabulary static analysis & secrets detection covers generally, just expressed against a real whole-program database instead of one file at a time. The @kind path-problem and the trailing PathNode/flowPath machinery are what let GitHub's UI render the entire chain — every intermediate function call the tainted value passed through — not just the two endpoints, which is the single biggest practical difference a reviewer feels when triaging a CodeQL alert versus a single-line Semgrep finding. The metadata block above the query isn't decorative either: @security-severity and @precision drive how GitHub ranks and displays the alert, and @id is the stable identifier a dismissal or a suppression comment refers back to.

Before a custom query goes anywhere near a merge-blocking pipeline, test it the same way you'd test application code: codeql test run against a small qlpack of known-good and known-bad fixtures with an .expected file recording the exact results the query should produce. Skipping this step is how a query with a typo in its sink predicate ships silently matching nothing, and nobody notices until the exact bug it was written for reappears in production.

How it plugs into GitHub code scanning as a native PR check

☺ Like you're 10: Turn it on with one click and GitHub figures out the rest, or write the exact recipe yourself when you need more control than the one-click version gives you.

GitHub offers two ways to wire CodeQL into a repository, and the choice mostly comes down to how much control you need over what gets scanned and when.

Default setup is a one-click flow under a repository's Settings → Code security → Code scanning: GitHub detects which languages are present, picks a build mode automatically, and runs CodeQL on a GitHub-managed schedule and on pushes — with no workflow file committed to the repository at all. It's the fastest path to coverage and the right default for a team that just wants CodeQL running with sane settings.

Advanced setup commits an actual workflow using github/codeql-action, and it's what you need the moment you want a custom query pack, a config file, path filters, or a schedule that's independent of the PR-triggered run:

# .github/workflows/codeql.yml
name: "CodeQL"
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: '30 1 * * 1'   # weekly deep scan — catches new query-pack coverage on unchanged code

jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      security-events: write   # required to upload SARIF and create alerts
      packages: read
      actions: read
      contents: read
    strategy:
      fail-fast: false
      matrix:
        language: [ 'python', 'javascript-typescript', 'java-kotlin' ]
    steps:
      - uses: actions/checkout@v4

      - uses: github/codeql-action/init@v3      # check the Action's current major-version tag before pinning
        with:
          languages: ${{ matrix.language }}
          config-file: ./.github/codeql/codeql-config.yml

      - uses: github/codeql-action/autobuild@v3  # skip this step and add manual build commands
                                                  # for a monorepo autobuild can't figure out cleanly
      - uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"

Every compiled language needs a build mode, and picking the right one is most of what makes an advanced-setup workflow reliable:

Build modeWhat happensUse it when
noneNo build — pure extraction/parseInterpreted languages: Python, JavaScript/TypeScript, Ruby
autobuildCodeQL guesses your build system (Maven, Gradle, Make, MSBuild, …) and runs itA single, conventional build in one language, one build file
manualYou supply the exact build commands the Action should runMonorepos, multi-module builds, or anything autobuild gets wrong or times out on

What makes this a genuinely native PR check, not just a scan that happens to run in CI, is what happens after the analyze step uploads its SARIF: results become entries in the repository's Security → Code scanning alerts tab, and any new alert a pull request's diff introduces gets annotated inline on the exact changed line in "Files changed" — a reviewer sees the finding without leaving the PR. Configure the "CodeQL" check as a required status check and a PR that introduces a new alert simply can't merge until it's fixed or the alert is explicitly dismissed with a reason. Alerts that already existed on the target branch before the PR don't block it — the same baseline principle Semgrep's --baseline-commit flag gives you, applied automatically by GitHub's own alert tracking instead of a CLI flag you have to remember. On supported plans, GitHub's Copilot Autofix can also propose a code-level fix suggestion directly on a CodeQL alert — worth knowing exists, but verify current availability and plan requirements against GitHub's own pricing page rather than assuming it's on by default.

⚠ Watch out — licensing is not the same everywhere

CodeQL analysis is free for public repositories, full stop. For private repositories, running code scanning through GitHub requires a GitHub Advanced Security (GHAS) license — a paid add-on on GitHub Enterprise. The CodeQL CLI itself can also be run standalone, outside GitHub Actions entirely, but its license terms restrict that kind of use — broadly, to security research and analyzing code you have the rights to — rather than granting unrestricted commercial use. Read the CLI's actual license terms before you build a private, non-GitHub pipeline around it; don't assume "the binary is downloadable" means "unrestricted to use."

Gotchas and failure modes

☺ Like you're 10: Almost every surprise traces back to the same two facts — building the catalog is expensive, and a clean scan only means the catalog was never asked the question that would have found the problem.

Where CodeQL sits against Semgrep, SonarQube, and the enterprise SAST tier

☺ Like you're 10: Different tools spend different amounts of time to see different amounts of depth — the right pipeline uses more than one, at different moments.

ToolAnalysis basisTypical speedDepthFits best at
CodeQLCompiles the codebase into a relational database; real interprocedural data flow via QL queriesMinutes to tens of minutes; a full build step for compiled languagesDeep — traces a tainted value across function and file boundariesMerges to main, scheduled scans; free on public GitHub repos, GHAS-licensed on private ones
SemgrepPer-file AST pattern matching; lightweight taint mode within a file/projectSeconds to low minutes, even on a large repoShallow by default, deliberately — no whole-program databaseEvery commit, every pull request; the fast, first, cheapest gate
SonarQubeA bundled multi-language rule engine plus "Security Hotspots" flagged for human review, wrapped in a Quality GateLow minutes; scales with a persistent server, not a stateless CLIModerate — broad coverage and code-quality metrics, shallower dataflow than CodeQLA team-facing dashboard combining security and code quality, with a human review step baked in
Commercial SAST (Checkmarx, Veracode, Fortify, …)Vendor-proprietary engines, generally compiled/whole-program like CodeQLMinutes to tens of minutes; often a hosted service rather than a local CLIDeep, with vendor-specific compliance mappings (PCI DSS, FedRAMP) as a selling pointRegulated environments where a named vendor's audit trail and support contract matter as much as the engine itself

The pattern from the Semgrep page holds here too, just restated from CodeQL's side of it: a mature pipeline runs Semgrep on every commit because it's cheap enough not to notice, and lets CodeQL earn its much higher cost on a schedule and on the services where an interprocedural chain is worth the wait to catch — not because one tool is strictly better, but because each is the right answer to a different question about how much time you're willing to spend for how much depth. GitHub's own native PR integration is CodeQL's other genuine differentiator against the commercial tier: no separate vendor dashboard, no extra login, results and inline annotations live exactly where the reviewer already is. See vulnerability management & triage for how findings from more than one of these tools get deduplicated into a single backlog once a pipeline is running more than one — which, per the table above, most mature ones eventually do.

🎬 At the Shift-Left Squad
🦝

Rocky the Raccoon: Found another one — a second wrapper, same shape as the last db.run() gap. Different file, same mistake.

🐢

Timmy the Turtle: Semgrep's rule only ever looked inside one file at a time, so it never had a chance of connecting this one to the last one. I wrote a CodeQL query instead — it traces the value across every function it passes through before it ever reaches db.run().

🦊

Foxy: So why not just run that instead of Semgrep, if it sees more?

🐢

Timmy the Turtle: Because building the database it needs takes real minutes, not seconds — nobody's waiting on it before every push. It runs on merges to main and once a week on a schedule. Semgrep still catches the obvious version of this in seconds, on every single PR.

🐘

Ellie the Elephant: Two scanners, same bug class, different reach. That's not redundant — that's the point.

🦫

Benny the Beaver: Fixing the new one now — bound parameter, same as last time. Starting to feel like I should just stop writing raw queries at all.

🐢

Timmy the Turtle: That's the actual fix, eventually. Until then, the query stays in the pack, and it'll catch the next one too.

✓ Checkpoint

1. What does "database" mean in CodeQL, and what are the two separate phases required to get a result from it? 2. Name the three built-in query suites and roughly how they differ. 3. Why write a custom QL query instead of relying on the default suites — what's the concrete example this page uses? 4. What's the practical difference between GitHub's default setup and advanced setup for code scanning, and what does advanced setup let you control that default doesn't? 5. Give two gotchas that come specifically from extraction requiring a real build for compiled languages.

Check your answers
  1. A CodeQL database is a relational-style snapshot of a codebase's AST, control-flow graph, data-flow graph, and call graph, produced by extraction. The two phases are codeql database create (build/extract the database — once) and codeql database analyze (run one or more QL queries against it — as many times as you like).
  2. code-scanning — a curated, high-confidence set safe to treat as merge-blocking. security-extended — adds lower-confidence and lower-severity security queries the default leaves out, better suited to advisory or scheduled scanning. security-and-qualitysecurity-extended plus maintainability/code-quality queries.
  3. Default suites model the sinks and sources of public frameworks; they have no idea a team's own internal wrapper (this page's example: an Acme db.run() helper around a database driver) is functionally the same sink. A custom QL query teaches CodeQL's data-flow engine about that wrapper specifically — the same gap Semgrep's custom taint-mode rule closes, but traced across function and file boundaries instead of within one file.
  4. Default setup is a one-click, no-workflow-file path where GitHub auto-detects languages and a build mode and runs on a GitHub-managed schedule. Advanced setup commits an actual .github/workflows/codeql.yml using github/codeql-action, which is what's required to use a custom config file, a custom query pack (like the example query on this page), path filters, matrix builds across languages, or a schedule independent of the PR-triggered run.
  5. Any two of: autobuild can pick the wrong build target or fail outright on a monorepo with multiple build files, silently producing an incomplete database that then reports as falsely "clean"; extraction is not incremental across commits, so every run re-extracts fully rather than analyzing just a diff, which is part of why CodeQL doesn't belong on every push; and a failed or partial extraction for a compiled language doesn't always surface as an obvious build error — it can just quietly under-cover the codebase.