Всем привет, меня зовут Денис Лимарев, я разработчик платежной системы Delivery Club. И сегодня я расскажу, как мне надоели однообразные ошибки и собственная невнимательность, и как я с этим борюсь. Недавно я написал статью о нашем линтере, где вскользь затрагивал возможность написания локальных проверок под конкретный проект. Сегодня раскрою эту тему подробнее и опишу приемы, упрощающие проверку кода мне и коллегам. А в конце статьи расскажу, как можно автоматизировать некоторые проверки ИБ из нашей недавней статьи, поделюсь дальнейшими планами по развитию и оставлю ссылку на доклад автора go-ruleguard (далее ruleguard).
Анализ синтаксиса здесь и сейчас
Во-первых, давайте ответим на вопрос «Зачем это нужно?». Как правило, в каждой компании, команде или даже проекте складываются свои требования, которые стоит учитывать, и зачастую о них знают далеко не все, особенно если это проект чужой команды. А их нужно учитывать при проверке новых изменений. Мы все люди, и рано или поздно ошибаемся, поэтому хотелось бы такие проверки отдать машине. Да и мало у кого есть желание писать одни и те же замечания из PR в PR. Отсюда возникает потребность в инструменте, с помощью которого можно было бы быстро проверить проект и сообщить о нарушении требований. Тут вам на помощь придет ruleguard. Правила для него можно написать быстро, для этого не требуется знать go/ast и алгоритмы его обхода, что упрощает создание и поддержку таких проверок. Также ruleguard поддерживают golangci-lint, go-critic, dcRules (далее линтеры), что упрощает интеграцию в CI/CD и другие этапы проверок.
Но об этом я уже писал в прошлой статье, а сегодня расскажу про неочевидные приемы написания правил.
Необходимый минимум, чтобы писать эффективные ruleguard-правила:
знать Go-синтаксис;
знать виды переменных ruleguard;
немного хитрости;
желание не писать одни и те же комментарии к PR.
Краткий обзор видов переменных ruleguard:
$_
— захватываем обращение, не сохраняя имя;$arg
— захватываем обращение с сохранением в переменнуюarg
;$*_
— захватываем всю область, например, параметры через запятую или кусок кода в несколько строк, не сохраняя;$*args
— захватываем всю область с сохранением в переменнуюargs
.
Краткое описание методов, используемых в правилах ruleguard:
Match
: здесь мы описываем шаблон правила, который будет искать фрагменты кода. Метод обязателен к использованию в правиле.Where
: после нахождения необходимых фрагментов кода метод позволяет отфильтровать ненужные участки по внутренней информации (см. следующий абзац). Метод необязателен.Report
: используется для вывода сообщения об ошибке. Метод необязателен только в случае использованияSuggest
, в остальных случаях обязателен.Suggest
: используется для автозамены кода (см. третий прием ниже). Метод необязателен.At
: ограничивает область примененияSuggest
иReport
конкретным выражением (см. четвертый прием ниже). Метод необязателен.
Захваченные фрагменты кода: что мы о них знаем?
При поиске необходимых шаблонов кода вы, возможно, выделите какие-то фрагменты, например, переменные, аргументы, названия функций или методов, участки кода после каких-то выражений. Если искомые фрагменты были выделены в именованные переменные, то ruleguard хранит о них такую информацию:
Текстовое представление.
Типы
user-defined
иunderlying
, например:type YourType int
, гдеYourType
—user-defined
, аint
—underlying
.Область видимости: сейчас вы можете узнать, принадлежит ли выделенный фрагмент к глобальной области видимости пакета.
Адресуемый ли это тип.
Номер строки в коде.
Размер структуры.
И многое другое: приводим ли тип к другому типу, реализует ли он интерфейс, название файла c фрагментом кода, импортирует ли пакет в библиотеку и т.д.
Теперь, когда у нас есть вся необходимая вводная информация, мы готовы разобрать приемы написания правил. К каждому описанному ниже правилу я дам пояснения в тексте или комментариях к коду.
Прием первый: есть ли в Go точка с запятой в конце выражения?
m.Match(`time.Now();`)
Здесь захватывается вызов функции пакета time
. Мы четко обозначаем тот факт, что после вызова должно идти следующее выражение, поэтому отбрасываем варианты, при которых результат функции передается в качестве аргумента. Так происходит потому, что Go при сборке кода на этапе парсинга файлов самостоятельно подставляет точку с запятой в конец выражения. Правила ruleguard (да и многих других линтеров) применяются к результатам работы Go-парсера. Таким образом, можно отбросить вложенные выражения или обозначить их последовательность.
Прием второй: как проверить только определенные методы пакета?
m.Match(`time.Now()`, `time.Since($_)`)
Здесь мы ищем вызовы пакета time
, а именно Now
и Since
. При этом мы не обращаем внимание на передаваемые методу Since
аргументы.
Прием третий: автоисправление кода
Иногда для исправления ошибки достаточно тривиального изменения кода. Например, подставить один символ или заменить вызов одной функции на другую. В таких случаях вы можете захотеть добавить автоисправление кода линтером, и ruleguard это поддерживает. Чтобы сделать автоисправление, нужно добавить в правило метод Suggest
:
func sprintfConcat(m dsl.Matcher) {
m.Match(`fmt.Sprintf("%s.%s", $x, $y)`).
Where(m["x"].Type.Is(`string`) && m["y"].Type.Is(`string`)).
Suggest(`$x + "." + $y`)
}
Здесь выбираются все вызовы fmt.Sprintf
, в которых обе переменные являются строкой, а шаблон форматирования — это конкатенация двух строк через точку.
Примечание: для корректной работы Suggest
необходимо именовать все захваченные фрагменты кода, к которым будет применено автоисправление; в нашем примере это: $x
, $y
.
Прием четвертый (пункт первый): что делать, если не уверен в правиле?
Если нет полной уверенности в правиле или хочется защитить его корректность от дальнейших изменений, то можно написать тест. Для этого вам понадобится написать обычный Go-тест:
import (
"testing"
"github.com/quasilyte/go-ruleguard/analyzer"
"golang.org/x/tools/go/analysis/analysistest"
)
func TestRules(t *testing.T) {
testdata := analysistest.TestData()
err := analyzer.Analyzer.Flags.Set("rules", "rules.go")
if err != nil {
t.Fatalf("set rules flag: %v", err)
}
analysistest.Run(t, testdata, analyzer.Analyzer, "./...")
}
После чего напишите тестовый код, на котором возможны или невозможны срабатывания ваших правил, положите его рядом с файлом теста в папку testdata и убедитесь в корректности работы правил.
Содержание правила в rules.go
:
func strconv(m dsl.Matcher) {
// Здесь мы ищем все вызовы fmt.Sprint/Sprintf, которые могут быть заменены на strconv.Itoa.
m.Match(`fmt.Sprintf("%d", $x)`, `fmt.Sprintf("%v", $x)`, `fmt.Sprint($x)`).
Where(m["x"].Type.Is(`int`)).
Suggest(`strconv.Itoa($x)`)
}
Тестовый код в positive.go
:
func Warn() {
var i int
_ = fmt.Sprintf("%v", i) // want `suggestion: strconv.Itoa(i)`
println(fmt.Sprintf("%s", i))// want `suggestion: strconv.Itoa(i)`
}
При обнаружении подходящих фрагментов кода ruleguard выдаст предупреждение, которое будет прочитано библиотекой go/analysis. Чтобы отметить участок тестового кода, для которого необходимо вывести предупреждение, справа от искомой строки напишите служебный комментарий формата // want
: "test warning"
, где "test warning"
— текст предупреждения. Полный рабочий пример теста и тестовых данных можно посмотреть здесь.
Примечание: текст предупреждения может быть описан в косых или двойных кавычках.
Прием четвертый (пункт второй): что делать, если не уверен в автоисправлении?
Напомню, что помимо обнаружения шаблонов кода линтеры могут делать автоисправление, и, к счастью, его тоже можно проверить. Для этого необходимо выполнить шаги из приема выше. Рядом с тестируемым кодом добавьте копию с исправлениями в отдельном файле c суффиксом .golden
.
Содержание правила в rules.go
:
func rangeExprCopy(m dsl.Matcher) {
// Здесь мы ищем все массивы, у которых размер элементов больше 256 байт.
m.Match(`for $_, $_ := range $x { $*_ }`,
`for $_, $_ = range $x { $*_ }`).
Where(m["x"].Type.Is("[$_]$_") && m["x"].Type.Size >= 256).
At(m["x"]).
Suggest(`&$x`)
}
Примечание: At
ограничивает функции Suggest
и Report
конкретным выражением из всего выбранного фрагмента кода. В данном случае это массив в $x
.
Тестовый код в positive.go
:
func warn() {
var xs [42]byte
for _, x := range xs { // want `suggestion: &xs`
println(x)
}
}
Эталонный код в positive.go.golden
:
func warn() {
var xs [42]byte
for _, x := range &xs {
println(x)
}
}
Примечание: эталонный код не должен содержать служебный комментарий, используемый линтером.
После этого добавьте новый тест, идентичный предыдущему, изменив только последнюю строку c analysistest.Run
на analysistest.RunWithSuggestedFixes
:
import (
"testing"
"github.com/quasilyte/go-ruleguard/analyzer"
"golang.org/x/tools/go/analysis/analysistest"
)
func TestRules(t *testing.T) {
testdata := analysistest.TestData()
err := analyzer.Analyzer.Flags.Set("rules", "rules.go")
if err != nil {
t.Fatalf("set rules flag: %v", err)
}
analysistest.RunWithSuggestedFixes(t, testdata, analyzer.Analyzer, "./...")
}
Полный рабочий пример теста и тестовых данных для автоисправления можно посмотреть здесь и здесь.
Прием пятый: захватываем все функции и свойства
Когда нам не важны аргументы или какая-то информация о функции, мы можем это отбросить с помощью $_
, например так:
m.Match(`for $_, $_ := range $_ { $*_; defer $_; $*_ }`)
Здесь мы ищем случаи, когда в цикле вызывается выражение defer
и нам не важно, в результате чего мы получаем данные для итерирования.
Прием шестой: мне не важны аргументы, кроме одного
Бывают ситуации, когда вам важен только один аргумент из списка переданных. Например, функция принимает тип variadic
, или заранее неизвестно, какое количество аргументов будет принимать функция, или вам просто лень всё перечислять, такое тоже бывает. В таком случае вы можете отбросить все аргументы или переменные и искать только нужный:
Захватываем все переменные с типом error
, которые создаются через присвоение :=
:
m.Match(`$*_, $err := $_;`).
Where(m["err"].Type.Is("error")
Захватываем все аргументы с типом map
, где нам не важен тип ключа и значения:
m.Match(`$_($*_, $map, $*_);`).
Where(m["map"].Type.Underlying.Is(`map[$_]$_`)
Примечание: если нам были бы необходимы хэш-таблицы (далее просто таблицы), имеющие в качестве ключа или значения только определенные типы, то выражение могло бы выглядеть иначе, например:
map[string]error
: будут отобраны только таблицы с данным типом;map[int]$_
: будут отобраны все таблицы с ключомint
и любым типом в качестве значения;map[$_]int
: будут отобраны таблицы с любым ключом и значением с типомint
.
Прием седьмой: ищем ошибки стиля именования
Так как ruleguard хранит текстовое представление фрагментов кода, то можно искать и проверять стиль именований, а также делать другие проверки стиля. Рассмотрим на примере проверки функций:
m.Match(
`func $x($*_) $*_ { $*_ }`, // выборка обычных функций
`func ($_) $x($*_) $*_ { $*_ }`, // выборка методов структур
`func ($_ $_) $x($*_) $*_ { $*_ }` // выборка методов структур с receiver
).
Where(!m["x"].Text.Matches(`^_$`) && (m["x"].Text.Matches(`-`) ||
m["x"].Text.Matches(`_`))). // находим все строки, содержащие подчеркивания или тире
Report("use camelCase naming strategy")
Прием восьмой: уменьшаем размер правил — пишем функции
Правила для ruleguard выглядят как код на Go, но им не являются. На самом деле они написаны на DSL, который интерпретируется библиотекой go-grep, поэтому они не обладают всеми возможностями Go. Тем не менее ruleguard поддерживает анонимные функции для упрощения и компактности правил. Рассмотрим на примере упрощения записи данных в интерфейс io.Writer
:
func writeBytes(m dsl.Matcher) {
isBuffer := func(v dsl.Var) bool {
return v.Type.Is(`bytes.Buffer`) || v.Type.Is(`*bytes.Buffer`)
}
m.Match(`io.WriteString($w, $buf.String())`).
Where(isBuffer(m["buf"])).
Suggest(`$w.Write($buf.Bytes())`)
m.Match(`io.WriteString($w, string($buf.Bytes()))`).
Where(isBuffer(m["buf"])).
Suggest(`$w.Write($buf.Bytes())`)
}
Здесь мы ищем все вызовы io.WriteString
, в которых тип переменной $buf
— bytes.Buffer
. Для уменьшения размера правила оборачиваем проверку типа в анонимную функцию. Также можно заметить автоисправление кода.
Прием девятый: подключаем сторонние бандлы с правилами
На данный момент есть много сторонних бандлов с правилами ruleguard, которые можно подключить в дополнение к своим (см. полезные ссылки ниже). Сделать это достаточно просто:
import (
"local/pkg/rules"
dcRules "github.com/delivery-club/delivery-club-rules"
"github.com/quasilyte/go-ruleguard/dsl"
)
func init() {
dsl.ImportRules("external-dc-rules", dcRules.Bundle)
// первый аргумент функции - префикс, который будет добавляться при импортировании // правил бандла, полезен, когда названия ваших правил пересекаются с названиями
// правил в стороннем бандле
dsl.ImportRules("local-dc-rules", rules.Bundle)
}
Файл с таким содержимым нужно выделить в отдельный пакет внутри проекта, далее абсолютный путь до файла можно использовать в линтерах. Пример проекта с использованием локальных и импортируемых бандлов.
Прием десятый: решаем проблему импортов сторонних библиотек
Описанная ниже ситуация возможна только при написании бандлов в отдельном репозитории. Ruleguard может импортировать стороннюю библиотеку, чтобы написать правила для нее. Для этого в правиле необходимо вызвать метод Import
, например:
import "github.com/quasilyte/go-ruleguard/dsl"
func queryWithoutContext(m dsl.Matcher) {
m.Import("github.com/jmoiron/sqlx")
m.Match(`$db.Queryx($*_)`).
Report(`don't send query to external storage without context`)
}
Здесь мы ищем вызовы методов sqlx
без контекста. Но если библиотеки github.com/jmoiron/sqlx в проверяемом проекте нет, то линтер упадет, так как не сможет ее подгрузить в рантайме. Для решения этой проблемы можно создать пакет с интерфейсами для сторонней библиотеки в проекте бандла, и после этого импортировать не стороннюю библиотеку, а интерфейсы для нее. Правило станет выглядеть так:
import (
"github.com/quasilyte/go-ruleguard/dsl"
_ "github.com/delivery-club/delivery-club-rules/pkg"
)
func queryWithoutContext(m dsl.Matcher) {
m.Import("github.com/delivery-club/delivery-club-rules/pkg") // пример расположения внутреннего пакета
m.Match(`$db.Queryx($*_)`)
Where(m["db"].Object.Is("Var") && m["db"].Type.Implements(`pkg.SQLDb`)).
Report(`don't send query to external storage without context`)
}
В последнем правиле делается безымянный Go-импорт пакета с интерфейсами для избежания проблем с Go vendoring. Затем вызывается импорт в правиле, чтобы можно было работать с пакетом.
Примечание: напомню, код правил не является кодом на Go, поэтому им нужен отдельный импорт.
В Where
мы проверяем, что найденные вызовы являются методами переменной, а переменная реализует необходимый интерфейс, где pkg.SQLDb
— интерфейс, который описывает методы библиотеки sqlx.
Прием одиннадцатый: ищем аргументы
m.Match(`fmt.Sprintf($*_)`).
Where(m["$$";].Node.Parent().Is(`ExprStmt`))
Здесь мы ищем все вызовы функции fmt.Sprintf
, вложенные в другие вызовы выражений. $$
обозначает предшествующее выражение, а проверка на соответствие ExprStmt
— факт того, что выражение принимает результат работы функции.
Прием двенадцатый: теги для правил
Правила ruleguard поддерживают разделение по тегам, что позволяет выполнять только нужные группы правил без их явного перечисления. Это полезно при работе с локальными для проекта правилами и при разработке наборов правил. С помощью тегов можно фильтровать экспериментальные, стилевые, медленные правила и так далее. Добавить теги легко, для этого перед объявлением правила необходимо описать комментарий в форме //doc:tags:
, без пробела после //
:
//doc:summary Detects Before call of time.Time that can be simplified.
//doc:tags style experimental
//doc:before !t.Before(tt)
//doc:after t.After(tt)
func timeComparisonSimplify(m dsl.Matcher) {
isTime := func(v dsl.Var) bool {
return v.Type.Is(`time.Time`) || v.Type.Is(`*time.Time`)
}
m.Match(`!$t.Before($tt)`).
Where(isTime(m["t"])).
Suggest("$t.After($tt)")
}
Здесь мы ищем все вызовы Before
типом time.Time
, перед которыми стоит отрицание.
Для фильтрации правил по тегам при выполнении через golangci-lint необходимо в конфигурации go-critic в блоке для ruleguard явно описать теги, которые должны быть включены или выключены. Это можно сделать в соответствующих полях enable
, disable
c знаком #
перед каждым названием тега.
Пример конфигурации .golangci.yml
:
linters-settings:
gocritic:
settings:
ruleguard:
enabled: '#diagnostic,#performance'
Фильтрация тегов была добавлена недавно и на текущий момент поддерживается только последними версиями go-critic (>=v0.6.3) и мастером golangci-lint — dcRules.
Примечание: помимо тегов есть и другие служебные комментарии: summary
, before
и after
. Основная их цель — объяснить смысл правила и суть изменений, к которым приведет его соблюдение.
Известные особенности, о которых стоит знать:
Не все Go-переменные имеют тип. Это связано с особенностью использования
_
— этот вид переменной не имеет типа. Поэтому когда вы проверяете переменную на принадлежность к типу или реализации интерфейса, правило может не сработать. Issue.Применение регулярных выражений к большим фрагментам кода может сильно замедлить проверку правил. Старайтесь по возможности добавлять дополнительные условия в
Where
перед вызовом регулярных выражений.Если вы импортируете пакет, которого нет в проекте, то линтер завершит выполнение с ошибкой. Issue.
Пока что нельзя проверить все переменные, константы или типы, если они записаны в сгруппированной форме. Issue.
Заключение
На данный момент ruleguard умеет многое, и библиотека продолжает развиваться. Недавно вышла новая версия go-critic (0.6.3), которая была уже добавлена в master golangci-lint; помимо новых проверок была также добавлена улучшенная функциональность ruleguard (~v0.3.16). Надеюсь, вам пригодится.
Тем временем силами @quasilyte и при поддержке open source-сообщества (в том числе при моем участие) дальше прорабатывается возможность написания более гибких правил, а именно:
Интерпретатор quasigo для возможности написания более широкого спектра условий.
Добавление локальных интерфейсов, чтобы уйти от необходимости писать отдельный пакет под интерфейсы, как это сделано в шестом приеме. Issue.
Обработка отсутствующих импортов в правилах для уменьшения случаев завершения выполнения с ошибкой. Issue.
Поддержка SSA для возможности написания более широкого спектра правил. Issue.
Добавление возможности блокировать конкретное правило, а не только весь линтер в golangci-lint. Issue.
Планы по развитию линтеров, в том числе из прошлой статьи, которые были реализованы к текущему дню:
Добавление возможности быстрого исправления кода через ruleguard: реализовано в golangci-lint 1.44.0 (PR).
Добавление возможности игнорирования конкретных ruleguard-правил в конфиге: реализовано в go-critic 0.6.2 (PR) и golangci-lint 1.44.0.
Добавлена возможность игнорировать правила по тегам ruleguard: реализовано в go-critic 0.6.3 (PR) и в мастере golangci-lint.
Помощь в тестировании и реализации submatch-выражений ruleguard (метод Contains): добавлено в go-critic 0.6.3 (PR), в golangci-lint обновление в мастере, ждем следующего релиза (>1.45.2).
Добавлена возможности компилировать линтер: реализовано в dcRules 0.7.0 (PR).
В ruleguard добавлена возможность проверять область видимости переменной (PR).
Мы окунулись в основы синтаксического анализа с помощью ruleguard, научились искать базовые шаблоны кода и писать под них тесты, рассмотрели виды переменных ruleguard и предпосылки для синтаксического анализа в вашем проекте. Надеюсь, описанные приемы помогут вам в поиске ошибок, стилевых неточностей, неоптимальных участков кода и многого другого.
Полезные ссылки
Реализация некоторых проверок ИБ из статьи коллег.
Послесловие
Смотрите запись митапа в VK Tech Talks за 14 апреля: Искандер (он же @quasilyte) выступил с докладом про Go-интерпретатор quasigo в ruleguard: если коротко, то интерпретатор позволит нам еще лучше оградить себя от случайных ошибок. Как небольшой спойлер, скажу, что сейчас на бенчмарках интерпретатор обгоняет такие популярные решения на Go, как yaegi и scriggo.