check-safety/main.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

195 lines
7.0 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
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{}
// 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 {
registry[i].Run(ctx, rep)
}
os.Exit(rep.exitCode)
}
func buildScanContext() *ScanContext {
targetDir := "."
var pluginPageDirs []string
var pluginRoots []string
orchestratorOnly := 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] == "--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("(running checks in %s [orchestrator only])\n", repoRoot)
} else {
fmt.Printf("(running checks in %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)")
}
}
fmt.Println()
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,
backendTargets: backendTargets,
pluginTargets: pluginTargets,
resolvedPluginTargets: resolvedPluginTargets,
frontendTargets: frontendTargets,
unresolvedPluginRoots: unresolvedPluginRoots,
pluginPageDirs: pluginPageDirs,
}
}