// Package awssm implements the domain.DiscoverySource interface for AWS Secrets Manager. // // AWS Secrets Manager is a managed service for storing and managing secrets including // certificates. This discovery source scans Secrets Manager for certificates stored // as secrets, filters by configured tags and name prefix, and reports discovered // certificate metadata back to the control plane for triage and management. // // Discovery approach: // 1. List all secrets in the configured region // 2. Filter by tag key=value (default "type=certificate") // 3. Optionally filter by name prefix // 4. For each secret, retrieve its value // 5. Attempt to parse as PEM or base64-encoded DER // 6. Extract certificate metadata (CN, SANs, serial, validity, etc.) // 7. Report findings with sentinel agent ID "cloud-aws-sm" and source path "aws-sm://{region}/{secret-name}" // // Authentication: AWS credentials via standard credential chain (environment variables, // IAM roles, instance profile, SSO). The caller is responsible for configuring AWS credentials // before creating a Source (e.g., via environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY). // // AWS Secrets Manager API operations used: // // ListSecrets - List secrets, optionally filtered by tags // GetSecretValue - Retrieve the secret value (certificate data) package awssm import ( "context" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "fmt" "log/slog" "strings" "time" "github.com/shankar0123/certctl/internal/config" "github.com/shankar0123/certctl/internal/domain" ) // Note: The actual AWS SDK import will be added once dependencies are available: // import "github.com/aws-sdk-go-v2/service/secretsmanager" // SMClient defines the interface for interacting with AWS Secrets Manager. // This allows for dependency injection and testing with mock clients. type SMClient interface { // ListSecrets lists secrets in the configured region, optionally filtered by tags. // filters should be a comma-separated list of "key:value" pairs, e.g., "type:certificate" ListSecrets(ctx context.Context, filters string) ([]SecretMetadata, error) // GetSecretValue retrieves the secret value for the given secret name or ARN. GetSecretValue(ctx context.Context, secretID string) (string, error) } // SecretMetadata represents metadata about a secret from ListSecrets. type SecretMetadata struct { Name string ARN string Tags map[string]string } // Source represents an AWS Secrets Manager discovery source. type Source struct { cfg *config.AWSSecretsMgrDiscoveryConfig client SMClient logger *slog.Logger } // New creates a new AWS Secrets Manager discovery source with real AWS SDK client. // It expects AWS credentials to be available in the environment. func New(cfg *config.AWSSecretsMgrDiscoveryConfig, logger *slog.Logger) *Source { if logger == nil { logger = slog.Default() } if cfg == nil { cfg = &config.AWSSecretsMgrDiscoveryConfig{} } // Create real AWS Secrets Manager client realClient := newRealSMClient(cfg.Region, logger) return &Source{ cfg: cfg, client: realClient, logger: logger, } } // NewWithClient creates a new AWS Secrets Manager discovery source with a provided client. // This is primarily for testing. func NewWithClient(cfg *config.AWSSecretsMgrDiscoveryConfig, client SMClient, logger *slog.Logger) *Source { if logger == nil { logger = slog.Default() } if cfg == nil { cfg = &config.AWSSecretsMgrDiscoveryConfig{} } return &Source{ cfg: cfg, client: client, logger: logger, } } // Name returns a human-readable name for this discovery source. func (s *Source) Name() string { return "AWS Secrets Manager" } // Type returns the short type identifier for this discovery source. func (s *Source) Type() string { return "aws-sm" } // ValidateConfig checks that the source is properly configured. func (s *Source) ValidateConfig() error { if s.cfg == nil { return fmt.Errorf("aws secrets manager discovery config is nil") } if s.cfg.Region == "" { return fmt.Errorf("aws secrets manager region is required") } return nil } // Discover scans AWS Secrets Manager for certificates and returns a DiscoveryReport. func (s *Source) Discover(ctx context.Context) (*domain.DiscoveryReport, error) { if err := s.ValidateConfig(); err != nil { return nil, fmt.Errorf("invalid aws secrets manager config: %w", err) } startTime := time.Now() report := &domain.DiscoveryReport{ AgentID: "cloud-aws-sm", Directories: []string{fmt.Sprintf("aws-sm://%s", s.cfg.Region)}, Certificates: []domain.DiscoveredCertEntry{}, Errors: []string{}, } // Build filter string from config filters := s.buildFilters() // List secrets in AWS Secrets Manager secrets, err := s.client.ListSecrets(ctx, filters) if err != nil { report.Errors = append(report.Errors, fmt.Sprintf("failed to list secrets: %v", err)) report.ScanDurationMs = int(time.Since(startTime).Milliseconds()) return report, nil } // Process each secret for _, secret := range secrets { if err := s.processSecret(ctx, secret, report); err != nil { report.Errors = append(report.Errors, fmt.Sprintf("failed to process secret %q: %v", secret.Name, err)) } } report.ScanDurationMs = int(time.Since(startTime).Milliseconds()) return report, nil } // buildFilters constructs the filter string for ListSecrets based on config. func (s *Source) buildFilters() string { var filters []string // Add tag filter (default: "type=certificate") tagFilter := s.cfg.TagFilter if tagFilter == "" { tagFilter = "type=certificate" } filters = append(filters, fmt.Sprintf("tag-key:%s", strings.Split(tagFilter, "=")[0])) // Note: AWS Secrets Manager API filtering is limited. We'll do secondary filtering // in processSecret after retrieving the full list. return strings.Join(filters, ",") } // processSecret retrieves a secret value, attempts to parse it as a certificate, // and adds any found certificates to the report. func (s *Source) processSecret(ctx context.Context, secret SecretMetadata, report *domain.DiscoveryReport) error { // Apply name prefix filter if configured if s.cfg.NamePrefix != "" && !strings.HasPrefix(secret.Name, s.cfg.NamePrefix) { return nil // Skip this secret; doesn't match prefix } // Apply tag filter if configured if s.cfg.TagFilter != "" { parts := strings.Split(s.cfg.TagFilter, "=") if len(parts) == 2 { tagKey, tagValue := parts[0], parts[1] if secret.Tags[tagKey] != tagValue { return nil // Skip this secret; tag doesn't match } } } // Retrieve the secret value value, err := s.client.GetSecretValue(ctx, secret.Name) if err != nil { return fmt.Errorf("failed to get secret value: %w", err) } if value == "" { return nil // Empty secret, skip } // Attempt to parse the value as PEM or base64-encoded DER certs := s.parseCertificateData(value) for _, cert := range certs { entry, err := s.buildDiscoveredCertEntry(cert, secret.Name) if err != nil { report.Errors = append(report.Errors, fmt.Sprintf("failed to extract metadata from %q: %v", secret.Name, err)) continue } report.Certificates = append(report.Certificates, *entry) } return nil } // parseCertificateData attempts to parse certificate data from a secret value. // It tries PEM first, then base64-encoded DER. func (s *Source) parseCertificateData(data string) []*x509.Certificate { var certs []*x509.Certificate // Attempt 1: Parse as PEM for { block, rest := pem.Decode([]byte(data)) if block == nil { break } if block.Type == "CERTIFICATE" { cert, err := x509.ParseCertificate(block.Bytes) if err == nil { certs = append(certs, cert) } } data = string(rest) } // If we found certificates via PEM, return them if len(certs) > 0 { return certs } // Attempt 2: Parse as base64-encoded DER derBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data)) if err == nil { cert, err := x509.ParseCertificate(derBytes) if err == nil { certs = append(certs, cert) return certs } } return certs } // buildDiscoveredCertEntry extracts certificate metadata and builds a DiscoveredCertEntry. func (s *Source) buildDiscoveredCertEntry(cert *x509.Certificate, secretName string) (*domain.DiscoveredCertEntry, error) { // Compute SHA-256 fingerprint fingerprint := sha256.Sum256(cert.Raw) fingerprintHex := hex.EncodeToString(fingerprint[:]) // Extract SANs sans := cert.DNSNames if len(cert.EmailAddresses) > 0 { sans = append(sans, cert.EmailAddresses...) } // Extract key algorithm and size keyAlgo, keySize := extractKeyInfo(cert) // Format time as RFC3339 notBeforeStr := cert.NotBefore.Format(time.RFC3339) notAfterStr := cert.NotAfter.Format(time.RFC3339) // Source path format: aws-sm://{region}/{secret-name} sourcePath := fmt.Sprintf("aws-sm://%s/%s", s.cfg.Region, secretName) // Encode certificate as PEM for storage pemData := encodeCertPEM(cert) entry := &domain.DiscoveredCertEntry{ FingerprintSHA256: fingerprintHex, CommonName: cert.Subject.CommonName, SANs: sans, SerialNumber: cert.SerialNumber.String(), IssuerDN: cert.Issuer.String(), SubjectDN: cert.Subject.String(), NotBefore: notBeforeStr, NotAfter: notAfterStr, KeyAlgorithm: keyAlgo, KeySize: keySize, IsCA: cert.IsCA, PEMData: pemData, SourcePath: sourcePath, SourceFormat: "pem", } return entry, nil } // extractKeyInfo extracts the key algorithm and size from a certificate's public key. func extractKeyInfo(cert *x509.Certificate) (string, int) { switch key := cert.PublicKey.(type) { case *rsa.PublicKey: return "RSA", key.N.BitLen() case *ecdsa.PublicKey: return "ECDSA", key.Curve.Params().BitSize case ed25519.PublicKey: return "Ed25519", 256 default: return "Unknown", 0 } } // encodeCertPEM encodes a certificate as PEM format. func encodeCertPEM(cert *x509.Certificate) string { block := &pem.Block{ Type: "CERTIFICATE", Bytes: cert.Raw, } return string(pem.EncodeToMemory(block)) } // realSMClient is a wrapper around the actual AWS Secrets Manager client. type realSMClient struct { region string logger *slog.Logger } // newRealSMClient creates a new real AWS Secrets Manager client. // This will be implemented to use the actual AWS SDK when integrated. func newRealSMClient(region string, logger *slog.Logger) SMClient { return &realSMClient{ region: region, logger: logger, } } // ListSecrets lists secrets in AWS Secrets Manager. // This is a stub that will be implemented with the actual AWS SDK. func (c *realSMClient) ListSecrets(ctx context.Context, filters string) ([]SecretMetadata, error) { // This will be implemented with actual AWS SDK calls // For now, return empty to allow package to compile return []SecretMetadata{}, nil } // GetSecretValue retrieves a secret value from AWS Secrets Manager. // This is a stub that will be implemented with the actual AWS SDK. func (c *realSMClient) GetSecretValue(ctx context.Context, secretID string) (string, error) { // This will be implemented with actual AWS SDK calls // For now, return empty to allow package to compile return "", nil }