From 80bf9885327a4ab2a63804943b297d9b588d3978 Mon Sep 17 00:00:00 2001 From: DevOps-spb-ru Date: Tue, 22 Sep 2026 12:32:22 +0300 Subject: [PATCH 1/2] Fix: parse connection counts without narrowing int64 to int MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions, TerminateBackends и ListDatabases сужали значение из psql до int (int(parseInt64(...))): CodeQL отмечал go/incorrect-integer-conversion. Добавлен parseCount: мусор, отрицательное число и переполнение int трактуются как 0, а не обрезаются. Разбор закрыт табличным тестом TestParseCount и fuzz-целью FuzzParseCount. --- SQL backup restore clone/CHANGELOG.md | 5 ++- .../internal/pg/client.go | 16 +++++++-- .../internal/pg/client_test.go | 28 +++++++++++++++ .../internal/pg/databases.go | 4 +-- .../internal/pg/parse_fuzz_test.go | 36 ++++++++++++++++++- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/SQL backup restore clone/CHANGELOG.md b/SQL backup restore clone/CHANGELOG.md index 435b718..37735e1 100644 --- a/SQL backup restore clone/CHANGELOG.md +++ b/SQL backup restore clone/CHANGELOG.md @@ -10,7 +10,10 @@ - образ `sqlbrc` собирался без даты сборки: `release-image.yml` не передавал `--build-arg DATE`, поэтому `sqlbrc --version` из образа печатал «сборка unknown», и сверить контейнер с бинарём - из релиза было нечем. Дата подставляется так же, как в релизных бинарях. + из релиза было нечем. Дата подставляется так же, как в релизных бинарях; +- число активных подключений из вывода `psql` разбирается без сужения `int64` до `int`: значение + вне диапазона `int` трактуется как 0, а не обрезается (CodeQL `go/incorrect-integer-conversion`). + Обрезание дало бы бессмысленный счётчик в списке баз и в сообщениях о завершении подключений. ### Изменилось diff --git a/SQL backup restore clone/internal/pg/client.go b/SQL backup restore clone/internal/pg/client.go index d014d8f..b8d94cd 100644 --- a/SQL backup restore clone/internal/pg/client.go +++ b/SQL backup restore clone/internal/pg/client.go @@ -361,7 +361,7 @@ func (c *Client) ListDatabases(ctx context.Context) ([]Database, error) { SizeBytes: parseInt64(row.Field(6)), AllowConn: parseBool(row.Field(7)), IsTemplate: parseBool(row.Field(8)), - Connections: int(parseInt64(row.Field(9))), + Connections: parseCount(row.Field(9)), }) } return databases, nil @@ -396,7 +396,7 @@ func parseBool(value string) bool { } // parseInt64 переводит значение в число; мусор трактуется как 0, потому что -// размер БД и число подключений — справочные поля. +// размер БД — справочное поле. func parseInt64(value string) int64 { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { @@ -404,3 +404,15 @@ func parseInt64(value string) int64 { } return parsed } + +// parseCount переводит значение из psql в счётчик (число подключений, строк): +// мусор, отрицательное число и переполнение int трактуются как 0. Сужать int64 +// до int нельзя: значение приходит извне, и обрезание дало бы бессмысленный +// счётчик вместо отказа. +func parseCount(value string) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} diff --git a/SQL backup restore clone/internal/pg/client_test.go b/SQL backup restore clone/internal/pg/client_test.go index 9b0c213..e7a8037 100644 --- a/SQL backup restore clone/internal/pg/client_test.go +++ b/SQL backup restore clone/internal/pg/client_test.go @@ -257,3 +257,31 @@ func TestClientExecLogsStderr(t *testing.T) { t.Errorf("без stderr вывод не попал в журнал: %q", log.String()) } } + +// TestParseCount проверяет разбор счётчика из вывода psql: число приходит извне, +// поэтому значение вне диапазона int трактуется как 0, а не обрезается сужением +// int64 до int (CodeQL: go/incorrect-integer-conversion). +func TestParseCount(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {name: "ноль", input: "0", want: 0}, + {name: "подключения", input: "3", want: 3}, + {name: "пробелы вокруг числа", input: " 12 ", want: 12}, + {name: "отрицательное", input: "-1", want: 0}, + {name: "пустой ввод", input: "", want: 0}, + {name: "не число", input: "мусор", want: 0}, + {name: "дробное", input: "1e3", want: 0}, + {name: "переполнение int64", input: "99999999999999999999999", want: 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := parseCount(test.input); got != test.want { + t.Errorf("parseCount(%q) = %d, ожидалось %d", test.input, got, test.want) + } + }) + } +} diff --git a/SQL backup restore clone/internal/pg/databases.go b/SQL backup restore clone/internal/pg/databases.go index 2e32db6..3ea2cb6 100644 --- a/SQL backup restore clone/internal/pg/databases.go +++ b/SQL backup restore clone/internal/pg/databases.go @@ -80,7 +80,7 @@ func (c *Client) Sessions(ctx context.Context, database, pattern string) (int, e if len(rows) == 0 { return 0, nil } - return int(parseInt64(rows[0].Field(0))), nil + return parseCount(rows[0].Field(0)), nil } // TerminateBackends завершает активные подключения к базе и возвращает их число. @@ -97,7 +97,7 @@ func (c *Client) TerminateBackends(ctx context.Context, database, pattern string if len(rows) == 0 { return 0, nil } - return int(parseInt64(rows[0].Field(0))), nil + return parseCount(rows[0].Field(0)), nil } // CreateDatabase создаёт базу с заданными параметрами. diff --git a/SQL backup restore clone/internal/pg/parse_fuzz_test.go b/SQL backup restore clone/internal/pg/parse_fuzz_test.go index 58b3d8f..2fc9b2a 100644 --- a/SQL backup restore clone/internal/pg/parse_fuzz_test.go +++ b/SQL backup restore clone/internal/pg/parse_fuzz_test.go @@ -1,6 +1,10 @@ package pg -import "testing" +import ( + "strconv" + "strings" + "testing" +) // FuzzParsePgVersion проверяет разбор версий: строка приходит из вывода внешней // утилиты PostgreSQL, поэтому паник быть не должно ни на каком вводе. @@ -84,3 +88,33 @@ func FuzzParseRestoreList(f *testing.F) { } }) } + +// FuzzParseCount проверяет разбор счётчика из вывода psql: значение приходит извне, +// поэтому результат обязан быть неотрицательным, а корректное число — вернуться без +// искажений: сужение int64 до int скрыло бы переполнение и дало бессмысленный счётчик. +func FuzzParseCount(f *testing.F) { + seeds := []string{ + "", " ", "0", "1", "3", " 12 ", "-1", "мусор", "1e3", "0x10", + "2147483647", "2147483648", "9223372036854775807", "99999999999999999999999", + } + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, value string) { + count := parseCount(value) + if count < 0 { + t.Errorf("отрицательный счётчик %d для %q", count, value) + } + + expected, err := strconv.Atoi(strings.TrimSpace(value)) + switch { + case err != nil, expected < 0: + if count != 0 { + t.Errorf("некорректное значение %q дало %d, ожидался 0", value, count) + } + case count != expected: + t.Errorf("parseCount(%q) = %d, ожидалось %d", value, count, expected) + } + }) +} From 214f3c0f2f994be7cddf818a66f83a152fb12c86 Mon Sep 17 00:00:00 2001 From: DevOps-spb-ru Date: Tue, 22 Sep 2026 12:32:22 +0300 Subject: [PATCH 2/2] CI: keep Trivy SARIF reports to the gate severities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trivy-action в формате sarif снимает TRIVY_SEVERITY, пока не задан limit-severities-for-sarif, поэтому в GitHub Security попадали находки LOW/UNKNOWN, которых ворота не видят. Вход задан в обоих SARIF-шагах trivy-scan.yml, во fs-шаге добавлен ignore-unfixed: true. Политика отчётности уточнена в README.md, SECURITY.md и .trivyignore. --- .github/workflows/trivy-scan.yml | 11 +++++++++++ .trivyignore | 3 ++- README.md | 2 +- SECURITY.md | 3 ++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index 4ffbc1b..64b201a 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -3,6 +3,9 @@ name: Trivy scan # Сканирование образа, файловой системы и Dockerfile. # Отчёты загружаются в GitHub Security (SARIF), а находки уровня CRITICAL/HIGH # роняют прогон: это «ворота», а не просто отчёт. +# В SARIF попадают те же уровни, что видят «ворота» (limit-severities-for-sarif): +# иначе в Security копились бы находки ниже уровня ворот (LOW/UNKNOWN), которых +# сам прогон не замечает, и отчёт расходился бы с проверкой. on: push: branches: [main] @@ -60,6 +63,10 @@ jobs: output: ${{ matrix.sarif }} severity: CRITICAL,HIGH ignore-unfixed: true + # Формат SARIF по умолчанию игнорирует severity: action снимает TRIVY_SEVERITY + # и кладёт в отчёт все находки, поэтому в GitHub Security попадало то, чего + # «ворота» не видят. Вход удерживает уровни отчёта теми же, что у «ворот». + limit-severities-for-sarif: true exit-code: "0" - name: Загрузить отчёт в GitHub Security # В PR из форка токен не имеет права писать в Security: загрузку пропускаем, @@ -123,6 +130,10 @@ jobs: format: sarif output: trivy-fs.sarif severity: CRITICAL,HIGH + # Как и у «ворот» ниже, в отчёт не попадает то, для чего ещё нет исправления; + # limit-severities-for-sarif удерживает уровни отчёта теми же, что у «ворот». + ignore-unfixed: true + limit-severities-for-sarif: true exit-code: "0" - name: Загрузить отчёт в GitHub Security if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) diff --git a/.trivyignore b/.trivyignore index 4898ee0..00c7201 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,7 +1,8 @@ # Исключения для «ворот» Trivy: находки во встроенном в образ сканере # (usr/local/bin/trivy — копия из aquasec/trivy:0.74.0), а не в коде cio. # Наш бинарь (usr/local/bin/cio) и пакеты alpine проверяются без исключений, -# находки по ним остаются видимыми в отчёте SARIF (GitHub Security). +# находки уровня CRITICAL/HIGH по ним остаются видимыми в отчёте SARIF +# (GitHub Security): в отчёт попадают те же уровни, что видят «ворота». # Записи уходят вместе с обновлением версии Trivy (Dependabot: .github/dependabot.yml); # срок годности (exp) заставляет перепроверить исключения, а не держать их вечно. CVE-2026-53495 exp:2026-12-31 diff --git a/README.md b/README.md index f9bb719..4a6a9a9 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ GitHub Actions (`.github/workflows`): | Workflow | Что проверяет | | --- | --- | | `ci.yml` | для каждого инструмента (`cio` и `sqlbrc`): линт (`golangci-lint` с правилами безопасности), достижимые уязвимости зависимостей (`govulncheck`), тесты (`go vet`, `go test -race`, покрытие в артефакте) и сборка бинаря; отдельный job собирает образ каждого инструмента и проверяет, что он запускается под непривилегированным пользователем (`cio`, `sqlbrc`) | -| `trivy-scan.yml` | уязвимости, секреты и конфигурацию образов обоих инструментов (`cio`, `sqlbrc`) и их файловой системы; находки CRITICAL/HIGH роняют прогон, отчёты уходят в GitHub Security | +| `trivy-scan.yml` | уязвимости, секреты и конфигурацию образов обоих инструментов (`cio`, `sqlbrc`) и их файловой системы; находки CRITICAL/HIGH (без исправления) роняют прогон, и те же уровни уходят в GitHub Security | | `codeql.yml` | статический анализ Go-кода обоих модулей (`cio`, `sqlbrc`), отчёты — в GitHub Security | Матрицы workflow перечисляют каталоги инструментов (`cio`, `sqlbrc`): новый инструмент попадает diff --git a/SECURITY.md b/SECURITY.md index 94a58b0..b3b6f40 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -91,7 +91,8 @@ - Статический анализ по каждому модулю: `golangci-lint` с набором `gosec`, CodeQL и `govulncheck` (достижимые уязвимости зависимостей). - Сканирование: Trivy (файловая система, Dockerfile, образ каждого инструмента) в CI; находки - CRITICAL/HIGH роняют прогон, отчёты — в GitHub Security. + CRITICAL/HIGH (без исправления) роняют прогон, и те же уровни уходят в GitHub Security + (отчёт не показывает находки ниже уровня «ворот»). - Секреты: проверка всей истории репозитория `gitleaks`; правило `secret-in-layer` ищет секреты в истории сборки образа. В тестовых данных — только фиктивные значения. - Релиз: SBOM и attestation сборки рядом с бинарями и образом, подпись образа cosign