mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 22:58:52 +00:00
21aeed4f4e
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
519 lines
15 KiB
Go
519 lines
15 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
// Package azurekv implements the domain.DiscoverySource interface for
|
|
// Azure Key Vault certificate discovery.
|
|
//
|
|
// Azure Key Vault is a cloud-based secret and certificate management service.
|
|
// This connector discovers certificates stored in an Azure Key Vault using the
|
|
// Azure Key Vault REST API with OAuth2 client credentials authentication.
|
|
//
|
|
// No Azure SDK dependency — uses stdlib net/http + OAuth2 for authentication.
|
|
//
|
|
// API endpoints used:
|
|
//
|
|
// GET /certificates?api-version=7.4 - List certificates
|
|
// GET /certificates/{name}/{version}?api-version=7.4 - Get certificate details
|
|
//
|
|
// Authentication: OAuth2 client credentials flow via Azure AD.
|
|
// Token is cached with 5-minute refresh buffer.
|
|
package azurekv
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
// Config represents the Azure Key Vault discovery configuration.
|
|
type Config struct {
|
|
// VaultURL is the Azure Key Vault URL (e.g., "https://myvault.vault.azure.net").
|
|
// Required. Set via CERTCTL_AZURE_KV_VAULT_URL environment variable.
|
|
VaultURL string `json:"vault_url"`
|
|
|
|
// TenantID is the Azure AD tenant ID (e.g., "00000000-0000-0000-0000-000000000000").
|
|
// Required. Set via CERTCTL_AZURE_KV_TENANT_ID environment variable.
|
|
TenantID string `json:"tenant_id"`
|
|
|
|
// ClientID is the Azure AD application (client) ID.
|
|
// Required. Set via CERTCTL_AZURE_KV_CLIENT_ID environment variable.
|
|
ClientID string `json:"client_id"`
|
|
|
|
// ClientSecret is the Azure AD application secret or certificate.
|
|
// Required. Set via CERTCTL_AZURE_KV_CLIENT_SECRET environment variable.
|
|
ClientSecret string `json:"client_secret"`
|
|
}
|
|
|
|
// cachedToken holds an OAuth2 access token and its expiry time.
|
|
type cachedToken struct {
|
|
token string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// certificateListResponse represents the Azure Key Vault list certificates response.
|
|
type certificateListResponse struct {
|
|
Value []struct {
|
|
ID string `json:"id"`
|
|
Attributes struct {
|
|
Enabled int64 `json:"enabled"`
|
|
Created int64 `json:"created"`
|
|
Updated int64 `json:"updated"`
|
|
Exp int64 `json:"exp"`
|
|
} `json:"attributes,omitempty"`
|
|
Tags map[string]string `json:"tags,omitempty"`
|
|
} `json:"value"`
|
|
NextLink string `json:"nextLink"`
|
|
}
|
|
|
|
// certificateBundle represents the Azure Key Vault certificate details response.
|
|
type certificateBundle struct {
|
|
ID string `json:"id"`
|
|
CER string `json:"cer"`
|
|
Attributes struct {
|
|
Enabled int64 `json:"enabled"`
|
|
Created int64 `json:"created"`
|
|
Updated int64 `json:"updated"`
|
|
Exp int64 `json:"exp"`
|
|
} `json:"attributes,omitempty"`
|
|
}
|
|
|
|
// KVClient is an interface for Azure Key Vault operations, allowing injection for testing.
|
|
type KVClient interface {
|
|
// ListCertificates retrieves the list of certificates in the vault.
|
|
ListCertificates(ctx context.Context, vaultURL string) ([]struct {
|
|
ID string
|
|
Attributes struct {
|
|
Exp int64
|
|
}
|
|
}, error)
|
|
// GetCertificate retrieves a specific certificate version.
|
|
GetCertificate(ctx context.Context, vaultURL, certName, version string) (*certificateBundle, error)
|
|
}
|
|
|
|
// Source implements domain.DiscoverySource for Azure Key Vault.
|
|
type Source struct {
|
|
config Config
|
|
logger *slog.Logger
|
|
client KVClient
|
|
}
|
|
|
|
// New creates a new Azure Key Vault discovery source with real HTTP client.
|
|
func New(cfg Config, logger *slog.Logger) *Source {
|
|
return &Source{
|
|
config: cfg,
|
|
logger: logger,
|
|
client: &httpKVClient{
|
|
config: cfg,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewWithClient creates a new Azure Key Vault discovery source with injected client (for testing).
|
|
func NewWithClient(cfg Config, client KVClient, logger *slog.Logger) *Source {
|
|
return &Source{
|
|
config: cfg,
|
|
logger: logger,
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
// Name returns a human-readable name for this discovery source.
|
|
func (s *Source) Name() string {
|
|
return "Azure Key Vault"
|
|
}
|
|
|
|
// Type returns the short type identifier for this discovery source.
|
|
func (s *Source) Type() string {
|
|
return "azure-kv"
|
|
}
|
|
|
|
// ValidateConfig checks that the Azure Key Vault configuration is valid.
|
|
func (s *Source) ValidateConfig() error {
|
|
if s.config.VaultURL == "" {
|
|
return fmt.Errorf("Azure Key Vault URL is required")
|
|
}
|
|
if s.config.TenantID == "" {
|
|
return fmt.Errorf("Azure Key Vault tenant ID is required")
|
|
}
|
|
if s.config.ClientID == "" {
|
|
return fmt.Errorf("Azure Key Vault client ID is required")
|
|
}
|
|
if s.config.ClientSecret == "" {
|
|
return fmt.Errorf("Azure Key Vault client secret is required")
|
|
}
|
|
|
|
// Basic URL validation
|
|
if !strings.HasPrefix(s.config.VaultURL, "https://") {
|
|
return fmt.Errorf("Azure Key Vault URL must use HTTPS")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Discover scans the Azure Key Vault and returns a DiscoveryReport.
|
|
func (s *Source) Discover(ctx context.Context) (*domain.DiscoveryReport, error) {
|
|
s.logger.Info("starting Azure Key Vault discovery", "vault_url", s.config.VaultURL)
|
|
|
|
report := &domain.DiscoveryReport{
|
|
AgentID: "cloud-azure-kv",
|
|
Directories: []string{fmt.Sprintf("azure-kv://%s/", s.config.VaultURL)},
|
|
Certificates: []domain.DiscoveredCertEntry{},
|
|
Errors: []string{},
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// List certificates
|
|
certs, err := s.client.ListCertificates(ctx, s.config.VaultURL)
|
|
if err != nil {
|
|
s.logger.Error("failed to list Azure Key Vault certificates", "error", err)
|
|
report.Errors = append(report.Errors, fmt.Sprintf("list certificates failed: %v", err))
|
|
return report, nil
|
|
}
|
|
|
|
// Process each certificate
|
|
for _, cert := range certs {
|
|
// Extract certificate name and version from ID
|
|
// ID format: https://myvault.vault.azure.net/certificates/mycert/version123
|
|
certName, version, err := extractCertNameAndVersion(cert.ID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to parse certificate ID", "id", cert.ID, "error", err)
|
|
report.Errors = append(report.Errors, fmt.Sprintf("parse cert ID failed: %v", err))
|
|
continue
|
|
}
|
|
|
|
// Get certificate details
|
|
certBundle, err := s.client.GetCertificate(ctx, s.config.VaultURL, certName, version)
|
|
if err != nil {
|
|
s.logger.Warn("failed to get certificate details", "name", certName, "version", version, "error", err)
|
|
report.Errors = append(report.Errors, fmt.Sprintf("get cert %s/%s failed: %v", certName, version, err))
|
|
continue
|
|
}
|
|
|
|
// Decode the base64-encoded DER certificate
|
|
if certBundle.CER == "" {
|
|
s.logger.Warn("empty certificate data", "name", certName, "version", version)
|
|
continue
|
|
}
|
|
|
|
derBytes, err := base64.StdEncoding.DecodeString(certBundle.CER)
|
|
if err != nil {
|
|
s.logger.Warn("failed to decode certificate", "name", certName, "version", version, "error", err)
|
|
report.Errors = append(report.Errors, fmt.Sprintf("decode cert %s/%s failed: %v", certName, version, err))
|
|
continue
|
|
}
|
|
|
|
// Parse certificate
|
|
x509Cert, err := x509.ParseCertificate(derBytes)
|
|
if err != nil {
|
|
s.logger.Warn("failed to parse certificate", "name", certName, "version", version, "error", err)
|
|
report.Errors = append(report.Errors, fmt.Sprintf("parse cert %s/%s failed: %v", certName, version, err))
|
|
continue
|
|
}
|
|
|
|
// Extract certificate metadata
|
|
entry := extractCertMetadata(x509Cert, certName, version)
|
|
|
|
// Encode as PEM for inclusion in report
|
|
certPEM := encodeCertPEM(derBytes)
|
|
entry.PEMData = certPEM
|
|
|
|
report.Certificates = append(report.Certificates, entry)
|
|
s.logger.Info("discovered certificate",
|
|
"name", certName,
|
|
"common_name", entry.CommonName,
|
|
"serial", entry.SerialNumber,
|
|
"not_after", entry.NotAfter)
|
|
}
|
|
|
|
report.ScanDurationMs = int(time.Since(startTime).Milliseconds())
|
|
|
|
s.logger.Info("Azure Key Vault discovery completed",
|
|
"certs_found", len(report.Certificates),
|
|
"errors", len(report.Errors),
|
|
"duration_ms", report.ScanDurationMs)
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// httpKVClient implements KVClient using Azure Key Vault REST API.
|
|
type httpKVClient struct {
|
|
config Config
|
|
httpClient *http.Client
|
|
|
|
// OAuth2 token caching
|
|
mu sync.Mutex
|
|
tokenCache *cachedToken
|
|
}
|
|
|
|
// ListCertificates retrieves the list of certificates in the vault.
|
|
func (c *httpKVClient) ListCertificates(ctx context.Context, vaultURL string) ([]struct {
|
|
ID string
|
|
Attributes struct {
|
|
Exp int64
|
|
}
|
|
}, error) {
|
|
var results []struct {
|
|
ID string
|
|
Attributes struct {
|
|
Exp int64
|
|
}
|
|
}
|
|
|
|
listURL := fmt.Sprintf("%s/certificates?api-version=7.4", strings.TrimSuffix(vaultURL, "/"))
|
|
|
|
for listURL != "" {
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("list certificates returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var listResp certificateListResponse
|
|
if err := json.Unmarshal(body, &listResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse list response: %w", err)
|
|
}
|
|
|
|
for _, cert := range listResp.Value {
|
|
results = append(results, struct {
|
|
ID string
|
|
Attributes struct {
|
|
Exp int64
|
|
}
|
|
}{
|
|
ID: cert.ID,
|
|
Attributes: struct {
|
|
Exp int64
|
|
}{Exp: cert.Attributes.Exp},
|
|
})
|
|
}
|
|
|
|
// Handle pagination
|
|
if listResp.NextLink == "" {
|
|
break
|
|
}
|
|
listURL = listResp.NextLink
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// GetCertificate retrieves a specific certificate version from the vault.
|
|
func (c *httpKVClient) GetCertificate(ctx context.Context, vaultURL, certName, version string) (*certificateBundle, error) {
|
|
token, err := c.getAccessToken(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
}
|
|
|
|
// Ensure vaultURL has no trailing slash
|
|
vaultURL = strings.TrimSuffix(vaultURL, "/")
|
|
|
|
// Build the certificate URL
|
|
// Format: https://myvault.vault.azure.net/certificates/mycert/version123?api-version=7.4
|
|
certURL := fmt.Sprintf("%s/certificates/%s/%s?api-version=7.4",
|
|
vaultURL, url.PathEscape(certName), url.PathEscape(version))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get certificate request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("get certificate returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var certBundle certificateBundle
|
|
if err := json.Unmarshal(body, &certBundle); err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate response: %w", err)
|
|
}
|
|
|
|
return &certBundle, nil
|
|
}
|
|
|
|
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
|
|
func (c *httpKVClient) getAccessToken(ctx context.Context) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Return cached token if still valid (5 min buffer)
|
|
if c.tokenCache != nil && time.Now().Add(5*time.Minute).Before(c.tokenCache.expiresAt) {
|
|
return c.tokenCache.token, nil
|
|
}
|
|
|
|
// Exchange client credentials for access token
|
|
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token",
|
|
url.PathEscape(c.config.TenantID))
|
|
|
|
form := url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {c.config.ClientID},
|
|
"client_secret": {c.config.ClientSecret},
|
|
"scope": {"https://vault.azure.net/.default"},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL,
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create token request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("token request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read token response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("token request returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return "", fmt.Errorf("failed to parse token response: %w", err)
|
|
}
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
return "", fmt.Errorf("empty access token in response")
|
|
}
|
|
|
|
// Cache token
|
|
c.tokenCache = &cachedToken{
|
|
token: tokenResp.AccessToken,
|
|
expiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
|
}
|
|
|
|
return tokenResp.AccessToken, nil
|
|
}
|
|
|
|
// extractCertNameAndVersion extracts the certificate name and version from the Azure ID.
|
|
// ID format: https://myvault.vault.azure.net/certificates/mycert/version123
|
|
func extractCertNameAndVersion(id string) (name, version string, err error) {
|
|
// Use regex to extract name and version from the ID URL
|
|
// Pattern: /certificates/{name}/{version}
|
|
re := regexp.MustCompile(`/certificates/([^/]+)/([^/]+)$`)
|
|
matches := re.FindStringSubmatch(id)
|
|
|
|
if len(matches) != 3 {
|
|
return "", "", fmt.Errorf("cannot parse certificate ID: %s", id)
|
|
}
|
|
|
|
return matches[1], matches[2], nil
|
|
}
|
|
|
|
// extractCertMetadata extracts metadata from a parsed X.509 certificate.
|
|
func extractCertMetadata(cert *x509.Certificate, certName, version string) domain.DiscoveredCertEntry {
|
|
// Extract Subject Alternative Names (DNS names and email addresses)
|
|
sans := []string{}
|
|
sans = append(sans, cert.DNSNames...)
|
|
|
|
// Extract key algorithm
|
|
keyAlgo := "unknown"
|
|
keySize := 0
|
|
|
|
switch pub := cert.PublicKey.(type) {
|
|
case *rsa.PublicKey:
|
|
keyAlgo = "RSA"
|
|
keySize = pub.N.BitLen()
|
|
case *ecdsa.PublicKey:
|
|
keyAlgo = "ECDSA"
|
|
keySize = pub.Curve.Params().BitSize
|
|
}
|
|
|
|
// Compute SHA-256 fingerprint
|
|
fp := sha256.Sum256(cert.Raw)
|
|
fingerprint := fmt.Sprintf("%X", fp)
|
|
|
|
// Format times as RFC3339
|
|
notBefore := cert.NotBefore.UTC().Format(time.RFC3339)
|
|
notAfter := cert.NotAfter.UTC().Format(time.RFC3339)
|
|
|
|
return domain.DiscoveredCertEntry{
|
|
FingerprintSHA256: fingerprint,
|
|
CommonName: cert.Subject.CommonName,
|
|
SANs: sans,
|
|
SerialNumber: fmt.Sprintf("%x", cert.SerialNumber),
|
|
IssuerDN: cert.Issuer.String(),
|
|
SubjectDN: cert.Subject.String(),
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
KeyAlgorithm: keyAlgo,
|
|
KeySize: keySize,
|
|
IsCA: cert.IsCA,
|
|
SourcePath: fmt.Sprintf("azure-kv://%s/%s", certName, version),
|
|
SourceFormat: "DER",
|
|
}
|
|
}
|
|
|
|
// encodeCertPEM encodes a DER certificate as PEM.
|
|
func encodeCertPEM(derBytes []byte) string {
|
|
var buf bytes.Buffer
|
|
pem.Encode(&buf, &pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: derBytes,
|
|
})
|
|
return buf.String()
|
|
}
|
|
|
|
// Ensure Source implements domain.DiscoverySource.
|
|
var _ domain.DiscoverySource = (*Source)(nil)
|