mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 10:38:52 +00:00
e1bcde4cf1
Extend certificate discovery from filesystem + network to cloud secret managers. Three pluggable DiscoverySource connectors feed into the existing discovery pipeline via sentinel agent pattern, with a 9th scheduler loop for periodic cloud scanning. - AWS Secrets Manager: aws-sdk-go-v2, tag/prefix filtering, 10 tests - Azure Key Vault: stdlib HTTP + OAuth2, base64 DER/PEM, 16 tests - GCP Secret Manager: stdlib HTTP + JWT OAuth2, label filter, 14 tests - CloudDiscoveryService orchestrator with 9 tests - 9th scheduler loop (6h default, atomic.Bool idempotency) - Discovery page: color-coded source type badges - 14 new env vars across CloudDiscoveryConfig structs - Docs: connectors.md, architecture.md, features.md, README updated 49 new tests. All CI checks pass (go vet, race, lint, coverage). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package awssm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/config"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// mockSMClient is a mock implementation of SMClient for testing.
|
|
type mockSMClient struct {
|
|
secrets map[string]string // secret name -> secret value
|
|
secretMetadata map[string]SecretMetadata // secret name -> metadata
|
|
listError error
|
|
getErrors map[string]error // secret name -> error
|
|
}
|
|
|
|
func newMockSMClient() *mockSMClient {
|
|
return &mockSMClient{
|
|
secrets: make(map[string]string),
|
|
secretMetadata: make(map[string]SecretMetadata),
|
|
getErrors: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (m *mockSMClient) ListSecrets(ctx context.Context, filters string) ([]SecretMetadata, error) {
|
|
if m.listError != nil {
|
|
return nil, m.listError
|
|
}
|
|
|
|
var result []SecretMetadata
|
|
for _, meta := range m.secretMetadata {
|
|
result = append(result, meta)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockSMClient) GetSecretValue(ctx context.Context, secretID string) (string, error) {
|
|
if err, ok := m.getErrors[secretID]; ok {
|
|
return "", err
|
|
}
|
|
return m.secrets[secretID], nil
|
|
}
|
|
|
|
// generateTestCert generates a test certificate with the given subject and returns it as PEM.
|
|
func generateTestCert(commonName string, sans []string) (string, *x509.Certificate, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: commonName},
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
DNSNames: sans,
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return string(certPEM), cert, nil
|
|
}
|
|
|
|
func TestSource_ValidateConfig_Success(t *testing.T) {
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
}
|
|
source := NewWithClient(cfg, newMockSMClient(), nil)
|
|
|
|
err := source.ValidateConfig()
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSource_ValidateConfig_MissingRegion(t *testing.T) {
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "",
|
|
}
|
|
source := NewWithClient(cfg, newMockSMClient(), nil)
|
|
|
|
err := source.ValidateConfig()
|
|
if err == nil {
|
|
t.Fatal("expected error for missing region")
|
|
}
|
|
if err.Error() != "aws secrets manager region is required" {
|
|
t.Fatalf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSource_Name(t *testing.T) {
|
|
source := NewWithClient(&config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}, newMockSMClient(), nil)
|
|
if source.Name() != "AWS Secrets Manager" {
|
|
t.Errorf("expected 'AWS Secrets Manager', got %s", source.Name())
|
|
}
|
|
}
|
|
|
|
func TestSource_Type(t *testing.T) {
|
|
source := NewWithClient(&config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}, newMockSMClient(), nil)
|
|
if source.Type() != "aws-sm" {
|
|
t.Errorf("expected 'aws-sm', got %s", source.Type())
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_Success(t *testing.T) {
|
|
// Generate test certificates
|
|
certPEM1, _, err := generateTestCert("test1.example.com", []string{"www.test1.example.com"})
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert 1: %v", err)
|
|
}
|
|
|
|
certPEM2, _, err := generateTestCert("test2.example.com", []string{"mail.test2.example.com", "smtp.test2.example.com"})
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert 2: %v", err)
|
|
}
|
|
|
|
// Set up mock client
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["cert1"] = certPEM1
|
|
mockClient.secrets["cert2"] = certPEM2
|
|
mockClient.secretMetadata["cert1"] = SecretMetadata{
|
|
Name: "cert1",
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:cert1",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
mockClient.secretMetadata["cert2"] = SecretMetadata{
|
|
Name: "cert2",
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:cert2",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
TagFilter: "type=certificate",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if report.AgentID != "cloud-aws-sm" {
|
|
t.Errorf("expected agent ID 'cloud-aws-sm', got %s", report.AgentID)
|
|
}
|
|
|
|
if len(report.Certificates) != 2 {
|
|
t.Errorf("expected 2 certificates, got %d", len(report.Certificates))
|
|
}
|
|
|
|
// Find the certificates by common name (order is not guaranteed)
|
|
var cert1, cert2 *domain.DiscoveredCertEntry
|
|
for i := range report.Certificates {
|
|
if report.Certificates[i].CommonName == "test1.example.com" {
|
|
cert1 = &report.Certificates[i]
|
|
} else if report.Certificates[i].CommonName == "test2.example.com" {
|
|
cert2 = &report.Certificates[i]
|
|
}
|
|
}
|
|
|
|
if cert1 == nil {
|
|
t.Fatalf("certificate with CN 'test1.example.com' not found")
|
|
}
|
|
if cert2 == nil {
|
|
t.Fatalf("certificate with CN 'test2.example.com' not found")
|
|
}
|
|
|
|
// Check first certificate
|
|
if len(cert1.SANs) != 1 || cert1.SANs[0] != "www.test1.example.com" {
|
|
t.Errorf("unexpected SANs for cert1: %v", cert1.SANs)
|
|
}
|
|
|
|
// Check second certificate has 2 SANs
|
|
if len(cert2.SANs) != 2 {
|
|
t.Errorf("expected 2 SANs for cert2, got %d", len(cert2.SANs))
|
|
}
|
|
|
|
// Check source path format for first cert
|
|
if cert1.SourcePath != "aws-sm://us-east-1/cert1" {
|
|
t.Errorf("unexpected source path for cert1: %s", cert1.SourcePath)
|
|
}
|
|
|
|
// Check that scan duration is reasonable
|
|
if report.ScanDurationMs < 0 {
|
|
t.Errorf("unexpected negative scan duration: %d", report.ScanDurationMs)
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_EmptyResults(t *testing.T) {
|
|
mockClient := newMockSMClient()
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if report.AgentID != "cloud-aws-sm" {
|
|
t.Errorf("expected agent ID 'cloud-aws-sm', got %s", report.AgentID)
|
|
}
|
|
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
|
|
}
|
|
|
|
if len(report.Errors) != 0 {
|
|
t.Errorf("expected 0 errors, got %d", len(report.Errors))
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_ListError(t *testing.T) {
|
|
mockClient := newMockSMClient()
|
|
mockClient.listError = fmt.Errorf("ListSecrets failed")
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover should not return error for list failure: %v", err)
|
|
}
|
|
|
|
// Should have recorded the error but still return a report
|
|
if len(report.Errors) != 1 {
|
|
t.Errorf("expected 1 error, got %d", len(report.Errors))
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_GetSecretError(t *testing.T) {
|
|
// Generate test certificate
|
|
certPEM, _, err := generateTestCert("good.example.com", nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["good-secret"] = certPEM
|
|
mockClient.secretMetadata["good-secret"] = SecretMetadata{
|
|
Name: "good-secret",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
mockClient.secrets["bad-secret"] = "dummy"
|
|
mockClient.secretMetadata["bad-secret"] = SecretMetadata{
|
|
Name: "bad-secret",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
mockClient.getErrors["bad-secret"] = fmt.Errorf("GetSecretValue failed")
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Should have 1 good certificate and 1 error
|
|
if len(report.Certificates) != 1 {
|
|
t.Errorf("expected 1 certificate, got %d", len(report.Certificates))
|
|
}
|
|
if len(report.Errors) != 1 {
|
|
t.Errorf("expected 1 error, got %d", len(report.Errors))
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_DERCert(t *testing.T) {
|
|
// Generate test certificate in DER format, then base64 encode it
|
|
_, parsedCert, err := generateTestCert("der.example.com", nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
derEncoded := base64.StdEncoding.EncodeToString(parsedCert.Raw)
|
|
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["der-cert"] = derEncoded
|
|
mockClient.secretMetadata["der-cert"] = SecretMetadata{
|
|
Name: "der-cert",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "us-east-1",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(report.Certificates) != 1 {
|
|
t.Errorf("expected 1 certificate, got %d", len(report.Certificates))
|
|
}
|
|
|
|
if report.Certificates[0].CommonName != "der.example.com" {
|
|
t.Errorf("expected CN 'der.example.com', got %s", report.Certificates[0].CommonName)
|
|
}
|
|
}
|
|
|
|
func TestSource_Discover_AgentIDAndSourcePath(t *testing.T) {
|
|
// Generate test certificate
|
|
certPEM, _, err := generateTestCert("source-path.example.com", nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test cert: %v", err)
|
|
}
|
|
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["my-secret"] = certPEM
|
|
mockClient.secretMetadata["my-secret"] = SecretMetadata{
|
|
Name: "my-secret",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Enabled: true,
|
|
Region: "eu-west-1",
|
|
}
|
|
source := NewWithClient(cfg, mockClient, nil)
|
|
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if report.AgentID != "cloud-aws-sm" {
|
|
t.Errorf("expected agent ID 'cloud-aws-sm', got %s", report.AgentID)
|
|
}
|
|
|
|
if report.Certificates[0].SourcePath != "aws-sm://eu-west-1/my-secret" {
|
|
t.Errorf("expected source path 'aws-sm://eu-west-1/my-secret', got %s", report.Certificates[0].SourcePath)
|
|
}
|
|
}
|
|
|