diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..b7e84177 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,13 @@ +# 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 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/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..cf664ef7 --- /dev/null +++ b/scripts/deep-scan.sh @@ -0,0 +1,116 @@ +#!/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 0 + fi + if "$@"; then + RESULT[$name]="OK" + else + RESULT[$name]="FAILED" + FAILED=1 + fi + return 0 +} + +# ── 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 --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 + 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 + return $? +} +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 + 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); 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; return $?; } +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 +if [[ "$FAILED" = 0 ]]; then echo "deep-scan: clean"; else echo "deep-scan: FINDINGS ABOVE"; fi +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)