Why GitHub uses CodeQL

GitHub's Product Security Engineering team uses CodeQL as a core part of its security tooling. CodeQL functions as a static analysis engine that treats code like a database, allowing security engineers to run sophisticated queries across a codebase rather than relying on simple text searches. This approach enables the team to identify vulnerabilities and enforce secure coding standards across more than 10,000 repositories.

The team applies CodeQL in three main configurations. First, default setup with both the default and security-extended query suites covers the vast majority of repositories, automatically providing security review on pull requests. Second, a few critical repositories—including GitHub's large Ruby monolith—use advanced setup with a custom query pack tailored to their specific needs. Third, the team uses multi-repository variant analysis (MRVA) for quick audits and to run custom queries that identify code patterns either unique to GitHub's codebases or requiring manual security review.

The workflow step used on the monolith for advanced setup is straightforward:

- name: Initialize CodeQL
    uses: github/codeql-action/init@v3
    with:
      languages: ${{ matrix.language }}
      config-file: ./.github/codeql/${{ matrix.language }}/codeql-config.yml

The Ruby configuration itself is standard, but it leverages the packs option in custom configuration files to enable the team's custom query pack during CodeQL analysis. This pack contains queries written specifically for the GitHub Ruby codebase.

Moving custom queries into a query pack

GitHub initially published custom CodeQL queries directly to the monolith repository, but this approach created several problems. Every new or updated query required going through the production deployment process. Queries stored outside a pack were also not pre-compiled, which slowed down CodeQL analysis in CI. Additionally, the query test suite ran as part of the monolith's CI jobs, meaning that new CodeQL CLI releases could break tests due to changes in query output—even when nothing in the pull request had changed. This caused confusion and frustration for engineers.

Publishing queries as a query pack to the GitHub Container Registry (GCR) resolved these pain points. While deploying query files directly to a repository is possible, the team recommends the pack approach for easier deployment and faster iteration.

Building and managing the query pack

When creating the query pack, the team faced a key challenge around dependency management, particularly with the ruby-all package. Custom queries extend classes from the default query suite, leveraging the existing ruby-all library to keep queries concise and maintainable. However, CodeQL library API changes can break queries.

To balance development against the latest features with release stability, the team uses a dual approach. During development, qlpack.yml specifies the latest version of ruby-all, so running codeql pack init pulls in the most current version:

// Our custom query pack's qlpack.yml

library: false
name: github/internal-ruby-codeql
version: 0.2.3
extractor: 'ruby'
dependencies:
  codeql/ruby-all: "*"
tests: 'test'
description: "Ruby CodeQL queries used internally at GitHub"

When ready to release, they pin the exact version in the codeql-pack.lock.yml file, guaranteeing queries run against the tested version:

// Our custom query pack's codeql-pack.lock.yml

lockVersion: 1.0.0
dependencies:
 ...
 codeql/ruby-all:
   version: 1.0.6

The team also maintains CodeQL unit tests that run sample code snippets against each query in CI, providing early detection of errors before publication.

The release flow for new queries follows a clear sequence:

  • Open a pull request with the new query.
  • Write unit tests for it.
  • Merge the pull request.
  • Increment the pack version in a separate pull request.
  • Run codeql pack init to resolve dependencies.
  • Correct unit tests as needed.
  • Publish the query pack to GCR.
  • Repositories configured with the pack automatically pick up the update.

Configuration and rollback strategy

Rather than locking the pack to a specific version in the CodeQL configuration file, GitHub chose to manage versioning through the GCR package publication itself. The monolith always retrieves the latest published pack version, enabling rapid rollback by simply republishing the package. When a released query produced an unacceptable number of false positives, the team removed it and published a new pack version in under 15 minutes—much faster than merging a pull request to revert a version pin in the configuration file.

