check-safety/proto_rbac.go
Alex Dunmow 2c7fe9aa62 fix: model orchestrator + cli as first-class scan targets (WO-WZ-023)
- resolveScanRoots recognizes the orchestrator layout (backend/go.mod +
  frontend/src) so backendDir resolves to backend/ — fixes the go.mod check,
  classifies the frontend as a standalone app (not a plugin), and routes tsc
  through the plain typecheck. Clears three orchestrator false-positives.
- check 2 (RBAC): only require MethodRoles for proto services a target actually
  serves (mounted connect handler). The orchestrator carries but does not serve
  the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli
  (definitions-only, vendors a registry client) from check 2.
- Skip buf-generated gen/ output in the placeholder check (real proto enum
  COMING_SOON values are not TODO debt).
- Normalize the color allowlist path so theme-card.tsx swatch previews match
  regardless of scan root.
- Whitelist the gitignored, tool-regenerated styles/package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:39:47 +08:00

881 lines
22 KiB
Go

package main
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
"golang.org/x/mod/modfile"
)
type envViolation struct {
file string
line int
envVar string
}
type protoOwnershipViolation struct {
root string
namespace string
coreProto string
}
type protoFreshnessResult struct {
checked bool
beforeStatus string
afterStatus string
output string
}
var (
bufModulePathPattern = regexp.MustCompile(`^(?:-\s*)?path:\s*"?([^"\s]+)"?\s*$`)
protoPackagePattern = regexp.MustCompile(`^\s*package\s+([A-Za-z0-9_.]+)\s*;\s*$`)
protoServicePattern = regexp.MustCompile(`^\s*service\s+([A-Za-z0-9_]+)\s*\{?\s*$`)
protoRPCPattern = regexp.MustCompile(`^\s*rpc\s+([A-Za-z0-9_]+)\s*\(`)
)
func checkEnvReads(root string) []envViolation {
var violations []envViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if isAllowedEnvReader(path) {
return nil
}
// Skip vendor
if strings.Contains(path, "/vendor/") || strings.Contains(path, "/.worktrees/") {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil // skip unparseable files
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Match os.Getenv("SECRET_VAR") and os.LookupEnv("SECRET_VAR")
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "os" || (sel.Sel.Name != "Getenv" && sel.Sel.Name != "LookupEnv") {
return true
}
if len(call.Args) != 1 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
envVar := strings.Trim(lit.Value, `"`)
if secretEnvVars[envVar] && !exemptSecretEnvVars[envVar] {
pos := fset.Position(call.Pos())
relPath, _ := filepath.Rel(root, pos.Filename)
if relPath == "" {
relPath = pos.Filename
}
violations = append(violations, envViolation{
file: relPath,
line: pos.Line,
envVar: envVar,
})
}
return true
})
return nil
}); err != nil {
violations = append(violations, envViolation{file: root, line: 0, envVar: err.Error()})
}
return violations
}
func checkPluginProtoOwnership(root string, display string) ([]protoOwnershipViolation, error) {
if !isPluginModuleRoot(root) {
return nil, nil
}
if hasLocalProtoSources(root) {
return nil, nil
}
coreRepoRoot, err := resolveBlockNinjaRepoRootFromModule(root)
if err != nil || coreRepoRoot == "" {
return nil, nil
}
packages, err := extractProcedurePackages(root)
if err != nil {
return nil, err
}
if len(packages) == 0 {
return nil, nil
}
corePackages, err := extractProcedurePackages(coreRepoRoot)
if err != nil {
return nil, err
}
corePackageSet := make(map[string]bool, len(corePackages))
for _, pkg := range corePackages {
corePackageSet[pkg] = true
}
var violations []protoOwnershipViolation
for _, pkg := range packages {
if strings.HasPrefix(pkg, "blockninja.") {
continue
}
if !corePackageSet[pkg] {
continue
}
nsRoot := strings.Split(pkg, ".")[0]
coreProto := filepath.Join(coreRepoRoot, "proto", nsRoot)
if !dirExists(coreProto) {
coreProto = filepath.Join(coreRepoRoot, "proto")
}
violations = append(violations, protoOwnershipViolation{
root: display,
namespace: pkg,
coreProto: coreProto,
})
}
return violations, nil
}
func extractBufProcedures(root string, allowedPackagePrefixes ...string) ([]string, error) {
moduleDirs, err := resolveBufModuleDirs(root)
if err != nil {
return nil, err
}
procedureSet := make(map[string]bool)
for _, moduleDir := range moduleDirs {
err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if info.Name() == "vendor" || info.Name() == ".git" || info.Name() == ".worktrees" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".proto") {
return nil
}
procedures, err := extractProtoProcedures(path)
if err != nil {
return err
}
for _, procedure := range procedures {
if !procedureMatchesPackagePrefixes(procedure, allowedPackagePrefixes) {
continue
}
procedureSet[procedure] = true
}
return nil
})
if err != nil {
return nil, err
}
}
procedures := make([]string, 0, len(procedureSet))
for procedure := range procedureSet {
procedures = append(procedures, procedure)
}
if len(procedures) == 0 {
return extractConnectProceduresRecursive(root, allowedPackagePrefixes...)
}
sort.Strings(procedures)
return procedures, nil
}
func extractProcedurePackages(root string) ([]string, error) {
procedures, err := extractBufProcedures(root)
if err != nil {
return nil, err
}
pkgs := make(map[string]bool)
for _, procedure := range procedures {
trimmed := strings.TrimPrefix(procedure, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 || parts[0] == "" {
continue
}
serviceName := parts[0]
lastDot := strings.LastIndex(serviceName, ".")
if lastDot <= 0 {
continue
}
pkgs[serviceName[:lastDot]] = true
}
out := make([]string, 0, len(pkgs))
for pkg := range pkgs {
out = append(out, pkg)
}
sort.Strings(out)
return out, nil
}
func procedureMatchesPackagePrefixes(procedure string, allowedPackagePrefixes []string) bool {
if len(allowedPackagePrefixes) == 0 {
return true
}
trimmed := strings.TrimPrefix(procedure, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 {
return false
}
pkg := parts[0]
for _, prefix := range allowedPackagePrefixes {
if strings.HasPrefix(pkg, prefix) {
return true
}
}
return false
}
func resolveBufModuleDirs(root string) ([]string, error) {
if !dirExists(root) {
return nil, fmt.Errorf("root does not exist: %s", root)
}
bufPath := filepath.Join(root, "buf.yaml")
if fileExists(bufPath) {
paths, err := parseBufModulePaths(bufPath)
if err != nil {
return nil, err
}
if len(paths) > 0 {
var dirs []string
for _, relPath := range paths {
moduleDir := filepath.Join(root, filepath.Clean(relPath))
if dirExists(moduleDir) {
dirs = append(dirs, moduleDir)
}
}
if len(dirs) > 0 {
return dirs, nil
}
}
}
fallback := filepath.Join(root, "proto")
if dirExists(fallback) {
return []string{fallback}, nil
}
return []string{root}, nil
}
func hasLocalProtoSources(root string) bool {
moduleDirs, err := resolveBufModuleDirs(root)
if err != nil {
return false
}
for _, moduleDir := range moduleDirs {
found := false
if err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".proto") {
found = true
return filepath.SkipAll
}
return nil
}); err != nil {
continue
}
if found {
return true
}
}
return false
}
func readModulePath(root string) (string, error) {
modPath, ok := findNearestGoModuleRoot(root)
if !ok {
return "", fmt.Errorf("no go.mod found for %s", root)
}
data, err := os.ReadFile(filepath.Join(modPath, "go.mod"))
if err != nil {
return "", err
}
file, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return "", err
}
if file.Module == nil {
return "", fmt.Errorf("go.mod missing module declaration")
}
return file.Module.Mod.Path, nil
}
func resolveBlockNinjaRepoRootFromModule(root string) (string, error) {
moduleRoot, ok := findNearestGoModuleRoot(root)
if !ok {
return "", fmt.Errorf("no go.mod found for %s", root)
}
data, err := os.ReadFile(filepath.Join(moduleRoot, "go.mod"))
if err != nil {
return "", err
}
file, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return "", err
}
for _, replace := range file.Replace {
if replace.Old.Path != "git.dev.alexdunmow.com/block/cms" {
continue
}
if replace.New.Version != "" {
continue
}
corePath := replace.New.Path
if !filepath.IsAbs(corePath) {
corePath = filepath.Join(moduleRoot, corePath)
}
corePath = filepath.Clean(corePath)
if dirExists(filepath.Join(corePath, "cmd", "check-safety")) {
return filepath.Clean(filepath.Join(corePath, "..")), nil
}
if dirExists(filepath.Join(corePath, "proto")) {
return corePath, nil
}
}
return "", nil
}
func parseBufModulePaths(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var paths []string
inModules := false
modulesIndent := 0
for rawLine := range strings.SplitSeq(string(data), "\n") {
line := strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if !inModules {
if trimmed == "modules:" {
inModules = true
modulesIndent = indent
}
continue
}
if indent <= modulesIndent && !strings.HasPrefix(trimmed, "-") {
break
}
if match := bufModulePathPattern.FindStringSubmatch(trimmed); len(match) == 2 {
paths = append(paths, match[1])
}
}
return paths, nil
}
func extractProtoProcedures(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer helpers.LogDeferredError(nil, "close proto source file", file.Close, "path", path)
var (
pkg string
service string
serviceDepth int
procedures []string
)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := stripProtoLineComment(scanner.Text())
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if pkg == "" {
if match := protoPackagePattern.FindStringSubmatch(trimmed); len(match) == 2 {
pkg = match[1]
}
}
if service == "" {
if match := protoServicePattern.FindStringSubmatch(trimmed); len(match) == 2 {
service = match[1]
serviceDepth = braceDelta(line)
if serviceDepth <= 0 {
serviceDepth = 1
}
}
continue
}
if pkg != "" {
if match := protoRPCPattern.FindStringSubmatch(trimmed); len(match) == 2 {
procedures = append(procedures, fmt.Sprintf("/%s.%s/%s", pkg, service, match[1]))
}
}
serviceDepth += braceDelta(line)
if serviceDepth <= 0 {
service = ""
serviceDepth = 0
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return procedures, nil
}
func stripProtoLineComment(line string) string {
if before, _, ok := strings.Cut(line, "//"); ok {
return before
}
return line
}
func braceDelta(line string) int {
return strings.Count(line, "{") - strings.Count(line, "}")
}
func extractPluginRoleMethods(root string) (map[string]string, error) {
methods := make(map[string]string)
fset := token.NewFileSet()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
switch info.Name() {
case ".git", ".worktrees", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
ast.Inspect(file, func(n ast.Node) bool {
comp, ok := n.(*ast.CompositeLit)
if !ok || !isRoleMapType(comp.Type) {
return true
}
for _, elt := range comp.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
lit, ok := kv.Key.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
method := strings.Trim(lit.Value, `"`)
if strings.HasPrefix(method, "/") && strings.Contains(method[1:], "/") {
methods[method] = normalizeRoleName(roleExprString(kv.Value))
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return methods, nil
}
func isRoleMapType(expr ast.Expr) bool {
mapType, ok := expr.(*ast.MapType)
if !ok {
return false
}
keyIdent, ok := mapType.Key.(*ast.Ident)
if !ok || keyIdent.Name != "string" {
return false
}
switch valueType := mapType.Value.(type) {
case *ast.Ident:
return valueType.Name == "Role"
case *ast.SelectorExpr:
return valueType.Sel != nil && valueType.Sel.Name == "Role"
default:
return false
}
}
// extractConnectProceduresRecursive walks a tree, parses *.connect.go files, and extracts
// procedure string constants. This is a fallback for plugin repos that ship generated Connect
// stubs but not local proto sources.
func extractConnectProceduresRecursive(root string, allowedPackagePrefixes ...string) ([]string, error) {
procedureSet := make(map[string]bool)
fset := token.NewFileSet()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
switch info.Name() {
case ".git", ".worktrees", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".connect.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
for _, spec := range genDecl.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 || len(vs.Values) == 0 {
continue
}
name := vs.Names[0].Name
if !strings.HasSuffix(name, "Procedure") {
continue
}
lit, ok := vs.Values[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
val := strings.Trim(lit.Value, `"`)
if procedureMatchesPackagePrefixes(val, allowedPackagePrefixes) {
procedureSet[val] = true
}
}
}
return nil
})
if err != nil {
return nil, err
}
procedures := make([]string, 0, len(procedureSet))
for procedure := range procedureSet {
procedures = append(procedures, procedure)
}
sort.Strings(procedures)
return procedures, nil
}
// extractRBACMethodRoles parses interceptor.go and extracts all keys and role values from the
// MethodRoles map (orchestrator/helpdesk) or the methodRolesSeed map (CMS — the static seed
// table behind the atomic-pointer copy-on-write live table; see interceptor.go).
func extractRBACMethodRoles(file string) (map[string]string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
return nil, err
}
methods := make(map[string]string)
ast.Inspect(f, func(n ast.Node) bool {
// Find: var MethodRoles = map[string]Role{ ... } (orchestrator/helpdesk)
// or: var methodRolesSeed = map[string]Role{ ... } (CMS copy-on-write seed)
vs, ok := n.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 {
return true
}
if name := vs.Names[0].Name; name != "MethodRoles" && name != "methodRolesSeed" {
return true
}
if len(vs.Values) == 0 {
return true
}
comp, ok := vs.Values[0].(*ast.CompositeLit)
if !ok {
return true
}
for _, elt := range comp.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
lit, ok := kv.Key.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
method := strings.Trim(lit.Value, `"`)
methods[method] = normalizeRoleName(roleExprString(kv.Value))
}
return false
})
return methods, nil
}
func roleExprString(expr ast.Expr) string {
switch value := expr.(type) {
case *ast.BasicLit:
if value.Kind == token.STRING {
return strings.Trim(value.Value, `"`)
}
case *ast.Ident:
return value.Name
case *ast.SelectorExpr:
if value.Sel != nil {
return value.Sel.Name
}
}
return ""
}
func normalizeRoleName(raw string) string {
switch raw {
case "", "RolePublic":
return "public"
}
if after, ok := strings.CutPrefix(raw, "Role"); ok {
raw = after
}
return strings.ToLower(raw)
}
// servedHandlerRefPattern matches a PACKAGE-QUALIFIED connect handler
// constructor reference, e.g. `orchestratorv1connect.NewSettingsServiceHandler`.
//
// A connect-go service is served by a repo iff its generated
// New{Service}Handler constructor is referenced (called) in that repo's
// HAND-WRITTEN backend code — that reference is how the handler gets mounted on
// the mux. The generated `*.connect.go` stubs DEFINE that constructor for every
// service the repo carries proto for, so a repo that vendors the whole
// blockninja.v1 surface only for frontend codegen (the orchestrator) still
// SERVES just the subset it actually mounts.
//
// We keep the package selector (`…connect`) rather than only the short service
// name because two proto packages can declare services that share a short name
// (`blockninja.v1.SettingsService` vs `orchestrator.v1.SettingsService`); the
// selector is what disambiguates which one is really mounted.
var servedHandlerRefPattern = regexp.MustCompile(`([A-Za-z0-9_]+connect)\.New([A-Za-z0-9_]+)ServiceHandler\b`)
// extractServedServiceKeys scans a backend target's HAND-WRITTEN Go source for
// package-qualified connect handler mounts and returns the set of served
// services, keyed as "<connectPkg>|<ServiceName>" (e.g.
// "orchestratorv1connect|SettingsService").
//
// Generated `*.connect.go` stubs and `_test.go` files are skipped: the stubs
// only DEFINE the constructors (unqualified `func New…ServiceHandler`) and would
// otherwise mark every carried service as served; tests may reference handlers
// for services that aren't mounted in the real server.
func extractServedServiceKeys(root string) (map[string]bool, error) {
served := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
switch info.Name() {
case ".git", ".worktrees", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil // skip unreadable files
}
for _, m := range servedHandlerRefPattern.FindAllSubmatch(data, -1) {
connectPkg := string(m[1])
service := string(m[2]) + "Service"
served[connectPkg+"|"+service] = true
}
return nil
})
if err != nil {
return nil, err
}
return served, nil
}
// procedureServedKey maps a proto procedure ("/pkg.sub.ServiceName/Method") to
// the served-service key produced by extractServedServiceKeys, deriving the
// connect package name from the proto package exactly the way
// protoc-gen-connect-go does: strip the dots and append "connect"
// (blockninja.v1 -> blockninjav1connect, orchestrator.v1 ->
// orchestratorv1connect). Returns false for procedures without a package.
func procedureServedKey(procedure string) (string, bool) {
trimmed := strings.TrimPrefix(procedure, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 || parts[0] == "" {
return "", false
}
serviceFull := parts[0] // e.g. orchestrator.v1.SettingsService
lastDot := strings.LastIndex(serviceFull, ".")
if lastDot <= 0 || lastDot == len(serviceFull)-1 {
return "", false
}
protoPkg := serviceFull[:lastDot] // orchestrator.v1
serviceName := serviceFull[lastDot+1:] // SettingsService
connectPkg := strings.ReplaceAll(protoPkg, ".", "") + "connect"
return connectPkg + "|" + serviceName, true
}
// filterProceduresToServed keeps only procedures whose service is actually
// mounted (served) by the target, per the served-service key set. This is what
// scopes check 2 to a repo's real serving surface: a repo may CARRY proto for
// procedures it never mounts (the orchestrator carries all of blockninja.v1 and
// helpdesk.v1 for frontend codegen but serves only orchestrator.v1), and those
// unmounted procedures are not real RBAC gaps.
//
// If the served set is empty — no recognised handler mounts, e.g. a target that
// mounts via a pattern we don't detect — the procedures are returned UNCHANGED
// so we never silently hide a genuine missing-RBAC finding.
func filterProceduresToServed(procedures []string, served map[string]bool) []string {
if len(served) == 0 {
return procedures
}
out := make([]string, 0, len(procedures))
for _, p := range procedures {
key, ok := procedureServedKey(p)
if !ok {
continue
}
if served[key] {
out = append(out, p)
}
}
return out
}
func isExcludedServiceMethod(method string) bool {
parts := strings.Split(method, "/")
if len(parts) < 3 {
return false
}
svcParts := strings.Split(parts[1], ".")
svcName := svcParts[len(svcParts)-1]
return excludedServices[svcName]
}
func checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) {
result := protoFreshnessResult{}
if !samePath(repoRoot, blockNinjaRepoRoot()) {
return result, nil
}
if !fileExists(filepath.Join(repoRoot, "Makefile")) || !fileExists(filepath.Join(repoRoot, "buf.gen.yaml")) {
return result, nil
}
result.checked = true
before, err := protoGeneratedStatus(repoRoot)
if err != nil {
return result, err
}
result.beforeStatus = before
output, runErr := runCommand(repoRoot, "make", "proto")
result.output = output
after, statusErr := protoGeneratedStatus(repoRoot)
if statusErr != nil {
return result, statusErr
}
result.afterStatus = after
if runErr != nil {
return result, fmt.Errorf("make proto failed: %w", runErr)
}
return result, nil
}
func (r protoFreshnessResult) changed() bool {
return r.checked && r.beforeStatus != r.afterStatus
}
func protoGeneratedStatus(repoRoot string) (string, error) {
paths := []string{
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api/src",
}
args := []string{"status", "--porcelain=v1", "--untracked-files=all", "--"}
args = append(args, paths...)
return runCommand(repoRoot, "git", args...)
}