Опирается на правила: R-SEC-SAST-1R-SEC-SAST-4 и R-SEC-SAST-X1 из Security Style Guide → раздел 1. SAST по коду.

Важно знать

  • golangci-lint с набором gosec+staticcheck+errcheck+errorlint+bodyclose обязателен — конфиг в .golangci.yml, не флагами в CI-скрипте.
  • gosec ловит SQLi, path traversal, hardcoded credentials, weak crypto, unsafe package, command injection, SSRF, math/rand в security-контексте (G404).
  • HIGH/CRITICAL breaking build: golangci-lint run без --exit-code 0, gosec -severity HIGH возвращает exit code 1.
  • semgrep с go-security rulesets дополняет gosec — пересекаются слабо, оба обязательны.
  • SARIF из gosec и semgrep загружается в GitHub Security tab через codeql-action/upload-sarif.
  • Suppressions через //nolint:gosec с кодом нарушения, причиной ≥ 30 символов и датой до: YYYY-MM-DD — без даты не принимается.
  • Blanket //nolint без кода и объяснения — запрещён (R-SEC-SAST-X1).

SAST (Static Application Security Testing) — анализ кода без выполнения. В Go-экосистеме инструментальная цепочка другая, чем в Java, но принципы R-SEC-* одни: сборка падает на HIGH/CRITICAL, suppressions имеют срок, findings идут в единый дашборд. golangci-lint объединяет несколько анализаторов под одним вызовом; gosec — специализированный security-линтер с детектами класса SQLi, path traversal, weak crypto.

Конфигурация golangci-lint

R-SEC-SAST-1: конфиг в корне репо, не флаги в CI.

# .golangci.yml
linters:
  enable:
    - gosec
    - staticcheck
    - errcheck
    - errorlint
    - bodyclose
    - exhaustive

linters-settings:
  gosec:
    severity: high
    confidence: high

issues:
  max-issues-per-linter: 0
  max-same-issues: 0

max-issues-per-linter: 0 и max-same-issues: 0 — без этих флагов golangci-lint обрезает вывод по умолчанию, реальная картина скрыта. В CI — без --exit-code 0:

# .github/workflows/pr.yml (фрагмент)
- name: SAST — golangci-lint
  run: golangci-lint run --max-issues-per-linter=0 --max-same-issues=0

Каждый enabled-линтер при finding возвращает exit code 1 → PR-гейт красный.

gosec — security-specific детекты

R-SEC-SAST-2: gosec запускается отдельно с SARIF-выводом для GitHub Code Scanning.

# CI step — PR gate
gosec -fmt sarif -out gosec.sarif -severity HIGH ./...

Что ловит gosec на домене Order/Product/Customer:

G101 — hardcoded credentials

// ПЛОХО — gosec G101
func connectDB() *sql.DB {
    db, _ := sql.Open("postgres", "host=localhost password=secret123 dbname=orders")
    return db
}

// ХОРОШО
func connectDB(cfg Config) (*sql.DB, error) {
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        return nil, fmt.Errorf("open db: %w", err)
    }
    return db, nil
}

G201/G202 — SQL-инъекция через конкатенацию строк

// ПЛОХО — gosec G202: конкатенация в SQL-запросе
func (r *OrderRepository) FindByStatus(ctx context.Context, status string) ([]Order, error) {
    query := "SELECT id, total FROM orders WHERE status = '" + status + "'"
    rows, err := r.db.QueryContext(ctx, query)
    // ...
}

// ХОРОШО — sqlc генерирует параметризованные запросы
// query.sql
-- name: FindOrdersByStatus :many
SELECT id, total, created_at FROM orders WHERE status = $1;

С sqlc/pgx конкатенация в SQL-запросах структурно невозможна — генерация заменяет ручной SQL. gosec G201/G202 при этом молчит.

G304 — path traversal

// ПЛОХО — gosec G304: путь от пользователя
func (h *ProductHandler) GetTemplate(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    data, err := os.ReadFile("templates/" + name)
    // ...
}

