Files
certctl/internal/connector/discovery/awssm/awssm.go
T
shankar0123 5dc698307b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit bc6039a (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

364 lines
11 KiB
Go

// 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/certctl-io/certctl/internal/config"
"github.com/certctl-io/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
}