Files
Agent-System/internal/archtest/scan.go
T
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- FIND-SEC-01: GetUserContext requires session ownership (handler 403 +
  SQL/memory owner filter); in-memory HTTP + isolated-PG regressions
- FIND-ARCH-01: archtest fails closed on untracked table writes (+noise
  exemption) and registers 50 existing writer tables in table_owners
- FIND-SEC-02: Authorizer without pool denies by default; explicit
  NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition
- FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact
  basename allowlist match; prefix-spoof and no-shell-eval tests
- FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed
  hard-refuses validated_only steps (new error codes + HTTP mapping)
- FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
2026-09-26 02:00:14 +00:00

479 lines
12 KiB
Go

package archtest
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
const internalPrefix = "chat-agent-system/internal/"
var (
sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`)
eventNameRe = regexp.MustCompile(`(?:document|intent\.plan)\.[a-z0-9_.]+`)
)
// sqlScanNoise 扫描正则的已知假阳性词汇:来自 SQL 子句(ON CONFLICT DO
// UPDATE SET、FOR UPDATE OF/SKIP LOCKED)与注释散文,不是真实表名。
// 它们不是策略豁免:新出现的未登记表仍会 fail-closed。
var sqlScanNoise = map[string]bool{
"set": true, "of": true, "skip": true, "below": true, "row": true,
"state": true, "step": true, "doc": true, "docgov": true,
"message": true, "project": true, "aggregation": true, "ref_count": true,
}
type fileClass int
const (
classIgnore fileClass = iota
classProduction
classTestOrFixture
classGoose
)
type ScanOptions struct {
Root string
// ExtraRoots are additional trees scanned as production Go (used by self-tests).
ExtraRoots []string
// ExtraSQL are extra files classified as goose/fixture SQL.
ExtraSQL []string
}
func Scan(opts ScanOptions, b *Boundaries) ([]Finding, error) {
var findings []Finding
roots := append([]string{filepath.Join(opts.Root, "internal")}, opts.ExtraRoots...)
fset := token.NewFileSet()
for _, root := range roots {
if root == "" {
continue
}
if _, err := os.Stat(root); err != nil {
continue
}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
name := d.Name()
if name == "testdata" || name == "vendor" || name == ".git" {
return filepath.SkipDir
}
return nil
}
class := classifyFile(opts.Root, path)
if strings.HasSuffix(path, ".go") {
switch class {
case classProduction:
fs, perr := scanProductionGo(fset, opts.Root, path, b)
if perr != nil {
return perr
}
findings = append(findings, fs...)
case classTestOrFixture, classGoose, classIgnore:
// Goose / tests / fixtures may mention tables and paths; they are not production writers.
}
}
return nil
})
if err != nil {
return nil, err
}
}
for _, sqlPath := range opts.ExtraSQL {
// Explicit proof that goose SQL is classified away from production writes.
if classifyFile(opts.Root, sqlPath) != classGoose && !strings.Contains(filepath.ToSlash(sqlPath), "goose") {
continue
}
}
return dedupe(findings), nil
}
func classifyFile(repoRoot, path string) fileClass {
rel := path
if repoRoot != "" {
if r, err := filepath.Rel(repoRoot, path); err == nil {
rel = r
}
}
slash := filepath.ToSlash(rel)
base := filepath.Base(path)
if strings.Contains(slash, "/testdata/") || strings.HasPrefix(slash, "testdata/") {
if strings.Contains(slash, "/goose/") || strings.HasPrefix(base, "000") && strings.HasSuffix(base, ".sql") {
return classGoose
}
if strings.HasSuffix(base, "_test.go") || strings.Contains(slash, "/fixture") || strings.Contains(slash, "/fixtures/") {
if strings.HasSuffix(base, ".go") && !strings.HasSuffix(base, "_test.go") && strings.Contains(slash, "/prod/") {
return classProduction
}
return classTestOrFixture
}
}
if strings.HasPrefix(slash, "deploy/postgres/goose/") || strings.Contains(slash, "/postgres/goose/") {
return classGoose
}
if strings.HasSuffix(base, "_test.go") {
return classTestOrFixture
}
if strings.HasSuffix(base, ".go") && strings.HasPrefix(slash, "internal/") {
if strings.HasPrefix(slash, "internal/archtest/") {
return classIgnore
}
return classProduction
}
if strings.HasSuffix(base, ".go") && (strings.Contains(slash, "/prod/") || strings.Contains(slash, "fixtures/prod/")) {
return classProduction
}
return classIgnore
}
func scanProductionGo(fset *token.FileSet, repoRoot, path string, b *Boundaries) ([]Finding, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
file, err := parser.ParseFile(fset, path, src, parser.SkipObjectResolution)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
pkg := packageID(repoRoot, path)
rel := relFile(repoRoot, path)
var out []Finding
out = append(out, scanImports(file, pkg, rel, b)...)
out = append(out, scanSQLWrites(string(src), pkg, rel, b)...)
out = append(out, scanPaths(file, pkg, rel, b)...)
out = append(out, scanEvents(file, string(src), pkg, rel, b)...)
out = append(out, scanUnwrap(string(src), pkg, rel, b)...)
out = append(out, scanDocflowLiteral(string(src), pkg, rel)...)
return out, nil
}
func packageID(repoRoot, path string) string {
rel := relFile(repoRoot, path)
slash := filepath.ToSlash(rel)
slash = strings.TrimPrefix(slash, "internal/")
dir := filepath.ToSlash(filepath.Dir(slash))
if dir == "." {
return filepath.Base(filepath.Dir(path))
}
return dir
}
func relFile(repoRoot, path string) string {
if repoRoot == "" {
return filepath.ToSlash(path)
}
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(rel)
}
func scanImports(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
allowed, ok := b.Imports[pkg]
if !ok {
// Unknown production package is itself a new import-graph node.
return []Finding{{Kind: KindImport, Package: pkg, File: rel, Symbol: pkg, Detail: "package missing from import allowlist"}}
}
var out []Finding
for _, spec := range file.Imports {
path := strings.Trim(spec.Path.Value, `"`)
if !strings.HasPrefix(path, internalPrefix) {
continue
}
dep := strings.TrimPrefix(path, internalPrefix)
if dep == pkg {
continue
}
if !allowedHas(allowed, dep) {
out = append(out, Finding{
Kind: KindImport,
Package: pkg,
File: rel,
Symbol: dep,
Detail: fmt.Sprintf("production import %s -> %s", pkg, dep),
})
}
}
return out
}
func scanSQLWrites(src, pkg, rel string, b *Boundaries) []Finding {
var out []Finding
for _, m := range sqlWriteRe.FindAllStringSubmatch(src, -1) {
table := strings.ToLower(m[2])
owners, tracked := b.TableOwners[table]
if !tracked {
// FIND-ARCH-01 回归:未登记表不再静默跳过(fail-closed)。
// 仅豁免扫描正则的已知假阳性词汇;真实新表必须登记 table_owners。
if !sqlScanNoise[table] {
out = append(out, Finding{
Kind: KindSQLWrite,
Package: pkg,
File: rel,
Symbol: table,
Detail: fmt.Sprintf("write to untracked table %s: register it in module-boundaries.yaml table_owners", table),
})
}
continue
}
if allowedHas(owners, pkg) {
continue
}
out = append(out, Finding{
Kind: KindSQLWrite,
Package: pkg,
File: rel,
Symbol: table,
Detail: fmt.Sprintf("%s writes %s", strings.ToLower(strings.Join(strings.Fields(m[1]), " ")), table),
})
}
return out
}
func scanPaths(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
consts := map[string]string{}
ast.Inspect(file, func(n ast.Node) bool {
vs, ok := n.(*ast.ValueSpec)
if !ok {
return true
}
for i, name := range vs.Names {
if i < len(vs.Values) {
if lit, ok := vs.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING {
consts[name.Name] = strings.Trim(lit.Value, `"`)
}
}
}
return true
})
var out []Finding
seen := map[string]bool{}
add := func(symbol, detail string) {
key := symbol + "|" + detail
if seen[key] {
return
}
seen[key] = true
owners, tracked := b.PathOwners[symbol]
if !tracked {
return
}
if allowedHas(owners, pkg) {
return
}
out = append(out, Finding{
Kind: KindPath,
Package: pkg,
File: rel,
Symbol: symbol,
Detail: detail,
})
}
ast.Inspect(file, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.BasicLit:
if x.Kind != token.STRING {
return true
}
val := strings.Trim(x.Value, `"`)
for lit := range b.PathOwners {
if pathLiteralHit(val, lit) {
add(lit, "string literal "+lit)
}
}
case *ast.Ident:
if val, ok := consts[x.Name]; ok {
for lit := range b.PathOwners {
if pathLiteralHit(val, lit) {
add(lit, "indirect const "+x.Name)
}
}
}
case *ast.CallExpr:
if !isJoinLike(x.Fun) {
return true
}
for _, arg := range x.Args {
switch a := arg.(type) {
case *ast.BasicLit:
if a.Kind == token.STRING {
val := strings.Trim(a.Value, `"`)
for lit := range b.PathOwners {
if pathLiteralHit(val, lit) {
add(lit, "filepath.Join/path.Join")
}
}
}
case *ast.Ident:
if val, ok := consts[a.Name]; ok {
for lit := range b.PathOwners {
if pathLiteralHit(val, lit) {
add(lit, "filepath.Join via const "+a.Name)
}
}
}
}
}
case *ast.BinaryExpr:
if x.Op != token.ADD {
return true
}
vals := []string{litOrConst(x.X, consts), litOrConst(x.Y, consts)}
for _, val := range vals {
for lit := range b.PathOwners {
if pathLiteralHit(val, lit) {
add(lit, "string concatenation")
}
}
}
}
return true
})
return out
}
func litOrConst(e ast.Expr, consts map[string]string) string {
switch x := e.(type) {
case *ast.BasicLit:
if x.Kind == token.STRING {
return strings.Trim(x.Value, `"`)
}
case *ast.Ident:
return consts[x.Name]
}
return ""
}
func isJoinLike(fun ast.Expr) bool {
sel, ok := fun.(*ast.SelectorExpr)
if !ok {
return false
}
pkg, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
if sel.Sel.Name != "Join" && sel.Sel.Name != "FromSlash" {
return false
}
return pkg.Name == "filepath" || pkg.Name == "path"
}
func pathLiteralHit(val, lit string) bool {
if val == "" || lit == "" {
return false
}
if val == lit {
return true
}
return strings.Contains(val, lit)
}
func scanEvents(file *ast.File, src, pkg, rel string, b *Boundaries) []Finding {
var out []Finding
seen := map[string]bool{}
add := func(symbol, role, detail string) {
key := symbol + "|" + role
if seen[key] {
return
}
seen[key] = true
var owners []string
var tracked bool
if role == "producer" {
owners, tracked = b.EventProducers[symbol]
} else {
owners, tracked = b.EventConsumers[symbol]
}
if !tracked {
return
}
if allowedHas(owners, pkg) {
return
}
out = append(out, Finding{
Kind: KindEvent,
Package: pkg,
File: rel,
Symbol: symbol,
Detail: detail,
})
}
if strings.Contains(src, "OnOutbox") {
isAssign := strings.Contains(src, "OnOutbox =")
isCallOrField := strings.Contains(src, "OnOutbox(") || strings.Contains(src, "OnOutbox func(")
if isCallOrField && !isAssign {
add("outbox.notify", "producer", "OnOutbox hook")
}
if isAssign {
add("outbox.notify", "consumer", "OnOutbox assignment")
}
}
for _, name := range eventNameRe.FindAllString(src, -1) {
if _, ok := b.EventProducers[name]; ok {
if allowedHas(b.EventConsumers[name], pkg) || allowedHas(b.EventConsumers["outbox.notify"], pkg) {
continue
}
add(name, "producer", "event name literal")
}
}
_ = file
return out
}
func scanUnwrap(src, pkg, rel string, b *Boundaries) []Finding {
if !strings.Contains(src, "uow.AsPG") {
return nil
}
if allowedHas(b.UnwrapOwners, pkg) {
return nil
}
return []Finding{{
Kind: KindImport,
Package: pkg,
File: rel,
Symbol: "uow.AsPG",
Detail: "application layer must not unwrap TxScope",
}}
}
func scanDocflowLiteral(src, pkg, rel string) []Finding {
if pkg != "server" {
return nil
}
if !strings.Contains(src, "docflow.Commands{") {
return nil
}
return []Finding{{
Kind: KindImport,
Package: pkg,
File: rel,
Symbol: "docflow.Commands",
Detail: "HTTP adapters must use the composition-root Commands instance",
}}
}
func dedupe(in []Finding) []Finding {
seen := map[string]bool{}
var out []Finding
for _, f := range in {
k := f.Key()
if seen[k] {
continue
}
seen[k] = true
out = append(out, f)
}
return out
}