- 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>
881 lines
22 KiB
Go
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...)
|
|
}
|