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>
207 lines
7.4 KiB
Go
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,
|
|
}
|
|
}
|