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 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) + } + }) +}