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:
102
products/02-iac-drift-detection/agent/cmd/drift/main.go
Normal file
102
products/02-iac-drift-detection/agent/cmd/drift/main.go
Normal 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
|
||||
}
|
||||
12
products/02-iac-drift-detection/agent/go.mod
Normal file
12
products/02-iac-drift-detection/agent/go.mod
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
86
products/02-iac-drift-detection/agent/pkg/models/drift.go
Normal file
86
products/02-iac-drift-detection/agent/pkg/models/drift.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user