commit cd88c808b0936afb481d5fa5c68a26b1acb31c26 Author: Alex Dunmow Date: Sat Jun 6 13:04:02 2026 +0800 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 [--flags]` - CMS Makefile shells into ../check-safety for safety-check / install-safety-checker targets Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..612c986 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/check-safety +*.test +*.out +.idea/ +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d0b0ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# check-safety + +Standalone Go module: BlockNinja static safety/lint runner. ~25 checks across the consolidated tree. + +## Layout + +``` +check-safety/ +├── main.go # CLI entry, orders all checks +├── check_*.go # one per check, wires its rule to the runner +├── .go # rule implementation (errleak.go, colors.go, ...) +├── *_test.go # unit tests per rule +├── golden_test.go # snapshot test that runs the whole binary on fixtures +├── testdata/golden/ # snapshot fixtures + expected stdout/exit +├── internal/helpers/ # vendored from cms/backend/internal/helpers (LogDeferredError) +└── internal/theme/ # vendored from cms/backend/internal/theme (Theme struct + helpers) +``` + +## Invariants when editing + +- This is **standalone** — no imports from sibling repos (CMS, orchestrator, plugins). The two vendored packages above are the only CMS surfaces it depends on, and they are intentional copies. +- `blockNinjaRepoRoot()` and `orchestratorRepoRoot()` in `lint_pipeline.go` hardcode the consolidated layout (`~/src/blockninja/{cms,orchestrator}`). Update them if the tree layout changes. +- Golden tests are characterisation tests — if you intentionally change a check's output, run `make test-update` and commit the new fixture. +- The CMS Makefile's `safety-check` and `install-safety-checker` targets shell out into this directory (`cd ../check-safety`). Keep the CLI contract stable: `check-safety [--flags]`. + +## Adding a check + +1. New `check_.go` with a `runCheck(rep *reporter, ...)` func. +2. New `.go` for the actual rule logic. +3. Add `_test.go` for unit coverage. +4. Wire into `main.go`'s ordered check list. Numbered comments at the top of `main.go` should stay in sync. +5. If the check is deterministic, add a fixture under `testdata/golden//` and `make test-update`. + +## Why vendored, not imported + +`internal/helpers` and `internal/theme` are inside CMS's `internal/` tree. Go's internal-package rule blocks cross-module imports of `internal/...`. Options were: refactor CMS to make them public (large blast radius), use a `replace` directive (still blocked by internal rule), or vendor (chosen). Drift between CMS and the vendored copies is the whole point of check 21 (preset validation against `theme.Theme`) — it surfaces schema changes. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c5ac53 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: build install run test test-short test-update tidy clean + +BIN := check-safety +TARGET ?= ../cms + +build: + go build -o $(BIN) . + +install: + go install . + +run: + go run . $(TARGET) + +test: + go test -count=1 ./... + +test-short: + go test -short -count=1 ./... + +test-update: + go test -count=1 -run TestGoldenCharacterization -update ./... + +tidy: + go mod tidy + +clean: + rm -f $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb1c5fb --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# check-safety + +Static safety checker for the BlockNinja codebase. Walks a target tree, runs ~25 invariant checks across Go and frontend sources, and exits non-zero on violations. + +Lives at `~/src/blockninja/check-safety/` as a standalone Go module, alongside `cms/`, `orchestrator/`, `core/`, etc. + +## Run + +```bash +# Scan CMS (default target) +make run + +# Scan something else +make run TARGET=../orchestrator + +# Direct +go run . ../sites/bidbuddy +``` + +## Install globally + +```bash +make install # → $GOPATH/bin/check-safety +check-safety ~/src/blockninja/cms +``` + +## Test + +```bash +make test # all tests (some shell out to npm/tsc/golangci-lint and may skip if absent) +make test-short # skip the long ones +make test-update # regenerate golden snapshots after intentional output changes +``` + +## What it checks + +See the comment block at the top of `main.go` for the canonical list. Highlights: + +- Secret env-var reads happen only inside `config.Load()` +- All RPC methods are registered in the RBAC interceptor +- Frontend uses generated ConnectRPC hooks, no hand-crafted clients +- No hardcoded colors, no `useState` for tab state, no npm/yarn lockfiles +- No raw SQL outside sqlc/Bob, no `err.Error()` leaked to HTTP clients +- Plugin presets validate against `theme.Theme` +- Standalone plugins stay on the published SDK boundary + +## How it stays in sync with CMS + +`internal/helpers/deferlog.go` and `internal/theme/*.go` are vendored copies from CMS (`cms/backend/internal/{helpers,theme}/`). Go's `internal/` rule blocks direct imports across modules, so they live here. When CMS changes the theme schema or `LogDeferredError`, re-copy them — that drift is the point of the preset-validation check. + +## Adding a new check + +1. Add a `check_.go` file with a `runCheck` func +2. Wire it into `main.go`'s check sequence +3. Add a goldens case in `golden_test.go` if the output is deterministic +4. Run `make test-update` to regenerate goldens diff --git a/any_usage.go b/any_usage.go new file mode 100644 index 0000000..2171a92 --- /dev/null +++ b/any_usage.go @@ -0,0 +1,221 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strings" +) + +type anyUsageWarning struct { + file string + line int + lang string + snippet string +} + +var ( + reTypeScriptAny = regexp.MustCompile(`\bany\b`) + reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`) + reTypeScriptColonAny = regexp.MustCompile(`:\s*any(?:\b|[\s,\]\)>|=])`) + reTypeScriptCastAny = regexp.MustCompile(`<\s*any\s*>`) + reTypeScriptArrayAny = regexp.MustCompile(`\bany\s*\[\]`) + reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`) +) + +func checkGoAnyUsage(root string) []anyUsageWarning { + var warnings []anyUsageWarning + + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.Contains(path, "/vendor/") || strings.HasSuffix(path, "_test.go") { + return nil + } + if strings.HasSuffix(path, ".pb.go") || strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, ".gen.go") || strings.HasSuffix(path, "_templ.go") { + return nil + } + + lines, readErr := readSourceLines(path) + if readErr != nil { + return nil + } + + fset := token.NewFileSet() + file, parseErr := parser.ParseFile(fset, path, nil, 0) + if parseErr != nil { + return nil + } + + relPath, _ := filepath.Rel(root, path) + relPath = filepath.ToSlash(relPath) + seen := make(map[int]bool) + recordAnyExpr := func(expr ast.Expr) { + for _, pos := range findAnyTypePositions(expr) { + position := fset.Position(pos) + if seen[position.Line] { + continue + } + seen[position.Line] = true + snippet := "" + if position.Line-1 >= 0 && position.Line-1 < len(lines) { + snippet = strings.TrimSpace(lines[position.Line-1]) + } + warnings = append(warnings, anyUsageWarning{ + file: relPath, + line: position.Line, + lang: "go", + snippet: snippet, + }) + } + } + + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.Field: + recordAnyExpr(node.Type) + case *ast.TypeSpec: + recordAnyExpr(node.Type) + case *ast.ValueSpec: + recordAnyExpr(node.Type) + case *ast.FuncDecl: + if node.Type != nil { + recordAnyExpr(node.Type) + } + case *ast.FuncLit: + if node.Type != nil { + recordAnyExpr(node.Type) + } + case *ast.TypeAssertExpr: + recordAnyExpr(node.Type) + case *ast.CompositeLit: + recordAnyExpr(node.Type) + } + return true + }) + + return nil + }) + + return warnings +} + +func findAnyTypePositions(expr ast.Expr) []token.Pos { + if expr == nil { + return nil + } + + var positions []token.Pos + switch node := expr.(type) { + case *ast.Ident: + if node.Name == "any" { + positions = append(positions, node.Pos()) + } + case *ast.ArrayType: + positions = append(positions, findAnyTypePositions(node.Elt)...) + case *ast.MapType: + positions = append(positions, findAnyTypePositions(node.Key)...) + positions = append(positions, findAnyTypePositions(node.Value)...) + case *ast.ChanType: + positions = append(positions, findAnyTypePositions(node.Value)...) + case *ast.Ellipsis: + positions = append(positions, findAnyTypePositions(node.Elt)...) + case *ast.ParenExpr: + positions = append(positions, findAnyTypePositions(node.X)...) + case *ast.StarExpr: + positions = append(positions, findAnyTypePositions(node.X)...) + case *ast.IndexExpr: + positions = append(positions, findAnyTypePositions(node.X)...) + positions = append(positions, findAnyTypePositions(node.Index)...) + case *ast.IndexListExpr: + positions = append(positions, findAnyTypePositions(node.X)...) + for _, index := range node.Indices { + positions = append(positions, findAnyTypePositions(index)...) + } + case *ast.SelectorExpr: + positions = append(positions, findAnyTypePositions(node.X)...) + case *ast.StructType: + if node.Fields != nil { + for _, field := range node.Fields.List { + positions = append(positions, findAnyTypePositions(field.Type)...) + } + } + case *ast.InterfaceType: + if node.Methods != nil { + for _, field := range node.Methods.List { + positions = append(positions, findAnyTypePositions(field.Type)...) + } + } + case *ast.FuncType: + if node.Params != nil { + for _, field := range node.Params.List { + positions = append(positions, findAnyTypePositions(field.Type)...) + } + } + if node.Results != nil { + for _, field := range node.Results.List { + positions = append(positions, findAnyTypePositions(field.Type)...) + } + } + } + + return positions +} + +func checkTypeScriptAnyUsage(root string) []anyUsageWarning { + var warnings []anyUsageWarning + + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != ".ts" && ext != ".tsx" { + return nil + } + if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") || strings.Contains(path, "/generated/") { + return nil + } + if strings.HasSuffix(path, ".d.ts") || strings.HasSuffix(path, ".gen.ts") || strings.HasSuffix(path, ".gen.tsx") { + return nil + } + + lines, readErr := readSourceLines(path) + if readErr != nil { + return nil + } + + relPath, _ := filepath.Rel(root, path) + relPath = filepath.ToSlash(relPath) + for idx, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") { + continue + } + if strings.Contains(line, "'any'") || strings.Contains(line, `"any"`) || strings.Contains(line, "`any") || strings.Contains(line, "any`") { + continue + } + if !reTypeScriptAny.MatchString(line) { + continue + } + if !reTypeScriptAsAny.MatchString(line) && !reTypeScriptColonAny.MatchString(line) && !reTypeScriptCastAny.MatchString(line) && !reTypeScriptArrayAny.MatchString(line) && !reTypeScriptEqAny.MatchString(line) { + continue + } + + warnings = append(warnings, anyUsageWarning{ + file: relPath, + line: idx + 1, + lang: "ts", + snippet: trimmed, + }) + } + + return nil + }) + + return warnings +} diff --git a/any_usage_test.go b/any_usage_test.go new file mode 100644 index 0000000..10fcdcb --- /dev/null +++ b/any_usage_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestCheckGoAnyUsageFlagsAnyTypes(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "sample.go"), `package sample + +type Payload struct { + Value any + Items []any +} + +func Map() map[string]any { + return nil +} +`, 0644) + + warnings := checkGoAnyUsage(root) + if len(warnings) < 3 { + t.Fatalf("checkGoAnyUsage() returned %d warnings, want at least 3: %#v", len(warnings), warnings) + } +} + +func TestCheckGoAnyUsageSkipsTestsAndGeneratedFiles(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "sample_test.go"), `package sample + +func TestThing(value any) {} +`, 0644) + writeTestFile(t, filepath.Join(root, "sample.pb.go"), `package sample + +type Payload struct { + Value any +} +`, 0644) + + warnings := checkGoAnyUsage(root) + if len(warnings) != 0 { + t.Fatalf("checkGoAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings) + } +} + +func TestCheckTypeScriptAnyUsageFlagsAnyPatterns(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "sample.ts"), `export type Row = any +export const fn = (value: any[]): any => value as any +`, 0644) + + warnings := checkTypeScriptAnyUsage(root) + if len(warnings) < 2 { + t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want at least 2: %#v", len(warnings), warnings) + } +} + +func TestCheckTypeScriptAnyUsageSkipsCommentsAndStrings(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "sample.ts"), `// any should not count +const label = "any"; +/* any */ +`, 0644) + + warnings := checkTypeScriptAnyUsage(root) + if len(warnings) != 0 { + t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings) + } +} diff --git a/authprecedence.go b/authprecedence.go new file mode 100644 index 0000000..8b6a829 --- /dev/null +++ b/authprecedence.go @@ -0,0 +1,196 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "git.dev.alexdunmow.com/block/check-safety/internal/helpers" +) + +type authPrecedenceViolation struct { + file string + line int + cookieLine int + snippet string +} + +// checkAuthPrecedence scans Go files for functions that extract auth tokens +// where cookies are checked before Bearer/Authorization headers. +// +// Bearer tokens are explicit per-request credentials. Cookies are ambient +// and may be stale (e.g., set by a prior registration while the caller +// intends to authenticate as a different user via Bearer). If both are +// present, Bearer must win. +// +// The anti-pattern: +// +// cookie, err := r.Cookie("access_token") // cookie read first +// if err == nil { token = cookie.Value } +// if token == "" { // bearer only as fallback +// auth := r.Header.Get("Authorization") +// ... +// } +// +// The correct pattern: +// +// auth := r.Header.Get("Authorization") // bearer first +// if bearer, ok := strings.CutPrefix(auth, "Bearer "); ok { +// token = bearer +// } +// if token == "" { // cookie only as fallback +// cookie, err := r.Cookie(...) +// ... +// } +func checkAuthPrecedence(root string) []authPrecedenceViolation { + var violations []authPrecedenceViolation + + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.Contains(path, "/vendor/") || strings.HasSuffix(path, "_test.go") { + return nil + } + + vs := scanFileAuthPrecedence(path, root) + violations = append(violations, vs...) + return nil + }) + + return violations +} + +// scanFileAuthPrecedence scans a single Go file for the cookie-before-bearer +// anti-pattern. It tracks, per function scope, the first line where a cookie +// is read and the first line where a Bearer/Authorization header is read. +// If the cookie read comes first AND the bearer read is inside a +// "if token == """ guard, it's a violation. +func scanFileAuthPrecedence(path, root string) []authPrecedenceViolation { + f, err := os.Open(path) + if err != nil { + return nil + } + defer helpers.LogDeferredError(nil, "close auth scan file", f.Close, "path", path) + + relPath, _ := filepath.Rel(root, path) + + var violations []authPrecedenceViolation + + scanner := bufio.NewScanner(f) + lineNum := 0 + braceDepth := 0 + funcStartDepth := -1 + + // Per-function state + cookieLine := 0 + bearerLine := 0 + bearerGuarded := false // bearer read is inside "if token == """ + + resetFunc := func() { + cookieLine = 0 + bearerLine = 0 + bearerGuarded = false + } + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // Track brace depth to detect function boundaries + for _, ch := range line { + switch ch { + case '{': + braceDepth++ + case '}': + braceDepth-- + } + } + + // Detect function start + if strings.HasPrefix(trimmed, "func ") || strings.HasPrefix(trimmed, "func(") { + resetFunc() + funcStartDepth = braceDepth + } + + // Detect function end + if funcStartDepth >= 0 && braceDepth < funcStartDepth { + // Function ended — emit violation if cookie came before bearer + if cookieLine > 0 && bearerLine > 0 && cookieLine < bearerLine && bearerGuarded { + violations = append(violations, authPrecedenceViolation{ + file: relPath, + line: cookieLine, + cookieLine: cookieLine, + snippet: "cookie checked before Bearer token — Bearer must take precedence over ambient cookies", + }) + } + resetFunc() + funcStartDepth = -1 + } + + // Detect cookie read: r.Cookie(...) + if cookieLine == 0 && (strings.Contains(trimmed, ".Cookie(") && !strings.HasPrefix(trimmed, "//")) { + // Exclude http.SetCookie and cookie-setting lines + if !strings.Contains(trimmed, "SetCookie") && !strings.Contains(trimmed, "http.Cookie{") { + cookieLine = lineNum + } + } + + // Detect bearer/authorization read + if bearerLine == 0 && !strings.HasPrefix(trimmed, "//") { + if strings.Contains(trimmed, `"Authorization"`) || + strings.Contains(trimmed, `"Bearer "`) || + strings.Contains(trimmed, `"Bearer "`) { + bearerLine = lineNum + // Check if this line or a recent line has a guard like `if token == ""` + // by scanning back up to 3 lines for `token == ""` + bearerGuarded = isBearerGuarded(path, lineNum) + } + } + } + + // Handle case where function is the last thing in the file + if cookieLine > 0 && bearerLine > 0 && cookieLine < bearerLine && bearerGuarded { + violations = append(violations, authPrecedenceViolation{ + file: relPath, + line: cookieLine, + cookieLine: cookieLine, + snippet: "cookie checked before Bearer token — Bearer must take precedence over ambient cookies", + }) + } + + return violations +} + +// isBearerGuarded checks whether the bearer read around the given line +// is inside a guard like `if token == ""` or `if tok == ""`, meaning +// it only fires when no cookie was found — the anti-pattern. +func isBearerGuarded(path string, bearerLineNum int) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer helpers.LogDeferredError(nil, "close bearer guard scan file", f.Close, "path", path) + + scanner := bufio.NewScanner(f) + lineNum := 0 + // Look at lines bearerLineNum-3 through bearerLineNum + for scanner.Scan() { + lineNum++ + if lineNum < bearerLineNum-3 { + continue + } + if lineNum > bearerLineNum { + break + } + line := strings.TrimSpace(scanner.Text()) + // Common guard patterns: `if token == ""`, `if tok == ""` + if strings.Contains(line, `== ""`) && + (strings.Contains(line, "token") || strings.Contains(line, "tok ")) { + return true + } + } + return false +} diff --git a/authprecedence_test.go b/authprecedence_test.go new file mode 100644 index 0000000..625c244 --- /dev/null +++ b/authprecedence_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckAuthPrecedence_CookieBeforeBearer(t *testing.T) { + // Anti-pattern: cookie checked first, bearer only as fallback. + src := `package auth + +import "net/http" + +func extractClaims(r *http.Request) string { + var token string + cookie, err := r.Cookie("access_token") + if err == nil && cookie.Value != "" { + token = cookie.Value + } + if token == "" { + auth := r.Header.Get("Authorization") + _ = auth + } + return token +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 1 { + t.Fatalf("expected 1 violation, got %d", len(vs)) + } + if vs[0].cookieLine == 0 { + t.Error("violation should have a cookieLine set") + } +} + +func TestCheckAuthPrecedence_BearerBeforeCookie(t *testing.T) { + // Correct pattern: bearer first, cookie fallback. + src := `package auth + +import "net/http" +import "strings" + +func extractClaims(r *http.Request) string { + var token string + authHeader := r.Header.Get("Authorization") + if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok { + token = after + } + if token == "" { + cookie, err := r.Cookie("access_token") + if err == nil && cookie.Value != "" { + token = cookie.Value + } + } + return token +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "good.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations, got %d: %+v", len(vs), vs) + } +} + +func TestCheckAuthPrecedence_CookieOnly(t *testing.T) { + // Cookie-only auth (no bearer at all) — not a violation. + src := `package auth + +import "net/http" + +func extractFromCookie(r *http.Request) string { + cookie, err := r.Cookie("session") + if err != nil { + return "" + } + return cookie.Value +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cookie_only.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations, got %d", len(vs)) + } +} + +func TestCheckAuthPrecedence_BearerOnly(t *testing.T) { + // Bearer-only auth (no cookie) — not a violation. + src := `package auth + +import "net/http" +import "strings" + +func extractBearer(r *http.Request) string { + auth := r.Header.Get("Authorization") + if after, ok := strings.CutPrefix(auth, "Bearer "); ok { + return after + } + return "" +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bearer_only.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations, got %d", len(vs)) + } +} + +func TestCheckAuthPrecedence_SetCookieIgnored(t *testing.T) { + // http.SetCookie should not be flagged as a cookie read. + src := `package auth + +import "net/http" +import "strings" + +func setCookieAndReadBearer(w http.ResponseWriter, r *http.Request) string { + http.SetCookie(w, &http.Cookie{Name: "session", Value: "abc"}) + auth := r.Header.Get("Authorization") + if after, ok := strings.CutPrefix(auth, "Bearer "); ok { + return after + } + return "" +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "setcookie.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations, got %d: %+v", len(vs), vs) + } +} + +func TestCheckAuthPrecedence_TestFilesIgnored(t *testing.T) { + // Test files should be ignored. + src := `package auth + +import "net/http" + +func extractClaims(r *http.Request) string { + var token string + cookie, err := r.Cookie("access_token") + if err == nil && cookie.Value != "" { + token = cookie.Value + } + if token == "" { + auth := r.Header.Get("Authorization") + _ = auth + } + return token +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad_test.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations for test files, got %d", len(vs)) + } +} + +func TestCheckAuthPrecedence_UnguardedBearerNotViolation(t *testing.T) { + // Cookie first but bearer is NOT guarded by `if token == ""` — + // both are read unconditionally, which is a different (acceptable) pattern + // where the code might merge or compare both. + src := `package auth + +import "net/http" +import "strings" + +func extractClaims(r *http.Request) string { + cookie, err := r.Cookie("access_token") + cookieToken := "" + if err == nil { + cookieToken = cookie.Value + } + auth := r.Header.Get("Authorization") + bearerToken := "" + if after, ok := strings.CutPrefix(auth, "Bearer "); ok { + bearerToken = after + } + if bearerToken != "" { + return bearerToken + } + return cookieToken +} +` + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "both.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + vs := checkAuthPrecedence(dir) + if len(vs) != 0 { + t.Fatalf("expected 0 violations for unguarded bearer, got %d: %+v", len(vs), vs) + } +} diff --git a/check_anyusage.go b/check_anyusage.go new file mode 100644 index 0000000..f21c36a --- /dev/null +++ b/check_anyusage.go @@ -0,0 +1,49 @@ +package main + +import "fmt" + +func init() { + register(Check{ + Seq: 24, + ID: "2e", + Title: "Warn on any usage in Go and TypeScript", + Run: func(ctx *ScanContext, rep *Reporter) { + // Check 2e: Warn on any usage in Go and TypeScript + fmt.Println("=== Check 2e: Warn on any usage in Go and TypeScript ===") + var anyWarnings []anyUsageWarning + for _, target := range ctx.backendTargets { + for _, w := range checkGoAnyUsage(target.root) { + w.file = prefixDisplayPath(target.displayOrRoot(), w.file) + anyWarnings = append(anyWarnings, w) + } + } + for _, target := range ctx.pluginTargets { + for _, w := range checkGoAnyUsage(target.root) { + w.file = prefixDisplayPath(target.display, w.file) + anyWarnings = append(anyWarnings, w) + } + } + for _, target := range collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets) { + for _, w := range checkTypeScriptAnyUsage(target.dir) { + w.file = prefixDisplayPath(target.label, w.file) + anyWarnings = append(anyWarnings, w) + } + } + if len(anyWarnings) > 0 { + fmt.Printf(" WARN: %d any usage warning(s):\n", len(anyWarnings)) + limit := min(len(anyWarnings), maxAnyWarningsPrinted) + for _, w := range anyWarnings[:limit] { + fmt.Printf(" %s:%d [%s] %s\n", w.file, w.line, w.lang, w.snippet) + } + if len(anyWarnings) > limit { + fmt.Printf(" ... %d additional warning(s) omitted\n", len(anyWarnings)-limit) + } + fmt.Println("\n Guidance: prefer concrete types, generics, typed maps/structs, or unknown-style narrowing patterns over any.") + } else { + fmt.Println(" OK: No any usage found in scanned Go or TypeScript sources") + } + + fmt.Println() + }, + }) +} diff --git a/check_authprecedence.go b/check_authprecedence.go new file mode 100644 index 0000000..97603b4 --- /dev/null +++ b/check_authprecedence.go @@ -0,0 +1,39 @@ +package main + +import "fmt" + +func init() { + register(Check{ + Seq: 190, + ID: "19", + Title: "Bearer token precedence over cookies in auth middleware", + Run: func(ctx *ScanContext, rep *Reporter) { + // Check 19: Bearer token precedence over cookies + fmt.Println("\n=== Check 19: Bearer token precedence over cookies in auth middleware ===") + var authPrecViolations []authPrecedenceViolation + for _, target := range ctx.backendTargets { + for _, v := range checkAuthPrecedence(target.root) { + v.file = prefixDisplayPath(target.display, v.file) + authPrecViolations = append(authPrecViolations, v) + } + } + for _, target := range ctx.pluginTargets { + for _, v := range checkAuthPrecedence(target.root) { + v.file = prefixDisplayPath(target.display, v.file) + authPrecViolations = append(authPrecViolations, v) + } + } + if len(authPrecViolations) > 0 { + fmt.Printf(" FAIL: %d auth precedence violation(s):\n", len(authPrecViolations)) + for _, v := range authPrecViolations { + fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) + } + fmt.Println("\n Fix: Check Bearer/Authorization header BEFORE cookies. Bearer is explicit; cookies are ambient and may be stale.") + rep.Fail() + } else { + fmt.Println(" OK: Bearer token takes precedence over cookies in all auth extraction") + printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets)) + } + }, + }) +} diff --git a/check_bareimports.go b/check_bareimports.go new file mode 100644 index 0000000..e6df900 --- /dev/null +++ b/check_bareimports.go @@ -0,0 +1,39 @@ +package main + +import "fmt" + +func init() { + register(Check{ + Seq: 80, + ID: "8", + Title: "Import @block-ninja/api from subpaths only", + Run: func(ctx *ScanContext, rep *Reporter) { + // Check 8: Import @block-ninja/api from subpaths only + fmt.Println("=== Check 8: Import @block-ninja/api from subpaths only ===") + if len(ctx.frontendTargets) > 0 { + var bareImports []bareImportViolation + for _, target := range ctx.frontendTargets { + for _, v := range checkBareImports(target.dir) { + v.file = prefixDisplayPath(target.label, v.file) + bareImports = append(bareImports, v) + } + } + if len(bareImports) > 0 { + fmt.Printf(" FAIL: %d bare import(s) from '@block-ninja/api' (naming conflicts):\n", len(bareImports)) + for _, v := range bareImports { + fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) + } + fmt.Println("\n Fix: Import from subpaths: @block-ninja/api/types, /queries, or /services") + rep.Fail() + } else { + fmt.Println(" OK: All @block-ninja/api imports use subpaths") + printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets)) + } + } else { + fmt.Println(" SKIP: no frontend sources found") + } + + fmt.Println() + }, + }) +} diff --git a/check_buttonautomation.go b/check_buttonautomation.go new file mode 100644 index 0000000..a070a0a --- /dev/null +++ b/check_buttonautomation.go @@ -0,0 +1,40 @@ +package main + +import "fmt" + +func init() { + register(Check{ + Seq: 100, + ID: "10", + Title: "Button automation attributes", + Run: func(ctx *ScanContext, rep *Reporter) { + // Check 10: Button automation attributes + fmt.Println("=== Check 10: Button automation attributes ===") + if len(ctx.frontendTargets) > 0 { + var buttonViolations []buttonViolation + for _, target := range ctx.frontendTargets { + for _, v := range checkButtonAutomation(target.dir) { + v.file = prefixDisplayPath(target.label, v.file) + buttonViolations = append(buttonViolations, v) + } + } + if len(buttonViolations) > 0 { + fmt.Printf(" FAIL: %d