В этой статье пойдёт речь о том, как команда Product Security Engineering в GitHub управляет внедрением CodeQL в масштабах всей компании — и как вы можете сделать то же самое.
Команда Product Security Engineering GitHub пишет код и внедряет инструменты, которые помогают обеспечивать безопасность кода, лежащего в основе GitHub. Мы используем GitHub Advanced Security (GHAS), чтобы выявлять, отслеживать и устранять уязвимости, а также чтобы обеспечивать соблюдение стандартов безопасного кодирования в масштабе всей компании. Одним из ключевых инструментов для масштабного анализа кода у нас является CodeQL.
CodeQL — это движок статического анализа от GitHub, который выполняет автоматический анализ безопасности. Его можно использовать для выполнения запросов к коду так же, как выполняются запросы к базе данных. Это гораздо более мощный способ анализа кода и выявления проблем по сравнению с традиционным текстовым поиском по кодовой базе.
В этой статье я подробно расскажу, как мы используем CodeQL для обеспечения безопасности GitHub и как вы можете применить эти уроки в своём проекте. Вы узнаете, почему и как мы используем:
Пользовательские пакеты запросов (и как мы их создаём и управляем ими).
Пользовательские запросы.Вариантный анализ для выявления потенциально небезопасных практик программирования.
Масштабирование CodeQL
Мы в GitHub применяем CodeQL различными способами.
Стандартная настройка с использованием стандартных и расширенных пакетов запросов по безопасности.
Стандартная настройка с использованием стандартных и расширенных пакетов запросов по безопасности покрывает потребности подавляющего большинства из более чем 10 000 наших репозиториев. При таких настройках пулл‑реквесты автоматически проходят проверку безопасности через CodeQL.Расширенная настройка с использованием пользовательского пакета запросов.
Некоторым репозиториям, таким как наш крупный Ruby‑монолит, требуется особое внимание, поэтому мы используем расширенную настройку с подключением пакета пользовательских запросов, чтобы максимально адаптировать анализ под наши потребности.Вариантный анализ для нескольких репозиториев (MRVA).
Для проведения вариантного анализа и быстрой аудиторской проверки мы используем MRVA. Мы также пишем собственные запросы на CodeQL для выявления паттернов в коде, которые либо характерны для кодовой базы GitHub, либо требуют ручной проверки со стороны инженера по безопасности.
Конкретный шаг пользовательского рабочего процесса Actions, который мы используем для нашего монолита, достаточно прост. Он выглядит так:
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/${{ matrix.language }}/codeql-config.yml
Наша конфигурация для Ruby — стандартная, но расширенная настройка позволяет использовать разнообразные опции через пользовательские конфигурационные файлы. Самое интересное здесь — это параметр packs
, с помощью которого мы подключаем наш пользовательский пакет запросов как часть анализа CodeQL. Этот пакет содержит набор запросов CodeQL, которые мы написали для Ruby, специально для кодовой базы GitHub.
Давайте подробнее разберёмся, зачем мы это сделали — и как!
Публикация пакета запросов CodeQL
Изначально мы размещали файлы запросов CodeQL напрямую в репозитории монолита GitHub, но позже отказались от такого подхода по нескольким причинам:
Для каждого нового или обновлённого запроса нужно было проходить процесс продакшен‑развёртывания.
Запросы, не включённые в пакет, не компилировались заранее, что замедляло выполнение анализа CodeQL в CI.
Наш набор тестов для запросов CodeQL выполнялся в рамках CI‑джоб монолита. При выходе новой версии CodeQL CLI иногда происходили сбои тестов запросов из‑за изменений в результатах выполнения запроса, даже если код в пулл‑реквестe не менялся. Это часто вызывало путаницу и раздражение у инженеров, так как ошибка не была связана с вносимыми изменениями.
Перейдя к публикации пакета запросов в GitHub Container Registry (GCR), мы упростили процесс и устранили многие из этих проблем, что упростило разработку и сопровождение запросов CodeQL. Поэтому, хотя всё ещё возможно размещать свои пользовательские запросы напрямую в репозитории, мы рекомендуем публиковать запросы в виде пакета в GCR для упрощения развёртывания и ускорения итераций.
Создание пакета запросов
При создании пользовательского пакета запросов нам пришлось учесть несколько важных моментов, особенно по части управления зависимостями, такими как пакет ruby‑all
.
Чтобы пользовательские запросы оставались поддерживаемыми и лаконичными, мы расширяем классы из стандартного набора запросов, например из библиотеки ruby‑all
. Это позволяет нам использовать уже существующую функциональность, а не изобретать велосипед заново, сохраняя запросы компактными и удобными для сопровождения. Однако изменения в API библиотек CodeQL могут вносить несовместимые изменения, что потенциально может приводить к устареванию запросов или ошибкам. Поскольку CodeQL запускается в составе системы CI, мы стремились минимизировать вероятность таких сбоев, так как они могут вызывать раздражение и потерю доверия со стороны разработчиков.
Мы разрабатываем запросы на основе самой актуальной версии пакета ruby‑all
, чтобы всегда использовать актуальную функциональность. Чтобы снизить риск того, что изменения в зависимостях негативно повлияют на CI, перед выпуском мы закрепляем версию ruby‑all
в файле codeql‑pack.lock.yml
. Это гарантирует, что при развёртывании запросы будут работать с протестированной версией ruby‑all
, избегая возможных проблем из‑за непреднамеренных обновлений.
Вот как мы управляем этой настройкой:
В файле
qlpack.yml
мы указываем зависимость на последнюю версиюruby‑all
.Во время разработки эта конфигурация подтягивает последнюю версию
ruby‑all
при выполнении командыcodeql pack init
, что позволяет нам оставаться на актуальной версии.
// Файл 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"
Перед выпуском фиксируем версию в файле codeql‑pack.lock.yml
, указывая точную версию для обеспечения стабильности и предотвращения проблем в CI.
// Файл codeql-pack.lock.yml для нашего пользовательского набора запросов
lockVersion: 1.0.0
dependencies:
...
codeql/ruby-all:
version: 1.0.6
Такой подход позволяет находить баланс между разработкой с использованием новейших возможностей ruby‑all
и обеспечением стабильности на этапе выпуска.
У нас также есть набор юнит‑тестов CodeQL, которые проверяют запросы на примерах кода. Это позволяет быстро определить, вызовет ли какой‑либо запрос ошибку ещё до публикации пакета. Эти тесты запускаются как часть CI‑процессов в репозитории пакета запросов, давая возможность рано ловить ошибки. Мы настоятельно рекомендуем писать юнит‑тесты для пользовательских запросов CodeQL, чтобы обеспечить их стабильность и надёжность.
В целом базовый процесс выпуска новых запросов CodeQL через пакет выглядит так:
Открыть пулл‑реквест с новым запросом.
Написать юнит‑тесты для нового запроса.
Смёржить пулл‑реквест.
Увеличить версию пакета в новом пулл‑реквестe.
Выполнить
codeql pack init
для разрешения зависимостей.При необходимости скорректировать юнит‑тесты.
Опубликовать пакет запросов в GitHub Container Registry (GCR).Репозитории, использующие этот пакет в своей конфигурации, начнут применять обновлённые запросы.
Мы убедились, что этот процесс обеспечивает хороший баланс между удобством разработки для нашей команды и стабильностью выпущенного пакета запросов.
Настройка репозитория для использования пользовательского пакета запросов
Мы не будем давать универсальные рекомендации по настройке, так как в конечном итоге это зависит от того, как развёртывание кода происходит именно в вашей организации. Мы решили не закреплять конкретную версию пакета в конфигурационном файле CodeQL (см. выше). Вместо этого мы управляем версионностью, публикуя пакет CodeQL в GCR. Это позволяет монолиту GitHub автоматически получать последнюю опубликованную версию пакета запросов. Чтобы откатить изменения, достаточно просто опубликовать новую версию пакета. Например, в одном случае мы выпустили запрос с большим количеством ложных срабатываний и смогли опубликовать новую версию пакета, в которой этот запрос был удалён менее чем за 15 минут. Это быстрее, чем потребовалось бы времени на создание и мёрдж пулл‑реквестa для отката версии в конфигурационном файле CodeQL.
Одна из проблем, с которой мы столкнулись при публикации пакета запросов в GCR, заключалась в том, чтобы найти способ удобно предоставить доступ к пакету для множества репозиториев в пределах нашей компании. Мы рассмотрели несколько вариантов:
Предоставить права доступа для отдельных репозиториев. На странице управления пакетами можно вручную назначить права для каждого репозитория. Однако при нашем количестве репозиториев этот способ оказался непрактичным, к тому же на данный момент нет возможности автоматизировать этот процесс через API.
Выпустить персональный токен доступа для раннера CodeQL Action. Мы могли бы создать персональный токен доступа (PAT), который имел бы права на чтение всех пакетов в нашей организации, и добавить его в раннер. Однако это потребовало бы управления новым токеном, и уровень доступа казался нам излишне широким, поскольку он позволял бы читать все наши приватные пакеты, а не только те, к которым требуется доступ.
Предоставить права доступа через привязанный репозиторий. В итоге мы реализовали третий вариант. Мы привязываем репозиторий к пакету и разрешаем пакету наследовать права доступа от привязанного репозитория.
Пользовательские запросы в пакете CodeQL
Мы пишем различные пользовательские запросы для использования в наших кастомных пакетах. Они охватывают специфичные для GitHub паттерны, которые не входят в стандартный пакет запросов CodeQL. Это позволяет нам адаптировать анализ под особенности нашей компании и кодовой базы. Примеры типов проверок, которые мы выполняем с помощью нашего пользовательского пакета запросов:
Использование API с высоким уровнем риска, специфичных для кода GitHub, которые могут быть опасными при получении неочищенных данных от пользователя.
Использование определённых встроенных методов Rails, для которых у нас есть более безопасные собственные аналоги.
Отсутствие обязательных методов авторизации в определениях конечных точек REST API и определениях объектов/мутаций GraphQL.
Конечные точки REST API и мутации GraphQL, где требуется явно определить методы контроля доступа для проверки прав пользователей. (Конкретно: запрос выявляет отсутствие таких методов, чтобы удостовериться, что права доступа к конечным точкам проверяются.)
Использование подписанных токенов, чтобы напомнить разработчикам привлекать Product Security в качестве ревьюеров при их использовании.
Пользовательские запросы могут использоваться не только для блокирования выпуска кода, но и в образовательных целях. Например, мы хотим уведомлять инженеров о применении метода ActiveRecord::decrypt. Этот метод, как правило, не следует использовать в продакшен‑коде, так как он приводит к расшифровке зашифрованных колонок и их сохранению в незашифрованном виде. В метаданных такого запроса мы используем уровень важности «рекомендация», поэтому такие предупреждения являются информационными. Это значит, что они могут отобразиться в пулл‑реквестe, но не приведут к сбою выполнения CI‑джобы CodeQL.
Использование более низкого уровня важности позволяет инженерам самостоятельно оценивать влияние новых запросов без немедленного вмешательства. Кроме того, такие предупреждения не отслеживаются в рамках нашей программы Fundamentals, что означает, что они не требуют срочного устранения, отражая степень зрелости запроса по мере дальнейшего уточнения его актуальности и оценки риска.
/**
* @id rb/github/use-of-activerecord-decrypt
* @description Не используйте метод `.decrypt` у моделей ActiveRecord. Это приведёт к расшифровке всех зашифрованных атрибутов
* и их сохранению в незашифрованном виде, что по сути отменяет шифрование и может сделать данные недоступными.
* Если нужно получить незашифрованное значение конкретного атрибута, используйте вызов my_model.attribute_name.
* @kind проблема
* @severity рекомендация
* @name Использование метода decrypt в ActiveRecord
* @tags безопасность
* внутреннее-использование-github
*/
import ruby
import DataFlow
import codeql.ruby.DataFlow
import codeql.ruby.frameworks.ActiveRecord
/** Находит вызовы метода `.decrypt`, где получателем может быть объект ActiveRecord */
class ActiveRecordDecryptMethodCall extends ActiveRecordInstanceMethodCall {
ActiveRecordDecryptMethodCall() { this.getMethodName() = "decrypt" }
}
from ActiveRecordDecryptMethodCall call
select call,
"Не используйте метод `.decrypt` у моделей ActiveRecord — это расшифрует все зашифрованные атрибуты и сохранит их в незашифрованном виде."
Другой образовательный запрос — это упомянутый выше запрос, с помощью которого мы выявляем отсутствие метода control_access
в классе, определяющем конечную точку REST API. Если пулл‑реквест добавляет новую конечную точку без control_access
, в нём автоматически появляется комментарий о том, что метод control_access
не найден, а его наличие является обязательным для конечных точек REST API. Это уведомляет ревьюера о потенциальной проблеме и напоминает разработчику её исправить.
/**
* @id rb/github/api-control-access
* @name REST API без 'control_access'
* @description Все REST API эндпоинты должны вызывать метод 'control_access', чтобы гарантировать доступ только для указанных типов акторов.
* @kind проблема
* @tags безопасность
* внутреннее-использование-github
* @precision высокая
* @problem.severity рекомендация
*/
import codeql.ruby.AST
import codeql.ruby.DataFlow
import codeql.ruby.TaintTracking
import codeql.ruby.ApiGraphs
// REST API эндпоинты в модуле Api::App, как правило, должны вызывать метод control_access
private DataFlow::ModuleNode appModule() {
result = API::getTopLevelMember("Api").getMember("App").getADescendentModule() and
not result = protectedApiModule() and
not result = staffAppApiModule()
}
// REST API эндпоинтам в модулях Api::Admin, Api::Staff, Api::Internal и Api::ThirdParty не обязательно вызывать метод control_access
private DataFlow::ModuleNode protectedApiModule() {
result =
API::getTopLevelMember(["Api"])
.getMember(["Admin", "Staff", "Internal", "ThirdParty"])
.getADescendentModule()
}
// REST API эндпоинтам в модуле Api::Staff::App также не обязательно вызывать метод control_access
private DataFlow::ModuleNode staffAppApiModule() {
result =
API::getTopLevelMember(["Api"]).getMember("Staff").getMember("App").getADescendentModule()
}
// Класс, описывающий REST API маршрут, в котором не был вызван метод control_access
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())
}
// Метод control_access вызывается где-то внутри переданного блока
predicate accessControlCalled(Block block) {
block.getAStmt().getAChild*().(MethodCall).getMethodName() = "control_access"
}
// Запрос возвращает те REST API эндпоинты, в которых не был вызван метод control_access
from ApiRouteWithoutControlAccess api
select api.getLocation(),
"Метод control_access не был обнаружен в этом REST API эндпоинте. Все такие эндпоинты должны вызывать этот метод, чтобы гарантировать доступ только для определённых типов акторов."
Вариантный анализ
Вариантный анализ (Variant Analysis, VA) — это процесс поиска вариантов известных уязвимостей. Этот метод особенно полезен, когда мы реагируем на сообщение о баг‑баунти или на инцидент безопасности. Для этого мы используем комбинацию инструментов, включая функцию поиска по коду в GitHub, собственные скрипты и CodeQL. Обычно мы начинаем с поиска по коду, чтобы найти паттерны, похожие на тот, который вызвал конкретную уязвимость, в различных репозиториях. Однако этого иногда недостаточно, поскольку поиск по коду семантически нечувствителен — он не может определить, является ли переменная объектом Active Record или используется ли она в выражении if
. Чтобы ответить на такие вопросы, мы используем CodeQL.
Когда мы пишем запросы CodeQL для вариантного анализа, нас гораздо меньше волнует количество ложных срабатываний, поскольку основная цель — предоставить результаты для последующего анализа инженерами по безопасности. Качество кода также не так критично, поскольку запросы используются только в рамках конкретного VA‑мероприятия. Вот некоторые примеры задач, для которых мы используем CodeQL в процессе VA:
Где в коде используются хеши SHA1?
Согласно недавнему отчёту о баг‑баунти, одна из наших внутренних конечных точек API была уязвима к SQL‑инъекции. Где мы передаём пользовательский ввод в эту конечную точку?
В некоторых библиотеках HTTP‑запросов для Ruby есть проблема с обработкой настройки прокси. Можно ли найти места, где мы создаём экземпляры таких библиотек с установкой прокси?
Недавно мы исследовали одну тонкую уязвимость в Rails. Нам нужно было обнаружить следующее условие в коде:
Параметр используется для поиска объекта Active Record.
Этот параметр затем повторно используется после поиска объекта Active Record.
Опасность здесь в том, что это может привести к уязвимости типа IDOR (Insecure Direct Object Reference), поскольку методы поиска в Active Record могут принимать массив. Если код сначала ищет объект Active Record для проверки доступа к ресурсу, а затем повторно использует другой элемент из этого массива для поиска объекта, это может привести к IDOR‑уязвимости. Написать запрос, который бы надёжно выявлял все уязвимые случаи такого паттерна, было бы сложно, но мы смогли создать запрос, который находил потенциальные уязвимости и выдавал список кодовых путей для ручного анализа. Мы запускали этот запрос на большом количестве наших Ruby‑кодовых баз, используя MRVA в CodeQL.
Сам запрос, который получился довольно грубым и не дотягивает до продакшен‑качества, приведён ниже:
/**
* @name черновик запроса: массив, переданный в AR
* @description массив передаётся в объект поиска ActiveRecord
*/
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
// Получает "финального" получателя в цепочке вызовов методов.
// Например, в `Foo.bar` это будет доступ к `Foo`, а в
// `foo.bar.baz("arg")` — переменная `foo`
private Expr getUltimateReceiver(MethodCall call) {
exists(Expr recv |
recv = call.getReceiver() and
(
result = getUltimateReceiver(recv)
or
not recv instanceof MethodCall and result = recv
)
)
}
// Имена методов классов моделей ActiveRecord, которые могут возвращать
// один или несколько экземпляров модели. Также включает метод `initialize`.
// См. 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 + ["", "!"]
)
// или:
// result = ["new", "create"]
}
// Вызов метода поиска экземпляра модели ActiveRecord
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
(
// Получатель — это ссылка на класс модели ActiveRecord по имени
recv.(ConstantReadAccess).getAQualifiedName() = cls.getAQualifiedName()
or
// Получатель — self, и вызов происходит внутри метода-синглтона
// модели ActiveRecord
recv instanceof SelfVariableAccess and
exists(SingletonMethod callScope |
callScope = call.getCfgScope() and
callScope = cls.getAMethod()
)
) and
(
call.getMethodName() = staticFinderMethodName()
or
// динамически сгенерированные методы поиска
call.getMethodName().indexOf("find_by_") = 0
)
)
}
final override ActiveRecordModelClass getClass() { result = cls }
}
// Аргумент, передаваемый в метод поиска модели
class FinderCallArgument extends DataFlow::Node {
private ActiveRecordModelFinderCall finderCallNode;
FinderCallArgument() { this = finderCallNode.getArgument(_) }
}
// Ссылка на значение из params-хеша
class ParamsHashReference extends DataFlow::CallNode {
private Rails::ParamsCall params;
// TODO: пока учитываются только прямые обращения к `params`
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()
}
// Не проверяет сложные случаи (например, if/else),
// Предназначено для быстрой ручной фильтрации интересных кейсов,
// поэтому работает максимально обобщённо, чтобы не упустить случаи
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)
// нас интересуют только повторные использования после поиска объекта
and y.getLocation().getStartLine() > source.getLocation().getStartLine())
}
}
// Выборка источников и приёмников, где есть поток данных от params в вызов поиска модели,
// и повторное использование тех же параметров после поиска
from ArrayPassedToActiveRecordFinder config, DataFlow::Node source, DataFlow::Node sink
where config.hasFlow(source, sink) and config.paramsUsedAfterLookups(source)
select source, sink.getLocation()
Заключение
CodeQL может быть крайне полезным для команд продуктовой безопасности, чтобы выявлять и предотвращать уязвимости в масштабах всей кодовой базы. Мы используем комбинацию запросов, которые запускаются в CI через пакет запросов, а также разовых запросов, выполняемых с помощью MRVA, чтобы находить потенциальные уязвимости и передавать информацию о них инженерам. Однако CodeQL полезен не только для поиска уязвимостей безопасности — он также помогает выявлять наличие или отсутствие мер безопасности, определённых в коде. Это экономит время нашей команды безопасности, автоматически выявляя некоторые проблемы, а также время инженеров, позволяя обнаруживать их на более ранних этапах разработки.
Если вам близка тема безопасности кода, вы работаете с инфраструктурой, занимаетесь DevOps или просто хотите лучше ориентироваться в границах зон ответственности внутри ИБ — в ближайшие недели в Otus пройдут несколько технически насыщенных и содержательных открытых урока. Записывайтесь по ссылкам ниже:
7 мая в 20:00 — DevSecOps, AppSec, Pentest: где начинается одно и заканчивается другое
Поможет разобраться в реальных границах между направлениями безопасности, понять, зачем их столько, и какие задачи они решают в современном IT12 мая в 20:00 — Безопасность в разработке dApps: уязвимости и защита
Подробно о типичных атаках на смарт-контракты, уязвимостях Solidity и практических способах защитить децентрализованные приложения22 мая в 20:00 — Киберпреступление и наказание: ответственность за инциденты в сфере КИИ
Правовой взгляд на ИБ: типы нарушений, примеры судебной практики, тренды в законах и риски для бизнеса и IT-команд.
Полный список открытых уроков по программированию и разработке смотрите в календаре.