fix: security audit remediation (AUDIT-001, 003, 004, 005, 006, 018)

- AUDIT-001: Validate OpenSSL revoke inputs (hex-only serials, RFC 5280 reasons)
- AUDIT-003: Enforce /20 CIDR size cap at API level (create + update)
- AUDIT-004: Support comma-separated CERTCTL_AUTH_SECRET for zero-downtime key rotation
- AUDIT-005: Add ReadHeaderTimeout (5s) to prevent Slowloris
- AUDIT-006: Document audit trail query parameter exclusion rationale
- AUDIT-018: Add immediate-run-on-start to short-lived expiry scheduler loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-28 14:11:16 -04:00
parent 591dcfb139
commit 6d508cf53f
15 changed files with 595 additions and 34 deletions
+36 -15
View File
@@ -58,6 +58,36 @@ func (s *NetworkScanService) GetTarget(ctx context.Context, id string) (*domain.
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 == "" {
@@ -66,14 +96,9 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
if len(target.CIDRs) == 0 {
return nil, fmt.Errorf("at least one CIDR is required")
}
// Validate CIDRs
for _, cidr := range target.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
// Try parsing as plain IP
if ip := net.ParseIP(cidr); ip == nil {
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
}
}
// 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}
@@ -115,13 +140,9 @@ func (s *NetworkScanService) UpdateTarget(ctx context.Context, id string, target
existing.Name = target.Name
}
if len(target.CIDRs) > 0 {
// Validate new CIDRs
for _, cidr := range target.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
if ip := net.ParseIP(cidr); ip == nil {
return nil, fmt.Errorf("invalid CIDR or IP: %s", cidr)
}
}
// Validate new CIDRs (syntax + /20 size cap)
if err := validateCIDRs(target.CIDRs); err != nil {
return nil, err
}
existing.CIDRs = target.CIDRs
}
+86
View File
@@ -391,6 +391,92 @@ func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) {
}
}
// AUDIT-003: CIDR size validation at API level
func TestValidateCIDRs_AcceptsValidSizes(t *testing.T) {
tests := []struct {
name string
cidrs []string
}{
{"single IP", []string{"192.168.1.1"}},
{"/24 network", []string{"10.0.0.0/24"}},
{"/20 network (max)", []string{"10.0.0.0/20"}},
{"/30 tiny network", []string{"10.0.0.0/30"}},
{"multiple valid", []string{"10.0.0.0/24", "192.168.1.0/24"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCIDRs(tt.cidrs)
if err != nil {
t.Errorf("expected valid CIDRs to be accepted, got error: %v", err)
}
})
}
}
func TestValidateCIDRs_RejectsOversized(t *testing.T) {
tests := []struct {
name string
cidrs []string
}{
{"/19 too large", []string{"10.0.0.0/19"}},
{"/16 way too large", []string{"10.0.0.0/16"}},
{"/8 massive", []string{"10.0.0.0/8"}},
{"/0 everything", []string{"0.0.0.0/0"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCIDRs(tt.cidrs)
if err == nil {
t.Errorf("expected oversized CIDR %v to be rejected", tt.cidrs)
}
})
}
}
func TestValidateCIDRs_RejectsInvalid(t *testing.T) {
err := validateCIDRs([]string{"not-a-cidr"})
if err == nil {
t.Error("expected invalid CIDR to be rejected")
}
}
func TestNetworkScanService_CreateTarget_RejectsOversizedCIDR(t *testing.T) {
repo := &mockNetworkScanRepo{}
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewNetworkScanService(repo, nil, auditService, nil)
_, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
Name: "Test",
CIDRs: []string{"10.0.0.0/8"},
})
if err == nil {
t.Fatal("expected CreateTarget to reject /8 CIDR")
}
}
func TestNetworkScanService_UpdateTarget_RejectsOversizedCIDR(t *testing.T) {
repo := &mockNetworkScanRepo{
targets: []*domain.NetworkScanTarget{
{ID: "nst-1", Name: "Original", CIDRs: []string{"10.0.0.0/24"}, Enabled: true},
},
}
auditRepo := newMockAuditRepository()
auditService := NewAuditService(auditRepo)
svc := NewNetworkScanService(repo, nil, auditService, nil)
// Try to update from /24 to /8 — should be rejected
_, err := svc.UpdateTarget(context.Background(), "nst-1", &domain.NetworkScanTarget{
CIDRs: []string{"10.0.0.0/8"},
})
if err == nil {
t.Fatal("expected UpdateTarget to reject /8 CIDR update (bypass attempt)")
}
}
func TestExpandCIDR_SingleLoopbackIP(t *testing.T) {
ips := expandCIDR("127.0.0.1")
if len(ips) != 0 {