The team encountered a practical obstacle when making the query pack accessible across multiple enterprise repositories, and evaluated three options:

  • Granting access per repository. The package management page allows individual repository permissions, but GitHub's scale made this unworkable—there are too many repositories to configure manually, and no API exists for programmatic setup.
  • Using a personal access token. A PAT in the CodeQL action runner could read all packages for the organization, but this felt too permissive since it would expose every private package, not just the query pack.
  • Linking a repository. GitHub ultimately linked a repository to the package and configured the package to inherit access permissions from that linked repository, which proved the most practical solution.

Custom query packs in production CI

GitHub’s security team maintains custom CodeQL query packs that go beyond the default rule set. These pack queries encode GitHub-specific patterns and engineering preferences that aren’t part of the standard security queries. The alerts they generate fall into several categories: high-risk APIs that may receive unsanitized user input, use of built-in Rails methods where GitHub has safer custom replacements, REST API endpoints or GraphQL mutations missing required authorization methods, and endpoints or mutations that fail to define an explicit access control method. The packs also flag uses of signed tokens, nudging engineers to request Product Security review when those tokens are involved.

Not every custom query is meant to block a merge. Many are educational, with metadata set to recommendation severity so they surface in pull requests without failing the CodeQL CI job. These lower-severity alerts are also excluded from GitHub’s Fundamentals program, so they don’t carry the same urgency as enforcing queries. That distinction lets the security team introduce and iterate on new queries while engineers can assess real impact without being blocked.

One example is a query targeting the ActiveRecord::decrypt method. The method is unsafe in production code because it can leave an encrypted column decrypted; the recommendation-level alert warns the developer rather than halting the PR. Another educational query checks whether a class defining a REST API endpoint includes a control_access method. When the method is absent, the query posts a comment on the pull request pointing out that control_access is required for REST endpoints, prompting both the author and reviewer to address the gap before merge.

/**
 * @id rb/github/use-of-activerecord-decrypt
 * @description Do not use the .decrypt method on AR models, this will decrypt all encrypted attributes and save
 * them unencrypted, effectively undoing encryption and possibly making the attributes inaccessible.
 * If you need to access the unencrypted value of any attribute, you can do so by calling my_model.attribute_name.
 * @kind problem
 * @severity recommendation
 * @name Use of ActiveRecord decrypt method
 * @tags security
 *      github-internal
 */

import ruby
import DataFlow
import codeql.ruby.DataFlow
import codeql.ruby.frameworks.ActiveRecord

/** Match against .decrypt method calls where the receiver may be an ActiveRecord object */
class ActiveRecordDecryptMethodCall extends ActiveRecordInstanceMethodCall {
  ActiveRecordDecryptMethodCall() { this.getMethodName() = "decrypt" }
}

from ActiveRecordDecryptMethodCall call
select call,
  "Do not use the .decrypt method on AR models, this will decrypt all encrypted attributes and save them unencrypted.

Because the control_access query only needs to detect the presence or absence of a single method definition, it’s a straightforward pattern to express in QL and useful as a model for security-control checks.

/**
 * @id rb/github/api-control-access
 * @name Rest API Without 'control_access'
 * @description All REST API endpoints must call the 'control_access' method, to ensure that only specified actor types are able to access the given endpoint.
 * @kind problem
 * @tags security
 * github-internal
 * @precision high
 * @problem.severity recommendation
 */

import codeql.ruby.AST
import codeql.ruby.DataFlow
import codeql.ruby.TaintTracking
import codeql.ruby.ApiGraphs

// Api::App REST API endpoints should generally call the control_access method
private DataFlow::ModuleNode appModule() {
  result = API::getTopLevelMember("Api").getMember("App").getADescendentModule() and
  not result = protectedApiModule() and
  not result = staffAppApiModule()
}

// Api::Admin, Api::Staff, Api::Internal, and Api::ThirdParty REST API endpoints do not need to call the control_access method
private DataFlow::ModuleNode protectedApiModule() {
  result =
    API::getTopLevelMember(["Api"])
        .getMember(["Admin", "Staff", "Internal", "ThirdParty"])
        .getADescendentModule()
}

