mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
Bundle D: Documentation & transparency sweep — 8 findings closed
Closes H-009 + L-001 + L-007 + L-008 + L-016 + L-017 + L-018 + M-027
from comprehensive-audit-2026-04-25.
H-009 — README JWT verified-already-clean
README has zero JWT mentions at audit time. docs/architecture.md
correctly documents JWT/OIDC integration via authenticating-gateway
pattern (line 905-912).
.github/workflows/ci.yml: new step
'Forbidden README JWT advertising regression guard (H-009)'
greps README for JWT-as-supported phrasing; passes verbatim
(gateway / pre-G-1) but fails build on net-new advertising.
L-001 (CWE-295) — InsecureSkipVerify per-site justification
Audit count was 8; recon found 13 production sites.
docs/tls.md: new 'InsecureSkipVerify justifications' table
enumerates each site by file:line with per-site rationale.
cmd/agent/verify.go:78, internal/tlsprobe/probe.go:54,
internal/service/network_scan.go:460: each previously-bare
InsecureSkipVerify: true now carries //nolint:gosec.
.github/workflows/ci.yml: new step
'Forbidden bare InsecureSkipVerify regression guard (L-001)'
fails build if any net-new ISV lands in non-test .go without
nolint:gosec on the same or preceding line.
L-007 — README dependency-audit commands
README.md: new Dependencies section with go list -m all | wc -l,
go mod why, govulncheck ./.... Honors operating-rules invariant.
L-008 — Release-time govulncheck gate
.github/workflows/release.yml: new 'Install govulncheck' +
'Run govulncheck (release gate)' steps in the matrix job.
Pinned to same install path as ci.yml. Default exit code
semantics (fail on called-vuln only, deferred-call advisories
tracked on master via L-021) keeps the gate appropriate.
L-016 — architecture.md drift fixes
docs/architecture.md: system-components diagram's '21 tables'
annotation removed (current 23; replaced with TEXT-keys
descriptor); connector-architecture '9 connectors' prose
replaced with grep ref + current 12-issuer list (added
Entrust/GlobalSign/EJBCA which were missing); API-design
'97 operations / 107 total' replaced with grep commands.
Connector subgraphs verified-current at 12/13/6.
L-017 — workspace CLAUDE.md verified-already-clean
Bundle B's pre-commit-gate refactor already converted current-
state numeric claims to grep commands. Phase 0 recon confirmed
zero remaining hardcoded counts.
L-018 — Defect age table
cowork/comprehensive-audit-2026-04-25/defect-age.md (NEW):
Tabulates all 9 High findings with first-mentioned commit,
closing bundle, days-open. Methodology snippet for re-running.
Key finding: 8 of 9 closed within 24h of audit publication.
M-027 — OpenAPI parity verified-already-clean
Audit's 'router 121 vs OpenAPI 125 — 4-op gap' was wrong
methodology. The 4-op 'gap' was exactly the 4 routes registered
via r.mux.Handle (auth-exempt allowlist) instead of r.Register.
When you count both dispatch shapes the totals match exactly.
internal/api/router/openapi_parity_test.go (NEW):
TestRouter_OpenAPIParity AST-walks router.go for both
Register and mux.Handle calls + walks api/openapi.yaml's
path/method nesting + asserts the sets match. Adding a route
without updating the spec fails CI permanently.
Audit deliverables:
audit-report.md: score 38/55 -> 46/55 closed
(High 7/9 -> 8/9; Medium 20/27 -> 21/27; Low 8/19 -> 14/19)
findings.yaml: 8 status flips open -> closed
defect-age.md: new file
certctl/CHANGELOG.md: Bundle D section
Verification:
TestRouter_OpenAPIParity PASS
L-001 grep guard self-test (after //nolint:gosec adds) PASS
H-009 grep guard self-test PASS
go test -count=1 -short on changed packages green
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Bundle D / Audit M-027: pin the router ↔ OpenAPI spec parity.
|
||||
//
|
||||
// The audit reported "router 121 vs OpenAPI 125 — 4 op gap" by counting
|
||||
// r.Register call sites with a regex. That methodology is incomplete: the
|
||||
// router additionally registers 4 routes via direct r.mux.Handle calls
|
||||
// (the Bundle B / M-002 AuthExemptRouterRoutes — health/ready/auth-info/
|
||||
// version). When you count BOTH dispatch shapes the totals match exactly.
|
||||
//
|
||||
// This test:
|
||||
// 1. Walks router.go's AST to enumerate every (method, path) tuple from
|
||||
// both r.Register AND r.mux.Handle sites.
|
||||
// 2. Walks api/openapi.yaml's path/method nesting to enumerate every
|
||||
// documented operation.
|
||||
// 3. Asserts the two sets are identical (modulo a tiny exception list
|
||||
// for routes that legitimately don't appear in the spec).
|
||||
//
|
||||
// Adding a new route without updating openapi.yaml fails this test.
|
||||
|
||||
// SpecParityExceptions is the documented allowlist of (method, path)
|
||||
// tuples that are intentionally NOT in api/openapi.yaml. Each entry must
|
||||
// have a justification — typically "internal" or "non-stable surface".
|
||||
//
|
||||
// At Bundle D close time, this list is empty. Future entries should be
|
||||
// rare — the OpenAPI spec is the source of truth for the public API
|
||||
// surface.
|
||||
var SpecParityExceptions = map[string]string{}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
routes, err := scanRouterRoutes("router.go")
|
||||
if err != nil {
|
||||
t.Fatalf("scan router.go: %v", err)
|
||||
}
|
||||
specOps, err := scanOpenAPIOperations("../../../api/openapi.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("scan openapi.yaml: %v", err)
|
||||
}
|
||||
|
||||
routeSet := make(map[string]bool, len(routes))
|
||||
for _, r := range routes {
|
||||
routeSet[r] = true
|
||||
}
|
||||
specSet := make(map[string]bool, len(specOps))
|
||||
for _, o := range specOps {
|
||||
specSet[o] = true
|
||||
}
|
||||
|
||||
var inRouterNotSpec, inSpecNotRouter []string
|
||||
for r := range routeSet {
|
||||
if !specSet[r] {
|
||||
if _, allow := SpecParityExceptions[r]; !allow {
|
||||
inRouterNotSpec = append(inRouterNotSpec, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
for s := range specSet {
|
||||
if !routeSet[s] {
|
||||
inSpecNotRouter = append(inSpecNotRouter, s)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(inRouterNotSpec)
|
||||
sort.Strings(inSpecNotRouter)
|
||||
|
||||
if len(inRouterNotSpec) > 0 {
|
||||
t.Errorf("routes in router.go but missing from api/openapi.yaml (%d):\n %s\n\n"+
|
||||
"Add the operation to openapi.yaml OR add an explicit exception to "+
|
||||
"SpecParityExceptions with a justification.",
|
||||
len(inRouterNotSpec), strings.Join(inRouterNotSpec, "\n "))
|
||||
}
|
||||
if len(inSpecNotRouter) > 0 {
|
||||
t.Errorf("operations in api/openapi.yaml but missing from router.go (%d):\n %s\n\n"+
|
||||
"Either implement the endpoint or remove it from openapi.yaml.",
|
||||
len(inSpecNotRouter), strings.Join(inSpecNotRouter, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
func scanRouterRoutes(name string) ([]string, error) {
|
||||
fset := token.NewFileSet()
|
||||
src, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
ast.Inspect(src, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
// We care about r.mux.Handle("METHOD /path", ...) and
|
||||
// r.Register("METHOD /path", ...). Both have a string literal as
|
||||
// arg[0].
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
isMuxHandle := false
|
||||
isRegister := sel.Sel.Name == "Register"
|
||||
if sel.Sel.Name == "Handle" {
|
||||
if inner, ok := sel.X.(*ast.SelectorExpr); ok && inner.Sel.Name == "mux" {
|
||||
isMuxHandle = true
|
||||
}
|
||||
}
|
||||
if !isMuxHandle && !isRegister {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
v := strings.Trim(lit.Value, "\"`")
|
||||
// Skip the generic Register helper itself (line 38: r.mux.Handle(pattern,...)
|
||||
// — pattern is a func arg, not a literal, so it would not be a BasicLit).
|
||||
// Skip non-METHOD-prefixed strings (defensive).
|
||||
if !looksLikeMethodPath(v) {
|
||||
return true
|
||||
}
|
||||
out = append(out, v)
|
||||
return true
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var methodPathRe = regexp.MustCompile(`^(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD) /`)
|
||||
|
||||
func looksLikeMethodPath(s string) bool {
|
||||
return methodPathRe.MatchString(s)
|
||||
}
|
||||
|
||||
// scanOpenAPIOperations walks openapi.yaml's paths block and returns
|
||||
// every (METHOD, PATH) tuple in the same "METHOD /path" string shape the
|
||||
// router uses. Naive but sufficient: the spec is hand-maintained YAML
|
||||
// with consistent 2-space-then-4-space indentation.
|
||||
func scanOpenAPIOperations(path string) ([]string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
inPaths := false
|
||||
currentPath := ""
|
||||
pathRe := regexp.MustCompile(`^ (/[^:]+):\s*$`)
|
||||
methodRe := regexp.MustCompile(`^ (get|post|put|delete|patch|options|head):\s*$`)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
if strings.HasPrefix(line, "paths:") {
|
||||
inPaths = true
|
||||
continue
|
||||
}
|
||||
if inPaths && line != "" && !strings.HasPrefix(line, " ") {
|
||||
inPaths = false
|
||||
continue
|
||||
}
|
||||
if !inPaths {
|
||||
continue
|
||||
}
|
||||
if m := pathRe.FindStringSubmatch(line); m != nil {
|
||||
currentPath = m[1]
|
||||
continue
|
||||
}
|
||||
if m := methodRe.FindStringSubmatch(line); m != nil && currentPath != "" {
|
||||
out = append(out, strings.ToUpper(m[1])+" "+currentPath)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -457,7 +457,7 @@ func (s *NetworkScanService) probeTLS(ctx context.Context, address string, timeo
|
||||
// connector communication, or any operation that trusts the certificate.
|
||||
// The endpoint's certificate chain is extracted and analyzed, not validated.
|
||||
// See TICKET-016 for full security audit rationale.
|
||||
InsecureSkipVerify: true,
|
||||
InsecureSkipVerify: true, //nolint:gosec // discovery probe; documented above + docs/tls.md L-001 table
|
||||
})
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
|
||||
@@ -51,7 +51,7 @@ func ProbeTLS(ctx context.Context, address string, timeout time.Duration) ProbeR
|
||||
// connector communication, or any operation that trusts the certificate.
|
||||
// The endpoint's certificate chain is extracted and analyzed, not validated.
|
||||
// See TICKET-016 for full security audit rationale.
|
||||
InsecureSkipVerify: true,
|
||||
InsecureSkipVerify: true, //nolint:gosec // discovery probe; documented above + docs/tls.md L-001 table
|
||||
})
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
|
||||
Reference in New Issue
Block a user