mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:21:29 +00:00
e720474fb7
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
126 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|