// ХОРОШО — белый список допустимых имён
func (h *ProductHandler) GetTemplate(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    allowed := map[string]bool{"invoice": true, "receipt": true, "catalog": true}
    if !allowed[name] {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    data, err := os.ReadFile(filepath.Join("templates", name+".html"))
    // ...
}

G401/G501 — weak crypto

// ПЛОХО — gosec G501: MD5 для security
import "crypto/md5"

func hashCustomerID(id string) string {
    h := md5.Sum([]byte(id))
    return hex.EncodeToString(h[:])
}

// ХОРОШО — MD5 допустим для non-security (content-id, ETag), не для паролей
// Для паролей — bcrypt (R-SEC-CRYPTO-1):
import "golang.org/x/crypto/bcrypt"

const bcryptCost = 12

func HashPassword(plain string) (string, error) {
    b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
    if err != nil {
        return "", fmt.Errorf("hash password: %w", err)
    }
    return string(b), nil
}

G404 — math/rand в security-контексте

// ПЛОХО — gosec G404: math/rand для токена сброса пароля
import "math/rand"

func GenerateResetToken() string {
    return fmt.Sprintf("%d", rand.Intn(1000000))
}

// ХОРОШО — crypto/rand (R-SEC-CRYPTO-2)
import "crypto/rand"

func GenerateResetToken(n int) (string, error) {
    b := make([]byte, n)
    if _, err := rand.Read(b); err != nil {
        return "", fmt.Errorf("generate token: %w", err)
    }
    return base64.URLEncoding.EncodeToString(b), nil
}

semgrep — дополнительный слой

R-SEC-SAST-2: semgrep с go-security rulesets дополняет gosec — пересечение детектов небольшое, совместное применение даёт более полное покрытие.

# CI step — PR gate
semgrep --config "p/golang" --sarif --output semgrep.sarif ./...

Полезные go-правила semgrep, которые gosec пропускает:

  • SSRF через http.Get(userInput) без валидации URL.
  • Открытые перенаправления (http.Redirect(w, r, userParam, 302)).
  • Небезопасное использование html/template vs text/template (XSS).
  • exec.Command с пользовательским вводом без санитизации.

errcheck и errorlint

R-SEC-SAST-1: errcheck обязателен — Go-специфичная проблема: проигнорированная ошибка от security-операции.

// ПЛОХО — errcheck: игнорирование ошибки bcrypt
func (s *CustomerService) Register(ctx context.Context, cmd RegisterCustomerCommand) error {
    hash, _ := bcrypt.GenerateFromPassword([]byte(cmd.Password), 12) // ошибка проглочена
    return s.repo.Save(ctx, Customer{PasswordHash: string(hash)})
}

// ХОРОШО
func (s *CustomerService) Register(ctx context.Context, cmd RegisterCustomerCommand) error {
    hash, err := bcrypt.GenerateFromPassword([]byte(cmd.Password), 12)
    if err != nil {
        return fmt.Errorf("hash password: %w", err)
    }
    return s.repo.Save(ctx, Customer{PasswordHash: string(hash)})
}

errorlint ловит сравнение ошибок через == вместо errors.Is — критично при обёрнутых ошибках (%w):

// ПЛОХО — errorlint: теряет wrapped error
if err == ErrOrderNotFound {
    return http.StatusNotFound
}

// ХОРОШО
if errors.Is(err, ErrOrderNotFound) {
    return http.StatusNotFound
}

Severity → действие

R-SEC-SAST-3: разная реакция по уровню.

SeverityИнструментДействие
HIGH/CRITICALgosec, semgrep, staticcheckСборка падает (exit code 1)
MEDIUMgosecОтчёт + обязательный комментарий ревьюера
LOWИгнорируется в продакшн-конфиге

golangci-lint run без --exit-code 0 возвращает 1 при любом enabled-finding. Не подавлять через --exit-code 0 — это превращает SAST в дашборд, который никто не смотрит (нарушение R-SEC-1).

Suppressions со сроком

R-SEC-SAST-4: //nolint:gosec с кодом нарушения, причиной и датой.

// ХОРОШО — код + причина + срок
//nolint:gosec // G304: путь пришёл от trusted-конфига (embed.FS fallback), не от пользователя; заменить на embed.FS до: 2026-12-01
f, err := os.Open(cfg.TemplatePath)
if err != nil {
    return nil, fmt.Errorf("open template %s: %w", cfg.TemplatePath, err)
}

// ПЛОХО — blanket //nolint без кода (R-SEC-SAST-X1)
//nolint
f, err := os.Open(cfg.TemplatePath)

Квартальный аудит просроченных suppressions:

grep -rn 'до:' . --include='*.go' | grep -v '_test.go'

Для MD5 в non-security контексте (content-id, ETag, не пароль) — suppression с явным объяснением:

//nolint:gosec // G401: sha1 используется только для content-id (ETag, не пароль), не как KDF; crypto/sha256 уже применяется там где нужна криптостойкость; до: 2026-12-01
h := sha1.New()

SARIF и GitHub Code Scanning

R-SEC-FIND-3: SARIF из gosec и semgrep загружается в GitHub Security tab.

# .github/workflows/pr.yml
- name: SAST — gosec
  run: gosec -fmt sarif -out gosec.sarif -severity HIGH ./...

- name: SAST — semgrep
  run: semgrep --config "p/golang" --sarif --output semgrep.sarif ./...

- name: Upload SARIF — gosec
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: gosec.sarif

- name: Upload SARIF — semgrep
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: semgrep.sarif

Единый дашборд без отдельной инфры. Findings видны в Security tab → Code scanning alerts с фильтрацией по инструменту, severity, статусу.

Где SAST не помогает

SAST имеет фундаментальные ограничения:

  • Не понимает runtime-значения: exec.Command("bash", userInput) — детектируется, но если userInput приходит через несколько слоёв — может не отследить.
  • False positives на доверенных путях (cfg.TemplatePath) — нужны suppressions с обоснованием.
  • Не покрывает бизнес-логику: ABAC bypass, race conditions на уровне сервисного слоя.

SAST — первый слой. Дополняется:

  • CVE в зависимостях (govulncheck, Trivy) — CVE в зависимостях.
  • Секреты в истории (Gitleaks) — Секреты в коде и истории.
  • Container-сканирование (Trivy image) — Container/image-уязвимости.

Что запрещено

АнтипаттернПравилоЧто взамен
//nolint без кода нарушения и причины ≥ 30 символовR-SEC-SAST-X1//nolint:gosec // G304: причина; до: YYYY-MM-DD
Suppression без даты до:R-SEC-SAST-4дата пересмотра обязательна
golangci-lint run --exit-code 0 в CIR-SEC-SAST-3без флага — exit code 1 на finding
MEDIUM-finding без комментария ревьюераR-SEC-SAST-3обязательный комментарий в PR
gosec без -severity HIGHR-SEC-SAST-1severity-порог явный
Конфиг линтера флагами в CI-скриптеR-SEC-SAST-1.golangci.yml в корне репо
max-issues-per-linter не выставлен в 0R-SEC-SAST-1реальная картина findings скрыта
Без SARIF-выводаR-SEC-SAST-2gosec -fmt sarif + upload-sarif action
SQL-запрос через конкатенацию строкR-SEC-SAST-2sqlc-генерация, параметризованные запросы
math/rand для токенов/паролейR-SEC-SAST-2crypto/rand

Куда дальше

  • CVE в зависимостях — govulncheck и Trivy, следующий слой защиты.
  • Секреты в коде и истории — Gitleaks pre-commit и full-history сканирование.
  • Container/image-уязвимости — Trivy image, distroless, non-root user.
  • Криптография в коде — bcrypt, AES-GCM, crypto/rand, JWT с JWKS keyfunc.
  • Реакция на findings — severity → SLA, квартальный аудит suppressions.