// Api::Staff::App REST API endpoints do not need to call the control_access method
private DataFlow::ModuleNode staffAppApiModule() {
  result =
    API::getTopLevelMember(["Api"]).getMember("Staff").getMember("App").getADescendentModule()
}

private class ApiRouteWithoutControlAccess extends DataFlow::CallNode {
  ApiRouteWithoutControlAccess() {
    this = appModule().getAModuleLevelCall(["get", "post", "delete", "patch", "put"]) and
    not performsAccessControl(this.getBlock())
  }
}

predicate performsAccessControl(DataFlow::BlockNode blocknode) {
  accessControlCalled(blocknode.asExpr().getExpr())
}

predicate accessControlCalled(Block block) {
  // the method `control_access` is called somewhere inside `block`
  block.getAStmt().getAChild*().(MethodCall).getMethodName() = "control_access"
}

from ApiRouteWithoutControlAccess api
select api.getLocation(),
  "The control_access method was not detected in this REST API endpoint. All REST API endpoints must call this method to ensure that the endpoint is only accessible to the specified actor types."

Variant analysis during incidents

When GitHub responds to a bug bounty submission or a security incident, the team often needs to search across the entire codebase for the same class of vulnerability — a process called variant analysis (VA). Code search handles simple pattern matching but falls short when a pattern requires semantic understanding, like knowing whether a variable is an Active Record object or whether it appears inside an if expression. For those cases, the team moves to CodeQL.

VA queries are built for speed and signal, not production quality. False positives matter less than getting a manageable list of code paths for security engineers to review manually. Common VA questions include locating all uses of SHA1 hashes, finding places where the codebase passes user input to a known SQL-injection-vulnerable internal API endpoint, or identifying HTTP request library instantiations that set a proxy configuration following a reported problem with how Ruby libraries handle that setting.

A recent Rails vulnerability illustrates how subtle these queries can be. The team wanted to detect a two-step pattern: a parameter used to look up an Active Record object that is later reused after that lookup. The risk is an insecure direct object reference (IDOR), since Rails finder methods accept arrays. If one element of a parameter array authorizes access but another element is later used for an object lookup, that can open an IDOR path. A query to detect all vulnerable instances of that pattern would be difficult to write, but writing one to find likely cases was tractable. The query produced a list of potential paths to analyze manually, and the team ran it across many Ruby repositories using CodeQL’s MRVA.

The query that detected this pattern is intentionally rough — “a bit hacky and not quite production grade,” in the team’s words — but sufficient for the one-time VA effort.

/**
 * @name wip array query
 * @description an array is passed to an AR finder object
 */

import ruby
import codeql.ruby.AST
import codeql.ruby.ApiGraphs
import codeql.ruby.frameworks.Rails
import codeql.ruby.frameworks.ActiveRecord
import codeql.ruby.frameworks.ActionController
import codeql.ruby.DataFlow
import codeql.ruby.Frameworks
import codeql.ruby.TaintTracking

// Gets the "final" receiver in a chain of method calls.
// For example, in `Foo.bar`, this would give the `Foo` access, and in
// `foo.bar.baz("arg")` it would give the `foo` variable access
private Expr getUltimateReceiver(MethodCall call) {
  exists(Expr recv |
    recv = call.getReceiver() and
    (
      result = getUltimateReceiver(recv)
      or
      not recv instanceof MethodCall and result = recv
    )
  )
}

// Names of class methods on ActiveRecord models that may return one or more
// instances of that model. This also includes the `initialize` method.
// See https://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html
private string staticFinderMethodName() {
  exists(string baseName |
    baseName = ["find_by", "find_or_create_by", "find_or_initialize_by", "where"] and
    result = baseName + ["", "!"]
  )
  // or
  // result = ["new", "create"]
}

private class ActiveRecordModelFinderCall extends ActiveRecordModelInstantiation, DataFlow::CallNode
{
  private ActiveRecordModelClass cls;

