Files
nexarch/internal/archtest/errorleak.go
patrickandClaude Sonnet 5 301d695abc
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
API-21: architekturtest gegen fehlertext-leck in http-antworten
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:06:31 +02:00

222 lines
6.4 KiB
Go

// Package archtest scannt den Quellbaum eines Moduls statisch (AST, keine
// Typprüfung) und erzwingt zwei QA-15/API-20/API-21-Regeln, die sich zur
// Laufzeit allein nicht garantieren lassen: kein Fehlertext direkt in eine
// HTTP-Antwort, und jeder HTTP-Dienst läuft über httpharden.
package archtest
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
)
// AllowDirective markiert eine bewusste, begründete Ausnahme. Die Zeile mit
// dem Fund (oder die Zeile direkt darüber) muss den Kommentar
// "// archtest:allow <Begründung>" tragen, sonst zählt der Fund.
const AllowDirective = "archtest:allow"
// Finding ist ein einzelner Regelverstoß mit Fundstelle.
type Finding struct {
File string
Line int
Rule string
Text string
}
func (f Finding) String() string {
return fmt.Sprintf("%s:%d: [%s] %s", f.File, f.Line, f.Rule, f.Text)
}
// Scan durchläuft alle .go-Dateien unter root (Modulwurzel), ausser Tests,
// vendor und den explizit ausgenommenen Paketen (relative Pfade ab root,
// z.B. "internal/apierror", "internal/httpharden" - dort ist das Schreiben
// von Fehlertexten bzw. der rohe ListenAndServe-Aufruf genau die erlaubte
// Implementierung der Regel selbst, kein Verstoss dagegen).
func Scan(root string, exemptPackages []string) ([]Finding, error) {
var findings []Finding
fset := token.NewFileSet()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := info.Name()
if base == "vendor" || base == "node_modules" || strings.HasPrefix(base, ".") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return relErr
}
relDir := filepath.ToSlash(filepath.Dir(rel))
for _, ex := range exemptPackages {
if relDir == ex || strings.HasPrefix(relDir, ex+"/") {
return nil
}
}
src, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
file, parseErr := parser.ParseFile(fset, path, src, parser.ParseComments)
if parseErr != nil {
return fmt.Errorf("parsen von %s: %w", path, parseErr)
}
allowedLines := allowedLinesInFile(fset, file)
lines := strings.Split(string(src), "\n")
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
pos := fset.Position(call.Pos())
if allowedLines[pos.Line] || (pos.Line > 0 && allowedLines[pos.Line-1]) {
return true
}
if isErrorLeakSink(call) {
findings = append(findings, Finding{
File: rel, Line: pos.Line, Rule: "err-in-response",
Text: strings.TrimSpace(lineText(lines, pos.Line)),
})
}
if isRawListenAndServe(call) {
findings = append(findings, Finding{
File: rel, Line: pos.Line, Rule: "raw-listen-and-serve",
Text: strings.TrimSpace(lineText(lines, pos.Line)),
})
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return findings, nil
}
func lineText(lines []string, n int) string {
if n-1 < 0 || n-1 >= len(lines) {
return ""
}
return lines[n-1]
}
// allowedLinesInFile sammelt alle Zeilen, deren Kommentar AllowDirective
// enthaelt (die Kommentarzeile selbst zaehlt als "allowed", ausserdem prueft
// Scan zusaetzlich die Vorzeile - deckt sowohl "// archtest:allow ..." direkt
// ueber dem Aufruf als auch einen Trailing-Kommentar in derselben Zeile ab).
func allowedLinesInFile(fset *token.FileSet, file *ast.File) map[int]bool {
allowed := map[int]bool{}
for _, cg := range file.Comments {
for _, c := range cg.List {
if strings.Contains(c.Text, AllowDirective) {
allowed[fset.Position(c.Pos()).Line] = true
}
}
}
return allowed
}
// isErrorLeakSink erkennt Aufrufe der Form w.Write(...), fmt.Fprintf(w, ...),
// fmt.Fprint(w, ...), fmt.Fprintln(w, ...) und json.NewEncoder(w).Encode(...),
// bei denen ein Argument (an beliebiger Verschachtelungstiefe) syntaktisch
// ein X.Error()-Aufruf ist. Ohne Typpruefung wird "w" als Namenskonvention
// fuer http.ResponseWriter genutzt (durchgaengig so in diesem Code
// verwendet, u.a. in apierror.Write selbst) - das erzeugt bewusst false
// negatives statt false positives bei anders benannten Variablen, die dann
// ueber Codereview auffallen muessten, nicht ueber diesen Test.
func isErrorLeakSink(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
switch sel.Sel.Name {
case "Write":
if ident, ok := sel.X.(*ast.Ident); !ok || ident.Name != "w" {
return false
}
case "Fprintf", "Fprint", "Fprintln":
pkg, ok := sel.X.(*ast.Ident)
if !ok || pkg.Name != "fmt" || len(call.Args) == 0 {
return false
}
if ident, ok := call.Args[0].(*ast.Ident); !ok || ident.Name != "w" {
return false
}
case "Encode":
inner, ok := sel.X.(*ast.CallExpr)
if !ok {
return false
}
innerSel, ok := inner.Fun.(*ast.SelectorExpr)
if !ok || innerSel.Sel.Name != "NewEncoder" || len(inner.Args) == 0 {
return false
}
if ident, ok := inner.Args[0].(*ast.Ident); !ok || ident.Name != "w" {
return false
}
default:
return false
}
for _, arg := range call.Args {
if containsErrorCall(arg) {
return true
}
}
return false
}
// containsErrorCall sucht rekursiv nach einem Aufruf ....Error() in arg.
func containsErrorCall(n ast.Node) bool {
found := false
ast.Inspect(n, func(node ast.Node) bool {
if found {
return false
}
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" && len(call.Args) == 0 {
found = true
return false
}
return true
})
return found
}
// isRawListenAndServe erkennt http.ListenAndServe(...) und
// (&http.Server{...}).ListenAndServe() bzw. srv.ListenAndServe() auf einer
// lokal deklarierten *http.Server-Variablen ausserhalb von httpharden.
// Aus Praezisionsgruenden (kein Typchecker) wird nur der eindeutige Fall
// http.ListenAndServe(...) erkannt, das reicht fuer diesen Codebestand
// (alle bekannten Dienste rufen entweder httpharden.ListenAndServe*, oder
// direkt http.ListenAndServe auf - letzteres ist genau der zu verbietende
// Fall).
func isRawListenAndServe(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "ListenAndServe" {
return false
}
pkg, ok := sel.X.(*ast.Ident)
return ok && pkg.Name == "http"
}