mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:01:31 +00:00
119986fa7e
The webhook notifier would previously accept any operator-configured URL
and hand it to http.Client without validation. That exposed two
SSRF classes (CWE-918):
* Reserved-address reachability — a misconfigured or adversarial
webhook URL pointing at 127.0.0.1, ::1, 169.254.169.254 (cloud
metadata), or 0.0.0.0 would succeed, exfiltrating request bodies
to local services or leaking short-lived cloud credentials.
* DNS rebinding — a hostname resolving to a public IP at validation
time and to a reserved IP at dial time would bypass any
URL-string-only check.
Fix installs two independent layers:
* validation.ValidateSafeURL runs at config-ingest time and before
every outbound POST. It rejects non-HTTP(S) schemes, empty hosts,
and literal reserved-IP hosts with a clear operator-facing error.
This is a fast early diagnostic.
* validation.SafeHTTPDialContext is installed on the webhook
http.Transport. It re-resolves the host at dial time, rejects any
resolved address whose address lies in a reserved range (loopback,
link-local, multicast, broadcast, unspecified, IPv6
link-local/multicast), and pins the resolved IP into the final
dial address so the TLS handshake targets the exact IP the guard
approved. This is the authoritative, TOCTOU-safe defence against
DNS rebinding.
The two layers are complementary — validateURL fails fast on obvious
misconfiguration; SafeHTTPDialContext fails closed when DNS changes
between validation and dial.
The existing unexported isReservedIP helper in
internal/service/network_scan.go is extracted into
internal/validation.IsReservedIP with byte-identical behaviour so the
webhook notifier and the network scanner share a single authoritative
reserved-address list. RFC 1918 ranges remain intentionally allowed
(certctl's self-hosted design). Broader unspecified / IPv6 link-local
coverage lives only in the stricter dial-time policy, where it belongs
for outbound HTTP egress.
Test seam: Connector gains an unexported validateURL func field and a
same-package newForTest constructor that installs a permissive
validator and the stdlib default transport. Production callers cannot
reach this constructor because it is unexported; only same-package
tests (package webhook) can use it. Same-package happy-path tests call
newForTest so they can point at httptest loopback servers without
being blocked by the production guard. The four SSRF-rejection tests
that verify the guard itself still call New so they exercise the real,
strict validator. This keeps the production SSRF defence
unconditionally on in real code while preserving legitimate unit-test
coverage.
Tests
-----
* internal/validation/ssrf_test.go (new) — 16-subtest pin on
IsReservedIP that is byte-identical with the original network-
scanner behaviour; ValidateSafeURL accept/reject matrix covering
HTTPS/HTTP, reserved-literal IPv4/IPv6, dangerous schemes
(file/gopher/ftp/javascript/data/ldap/dict/jar), missing hosts,
and malformed inputs; SafeHTTPDialContext rejects literal reserved
addresses and hosts resolving to reserved addresses (DNS-rebinding
coverage via localhost).
* internal/connector/notifier/webhook/webhook_test.go — happy-path
tests switched to newForTest; production-guard SSRF-rejection
tests (TestValidateConfig_RejectsReservedURLs,
TestValidateConfig_RejectsDangerousScheme,
TestPostWebhook_RejectsReservedURL,
TestPostWebhook_RejectsDangerousScheme) continue to call New so
they exercise the unconditionally-installed production validator.
Wire-format invariants preserved
--------------------------------
* Outbound HTTP request shape (method, headers, body, HMAC
signature) unchanged.
* network_scan.go behaviour unchanged — validation.IsReservedIP is
byte-identical with the deleted helper.
* RFC 1918 (10/8, 172.16/12, 192.168/16) remain allowed for both
outbound webhook and CIDR expansion, matching the self-hosted
design.
Verification
------------
* go test -race ./internal/validation/... ./internal/connector/
notifier/webhook/... ./internal/service/... — green.
* Full-suite go test -race ./... — green (GOTMPDIR=/dev/shm to
sidestep full /tmp on the sandbox host).
* Coverage gates pass: service 68.8% >= 55%, handler 83.6% >= 60%,
domain 82.0% >= 40%, middleware 63.8% >= 30%. Overall 67.8%.
Webhook package 91.5% line coverage; validation package
ValidateSafeURL/SafeHTTPDialContext 78-100% per function.
* govulncheck ./... — no vulnerabilities found.
* golangci-lint run on touched H-4 production code — clean. Pre-
existing errcheck/gosimple warnings in scope-adjacent files
(webhook_test.go:270 w.Write, network_scan.go:120/173/265/305)
verified against 3853b74 to predate this commit; left alone per
scope guard.
Operational notes
-----------------
* No migration needed. The guard is pure Go code; existing webhook
configs continue to work unless they point at reserved addresses,
in which case they now fail closed with a clear error.
* Existing operators who rely on webhook POST to 127.0.0.1 or
::1 (e.g., local receivers on the same host as certctl-server)
must expose their receiver on an RFC 1918 address or public IP.
This is deliberate — the threat model for webhook notifiers
includes untrusted operator-supplied URLs.
Scope guard: H-4 only. H-5, H-6, M-*, L-*, and I-* findings remain
open and are tracked separately. No drive-by refactors.
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/repository"
|
|
"github.com/shankar0123/certctl/internal/tlsprobe"
|
|
"github.com/shankar0123/certctl/internal/validation"
|
|
)
|
|
|
|
// SentinelAgentID is the agent ID used for network-discovered certificates.
|
|
// This allows the existing discovery dedup constraint (fingerprint, agent_id, source_path)
|
|
// to work without schema changes.
|
|
const SentinelAgentID = "server-scanner"
|
|
|
|
// NetworkScanService manages active TLS scanning of network endpoints.
|
|
type NetworkScanService struct {
|
|
networkScanRepo repository.NetworkScanRepository
|
|
discoveryService *DiscoveryService
|
|
auditService *AuditService
|
|
logger *slog.Logger
|
|
concurrency int
|
|
}
|
|
|
|
// NewNetworkScanService creates a new network scan service.
|
|
func NewNetworkScanService(
|
|
networkScanRepo repository.NetworkScanRepository,
|
|
discoveryService *DiscoveryService,
|
|
auditService *AuditService,
|
|
logger *slog.Logger,
|
|
) *NetworkScanService {
|
|
return &NetworkScanService{
|
|
networkScanRepo: networkScanRepo,
|
|
discoveryService: discoveryService,
|
|
auditService: auditService,
|
|
logger: logger,
|
|
concurrency: 50,
|
|
}
|
|
}
|
|
|
|
// ListTargets returns all network scan targets.
|
|
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
|
return s.networkScanRepo.List(ctx)
|
|
}
|
|
|
|
// GetTarget retrieves a network scan target by ID.
|
|
func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error) {
|
|
return s.networkScanRepo.Get(ctx, id)
|
|
}
|
|
|
|
// maxCIDRHostBits is the maximum number of host bits allowed in a CIDR range.
|
|
// A /20 network has 12 host bits = 4096 IPs max. This prevents operators from
|
|
// accidentally creating scan targets that would exhaust server resources.
|
|
const maxCIDRHostBits = 12
|
|
|
|
// validateCIDRs validates a list of CIDRs for syntax correctness and size limits.
|
|
// Each CIDR must be a valid CIDR notation or plain IP address, and no single CIDR
|
|
// may be larger than /20 (4096 IPs). This validation runs at API request time so
|
|
// operators get an immediate 400 error instead of a silent truncation at scan time.
|
|
func validateCIDRs(cidrs []string) error {
|
|
for _, cidr := range cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
// Try parsing as plain IP (single host)
|
|
if ip := net.ParseIP(cidr); ip == nil {
|
|
return fmt.Errorf("invalid CIDR or IP: %s", cidr)
|
|
}
|
|
continue // Single IPs are always valid size
|
|
}
|
|
// Enforce /20 size cap at API level
|
|
ones, bits := ipNet.Mask.Size()
|
|
hostBits := bits - ones
|
|
if hostBits > maxCIDRHostBits {
|
|
return fmt.Errorf("CIDR %s is too large (/%d has %d host bits, max /%d with %d host bits = 4096 IPs)",
|
|
cidr, ones, hostBits, bits-maxCIDRHostBits, maxCIDRHostBits)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateTarget creates a new network scan target.
|
|
func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
if target.Name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
if len(target.CIDRs) == 0 {
|
|
return nil, fmt.Errorf("at least one CIDR is required")
|
|
}
|
|
// Validate CIDRs (syntax + /20 size cap)
|
|
if err := validateCIDRs(target.CIDRs); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(target.Ports) == 0 {
|
|
target.Ports = []int64{443}
|
|
}
|
|
if target.ScanIntervalHours == 0 {
|
|
target.ScanIntervalHours = 6
|
|
}
|
|
if target.TimeoutMs == 0 {
|
|
target.TimeoutMs = 5000
|
|
}
|
|
target.ID = generateID("nst")
|
|
target.Enabled = true
|
|
target.CreatedAt = time.Now()
|
|
target.UpdatedAt = time.Now()
|
|
|
|
if err := s.networkScanRepo.Create(ctx, target); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser,
|
|
"network_scan_target_created", "network_scan_target", target.ID,
|
|
map[string]interface{}{
|
|
"name": target.Name,
|
|
"cidrs": target.CIDRs,
|
|
"ports": target.Ports,
|
|
})
|
|
|
|
return target, nil
|
|
}
|
|
|
|
// UpdateTarget updates an existing network scan target.
|
|
func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
existing, err := s.networkScanRepo.Get(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if target.Name != "" {
|
|
existing.Name = target.Name
|
|
}
|
|
if len(target.CIDRs) > 0 {
|
|
// Validate new CIDRs (syntax + /20 size cap)
|
|
if err := validateCIDRs(target.CIDRs); err != nil {
|
|
return nil, err
|
|
}
|
|
existing.CIDRs = target.CIDRs
|
|
}
|
|
if len(target.Ports) > 0 {
|
|
existing.Ports = target.Ports
|
|
}
|
|
if target.ScanIntervalHours > 0 {
|
|
existing.ScanIntervalHours = target.ScanIntervalHours
|
|
}
|
|
if target.TimeoutMs > 0 {
|
|
existing.TimeoutMs = target.TimeoutMs
|
|
}
|
|
// Always update enabled field (it's a boolean, so 0-value is meaningful)
|
|
existing.Enabled = target.Enabled
|
|
|
|
if err := s.networkScanRepo.Update(ctx, existing); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return existing, nil
|
|
}
|
|
|
|
// DeleteTarget removes a network scan target.
|
|
func (s *NetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
|
if err := s.networkScanRepo.Delete(ctx, id); err != nil {
|
|
return fmt.Errorf("failed to delete network scan target: %w", err)
|
|
}
|
|
|
|
s.auditService.RecordEvent(ctx, "operator", domain.ActorTypeUser,
|
|
"network_scan_target_deleted", "network_scan_target", id, nil)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ScanAllTargets runs the active TLS scan for all enabled targets.
|
|
// This is called by the scheduler on the configured interval.
|
|
func (s *NetworkScanService) ScanAllTargets(ctx context.Context) error {
|
|
targets, err := s.networkScanRepo.ListEnabled(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list enabled targets: %w", err)
|
|
}
|
|
|
|
if len(targets) == 0 {
|
|
if s.logger != nil {
|
|
s.logger.Debug("no enabled network scan targets")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if s.logger != nil {
|
|
s.logger.Info("starting network scan", "targets", len(targets))
|
|
}
|
|
|
|
for _, target := range targets {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
s.scanTarget(ctx, target)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TriggerScan runs an immediate scan for a specific target.
|
|
func (s *NetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
|
target, err := s.networkScanRepo.Get(ctx, targetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.scanTarget(ctx, target), nil
|
|
}
|
|
|
|
// scanTarget scans a single network target and feeds results into the discovery pipeline.
|
|
func (s *NetworkScanService) scanTarget(ctx context.Context, target *domain.NetworkScanTarget) *domain.DiscoveryScan {
|
|
startTime := time.Now()
|
|
if s.logger != nil {
|
|
s.logger.Info("scanning network target",
|
|
"target_id", target.ID,
|
|
"name", target.Name,
|
|
"cidrs", target.CIDRs,
|
|
"ports", target.Ports)
|
|
}
|
|
|
|
// Expand CIDRs to individual IPs
|
|
endpoints := s.expandEndpoints(target.CIDRs, target.Ports)
|
|
if s.logger != nil {
|
|
s.logger.Debug("expanded endpoints", "count", len(endpoints))
|
|
}
|
|
|
|
// Scan endpoints concurrently
|
|
timeout := time.Duration(target.TimeoutMs) * time.Millisecond
|
|
results := s.scanEndpoints(ctx, endpoints, timeout)
|
|
|
|
// Collect discovered cert entries
|
|
var entries []domain.DiscoveredCertEntry
|
|
var scanErrors []string
|
|
for _, result := range results {
|
|
if result.Error != "" {
|
|
// Only log connection errors at debug level (many hosts won't have TLS)
|
|
if s.logger != nil {
|
|
s.logger.Debug("scan endpoint error",
|
|
"address", result.Address,
|
|
"error", result.Error)
|
|
}
|
|
continue
|
|
}
|
|
entries = append(entries, result.Certs...)
|
|
}
|
|
|
|
scanDuration := time.Since(startTime)
|
|
if s.logger != nil {
|
|
s.logger.Info("network target scan completed",
|
|
"target_id", target.ID,
|
|
"endpoints_scanned", len(endpoints),
|
|
"certificates_found", len(entries),
|
|
"errors", len(scanErrors),
|
|
"duration_ms", scanDuration.Milliseconds())
|
|
}
|
|
|
|
// Update scan results on target
|
|
s.networkScanRepo.UpdateScanResults(ctx, target.ID, time.Now(),
|
|
int(scanDuration.Milliseconds()), len(entries))
|
|
|
|
// Feed into discovery pipeline if we found certs
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Build directories list from CIDRs for the scan record
|
|
dirs := make([]string, len(target.CIDRs))
|
|
copy(dirs, target.CIDRs)
|
|
|
|
report := &domain.DiscoveryReport{
|
|
AgentID: SentinelAgentID,
|
|
Directories: dirs,
|
|
Certificates: entries,
|
|
Errors: scanErrors,
|
|
ScanDurationMs: int(scanDuration.Milliseconds()),
|
|
}
|
|
|
|
scan, err := s.discoveryService.ProcessDiscoveryReport(ctx, report)
|
|
if err != nil {
|
|
if s.logger != nil {
|
|
s.logger.Error("failed to process network scan report",
|
|
"target_id", target.ID,
|
|
"error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return scan
|
|
}
|
|
|
|
// expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints.
|
|
// Filters out reserved IP ranges and logs warnings.
|
|
func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string {
|
|
var endpoints []string
|
|
|
|
for _, cidr := range cidrs {
|
|
ips := expandCIDR(cidr)
|
|
if ips == nil || len(ips) == 0 {
|
|
if s.logger != nil {
|
|
s.logger.Warn("CIDR range filtered (reserved or too large)",
|
|
"cidr", cidr)
|
|
}
|
|
continue
|
|
}
|
|
for _, ip := range ips {
|
|
for _, port := range ports {
|
|
endpoints = append(endpoints, fmt.Sprintf("%s:%d", ip, port))
|
|
}
|
|
}
|
|
}
|
|
|
|
return endpoints
|
|
}
|
|
|
|
// The reserved-IP filter used by expandCIDR previously lived here as an
|
|
// unexported isReservedIP helper. It has been moved to
|
|
// internal/validation.IsReservedIP so the webhook notifier can share a single
|
|
// authoritative implementation (H-4, CWE-918). The behaviour is
|
|
// byte-identical with the previous helper — RFC 1918 is intentionally NOT
|
|
// filtered, matching certctl's self-hosted design. If you change the
|
|
// validation package's IsReservedIP, you are changing the network-scanner's
|
|
// behaviour; audit both code paths together.
|
|
|
|
// expandCIDR expands a CIDR notation or single IP into a list of IPs.
|
|
// Limits expansion to /20 (4096 IPs) to prevent accidental huge scans.
|
|
// Filters out reserved IP ranges (via validation.IsReservedIP) to prevent
|
|
// SSRF amplification via network-scan targets pointed at cloud metadata or
|
|
// loopback.
|
|
func expandCIDR(cidr string) []string {
|
|
// Try as CIDR first
|
|
ip, ipNet, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
// Try as single IP
|
|
if singleIP := net.ParseIP(cidr); singleIP != nil {
|
|
if validation.IsReservedIP(singleIP) {
|
|
return nil
|
|
}
|
|
return []string{singleIP.String()}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Count network size and cap at /20
|
|
ones, bits := ipNet.Mask.Size()
|
|
hostBits := bits - ones
|
|
if hostBits > 12 { // More than 4096 hosts
|
|
return nil // Skip overly large networks
|
|
}
|
|
|
|
var ips []string
|
|
for ip := ip.Mask(ipNet.Mask); ipNet.Contains(ip); incrementIP(ip) {
|
|
// Skip reserved IPs
|
|
if validation.IsReservedIP(ip) {
|
|
continue
|
|
}
|
|
|
|
// Copy IP before appending (net.IP is a mutable slice)
|
|
ipCopy := make(net.IP, len(ip))
|
|
copy(ipCopy, ip)
|
|
ips = append(ips, ipCopy.String())
|
|
}
|
|
|
|
// Remove network and broadcast for IPv4 /31 and larger
|
|
if len(ips) > 2 {
|
|
ips = ips[1 : len(ips)-1]
|
|
}
|
|
|
|
return ips
|
|
}
|
|
|
|
// incrementIP increments an IP address by one.
|
|
func incrementIP(ip net.IP) {
|
|
for j := len(ip) - 1; j >= 0; j-- {
|
|
ip[j]++
|
|
if ip[j] > 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// scanEndpoints probes TLS endpoints concurrently and returns results.
|
|
func (s *NetworkScanService) scanEndpoints(ctx context.Context, endpoints []string, timeout time.Duration) []domain.NetworkScanResult {
|
|
results := make([]domain.NetworkScanResult, len(endpoints))
|
|
sem := make(chan struct{}, s.concurrency)
|
|
var wg sync.WaitGroup
|
|
|
|
for i, endpoint := range endpoints {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(idx int, addr string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
results[idx] = s.probeTLS(ctx, addr, timeout)
|
|
}(i, endpoint)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
// probeTLS connects to an endpoint, performs a TLS handshake, and extracts certificates.
|
|
func (s *NetworkScanService) probeTLS(ctx context.Context, address string, timeout time.Duration) domain.NetworkScanResult {
|
|
startTime := time.Now()
|
|
result := domain.NetworkScanResult{Address: address}
|
|
|
|
dialer := &net.Dialer{Timeout: timeout}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
|
|
// SECURITY NOTE: InsecureSkipVerify is intentionally set to true here.
|
|
// The network scanner must discover 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,
|
|
})
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
result.LatencyMs = int(time.Since(startTime).Milliseconds())
|
|
return result
|
|
}
|
|
defer conn.Close()
|
|
|
|
result.LatencyMs = int(time.Since(startTime).Milliseconds())
|
|
|
|
// Extract certificates from TLS connection state
|
|
state := conn.ConnectionState()
|
|
for _, cert := range state.PeerCertificates {
|
|
entry := tlsCertToEntry(cert, address)
|
|
result.Certs = append(result.Certs, entry)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// tlsCertToEntry converts an x509.Certificate from a TLS handshake into a DiscoveredCertEntry.
|
|
func tlsCertToEntry(cert *x509.Certificate, address string) domain.DiscoveredCertEntry {
|
|
// Compute SHA-256 fingerprint using shared tlsprobe package
|
|
fingerprint := tlsprobe.CertFingerprint(cert)
|
|
|
|
// Encode as PEM
|
|
pemBlock := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
pemData := string(pem.EncodeToMemory(pemBlock))
|
|
|
|
// Key algorithm and size using shared tlsprobe package
|
|
keyAlg, keySize := tlsprobe.CertKeyInfo(cert)
|
|
|
|
return domain.DiscoveredCertEntry{
|
|
FingerprintSHA256: fingerprint,
|
|
CommonName: cert.Subject.CommonName,
|
|
SANs: cert.DNSNames,
|
|
SerialNumber: cert.SerialNumber.Text(16),
|
|
IssuerDN: cert.Issuer.String(),
|
|
SubjectDN: cert.Subject.String(),
|
|
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
|
|
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
|
KeyAlgorithm: keyAlg,
|
|
KeySize: keySize,
|
|
IsCA: cert.IsCA,
|
|
PEMData: pemData,
|
|
SourcePath: address,
|
|
SourceFormat: "network",
|
|
}
|
|
}
|