mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 17:48: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>
526 lines
14 KiB
Go
526 lines
14 KiB
Go
package gcpsm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/config"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// mockSMClient implements SMClient for testing.
|
|
type mockSMClient struct {
|
|
secrets map[string][]byte
|
|
accessErrors map[string]error
|
|
listSecretsError error
|
|
listSecretsHook func(ctx context.Context, project string) ([]SecretEntry, error)
|
|
}
|
|
|
|
func newMockSMClient() *mockSMClient {
|
|
return &mockSMClient{
|
|
secrets: make(map[string][]byte),
|
|
accessErrors: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (m *mockSMClient) ListSecrets(ctx context.Context, project string) ([]SecretEntry, error) {
|
|
if m.listSecretsHook != nil {
|
|
return m.listSecretsHook(ctx, project)
|
|
}
|
|
|
|
if m.listSecretsError != nil {
|
|
return nil, m.listSecretsError
|
|
}
|
|
|
|
var entries []SecretEntry
|
|
for name := range m.secrets {
|
|
entries = append(entries, SecretEntry{
|
|
Name: fmt.Sprintf("projects/%s/secrets/%s", project, name),
|
|
Labels: map[string]string{"type": "certificate"},
|
|
})
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m *mockSMClient) AccessSecretVersion(ctx context.Context, project, secretName string) ([]byte, error) {
|
|
if err, ok := m.accessErrors[secretName]; ok {
|
|
return nil, err
|
|
}
|
|
if data, ok := m.secrets[secretName]; ok {
|
|
return data, nil
|
|
}
|
|
return nil, fmt.Errorf("secret not found: %s", secretName)
|
|
}
|
|
|
|
// generateTestCertificate generates a self-signed test certificate.
|
|
func generateTestCertificate(cn string, expire time.Duration) (*x509.Certificate, []byte, error) {
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Create a certificate template
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: cn,
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(expire),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{
|
|
x509.ExtKeyUsageServerAuth,
|
|
},
|
|
DNSNames: []string{"example.com", "*.example.com"},
|
|
EmailAddresses: []string{"test@example.com"},
|
|
}
|
|
|
|
// Self-sign the certificate
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Parse the DER-encoded cert
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Return both the cert object and the PEM-encoded version
|
|
pemData := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certDER,
|
|
})
|
|
|
|
return cert, pemData, nil
|
|
}
|
|
|
|
// createTempServiceAccountKey creates a temporary service account key file for testing.
|
|
func createTempServiceAccountKey() (string, error) {
|
|
tmpfile, err := os.CreateTemp("", "gcpsm-test-*.json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tmpfile.Close()
|
|
|
|
// Generate a minimal RSA key for the test
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Convert to PKCS#8 PEM format
|
|
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privateKeyDER,
|
|
})
|
|
|
|
// Create a minimal service account key JSON
|
|
keyJSON := fmt.Sprintf(`{
|
|
"type": "service_account",
|
|
"project_id": "test-project",
|
|
"private_key": %q,
|
|
"client_email": "test@test-project.iam.gserviceaccount.com",
|
|
"token_uri": "https://oauth2.googleapis.com/token"
|
|
}`, string(privateKeyPEM))
|
|
|
|
_, err = tmpfile.WriteString(keyJSON)
|
|
if err != nil {
|
|
os.Remove(tmpfile.Name())
|
|
return "", err
|
|
}
|
|
|
|
return tmpfile.Name(), nil
|
|
}
|
|
|
|
func TestValidateConfig_Success(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := New(cfg, slog.Default())
|
|
if err := source.ValidateConfig(); err != nil {
|
|
t.Errorf("ValidateConfig failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_MissingProject(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := New(cfg, slog.Default())
|
|
if err := source.ValidateConfig(); err == nil {
|
|
t.Error("expected ValidateConfig to fail with missing project")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_MissingCredentials(t *testing.T) {
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: "",
|
|
}
|
|
|
|
source := New(cfg, slog.Default())
|
|
if err := source.ValidateConfig(); err == nil {
|
|
t.Error("expected ValidateConfig to fail with missing credentials")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_InvalidCredentialsFile(t *testing.T) {
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: "/nonexistent/path/to/creds.json",
|
|
}
|
|
|
|
source := New(cfg, slog.Default())
|
|
if err := source.ValidateConfig(); err == nil {
|
|
t.Error("expected ValidateConfig to fail with invalid credentials file")
|
|
}
|
|
}
|
|
|
|
func TestDiscover_Success(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
// Generate two test certificates: one valid, one that will cause a parse error
|
|
validCert, validPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
|
|
// Create a mock client with both secrets
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["valid-cert"] = validPEM
|
|
mockClient.secrets["invalid-data"] = []byte("not a certificate at all")
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover failed: %v", err)
|
|
}
|
|
|
|
// Should have discovered 1 valid certificate
|
|
if len(report.Certificates) != 1 {
|
|
t.Errorf("expected 1 certificate, got %d", len(report.Certificates))
|
|
}
|
|
|
|
// Should have 1 error (invalid-data)
|
|
if len(report.Errors) != 1 {
|
|
t.Errorf("expected 1 error, got %d", len(report.Errors))
|
|
}
|
|
|
|
// Verify certificate metadata
|
|
entry := report.Certificates[0]
|
|
if entry.CommonName != "test.example.com" {
|
|
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
|
}
|
|
if entry.KeyAlgorithm != "RSA" {
|
|
t.Errorf("expected RSA key algorithm, got %s", entry.KeyAlgorithm)
|
|
}
|
|
if entry.KeySize != 2048 {
|
|
t.Errorf("expected 2048-bit key, got %d", entry.KeySize)
|
|
}
|
|
|
|
// Verify source path
|
|
if !contains(report.Directories, "gcp-sm://test-project/") {
|
|
t.Errorf("expected directory 'gcp-sm://test-project/', got %v", report.Directories)
|
|
}
|
|
|
|
// Verify fingerprint calculation
|
|
if entry.FingerprintSHA256 == "" {
|
|
t.Error("expected non-empty fingerprint")
|
|
}
|
|
|
|
// Verify SANs
|
|
if !contains(entry.SANs, "example.com") || !contains(entry.SANs, "*.example.com") {
|
|
t.Errorf("expected DNS SANs, got %v", entry.SANs)
|
|
}
|
|
|
|
// Verify cert serial number matches
|
|
if entry.SerialNumber != fmt.Sprintf("%x", validCert.SerialNumber) {
|
|
t.Errorf("serial number mismatch: expected %x, got %s", validCert.SerialNumber, entry.SerialNumber)
|
|
}
|
|
}
|
|
|
|
func TestDiscover_EmptySecrets(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
mockClient := newMockSMClient()
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover failed: %v", err)
|
|
}
|
|
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
|
|
}
|
|
}
|
|
|
|
func TestDiscover_ListSecretsError(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
// Create a mock client that fails on ListSecrets
|
|
mockClient := newMockSMClient()
|
|
mockClient.listSecretsError = fmt.Errorf("simulated ListSecrets error")
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
report, err := source.Discover(context.Background())
|
|
|
|
// Should return error
|
|
if err == nil {
|
|
t.Error("expected Discover to fail when ListSecrets fails")
|
|
}
|
|
|
|
// But should still return a report with the error recorded
|
|
if report == nil || len(report.Errors) == 0 {
|
|
t.Error("expected error to be recorded in report")
|
|
}
|
|
}
|
|
|
|
func TestDiscover_AccessSecretError(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
mockClient := newMockSMClient()
|
|
mockClient.accessErrors["broken-secret"] = fmt.Errorf("simulated AccessSecretVersion error")
|
|
// Add to list via the hook since we need it listed but access should fail
|
|
mockClient.listSecretsHook = func(ctx context.Context, project string) ([]SecretEntry, error) {
|
|
return []SecretEntry{
|
|
{Name: fmt.Sprintf("projects/%s/secrets/broken-secret", project), Labels: map[string]string{"type": "certificate"}},
|
|
}, nil
|
|
}
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
report, _ := source.Discover(context.Background())
|
|
|
|
// Should record error but not fail the whole operation
|
|
if len(report.Errors) == 0 {
|
|
t.Error("expected error to be recorded in report")
|
|
}
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certificates, got %d", len(report.Certificates))
|
|
}
|
|
}
|
|
|
|
func TestDiscover_AgentIDAndSourcePath(t *testing.T) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
_, certPEM, err := generateTestCertificate("test.example.com", 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
|
|
mockClient := newMockSMClient()
|
|
mockClient.secrets["my-cert"] = certPEM
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "my-gcp-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
report, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover failed: %v", err)
|
|
}
|
|
|
|
// Verify agent ID
|
|
if report.AgentID != "cloud-gcp-sm" {
|
|
t.Errorf("expected agent ID 'cloud-gcp-sm', got '%s'", report.AgentID)
|
|
}
|
|
|
|
// Verify source path format
|
|
if len(report.Certificates) > 0 {
|
|
entry := report.Certificates[0]
|
|
expectedPath := "gcp-sm://my-gcp-project/my-cert"
|
|
if entry.SourcePath != expectedPath {
|
|
t.Errorf("expected source path '%s', got '%s'", expectedPath, entry.SourcePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseCertificate_PEM(t *testing.T) {
|
|
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
|
|
cert, err := parseCertificate(certPEM)
|
|
if err != nil {
|
|
t.Errorf("failed to parse PEM certificate: %v", err)
|
|
}
|
|
|
|
if cert.Subject.CommonName != "test.com" {
|
|
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
|
}
|
|
}
|
|
|
|
func TestParseCertificate_Base64DER(t *testing.T) {
|
|
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
|
|
// Decode PEM and re-encode as base64 DER
|
|
block, _ := pem.Decode(certPEM)
|
|
base64DER := []byte(base64.StdEncoding.EncodeToString(block.Bytes))
|
|
|
|
cert, err := parseCertificate(base64DER)
|
|
if err != nil {
|
|
t.Errorf("failed to parse base64 DER certificate: %v", err)
|
|
}
|
|
|
|
if cert.Subject.CommonName != "test.com" {
|
|
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
|
}
|
|
}
|
|
|
|
func TestParseCertificate_RawDER(t *testing.T) {
|
|
_, certPEM, err := generateTestCertificate("test.com", 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
|
|
// Decode PEM to get raw DER
|
|
block, _ := pem.Decode(certPEM)
|
|
|
|
cert, err := parseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Errorf("failed to parse raw DER certificate: %v", err)
|
|
}
|
|
|
|
if cert.Subject.CommonName != "test.com" {
|
|
t.Errorf("expected CN 'test.com', got '%s'", cert.Subject.CommonName)
|
|
}
|
|
}
|
|
|
|
func TestParseCertificate_Invalid(t *testing.T) {
|
|
invalidData := []byte("not a certificate at all")
|
|
|
|
_, err := parseCertificate(invalidData)
|
|
if err == nil {
|
|
t.Error("expected parseCertificate to fail on invalid data")
|
|
}
|
|
}
|
|
|
|
// Helper function to check if a slice contains a string
|
|
func contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestSourceImplementsInterface ensures Source implements domain.DiscoverySource
|
|
func TestSourceImplementsInterface(t *testing.T) {
|
|
var _ domain.DiscoverySource = (*Source)(nil)
|
|
}
|
|
|
|
// BenchmarkDiscover provides basic performance metrics for discovery
|
|
func BenchmarkDiscover(b *testing.B) {
|
|
tmpfile, err := createTempServiceAccountKey()
|
|
if err != nil {
|
|
b.Fatalf("failed to create temp key file: %v", err)
|
|
}
|
|
defer os.Remove(tmpfile)
|
|
|
|
// Generate 10 test certificates
|
|
mockClient := newMockSMClient()
|
|
for i := 0; i < 10; i++ {
|
|
_, certPEM, err := generateTestCertificate(fmt.Sprintf("test%d.example.com", i), 24*time.Hour)
|
|
if err != nil {
|
|
b.Fatalf("failed to generate test certificate: %v", err)
|
|
}
|
|
mockClient.secrets[fmt.Sprintf("cert-%d", i)] = certPEM
|
|
}
|
|
|
|
cfg := &config.GCPSecretMgrDiscoveryConfig{
|
|
Project: "test-project",
|
|
Credentials: tmpfile,
|
|
}
|
|
|
|
source := NewWithClient(cfg, mockClient, slog.Default())
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := source.Discover(context.Background())
|
|
if err != nil {
|
|
b.Fatalf("Discover failed: %v", err)
|
|
}
|
|
}
|
|
}
|