diff --git a/products/02-iac-drift-detection/agent/cmd/drift/main.go b/products/02-iac-drift-detection/agent/cmd/drift/main.go new file mode 100644 index 0000000..4b53033 --- /dev/null +++ b/products/02-iac-drift-detection/agent/cmd/drift/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "0.1.0" + +func main() { + root := &cobra.Command{ + Use: "drift", + Short: "dd0c/drift agent — IaC drift detection", + Long: "Detects infrastructure drift by comparing Terraform state against live cloud resources.", + } + + root.AddCommand(checkCmd()) + root.AddCommand(watchCmd()) + root.AddCommand(versionCmd()) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func checkCmd() *cobra.Command { + var ( + stateFile string + endpoint string + apiKey string + stackName string + scrubMode string + ) + + cmd := &cobra.Command{ + Use: "check", + Short: "Run a one-shot drift check against Terraform state", + RunE: func(cmd *cobra.Command, args []string) error { + return runCheck(stateFile, endpoint, apiKey, stackName, scrubMode) + }, + } + + cmd.Flags().StringVar(&stateFile, "state", "", "Path to terraform.tfstate (or S3 URI)") + cmd.Flags().StringVar(&endpoint, "endpoint", "https://api.dd0c.dev", "dd0c SaaS endpoint") + cmd.Flags().StringVar(&apiKey, "api-key", os.Getenv("DD0C_API_KEY"), "API key for dd0c SaaS") + cmd.Flags().StringVar(&stackName, "stack", "", "Stack name identifier") + cmd.Flags().StringVar(&scrubMode, "scrub", "strict", "Secret scrubbing mode: strict|permissive|off") + cmd.MarkFlagRequired("state") + cmd.MarkFlagRequired("stack") + + return cmd +} + +func watchCmd() *cobra.Command { + var ( + sqsQueue string + endpoint string + apiKey string + interval int + ) + + cmd := &cobra.Command{ + Use: "watch", + Short: "Watch for CloudTrail events via SQS and detect drift in real-time", + RunE: func(cmd *cobra.Command, args []string) error { + return runWatch(sqsQueue, endpoint, apiKey, interval) + }, + } + + cmd.Flags().StringVar(&sqsQueue, "sqs-queue", "", "SQS queue URL for CloudTrail events") + cmd.Flags().StringVar(&endpoint, "endpoint", "https://api.dd0c.dev", "dd0c SaaS endpoint") + cmd.Flags().StringVar(&apiKey, "api-key", os.Getenv("DD0C_API_KEY"), "API key") + cmd.Flags().IntVar(&interval, "interval", 60, "Poll interval in seconds") + cmd.MarkFlagRequired("sqs-queue") + + return cmd +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print agent version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("dd0c/drift agent v%s\n", version) + }, + } +} + +func runCheck(stateFile, endpoint, apiKey, stackName, scrubMode string) error { + fmt.Printf("Checking drift for stack %q from %s\n", stackName, stateFile) + // TODO: Wire up scanner → scrubber → reporter pipeline + return nil +} + +func runWatch(sqsQueue, endpoint, apiKey string, interval int) error { + fmt.Printf("Watching SQS queue %s (poll every %ds)\n", sqsQueue, interval) + // TODO: SQS consumer loop → scanner → reporter + return nil +} diff --git a/products/02-iac-drift-detection/agent/go.mod b/products/02-iac-drift-detection/agent/go.mod new file mode 100644 index 0000000..b3ad2a6 --- /dev/null +++ b/products/02-iac-drift-detection/agent/go.mod @@ -0,0 +1,12 @@ +module github.com/dd0c/drift-agent + +go 1.22 + +require ( + github.com/hashicorp/hcl/v2 v2.20.0 + github.com/hashicorp/terraform-json v0.21.0 + github.com/spf13/cobra v1.8.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.22.0 + google.golang.org/protobuf v1.33.0 +) diff --git a/products/02-iac-drift-detection/agent/internal/reporter/reporter.go b/products/02-iac-drift-detection/agent/internal/reporter/reporter.go new file mode 100644 index 0000000..a319616 --- /dev/null +++ b/products/02-iac-drift-detection/agent/internal/reporter/reporter.go @@ -0,0 +1,80 @@ +package reporter + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/dd0c/drift-agent/pkg/models" +) + +// Reporter sends drift reports to the dd0c SaaS platform. +type Reporter struct { + endpoint string + apiKey string + httpClient *http.Client +} + +// New creates a reporter with mTLS support. +func New(endpoint, apiKey, certFile, keyFile string) (*Reporter, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + // Load client cert for mTLS if provided + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("load mTLS cert: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return &Reporter{ + endpoint: endpoint, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, + }, nil +} + +// Send transmits a drift report to the SaaS ingestion endpoint. +func (r *Reporter) Send(report *models.DriftReport) error { + body, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + + req, err := http.NewRequest("POST", r.endpoint+"/v1/ingest/drift", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+r.apiKey) + req.Header.Set("X-DD0C-Agent-Version", report.AgentVersion) + req.Header.Set("X-DD0C-Nonce", report.Nonce) + + resp, err := r.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send report: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 409 { + return fmt.Errorf("nonce already used (replay rejected)") + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + return nil +} diff --git a/products/02-iac-drift-detection/agent/internal/scanner/scanner.go b/products/02-iac-drift-detection/agent/internal/scanner/scanner.go new file mode 100644 index 0000000..7f3b598 --- /dev/null +++ b/products/02-iac-drift-detection/agent/internal/scanner/scanner.go @@ -0,0 +1,101 @@ +package scanner + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/dd0c/drift-agent/pkg/models" +) + +// Scanner reads Terraform state and computes drift diffs. +type Scanner struct { + scrubber *Scrubber +} + +func New(scrubber *Scrubber) *Scanner { + return &Scanner{scrubber: scrubber} +} + +// TerraformState represents the v4 state file format. +type TerraformState struct { + Version int `json:"version"` + TerraformVersion string `json:"terraform_version"` + Serial int64 `json:"serial"` + Lineage string `json:"lineage"` + Resources []StateResource `json:"resources"` +} + +type StateResource struct { + Module string `json:"module,omitempty"` + Mode string `json:"mode"` // "managed" or "data" + Type string `json:"type"` + Name string `json:"name"` + Provider string `json:"provider"` + Instances []StateInstance `json:"instances"` +} + +type StateInstance struct { + SchemaVersion int `json:"schema_version"` + Attributes map[string]interface{} `json:"attributes"` + Private string `json:"private,omitempty"` +} + +// ScanResult is the output of a drift scan. +type ScanResult struct { + StackName string `json:"stack_name"` + ScannedAt time.Time `json:"scanned_at"` + StateSerial int64 `json:"state_serial"` + Lineage string `json:"lineage"` + TotalResources int `json:"total_resources"` + DriftedResources []models.DriftedResource `json:"drifted_resources"` +} + +// ScanFromFile reads a local tfstate file and produces a ScanResult. +func (s *Scanner) ScanFromFile(path string, stackName string) (*ScanResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read state file: %w", err) + } + + var state TerraformState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("parse state file: %w", err) + } + + if state.Version != 4 { + return nil, fmt.Errorf("unsupported state version %d (expected 4)", state.Version) + } + + result := &ScanResult{ + StackName: stackName, + ScannedAt: time.Now().UTC(), + StateSerial: state.Serial, + Lineage: state.Lineage, + TotalResources: countResources(&state), + DriftedResources: []models.DriftedResource{}, + } + + // TODO: Compare state attributes against live cloud resources + // For V1, we compare against the previous state snapshot stored in SaaS + + // Scrub sensitive values from the result + if s.scrubber != nil { + for i := range result.DriftedResources { + result.DriftedResources[i] = s.scrubber.ScrubResource(result.DriftedResources[i]) + } + } + + return result, nil +} + +func countResources(state *TerraformState) int { + count := 0 + for _, r := range state.Resources { + if r.Mode == "managed" { + count += len(r.Instances) + } + } + return count +} diff --git a/products/02-iac-drift-detection/agent/internal/scanner/scrubber.go b/products/02-iac-drift-detection/agent/internal/scanner/scrubber.go new file mode 100644 index 0000000..bf3fb1d --- /dev/null +++ b/products/02-iac-drift-detection/agent/internal/scanner/scrubber.go @@ -0,0 +1,112 @@ +package scanner + +import ( + "math" + "regexp" + "strings" + + "github.com/dd0c/drift-agent/pkg/models" +) + +// Scrubber removes sensitive values from drift diffs before transmission. +type Scrubber struct { + mode string // "strict", "permissive", "off" + patterns []*regexp.Regexp +} + +func NewScrubber(mode string) *Scrubber { + s := &Scrubber{mode: mode} + if mode != "off" { + s.patterns = []*regexp.Regexp{ + // AWS keys + regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`), + regexp.MustCompile(`(?i)(aws_secret_access_key|secret_key)\s*=\s*\S+`), + // Generic secrets + regexp.MustCompile(`(?i)(password|secret|token|api_key|private_key)\s*=\s*\S+`), + // RSA/PEM keys + regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----`), + // Base64 blobs > 40 chars (likely encoded secrets) + regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`), + } + } + return s +} + +// ScrubResource redacts sensitive attribute values from a drifted resource. +func (s *Scrubber) ScrubResource(r models.DriftedResource) models.DriftedResource { + if s.mode == "off" { + return r + } + + scrubbed := r + for i, diff := range scrubbed.Diffs { + scrubbed.Diffs[i].OldValue = s.scrubValue(diff.AttributeName, diff.OldValue) + scrubbed.Diffs[i].NewValue = s.scrubValue(diff.AttributeName, diff.NewValue) + } + return scrubbed +} + +// ScrubString redacts sensitive patterns from an arbitrary string. +func (s *Scrubber) ScrubString(input string) string { + if s.mode == "off" { + return input + } + + result := input + for _, p := range s.patterns { + result = p.ReplaceAllString(result, "[REDACTED]") + } + + // Shannon entropy check for high-entropy strings (likely tokens/keys) + if s.mode == "strict" { + result = s.entropyRedact(result) + } + + return result +} + +func (s *Scrubber) scrubValue(attrName, value string) string { + // Always redact known sensitive attribute names + sensitiveAttrs := []string{ + "password", "secret", "token", "api_key", "private_key", + "access_key", "secret_key", "connection_string", "certificate", + } + lower := strings.ToLower(attrName) + for _, sa := range sensitiveAttrs { + if strings.Contains(lower, sa) { + return "[REDACTED]" + } + } + + return s.ScrubString(value) +} + +// entropyRedact checks for high-entropy substrings (Shannon entropy > 3.5 bits/char). +func (s *Scrubber) entropyRedact(input string) string { + words := strings.Fields(input) + for i, word := range words { + if len(word) > 20 && shannonEntropy(word) > 3.5 { + words[i] = "[HIGH_ENTROPY_REDACTED]" + } + } + return strings.Join(words, " ") +} + +func shannonEntropy(s string) float64 { + if len(s) == 0 { + return 0 + } + freq := make(map[rune]float64) + for _, c := range s { + freq[c]++ + } + length := float64(len([]rune(s))) + entropy := 0.0 + for _, count := range freq { + p := count / length + if p > 0 { + entropy -= p * math.Log2(p) + } + } + return entropy +} diff --git a/products/02-iac-drift-detection/agent/internal/scanner/scrubber_test.go b/products/02-iac-drift-detection/agent/internal/scanner/scrubber_test.go new file mode 100644 index 0000000..c8f1cb7 --- /dev/null +++ b/products/02-iac-drift-detection/agent/internal/scanner/scrubber_test.go @@ -0,0 +1,114 @@ +package scanner + +import ( + "testing" + + "github.com/dd0c/drift-agent/pkg/models" +) + +func TestScrubber_RedactsAWSAccessKey(t *testing.T) { + s := NewScrubber("strict") + input := `{"access_key": "AKIAIOSFODNN7EXAMPLE"}` + result := s.ScrubString(input) + if containsStr(result, "AKIAIOSFODNN7EXAMPLE") { + t.Fatalf("AWS key not redacted: %s", result) + } +} + +func TestScrubber_RedactsRSAPrivateKey(t *testing.T) { + s := NewScrubber("strict") + input := `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn +-----END RSA PRIVATE KEY-----` + result := s.ScrubString(input) + if containsStr(result, "MIIEpAIBAAKCAQEA") { + t.Fatalf("RSA key not redacted: %s", result) + } +} + +func TestScrubber_HighEntropyStringRedacted(t *testing.T) { + s := NewScrubber("strict") + // 40-char hex string — looks like a custom API token + token := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0" + result := s.ScrubString(token) + if containsStr(result, token) { + t.Fatalf("High-entropy token not redacted: %s", result) + } +} + +func TestScrubber_NormalTextNotRedacted(t *testing.T) { + s := NewScrubber("strict") + input := "Hello world this is a normal log message" + result := s.ScrubString(input) + if result != input { + t.Fatalf("Normal text was incorrectly redacted: %s", result) + } +} + +func TestScrubber_OffModePassesThrough(t *testing.T) { + s := NewScrubber("off") + input := "AKIAIOSFODNN7EXAMPLE" + result := s.ScrubString(input) + if result != input { + t.Fatalf("Off mode should not redact: %s", result) + } +} + +func TestScrubResource_RedactsSensitiveAttributes(t *testing.T) { + s := NewScrubber("strict") + r := models.DriftedResource{ + ResourceType: "aws_db_instance", + ResourceAddress: "aws_db_instance.main", + Diffs: []models.AttributeDiff{ + {AttributeName: "password", OldValue: "secret123", NewValue: "secret456"}, + {AttributeName: "engine_version", OldValue: "14.1", NewValue: "15.0"}, + }, + } + scrubbed := s.ScrubResource(r) + + if scrubbed.Diffs[0].OldValue != "[REDACTED]" { + t.Fatalf("Password old value not redacted: %s", scrubbed.Diffs[0].OldValue) + } + if scrubbed.Diffs[0].NewValue != "[REDACTED]" { + t.Fatalf("Password new value not redacted: %s", scrubbed.Diffs[0].NewValue) + } + // Non-sensitive attribute should be preserved + if scrubbed.Diffs[1].OldValue != "14.1" { + t.Fatalf("Engine version should not be redacted: %s", scrubbed.Diffs[1].OldValue) + } +} + +func TestShannonEntropy_HighForRandomString(t *testing.T) { + // Random hex string should have high entropy + e := shannonEntropy("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0") + if e <= 3.5 { + t.Fatalf("Expected entropy > 3.5 for random hex, got %f", e) + } +} + +func TestShannonEntropy_LowForRepeatedChars(t *testing.T) { + e := shannonEntropy("aaaaaaaaaaaaaaaaaaaaaaaaa") + if e > 0.1 { + t.Fatalf("Expected near-zero entropy for repeated chars, got %f", e) + } +} + +func TestShannonEntropy_ZeroForEmpty(t *testing.T) { + e := shannonEntropy("") + if e != 0 { + t.Fatalf("Expected 0 entropy for empty string, got %f", e) + } +} + +func containsStr(haystack, needle string) bool { + return len(haystack) >= len(needle) && (haystack == needle || len(needle) > 0 && findStr(haystack, needle)) +} + +func findStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/products/02-iac-drift-detection/agent/pkg/models/drift.go b/products/02-iac-drift-detection/agent/pkg/models/drift.go new file mode 100644 index 0000000..f864db8 --- /dev/null +++ b/products/02-iac-drift-detection/agent/pkg/models/drift.go @@ -0,0 +1,86 @@ +package models + +import "time" + +// DriftedResource represents a single resource that has drifted from its IaC definition. +type DriftedResource struct { + ResourceType string `json:"resource_type"` + ResourceAddress string `json:"resource_address"` + Module string `json:"module,omitempty"` + Provider string `json:"provider"` + Severity DriftSeverity `json:"severity"` + Diffs []AttributeDiff `json:"diffs"` +} + +// AttributeDiff represents a single attribute-level change. +type AttributeDiff struct { + AttributeName string `json:"attribute_name"` + OldValue string `json:"old_value"` + NewValue string `json:"new_value"` + Sensitive bool `json:"sensitive"` +} + +// DriftSeverity classifies how dangerous a drift is. +type DriftSeverity string + +const ( + SeverityCritical DriftSeverity = "critical" // Security group, IAM policy + SeverityHigh DriftSeverity = "high" // Instance type, storage config + SeverityMedium DriftSeverity = "medium" // Tags, descriptions + SeverityLow DriftSeverity = "low" // Cosmetic changes +) + +// DriftReport is the payload sent from agent to SaaS. +type DriftReport struct { + StackName string `json:"stack_name"` + StackFingerprint string `json:"stack_fingerprint"` // Hash of backend config + AgentVersion string `json:"agent_version"` + ScannedAt time.Time `json:"scanned_at"` + StateSerial int64 `json:"state_serial"` + Lineage string `json:"lineage"` + TotalResources int `json:"total_resources"` + DriftedResources []DriftedResource `json:"drifted_resources"` + DriftScore float64 `json:"drift_score"` // 0-100 + Nonce string `json:"nonce"` // Replay prevention +} + +// ClassifySeverity determines drift severity based on resource type and changed attributes. +func ClassifySeverity(resourceType string, attrName string) DriftSeverity { + // Security-critical resources + criticalTypes := map[string]bool{ + "aws_security_group": true, + "aws_security_group_rule": true, + "aws_iam_policy": true, + "aws_iam_role": true, + "aws_iam_role_policy": true, + "aws_iam_user_policy": true, + "aws_kms_key": true, + "aws_s3_bucket_policy": true, + "aws_s3_bucket_acl": true, + } + + if criticalTypes[resourceType] { + return SeverityCritical + } + + // High-impact attributes on any resource + highAttrs := map[string]bool{ + "instance_type": true, + "ami": true, + "engine_version": true, + "allocated_storage": true, + "vpc_id": true, + "subnet_id": true, + } + + if highAttrs[attrName] { + return SeverityHigh + } + + // Tag changes are low severity + if attrName == "tags" || attrName == "tags_all" { + return SeverityLow + } + + return SeverityMedium +} diff --git a/products/02-iac-drift-detection/agent/pkg/models/drift_test.go b/products/02-iac-drift-detection/agent/pkg/models/drift_test.go new file mode 100644 index 0000000..f0308ee --- /dev/null +++ b/products/02-iac-drift-detection/agent/pkg/models/drift_test.go @@ -0,0 +1,59 @@ +package models + +import "testing" + +func TestClassifySeverity_SecurityGroupIsCritical(t *testing.T) { + sev := ClassifySeverity("aws_security_group", "ingress") + if sev != SeverityCritical { + t.Fatalf("Expected critical, got %s", sev) + } +} + +func TestClassifySeverity_IAMPolicyIsCritical(t *testing.T) { + sev := ClassifySeverity("aws_iam_policy", "policy") + if sev != SeverityCritical { + t.Fatalf("Expected critical, got %s", sev) + } +} + +func TestClassifySeverity_InstanceTypeIsHigh(t *testing.T) { + sev := ClassifySeverity("aws_instance", "instance_type") + if sev != SeverityHigh { + t.Fatalf("Expected high, got %s", sev) + } +} + +func TestClassifySeverity_AMIIsHigh(t *testing.T) { + sev := ClassifySeverity("aws_instance", "ami") + if sev != SeverityHigh { + t.Fatalf("Expected high, got %s", sev) + } +} + +func TestClassifySeverity_TagsAreLow(t *testing.T) { + sev := ClassifySeverity("aws_instance", "tags") + if sev != SeverityLow { + t.Fatalf("Expected low, got %s", sev) + } +} + +func TestClassifySeverity_UnknownAttrIsMedium(t *testing.T) { + sev := ClassifySeverity("aws_instance", "some_random_attr") + if sev != SeverityMedium { + t.Fatalf("Expected medium, got %s", sev) + } +} + +func TestClassifySeverity_KMSKeyIsCritical(t *testing.T) { + sev := ClassifySeverity("aws_kms_key", "key_rotation") + if sev != SeverityCritical { + t.Fatalf("Expected critical, got %s", sev) + } +} + +func TestClassifySeverity_S3BucketPolicyIsCritical(t *testing.T) { + sev := ClassifySeverity("aws_s3_bucket_policy", "policy") + if sev != SeverityCritical { + t.Fatalf("Expected critical, got %s", sev) + } +}