Files
certctl/internal/tlsprobe/probe.go
T
shankar0123 e720474fb7 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
2026-04-27 00:47:15 +00:00

126 lines
4.1 KiB
Go

package tlsprobe
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"net"
"time"
)
// ProbeResult contains the result of probing a TLS endpoint.
type ProbeResult struct {
Address string `json:"address"`
Success bool `json:"success"`
Fingerprint string `json:"fingerprint"` // SHA-256 hex fingerprint of leaf cert
TLSVersion string `json:"tls_version"` // e.g. "TLS 1.3"
CipherSuite string `json:"cipher_suite"` // e.g. "TLS_AES_128_GCM_SHA256"
Subject string `json:"subject"` // cert subject CN
Issuer string `json:"issuer"` // cert issuer CN
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
SerialNumber string `json:"serial_number"`
ResponseTimeMs int `json:"response_time_ms"`
Error string `json:"error,omitempty"`
}
// ProbeTLS connects to a TLS endpoint, performs a handshake, and extracts certificate metadata.
// It uses InsecureSkipVerify to discover all certificates including self-signed and expired ones.
// This is safe because the certificate data is extracted and analyzed, not validated for trust.
func ProbeTLS(ctx context.Context, address string, timeout time.Duration) ProbeResult {
startTime := time.Now()
result := ProbeResult{
Address: address,
Success: false,
}
dialer := &net.Dialer{
Timeout: timeout,
}
conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
// SECURITY NOTE: InsecureSkipVerify is intentionally set to true here.
// The health checker must monitor ALL certificates including self-signed,
// expired, and internal CA certificates. This setting is scoped to discovery
// probing only — it is NEVER used for control-plane API calls, issuer
// 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, //nolint:gosec // discovery probe; documented above + docs/tls.md L-001 table
})
if err != nil {
result.Error = err.Error()
result.ResponseTimeMs = int(time.Since(startTime).Milliseconds())
return result
}
defer conn.Close()
result.ResponseTimeMs = int(time.Since(startTime).Milliseconds())
result.Success = true
// Extract certificates from TLS connection state
state := conn.ConnectionState()
if len(state.PeerCertificates) > 0 {
cert := state.PeerCertificates[0]
result.Fingerprint = CertFingerprint(cert)
result.Subject = cert.Subject.CommonName
result.Issuer = cert.Issuer.CommonName
result.NotBefore = cert.NotBefore
result.NotAfter = cert.NotAfter
result.SerialNumber = cert.SerialNumber.Text(16)
}
// Extract TLS version string
result.TLSVersion = tlsVersionString(state.Version)
// Extract cipher suite name
result.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
return result
}
// CertFingerprint computes the SHA-256 fingerprint of a certificate (hex-encoded).
func CertFingerprint(cert *x509.Certificate) string {
fingerprintBytes := sha256.Sum256(cert.Raw)
return hex.EncodeToString(fingerprintBytes[:])
}
// CertKeyInfo extracts key algorithm name and size from a certificate.
// Returns algorithm name (e.g., "RSA", "ECDSA", "Ed25519") and key size in bits.
func CertKeyInfo(cert *x509.Certificate) (string, int) {
switch pub := cert.PublicKey.(type) {
case *rsa.PublicKey:
return "RSA", pub.N.BitLen()
case *ecdsa.PublicKey:
return "ECDSA", pub.Curve.Params().BitSize
default:
switch cert.PublicKeyAlgorithm {
case x509.Ed25519:
return "Ed25519", 256
default:
return cert.PublicKeyAlgorithm.String(), 0
}
}
}
// tlsVersionString converts a TLS version constant to a human-readable string.
func tlsVersionString(version uint16) string {
switch version {
case tls.VersionTLS10:
return "TLS 1.0"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS13:
return "TLS 1.3"
default:
return fmt.Sprintf("TLS 0x%x", version)
}
}