check-safety/main.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
Reporter is now a collector: checks declare a verdict (OK/Skip/Warn/Fail/
Fatal) plus findings, and a single central render() prints output. Default
output is silent on pass/skip — only FAIL/WARN/ERR checks print, followed by
one tally line, so a clean run is two lines. --verbose restores full per-check
output. All ~30 checks were converted to this API; orphaned guidance/label
helpers (printPerTargetOKLines, per-check *Help blocks, colors_format.go) were
removed.

The any-usage check (2e) now defaults to only the unstaged working-tree diff
(changed lines), via a new per-repo git-diff index in changedlines.go; --all-any
restores the full scan. Not-a-git-repo / no-diff warns on nothing.

Golden fixtures regenerated; integration tests updated to the new format; added
unit tests for the diff index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:16:56 +08:00

207 lines
7.4 KiB
Go

// check-safety verifies critical backend and frontend safety invariants.
//
// Run: go run ./cmd/check-safety .
//
// Checks:
// 1. No secret env var reads outside config.Load()
// 2. All RPC methods registered in RBAC interceptor
// 3. Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint checks
// 4. Frontend code is auto-formatted, lint-clean, and typecheck-clean
// 5. Frontend uses generated ConnectRPC hooks
// 6. No hardcoded colors in frontend
// 7. No useState for tab state in route files
// 8. Import @block-ninja/api from subpaths only
// 9. No npm/yarn lockfiles (pnpm only)
//
// 10. Button automation attributes
//
// 11. Plugin frontend discovery
// 12. No placeholder code; only shipped features
// 13. No reinvented utilities (use helpers)
// 14. RPC query/mutation error handling
// 15. No raw SQL outside sqlc/Bob
// 16. No err.Error() leaked to HTTP clients
// 17. Services/handlers in correct directories
// 18. No TODO markers in production code
// 19. Plugin segmentation (safety-rules.yml)
// 19. Bearer token precedence over cookies in auth middleware
// 20. Tailwind v4 configuration (PostCSS plugin, CSS directives, @config)
// 21. Plugin presets.json validation (type-safe unmarshal against theme.Theme)
// 22. Standalone plugins must stay on the published SDK boundary (imports, go.mod, version)
// 23. sqlc uuid overrides in standalone plugins and cmd configs must use github.com/google/uuid
// 24. Warn on any usage in Go and TypeScript
// 25. sqlc compile and buf generate must succeed for scanned roots that define them
// 26. Orchestrator backend tests must pass when checking the core BlockNinja repo
// 27. No hand-rolled HTML sanitization — use bluemonday
// 28. Public page handlers inject admin toolbar data
// 29. No -buildmode=plugin targets in ported plugin repos (disabled until WO-WZ-017)
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
var secretEnvVars = map[string]bool{
"JWT_SECRET": true,
"PUBLIC_JWT_SECRET": true,
"PLATFORM_SECRETS_KEY": true,
"AI_KEY_ENCRYPTION_KEY": true,
"STORAGE_ENCRYPTION_KEY": true,
"COMM_KEY_ENCRYPTION_KEY": true,
"ORCHESTRATOR_SECRET": true,
"SMTP_PASSWORD": true,
"CMS_BUILDER_SECRET": true,
"VAULT_TOKEN": true,
"VAULT_ROLE_ID": true,
"VAULT_SECRET_ID": true,
"CALCOM_API_KEY": true,
}
var exemptSecretEnvVars = map[string]bool{
"VAULT_TOKEN": true, // OpenBao/Vault clients may need ambient token access outside startup.
}
// Files allowed to read secret env vars
func isAllowedEnvReader(path string) bool {
return strings.HasSuffix(path, "config/config.go") || strings.HasSuffix(path, "_test.go")
}
// Services that are internal (not routed through the RBAC interceptor)
// Services that use their own auth middleware instead of the RBAC interceptor
var excludedServices = map[string]bool{
"ManagementService": true, // Orchestrator-to-CMS, uses X-Orchestrator-Secret
// Plugin-managed services: RBAC entries provided via MergeMethodRoles at runtime
"CommunityReviewsService": true,
"CommunityModerationService": true,
"UserEngagementService": true,
"DataSuggestionsService": true,
"WikiService": true, // Symposium plugin
}
const maxAnyWarningsPrinted = 120
func main() {
ctx := buildScanContext()
rep := &Reporter{verbose: ctx.verbose}
// Checks self-register via init(); run them in declared Seq order.
sort.Slice(registry, func(i, j int) bool { return registry[i].Seq < registry[j].Seq })
for i := range registry {
rep.begin(registry[i].Seq, registry[i].ID, registry[i].Title)
registry[i].Run(ctx, rep)
rep.end()
}
rep.render()
os.Exit(rep.exitCode)
}
func buildScanContext() *ScanContext {
targetDir := "."
var pluginPageDirs []string
var pluginRoots []string
orchestratorOnly := false
allAny := false
verbose := false
// Parse args: first positional arg is backendDir. Plugin frontend paths may be passed
// as direct source dirs via --plugin-pages or as plugin roots via --plugin-dir.
args := os.Args[1:]
for i := 0; i < len(args); i++ {
if args[i] == "--orchestrator" {
orchestratorOnly = true
} else if args[i] == "--all-any" {
allAny = true
} else if args[i] == "--verbose" || args[i] == "-v" {
verbose = true
} else if args[i] == "--plugin-pages" {
// Consume all following args until next flag or end
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
pluginPageDirs = append(pluginPageDirs, args[i])
}
i-- // back up so outer loop's i++ doesn't skip
} else if args[i] == "--plugin-dir" || args[i] == "--plugin-dirs" {
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
pluginRoots = append(pluginRoots, args[i])
}
i--
} else if !strings.HasPrefix(args[i], "--") && targetDir == "." {
targetDir = args[i]
}
}
cwd, err := os.Getwd()
if err != nil {
fmt.Printf(" ERROR: failed to resolve working directory: %v\n", err)
os.Exit(2)
}
targetDir = defaultScanTargetDir(targetDir, pluginRoots, cwd)
// If the target is a plugin directory (has plugin.mod) and no explicit plugin roots were
// passed, register it automatically so ninjatpl and other plugin-specific checks run.
if len(pluginRoots) == 0 {
if abs, absErr := filepath.Abs(targetDir); absErr == nil && fileExists(filepath.Join(abs, "plugin.mod")) {
pluginRoots = append(pluginRoots, abs)
}
}
backendDir, repoRoot, err := resolveScanRoots(targetDir)
if err != nil {
fmt.Printf(" ERROR: failed to resolve scan roots: %v\n", err)
os.Exit(2)
}
if orchestratorOnly {
orchRoot := orchestratorRepoRoot()
orchBackend := filepath.Join(orchRoot, "backend")
if orchRoot == "" || !dirExists(orchBackend) {
fmt.Printf(" ERROR: orchestrator repo not found at %s\n", orchRoot)
os.Exit(2)
}
backendDir = orchBackend
repoRoot = orchRoot
}
scanPath, err := filepath.Abs(targetDir)
if err != nil {
scanPath = targetDir
}
if orchestratorOnly {
fmt.Printf("check-safety %s [orchestrator only]\n", repoRoot)
} else {
fmt.Printf("check-safety %s\n", scanPath)
}
if !orchestratorOnly && len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
blockNinjaRepo := blockNinjaRepoRoot()
if samePath(scanPath, blockNinjaRepo) || samePath(scanPath, filepath.Join(blockNinjaRepo, "backend")) {
fmt.Println("(for a plugin: use --plugin-dir <path> or run in the plugin directory)")
}
}
includeCoreTargets := shouldIncludeCoreTargets(targetDir, backendDir, pluginRoots, pluginPageDirs)
backendTargets := collectBackendScanTargets(repoRoot, backendDir, includeCoreTargets)
pluginTargets, unresolvedPluginRoots := resolvePluginTargets(repoRoot, pluginRoots)
resolvedPluginTargets := pluginTargets
pluginTargets = filterPluginTargets(pluginTargets, backendDir)
frontendTargets := collectFrontendScanTargets(repoRoot, backendDir, pluginTargets, includeCoreTargets)
frontendTargets = appendManualFrontendTargets(repoRoot, frontendTargets, pluginPageDirs)
return &ScanContext{
repoRoot: repoRoot,
backendDir: backendDir,
orchestratorOnly: orchestratorOnly,
includeCoreTargets: includeCoreTargets,
allAny: allAny,
verbose: verbose,
backendTargets: backendTargets,
pluginTargets: pluginTargets,
resolvedPluginTargets: resolvedPluginTargets,
frontendTargets: frontendTargets,
unresolvedPluginRoots: unresolvedPluginRoots,
pluginPageDirs: pluginPageDirs,
}
}