Scaffold dd0c/drift Go agent: CLI, scanner, scrubber, reporter, models

- cobra CLI: check (one-shot), watch (SQS consumer), version
- models: DriftReport, DriftedResource, severity classification (critical/high/medium/low)
- scanner: Terraform v4 state parser, resource counter
- scrubber: regex + Shannon entropy secret detection (strict/permissive/off modes)
- reporter: mTLS HTTP client with nonce replay prevention
- tests: severity classification (8 cases), scrubber (AWS keys, RSA, entropy, attributes)
This commit is contained in:
2026-03-01 02:42:53 +00:00
parent e626608535
commit 31cb36fb77
8 changed files with 666 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}