Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Confirmed false positives, pinned by fingerprint (commit:file:rule:line).
# Verify before adding: `git show <commit> -- <file>` 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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/creds/reverse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions scripts/deep-scan.sh
Original file line number Diff line number Diff line change
@@ -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"
74 changes: 74 additions & 0 deletions scripts/semgrep-filter.py
Original file line number Diff line number Diff line change
@@ -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)
Loading