  ActiveRecordModelFinderCall() {
    exists(MethodCall call, Expr recv |
      call = this.asExpr().getExpr() and
      recv = getUltimateReceiver(call) and
      (
        // The receiver refers to an `ActiveRecordModelClass` by name
        recv.(ConstantReadAccess).getAQualifiedName() = cls.getAQualifiedName()
        or
        // The receiver is self, and the call is within a singleton method of
        // the `ActiveRecordModelClass`
        recv instanceof SelfVariableAccess and
        exists(SingletonMethod callScope |
          callScope = call.getCfgScope() and
          callScope = cls.getAMethod()
        )
      ) and
      (
        call.getMethodName() = staticFinderMethodName()
        or
        // dynamically generated finder methods
        call.getMethodName().indexOf("find_by_") = 0
      )
    )
  }

  final override ActiveRecordModelClass getClass() { result = cls }
}

class FinderCallArgument extends DataFlow::Node {
  private ActiveRecordModelFinderCall finderCallNode;

  FinderCallArgument() { this = finderCallNode.getArgument(_) }
}

class ParamsHashReference extends DataFlow::CallNode {
  private Rails::ParamsCall params;

  // TODO: only direct element references against `params` calls are considered
  ParamsHashReference() { this.getReceiver().asExpr().getExpr() = params }

  string getArgString() {
    result = this.getArgument(0).asExpr().getConstantValue().getStringlikeValue()
  }
}

class ArrayPassedToActiveRecordFinder extends TaintTracking::Configuration {
  ArrayPassedToActiveRecordFinder() { this = "ArrayPassedToActiveRecordFinder" }

  override predicate isSource(DataFlow::Node source) { source instanceof ParamsHashReference }

  override predicate isSink(DataFlow::Node sink) {
    sink instanceof FinderCallArgument
  }

  string getParamsArg(DataFlow::CallNode paramsCall) {
    result = paramsCall.getArgument(0).asExpr().getConstantValue().getStringlikeValue()
  }

  // this doesn't check for anything fancy like whether it's reuse in a if/else
  // only intended for quick manual audit filtering of interesting candidates
  // so remains fairly broad to not induce false negatives
  predicate paramsUsedAfterLookups(DataFlow::Node source) {
    exists(DataFlow::CallNode y | y instanceof ParamsHashReference
    and source.getEnclosingMethod() = y.getEnclosingMethod()
    and source != y
    and getParamsArg(source) = getParamsArg(y)
    // we only care if it's used again AFTER an object lookup
    and y.getLocation().getStartLine() > source.getLocation().getStartLine())
  }
}

from ArrayPassedToActiveRecordFinder config, DataFlow::Node source, DataFlow::Node sink
where config.hasFlow(source, sink) and config.paramsUsedAfterLookups(source)
select source, sink.getLocation()

Getting started with custom queries

The engineering team recommends several resources for developers who want to write custom CodeQL queries. The “CodeQL zero to hero” blog series covers static analysis fundamentals for vulnerability research. The official CodeQL documentation on writing queries provides reference material, and the CodeQL extension for Visual Studio Code offers a practical environment for development and testing. Workshop materials from GitHub Universe and from GitHub Satellite 2020 include hands-on examples in Java and JavaScript, and the GitHub ReadME project has a beginner’s guide to running and managing custom queries.

The broader takeaway is that CodeQL works in two complementary modes for a product security team. Queries running in CI as part of a query pack provide steady, automated detection and communicate findings to engineers at the point where they’re writing code. One-off MRVA queries serve incident response and deepest cleanups.

CodeQL also serves more than vulnerability hunting. Since security controls like authorization checks are themselves code, CodeQL can verify their presence or absence just as easily as it detects dangerous API calls. That dual capability — catching vulnerability classes while validating that required controls exist — automates a meaningful slice of what would otherwise be manual security review, surfacing problems early in development and letting both engineers and the security team work faster.