Split monolithic rule into per-test rules, collect gathers facts only
This commit is contained in:
parent
5b71e85f49
commit
4177fcdc7b
14 changed files with 758 additions and 259 deletions
|
|
@ -58,8 +58,11 @@ func probeTypeString(ep contract.TLSEndpoint) string {
|
|||
|
||||
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
|
||||
// given endpoint and returns a populated TLSProbe. It never returns an error:
|
||||
// transport/handshake failures are recorded on the probe so the caller can
|
||||
// still surface them in the report.
|
||||
// transport/handshake failures are recorded on the probe as raw fields so
|
||||
// rules can classify them.
|
||||
//
|
||||
// This function MUST NOT decide severity or pass/fail: it only gathers
|
||||
// observation data. All judgement happens in CheckRules (see rules_*.go).
|
||||
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
|
||||
start := time.Now()
|
||||
host := strings.TrimSuffix(ep.Host, ".")
|
||||
|
|
@ -70,11 +73,13 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
}
|
||||
|
||||
p := TLSProbe{
|
||||
Host: host,
|
||||
Port: ep.Port,
|
||||
Endpoint: addr,
|
||||
Type: probeTypeString(ep),
|
||||
SNI: sni,
|
||||
Host: host,
|
||||
Port: ep.Port,
|
||||
Endpoint: addr,
|
||||
Type: probeTypeString(ep),
|
||||
SNI: sni,
|
||||
RequireSTARTTLS: ep.RequireSTARTTLS,
|
||||
STARTTLSDialect: ep.STARTTLS,
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
|
@ -83,13 +88,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
d := &net.Dialer{}
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
p.TCPError = err.Error()
|
||||
p.Error = "dial: " + err.Error()
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "tcp_unreachable",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err),
|
||||
Fix: "Check DNS, firewall, and that the service listens on this port.",
|
||||
})
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
|
@ -101,23 +101,28 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
|
||||
tlsConn, err := handshake(conn, ep, sni)
|
||||
if err != nil {
|
||||
p.HandshakeError = err.Error()
|
||||
p.Error = err.Error()
|
||||
p.Issues = append(p.Issues, classifyHandshakeError(ep, err))
|
||||
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
|
||||
p.STARTTLSNotOffered = true
|
||||
}
|
||||
if errors.Is(err, errUnsupportedStartTLSProto) {
|
||||
p.STARTTLSUnsupportedProto = true
|
||||
}
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
|
||||
p.TLSHandshakeOK = true
|
||||
state := tlsConn.ConnectionState()
|
||||
p.TLSVersionNum = state.Version
|
||||
p.TLSVersion = tls.VersionName(state.Version)
|
||||
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
|
||||
p.CipherSuiteID = state.CipherSuite
|
||||
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "no_peer_cert",
|
||||
Severity: SeverityCrit,
|
||||
Message: "Server presented no certificate.",
|
||||
})
|
||||
p.NoPeerCert = true
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
|
@ -130,16 +135,16 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId))
|
||||
}
|
||||
p.Subject = leaf.Subject.CommonName
|
||||
p.DNSNames = append(p.DNSNames, leaf.DNSNames...)
|
||||
p.DNSNames = leaf.DNSNames
|
||||
p.Chain = buildChain(state.PeerCertificates)
|
||||
|
||||
hostnameMatch := leaf.VerifyHostname(sni) == nil
|
||||
p.HostnameMatch = &hostnameMatch
|
||||
|
||||
// Chain verification against system roots, using intermediates presented
|
||||
// by the server. We run this independently from Go's tls.Config
|
||||
// verification so we can report a dedicated "chain invalid" issue rather
|
||||
// than failing the whole handshake.
|
||||
// by the server. Running it separately from tls.Config verification
|
||||
// means we can record it as a raw observation rather than aborting the
|
||||
// handshake, rules classify it afterwards.
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, c := range state.PeerCertificates[1:] {
|
||||
intermediates.AddCert(c)
|
||||
|
|
@ -152,48 +157,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
})
|
||||
chainValid := verifyErr == nil
|
||||
p.ChainValid = &chainValid
|
||||
if !chainValid {
|
||||
msg := "Invalid certificate chain"
|
||||
if verifyErr != nil {
|
||||
msg = "Invalid certificate chain: " + verifyErr.Error()
|
||||
}
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "chain_invalid",
|
||||
Severity: SeverityCrit,
|
||||
Message: msg,
|
||||
Fix: "Serve the full intermediate chain and ensure the root is trusted.",
|
||||
})
|
||||
}
|
||||
if !hostnameMatch {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "hostname_mismatch",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")),
|
||||
Fix: "Re-issue the certificate with a matching SAN.",
|
||||
})
|
||||
}
|
||||
if leaf.NotAfter.Before(now) {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "expired",
|
||||
Severity: SeverityCrit,
|
||||
Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339),
|
||||
Fix: "Renew the certificate.",
|
||||
})
|
||||
} else if leaf.NotAfter.Sub(now) < 14*24*time.Hour {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "expiring_soon",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")",
|
||||
Fix: "Renew before expiry.",
|
||||
})
|
||||
}
|
||||
if state.Version < tls.VersionTLS12 {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "weak_tls_version",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.",
|
||||
Fix: "Disable TLS 1.0/1.1 on the server.",
|
||||
})
|
||||
if verifyErr != nil {
|
||||
p.ChainVerifyErr = verifyErr.Error()
|
||||
}
|
||||
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
|
|
@ -202,8 +167,8 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
|
||||
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
|
||||
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
|
||||
// the chain separately in probe so an invalid chain becomes a structured
|
||||
// Issue rather than aborting the handshake.
|
||||
// the chain separately in probe so an invalid chain becomes a raw
|
||||
// observation rather than aborting the handshake.
|
||||
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
|
||||
cfg := &tls.Config{
|
||||
ServerName: sni,
|
||||
|
|
@ -220,7 +185,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
|
|||
|
||||
up, ok := starttlsUpgraders[ep.STARTTLS]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS)
|
||||
return nil, fmt.Errorf("%w: %q", errUnsupportedStartTLSProto, ep.STARTTLS)
|
||||
}
|
||||
if err := up(conn, sni); err != nil {
|
||||
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
|
||||
|
|
@ -232,34 +197,10 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
|
|||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// classifyHandshakeError converts a dial/handshake error into a structured
|
||||
// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic
|
||||
// for some endpoints) from hard failures.
|
||||
func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue {
|
||||
msg := err.Error()
|
||||
|
||||
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
|
||||
sev := SeverityWarn
|
||||
if ep.RequireSTARTTLS {
|
||||
sev = SeverityCrit
|
||||
}
|
||||
return Issue{
|
||||
Code: "starttls_not_offered",
|
||||
Severity: sev,
|
||||
Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg),
|
||||
Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.",
|
||||
}
|
||||
}
|
||||
|
||||
return Issue{
|
||||
Code: "handshake_failed",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg),
|
||||
Fix: "Inspect the server's TLS configuration and certificate.",
|
||||
}
|
||||
}
|
||||
|
||||
var errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||
var (
|
||||
errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||
errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol")
|
||||
)
|
||||
|
||||
func isStartTLSUnsupported(err error) bool {
|
||||
return errors.Is(err, errStartTLSNotOffered)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue