check-safety/check_rbac.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
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>
2026-07-04 01:16:56 +08:00

200 lines
5.9 KiB
Go

package main
import (
"path/filepath"
"sort"
)
func init() {
register(Check{
Seq: 20,
ID: "2",
Title: "RPC methods registered in RBAC interceptor",
Run: func(ctx *ScanContext, rep *Reporter) {
protoMethods := make([]string, 0)
rbacMethods := make(map[string]string)
protoMethodSet := make(map[string]bool)
requiresRPCDiscovery := false
var rbacSummaries []rbacTargetSummary
for _, target := range ctx.backendTargets {
rbacFile := filepath.Join(target.root, "internal/rbac/interceptor.go")
hasCoreRBAC := fileExists(rbacFile)
summary := rbacTargetSummary{
label: target.displayOrRoot(),
roleByMethod: make(map[string]string),
}
if hasCoreRBAC {
requiresRPCDiscovery = true
summary.requiresRPCs = true
targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...)
if err != nil {
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
}
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRBACMethods, err := extractRBACMethodRoles(rbacFile)
if err != nil {
rep.Fatal("failed to parse RBAC interceptor for %s: %v", target.displayOrRoot(), err)
}
for method, role := range targetRBACMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
continue
}
if len(target.allowedPackagePrefixes) > 0 {
requiresRPCDiscovery = true
summary.requiresRPCs = true
}
targetProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
}
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
rep.Fatal("failed to parse RBAC roles for %s: %v", target.displayOrRoot(), err)
}
for method, role := range targetRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
for _, target := range ctx.pluginTargets {
summary := rbacTargetSummary{
label: target.display,
roleByMethod: make(map[string]string),
}
pluginProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
rep.Fatal("failed to parse plugin proto files for %s: %v", target.display, err)
}
summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...)
for _, pm := range pluginProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
pluginRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
rep.Fatal("failed to parse plugin RBAC roles for %s: %v", target.display, err)
}
for method, role := range pluginRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
if len(protoMethods) == 0 {
if requiresRPCDiscovery {
rep.Fatal("no RPC procedures found in proto sources or generated Connect files")
}
rep.Skip("no proto-backed RPC procedures found in scanned target(s)")
} else {
var missing []string
for _, m := range protoMethods {
if isExcludedServiceMethod(m) {
continue
}
if _, ok := rbacMethods[m]; !ok {
missing = append(missing, m)
}
}
var stale []string
for m := range rbacMethods {
if !protoMethodSet[m] {
if isExcludedServiceMethod(m) {
continue
}
stale = append(stale, m)
}
}
sort.Strings(stale)
for i := range rbacSummaries {
summary := &rbacSummaries[i]
for _, method := range summary.protoMethods {
if isExcludedServiceMethod(method) {
continue
}
if _, ok := summary.roleByMethod[method]; !ok {
summary.missingCount++
}
}
for method := range summary.roleByMethod {
if isExcludedServiceMethod(method) {
continue
}
if !containsString(summary.protoMethods, method) {
summary.staleCount++
}
}
}
for _, summary := range rbacSummaries {
if len(summary.protoMethods) == 0 {
if summary.requiresRPCs {
rep.Fail("%s — no RPC procedures found", summary.label)
} else {
rep.OK("%s — no proto-backed RPC procedures", summary.label)
}
continue
}
rep.OK("%s — %d RPC methods discovered", summary.label, len(summary.protoMethods))
if summary.missingCount > 0 {
rep.Findingf("missing RBAC entries: %d", summary.missingCount)
}
if summary.staleCount > 0 {
rep.Findingf("stale RBAC entries: %d", summary.staleCount)
}
}
if len(missing) > 0 {
rep.Fail("%d RPC method(s) NOT in MethodRoles (will get permission_denied)", len(missing))
for _, m := range missing {
rep.Findingf("- %s", m)
}
}
if len(stale) > 0 {
rep.Warn("%d RBAC entries have no matching proto method (stale?)", len(stale))
for _, m := range stale {
rep.Findingf("- %s", m)
}
}
if len(missing) == 0 && len(stale) == 0 {
rep.OK("All %d RPC methods are registered in MethodRoles", len(protoMethods))
} else if len(missing) == 0 {
rep.OK("All %d RPC methods are registered (but %d stale entries exist)", len(protoMethods), len(stale))
}
}
},
})
}