check-safety/main.go
Alex Dunmow c048766075 feat(checks): add check 28 — public page handlers inject admin toolbar
New AST check scans handler render functions that build site settings
(getSiteSettings + doc["site_settings"]) and fails if they don't also set
siteSettings["toolbar"], so admin-toolbar injection can't silently regress
when a new public page type is added.

- toolbar.go: AST scanner; exempts renderListingPage (auto SEO pages) and
  renderVersionPreview (carries its own preview banner).
- check_toolbar.go: registration at Seq 280 (ID "28").
- registry_test.go: +1 expected check; golden fixtures regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:37:59 +08:00

196 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
// 28. Public page handlers inject admin toolbar data
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,
}
}