From 7d9e0f14db7aae79fe2c284c6023ac5888025ea6 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Sat, 8 Aug 2026 00:41:59 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tooling):=20make=20deep-scan=20?= =?UTF-8?q?=E2=80=94=20tier-3=20local=20analysis;=20fix=20TLS=20floor=20on?= =?UTF-8?q?=20reverse-bootstrap=20listener?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 2026-08-07 check tiering: pre-push = fast gate, CI = path-gated required checks with Sonar as the hosted SAST, and now Tier-3 = the heavy/free analysis that runs on the dev machine instead of runner minutes and sees the whole tree + whole git history instead of a PR diff. scripts/deep-scan.sh (make deep-scan), all sections tolerant of a missing tool: semgrep p/golang + p/typescript + p/security-audit, scanned OFFLINE from ~/.cache/semgrep-rules — letting semgrep resolve registry configs itself stalled for minutes while curl fetched the same packs in 0.2 s, so the cache is refreshed via curl with a hard 30 s cap govulncheck Go CVEs with reachability osv-scanner go.mod + web/package-lock.json against the OSV database npm audit advisories (removed from CI as a Trivy duplicate) gitleaks secrets over ALL 2284 commits — the CI action only scans a push/PR commit range and can never resurface history Findings from the first full run, all triaged: REAL: the agent's reverse-bootstrap TLS listener (creds/reverse.go) set no MinVersion and silently accepted TLS 1.2, while the long-lived listener enforces 1.3 (S-7) and the CI security gate only greps listen.go. Fixed: MinVersion 1.3, no compatibility cost (the only peer is the panel's modern Go TLS stack). FP, suppressed as CLASSES with reasons in scripts/semgrep-filter.py (ratchet, archguard convention): SQL-identifier Sprintf, math/rand jitter, dynamic cookie Secure, LimitReader-capped decompression, rooted-path Clean over embed.FS. FP: gitleaks generic-api-key on a function SIGNATURE (parameter list 'secret, expirationRFC3339 string') — pinned by fingerprint in .gitleaksignore. --- .gitleaksignore | 8 +++ CLAUDE.md | 6 ++ Makefile | 6 ++ internal/agent/creds/reverse.go | 7 ++ scripts/deep-scan.sh | 112 ++++++++++++++++++++++++++++++++ scripts/semgrep-filter.py | 74 +++++++++++++++++++++ 6 files changed, 213 insertions(+) create mode 100644 .gitleaksignore create mode 100755 scripts/deep-scan.sh create mode 100644 scripts/semgrep-filter.py diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..16d902ee --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Confirmed false positives, pinned by fingerprint (commit:file:rule:line). +# Verify before adding: `git show -- ` and check the match +# is not a real secret. + +# generic-api-key matched the parameter list of buildAdoptedClientState — +# literally `secret, expirationRFC3339 string` in a function signature; +# the "high-entropy token" is the identifier expirationRFC3339. +e4dfe3964fd35aadf360015a4aea9df26bba4992:internal/controlplane/server/clients_discovery.go:generic-api-key:303 diff --git a/CLAUDE.md b/CLAUDE.md index 0a3353d7..3d4ef240 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,6 +180,12 @@ cd web && npm run build:embed # build into cmd/control-plane/.embedded-ui # sqlc sqlc generate +# Tier-3 deep local analysis (semgrep offline-cache, osv-scanner, +# govulncheck, npm audit, gitleaks over FULL history) — manual, free, +# spends dev-machine time instead of CI minutes. Triaged-FP allowlists: +# scripts/semgrep-filter.py (rule classes) + .gitleaksignore (fingerprints). +make deep-scan + # Docker docker compose -f deploy/docker-compose.sqlite.yml up --build -d docker compose -f deploy/docker-compose.postgres.yml up --build -d # dev — default creds diff --git a/Makefile b/Makefile index 787ab1ea..c6feb000 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,12 @@ lint: vuln: govulncheck ./... +# Tier-3 deep local analysis (semgrep, osv-scanner, npm audit, gitleaks +# full-history). Heavy/free tools that run on the dev machine instead of +# CI — see scripts/deep-scan.sh header for the tiering rationale. +deep-scan: + bash scripts/deep-scan.sh + check: lint test vuln build: gen-install-script diff --git a/internal/agent/creds/reverse.go b/internal/agent/creds/reverse.go index ca9bcb76..b07c8a1d 100644 --- a/internal/agent/creds/reverse.go +++ b/internal/agent/creds/reverse.go @@ -138,6 +138,13 @@ func ReverseBootstrap(cfg ReverseBootstrapConfig) error { // short-lived and accepts a single connection, so the cost of // disabling resumption is negligible. SessionTicketsDisabled: true, + // Same TLS 1.3 floor as the long-lived agent listener (S-7, + // internal/agent/transport/listen.go). Without an explicit + // MinVersion this bootstrap listener silently accepted TLS 1.2 — + // found by the deep-scan semgrep pass, 2026-08-08. The only + // legitimate peer is the panel dialing with a modern Go TLS + // stack, so there is no compatibility cost. + MinVersion: tls.VersionTLS13, } // Reverse-bootstrap is bounded by reverseBootstrapTimeout end-to-end; tie diff --git a/scripts/deep-scan.sh b/scripts/deep-scan.sh new file mode 100755 index 00000000..04544009 --- /dev/null +++ b/scripts/deep-scan.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Tier-3: deep local analysis, run manually (`make deep-scan`). +# +# The 2026-08-07 CI simplification split checks into three tiers: +# 1. pre-push — fast gate (lint, unit, build) +# 2. CI — path-gated pipeline, 9 required checks, Sonar as SAST +# 3. THIS — heavy/free tools that spend developer-machine time +# instead of runner minutes, see the whole tree and the +# whole git history instead of a PR diff +# +# Every section is tolerant: a missing tool is reported and skipped, a +# finding marks the run failed but does not stop later sections. Exit +# code 1 if any section failed. +# +# Tools (all free): +# semgrep pip venv ~/.local/bin/semgrep SAST Go+TS (OWASP) +# osv-scanner go install ~/go/bin/osv-scanner CVE: go.mod + npm lock +# govulncheck go install ~/go/bin/govulncheck Go CVE w/ reachability +# gitleaks release bin ~/go/bin/gitleaks secrets, FULL history +# npm audit bundled — npm advisories +set -u +cd "$(dirname "$0")/.." || exit 1 + +PATH="$HOME/go/bin:$HOME/.local/bin:$PATH" +declare -A RESULT +FAILED=0 + +run_section() { + local name="$1" tool="$2"; shift 2 + echo + echo "═══ deep-scan: $name ═══" + if ! command -v "$tool" >/dev/null 2>&1; then + echo "--- $tool not installed, skipping (see header for install source)" + RESULT[$name]="SKIPPED (no $tool)" + return + fi + if "$@"; then + RESULT[$name]="OK" + else + RESULT[$name]="FAILED" + FAILED=1 + fi +} + +# ── SAST: semgrep over Go + TS with community security rules ───────────── +# p/golang + p/typescript are language correctness/security packs; +# p/security-audit adds cross-language OWASP-style checks. +# +# Rules are served from a LOCAL cache and the scan runs offline. Letting +# semgrep resolve p/... configs itself stalls for many minutes when its +# API endpoint is slow (observed 2026-08-08: the CDN answered curl in +# 0.2 s while semgrep's own client timed out repeatedly), so the cache +# is refreshed via curl with a hard 30 s cap and the scan never touches +# the network. Re-run refresh manually to pick up new rules. +SEMGREP_CACHE="$HOME/.cache/semgrep-rules" +refresh_semgrep_rules() { + mkdir -p "$SEMGREP_CACHE" + local p + for p in golang typescript security-audit; do + curl -sSfL --max-time 30 "https://semgrep.dev/c/p/$p" \ + -o "$SEMGREP_CACHE/$p.yml.tmp" \ + && mv "$SEMGREP_CACHE/$p.yml.tmp" "$SEMGREP_CACHE/$p.yml" \ + || echo "--- refresh of p/$p failed, keeping cached copy (if any)" + done +} +semgrep_scan() { + # Refresh only when a pack is missing entirely; otherwise scan from + # cache (refresh by hand: rm ~/.cache/semgrep-rules/*.yml). + [ -s "$SEMGREP_CACHE/golang.yml" ] || refresh_semgrep_rules + if ! [ -s "$SEMGREP_CACHE/golang.yml" ]; then + echo "--- no cached rules and semgrep.dev unreachable" + return 2 + fi + semgrep scan --quiet --metrics=off --json \ + --config "$SEMGREP_CACHE/golang.yml" \ + --config "$SEMGREP_CACHE/typescript.yml" \ + --config "$SEMGREP_CACHE/security-audit.yml" \ + --exclude 'web/node_modules' --exclude '*.gen.go' --exclude '*.gen.ts' \ + --exclude '*.pb.go' \ + --exclude 'internal/dbsqlc' --exclude 'web/dist' --exclude 'web/storybook-static' \ + | python3 scripts/semgrep-filter.py +} +run_section "semgrep (SAST Go+TS)" semgrep semgrep_scan + +# ── Go CVEs with reachability (only flags vulns in code paths we call) ─── +run_section "govulncheck" govulncheck govulncheck ./... + +# ── Lockfile CVEs from the OSV database (Go + npm in one pass) ─────────── +osv_scan() { + osv-scanner scan source --lockfile go.mod --lockfile web/package-lock.json +} +run_section "osv-scanner (go.mod + npm lock)" osv-scanner osv_scan + +# ── npm advisories (removed from CI as a Trivy duplicate; free locally) ── +npm_audit() { (cd web && npm audit --audit-level=moderate); } +run_section "npm audit" npm npm_audit + +# ── Secrets over the ENTIRE git history ────────────────────────────────── +# The CI gitleaks action only scans the commit range of a push/PR, so a +# secret that landed long ago never resurfaces there. This scans all +# commits every time. +gitleaks_scan() { gitleaks detect --source . --redact --exit-code 1; } +run_section "gitleaks (full history)" gitleaks gitleaks_scan + +# ── Summary ────────────────────────────────────────────────────────────── +echo +echo "═══ deep-scan summary ═══" +for name in "${!RESULT[@]}"; do + printf " %-32s %s\n" "$name" "${RESULT[$name]}" +done +[ "$FAILED" = 0 ] && echo "deep-scan: clean" || echo "deep-scan: FINDINGS ABOVE" +exit $FAILED diff --git a/scripts/semgrep-filter.py b/scripts/semgrep-filter.py new file mode 100644 index 00000000..7dbcc008 --- /dev/null +++ b/scripts/semgrep-filter.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Filter semgrep JSON output through a triaged rule allowlist. + +Reads `semgrep --json` on stdin, drops findings whose rule (last segment +of check_id) was triaged as a false-positive CLASS for this codebase on +2026-08-08, prints the rest, exits 1 if any remain. + +The allowlist is a ratchet (archguard convention): entries may be +removed, never added without re-triaging and recording the reason here. +Suppressing a whole rule is deliberate — these are cases where the rule's +premise does not apply to this codebase, not individual noisy matches. +""" + +import json +import sys + +# rule-suffix -> why every match of it here is a false positive +ALLOWED = { + "string-formatted-query": ( + "matches are table/column IDENTIFIERS from internal constants " + "(VACUUM INTO, rotate-key table list, prune, migrateguard); SQL " + "cannot bind identifiers as parameters. Values still go through " + "placeholders — sqlc + rowserrcheck cover that side." + ), + "math-random-used": ( + "reconnect jitter / backoff spread, explicitly non-cryptographic; " + "all key material uses crypto/rand (gosec G404 scope agrees)." + ), + "cookie-missing-secure": ( + "Secure is assigned dynamically via sessionCookieSecure() with " + "__Host- prefix logic (Q3.U-S-13 / S-22); the rule only sees the " + "literal struct field absent." + ), + "potential-dos-via-decompression-bomb": ( + "updates/download.go wraps both the archive body and every tar " + "entry in io.LimitReader with explicit caps." + ), + "filepath-clean-misuse": ( + "ui.go cleans a ROOTED URL path (leading slash guarantees .. is " + "eliminated) and serves from embed.FS, a sealed VFS that rejects " + "non-ValidPath names anyway." + ), +} + +data = json.load(sys.stdin) +kept, suppressed = [], {} +for r in data.get("results", []): + suffix = r["check_id"].rsplit(".", 1)[-1] + if suffix in ALLOWED: + suppressed[suffix] = suppressed.get(suffix, 0) + 1 + else: + kept.append(r) + +for suffix, n in sorted(suppressed.items()): + print(f" suppressed {n:2d}x {suffix} (triaged FP class)") + +for r in kept: + path = r["path"] + line = r["start"]["line"] + suffix = r["check_id"].rsplit(".", 1)[-1] + msg = r["extra"]["message"].split("\n")[0][:160] + print(f"\n {path}:{line} [{suffix}]\n {msg}") + +errors = data.get("errors", []) +if errors: + print(f"\n semgrep reported {len(errors)} internal error(s):") + for e in errors[:5]: + print(f" {str(e.get('message', e))[:160]}") + +if kept: + print(f"\n {len(kept)} finding(s) need triage: fix, or add the rule " + f"suffix to ALLOWED in scripts/semgrep-filter.py with a reason.") + sys.exit(1) +sys.exit(0) From 94b4f75ffedca4f05acb7d6ea78570f72b4c96b4 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Sat, 8 Aug 2026 00:48:29 +0300 Subject: [PATCH 2/2] review: fix the scanner findings the tooling PR triggered on itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gitleaks: the .gitleaksignore comment QUOTED the false-positive text, and once committed the quote itself matched generic-api-key. Reworded to describe without quoting; the already-pushed revision is pinned by its own fingerprint. - Sonar shell rules on deep-scan.sh: curl now pins --proto '=https' --tlsv1.2 (S6506), [[ ]] over [ ] (S7688), explicit returns (S7682) — as status PROPAGATION (return $?) in the scan functions, where a bare return 0 would have swallowed findings. --- .gitleaksignore | 11 ++++++++--- scripts/deep-scan.sh | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 16d902ee..b7e84177 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -2,7 +2,12 @@ # Verify before adding: `git show -- ` and check the match # is not a real secret. -# generic-api-key matched the parameter list of buildAdoptedClientState — -# literally `secret, expirationRFC3339 string` in a function signature; -# the "high-entropy token" is the identifier expirationRFC3339. +# generic-api-key matched a FUNCTION SIGNATURE in buildAdoptedClientState: +# a parameter named like a credential followed by a long camelCase +# identifier that the rule scored as a high-entropy token. No value of +# any kind is present at that line — only parameter names and types. e4dfe3964fd35aadf360015a4aea9df26bba4992:internal/controlplane/server/clients_discovery.go:generic-api-key:303 + +# The previous revision of THIS file quoted the trigger text in its +# comment, which itself matched generic-api-key once committed. +7d9e0f14db7aae79fe2c284c6023ac5888025ea6:.gitleaksignore:generic-api-key:6 diff --git a/scripts/deep-scan.sh b/scripts/deep-scan.sh index 04544009..cf664ef7 100755 --- a/scripts/deep-scan.sh +++ b/scripts/deep-scan.sh @@ -32,7 +32,7 @@ run_section() { if ! command -v "$tool" >/dev/null 2>&1; then echo "--- $tool not installed, skipping (see header for install source)" RESULT[$name]="SKIPPED (no $tool)" - return + return 0 fi if "$@"; then RESULT[$name]="OK" @@ -40,6 +40,7 @@ run_section() { RESULT[$name]="FAILED" FAILED=1 fi + return 0 } # ── SAST: semgrep over Go + TS with community security rules ───────────── @@ -57,17 +58,18 @@ refresh_semgrep_rules() { mkdir -p "$SEMGREP_CACHE" local p for p in golang typescript security-audit; do - curl -sSfL --max-time 30 "https://semgrep.dev/c/p/$p" \ + curl -sSfL --proto '=https' --tlsv1.2 --max-time 30 "https://semgrep.dev/c/p/$p" \ -o "$SEMGREP_CACHE/$p.yml.tmp" \ && mv "$SEMGREP_CACHE/$p.yml.tmp" "$SEMGREP_CACHE/$p.yml" \ || echo "--- refresh of p/$p failed, keeping cached copy (if any)" done + return 0 } semgrep_scan() { # Refresh only when a pack is missing entirely; otherwise scan from # cache (refresh by hand: rm ~/.cache/semgrep-rules/*.yml). - [ -s "$SEMGREP_CACHE/golang.yml" ] || refresh_semgrep_rules - if ! [ -s "$SEMGREP_CACHE/golang.yml" ]; then + [[ -s "$SEMGREP_CACHE/golang.yml" ]] || refresh_semgrep_rules + if ! [[ -s "$SEMGREP_CACHE/golang.yml" ]]; then echo "--- no cached rules and semgrep.dev unreachable" return 2 fi @@ -79,6 +81,7 @@ semgrep_scan() { --exclude '*.pb.go' \ --exclude 'internal/dbsqlc' --exclude 'web/dist' --exclude 'web/storybook-static' \ | python3 scripts/semgrep-filter.py + return $? } run_section "semgrep (SAST Go+TS)" semgrep semgrep_scan @@ -88,18 +91,19 @@ run_section "govulncheck" govulncheck govulncheck ./... # ── Lockfile CVEs from the OSV database (Go + npm in one pass) ─────────── osv_scan() { osv-scanner scan source --lockfile go.mod --lockfile web/package-lock.json + return $? } run_section "osv-scanner (go.mod + npm lock)" osv-scanner osv_scan # ── npm advisories (removed from CI as a Trivy duplicate; free locally) ── -npm_audit() { (cd web && npm audit --audit-level=moderate); } +npm_audit() { (cd web && npm audit --audit-level=moderate); return $?; } run_section "npm audit" npm npm_audit # ── Secrets over the ENTIRE git history ────────────────────────────────── # The CI gitleaks action only scans the commit range of a push/PR, so a # secret that landed long ago never resurfaces there. This scans all # commits every time. -gitleaks_scan() { gitleaks detect --source . --redact --exit-code 1; } +gitleaks_scan() { gitleaks detect --source . --redact --exit-code 1; return $?; } run_section "gitleaks (full history)" gitleaks gitleaks_scan # ── Summary ────────────────────────────────────────────────────────────── @@ -108,5 +112,5 @@ echo "═══ deep-scan summary ═══" for name in "${!RESULT[@]}"; do printf " %-32s %s\n" "$name" "${RESULT[$name]}" done -[ "$FAILED" = 0 ] && echo "deep-scan: clean" || echo "deep-scan: FINDINGS ABOVE" -exit $FAILED +if [[ "$FAILED" = 0 ]]; then echo "deep-scan: clean"; else echo "deep-scan: FINDINGS ABOVE"; fi +exit "$FAILED"