From 38e9f9190bc602a66c16e4a6a5934cc3a4dcd328 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 13:17:52 -0400 Subject: [PATCH 1/5] feat: add OpenSearch resource type for version drift detection Adds full OpenSearch support across the detection pipeline: - EOL provider: add ProductMapping for amazon-opensearch, version normalization (strip OpenSearch_ prefix, truncate to major.minor), and fix convertCycle to correctly handle two-tier lifecycle where eol = end of standard support and extendedSupport = real EOL - Wiz inventory source: CSV parsing for OpenSearch domains with nativeType filtering (domains only, not snapshots/policies), version prefix stripping, and tag-based service attribution - Detector: follows Aurora/EKS pattern for inventory fetch, EOL check, and policy evaluation - Server wiring: WIZ_OPENSEARCH_REPORT_ID config, inventory source, EOL provider, and detector registration Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 19 ++ pkg/detector/opensearch/detector.go | 115 +++++++++ pkg/detector/opensearch/detector_test.go | 283 +++++++++++++++++++++++ pkg/eol/endoflife/provider.go | 33 ++- pkg/eol/endoflife/provider_test.go | 190 ++++++++++++++- pkg/eol/mock/provider.go | 2 +- pkg/inventory/wiz/fixtures_test.go | 18 ++ pkg/inventory/wiz/opensearch.go | 218 +++++++++++++++++ pkg/inventory/wiz/opensearch_test.go | 158 +++++++++++++ 9 files changed, 1033 insertions(+), 3 deletions(-) create mode 100644 pkg/detector/opensearch/detector.go create mode 100644 pkg/detector/opensearch/detector_test.go create mode 100644 pkg/inventory/wiz/opensearch.go create mode 100644 pkg/inventory/wiz/opensearch_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 0749c7a..ae8e3f4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,6 +20,7 @@ import ( "github.com/block/Version-Guard/pkg/detector/aurora" "github.com/block/Version-Guard/pkg/detector/eks" + "github.com/block/Version-Guard/pkg/detector/opensearch" "github.com/block/Version-Guard/pkg/eol" eolendoflife "github.com/block/Version-Guard/pkg/eol/endoflife" "github.com/block/Version-Guard/pkg/inventory" @@ -51,6 +52,7 @@ type ServerCLI struct { WizAuroraReportID string `help:"Wiz saved report ID for Aurora inventory" env:"WIZ_AURORA_REPORT_ID"` WizElastiCacheReportID string `help:"Wiz saved report ID for ElastiCache inventory" env:"WIZ_ELASTICACHE_REPORT_ID"` WizEKSReportID string `help:"Wiz saved report ID for EKS inventory" env:"WIZ_EKS_REPORT_ID"` + WizOpenSearchReportID string `help:"Wiz saved report ID for OpenSearch inventory" env:"WIZ_OPENSEARCH_REPORT_ID"` // AWS configuration (for EOL APIs) AWSRegion string `help:"AWS region for EOL APIs" default:"us-west-2" env:"AWS_REGION"` @@ -198,6 +200,11 @@ func (s *ServerCLI) Run(_ *kong.Context) error { WithTagConfig(tagConfig) fmt.Println("✓ EKS inventory source configured (Wiz)") } + if s.WizOpenSearchReportID != "" { + invSources[types.ResourceTypeOpenSearch] = wiz.NewOpenSearchInventorySource(wizClient, s.WizOpenSearchReportID). + WithTagConfig(tagConfig) + fmt.Println("✓ OpenSearch inventory source configured (Wiz)") + } } else { // No Wiz credentials — use mock inventory fmt.Println("⚠️ No Wiz credentials configured — using mock inventory data") @@ -251,6 +258,10 @@ func (s *ServerCLI) Run(_ *kong.Context) error { eolProviders[types.ResourceTypeElastiCache] = eolendoflife.NewProvider(eolHTTPClient, cacheTTL) fmt.Println("✓ ElastiCache EOL provider configured (endoflife.date API)") + // OpenSearch EOL provider + eolProviders[types.ResourceTypeOpenSearch] = eolendoflife.NewProvider(eolHTTPClient, cacheTTL) + fmt.Println("✓ OpenSearch EOL provider configured (endoflife.date API)") + // Initialize policy engine policyEngine := policy.NewDefaultPolicy() @@ -264,6 +275,14 @@ func (s *ServerCLI) Run(_ *kong.Context) error { ) fmt.Println("✓ Aurora detector initialized") } + if invSources[types.ResourceTypeOpenSearch] != nil && eolProviders[types.ResourceTypeOpenSearch] != nil { + detectors[types.ResourceTypeOpenSearch] = opensearch.NewDetector( + invSources[types.ResourceTypeOpenSearch], + eolProviders[types.ResourceTypeOpenSearch], + policyEngine, + ) + fmt.Println("✓ OpenSearch detector initialized") + } // Note: ElastiCache detector not yet implemented in open-source version // You can implement it by following the pattern in pkg/detector/aurora/ if invSources[types.ResourceTypeEKS] != nil && eolProviders[types.ResourceTypeEKS] != nil { diff --git a/pkg/detector/opensearch/detector.go b/pkg/detector/opensearch/detector.go new file mode 100644 index 0000000..b4f7325 --- /dev/null +++ b/pkg/detector/opensearch/detector.go @@ -0,0 +1,115 @@ +package opensearch + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + + "github.com/block/Version-Guard/pkg/eol" + "github.com/block/Version-Guard/pkg/inventory" + "github.com/block/Version-Guard/pkg/policy" + "github.com/block/Version-Guard/pkg/types" +) + +// Detector implements version drift detection for OpenSearch clusters +type Detector struct { + inventory inventory.InventorySource + eol eol.Provider + policy policy.VersionPolicy +} + +// NewDetector creates a new OpenSearch detector +func NewDetector( + inventory inventory.InventorySource, + eol eol.Provider, + policy policy.VersionPolicy, +) *Detector { + return &Detector{ + inventory: inventory, + eol: eol, + policy: policy, + } +} + +// Name returns the name of this detector +func (d *Detector) Name() string { + return "opensearch-detector" +} + +// ResourceType returns the resource type this detector handles +func (d *Detector) ResourceType() types.ResourceType { + return types.ResourceTypeOpenSearch +} + +// Detect scans OpenSearch resources and detects version drift +func (d *Detector) Detect(ctx context.Context) ([]*types.Finding, error) { + // Step 1: Fetch inventory + resources, err := d.inventory.ListResources(ctx, types.ResourceTypeOpenSearch) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch OpenSearch inventory") + } + + if len(resources) == 0 { + // No resources to scan + return []*types.Finding{}, nil + } + + // Step 2: For each resource, fetch EOL data and classify + var findings []*types.Finding + for _, resource := range resources { + finding, err := d.detectResource(ctx, resource) + if err != nil { + // Log error but continue with other resources + // TODO: add proper logging + _ = fmt.Sprintf("failed to detect drift for %s: %v", resource.ID, err) + continue + } + + if finding != nil { + findings = append(findings, finding) + } + } + + return findings, nil +} + +// detectResource detects drift for a single resource +func (d *Detector) detectResource(ctx context.Context, resource *types.Resource) (*types.Finding, error) { + // Fetch EOL data for this version + lifecycle, err := d.eol.GetVersionLifecycle(ctx, resource.Engine, resource.CurrentVersion) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch EOL data for %s %s", + resource.Engine, resource.CurrentVersion) + } + + // Classify using policy + status := d.policy.Classify(resource, lifecycle) + + // Generate message and recommendation + message := d.policy.GetMessage(resource, lifecycle, status) + recommendation := d.policy.GetRecommendation(resource, lifecycle, status) + + // Create finding + finding := &types.Finding{ + ResourceID: resource.ID, + ResourceName: resource.Name, + ResourceType: resource.Type, + Service: resource.Service, + CloudAccountID: resource.CloudAccountID, + CloudRegion: resource.CloudRegion, + CloudProvider: resource.CloudProvider, + Brand: resource.Brand, + CurrentVersion: resource.CurrentVersion, + Engine: resource.Engine, + Status: status, + Message: message, + Recommendation: recommendation, + EOLDate: lifecycle.EOLDate, + DetectedAt: time.Now(), + UpdatedAt: time.Now(), + } + + return finding, nil +} diff --git a/pkg/detector/opensearch/detector_test.go b/pkg/detector/opensearch/detector_test.go new file mode 100644 index 0000000..506f1b2 --- /dev/null +++ b/pkg/detector/opensearch/detector_test.go @@ -0,0 +1,283 @@ +package opensearch + +import ( + "context" + "testing" + "time" + + "github.com/block/Version-Guard/pkg/eol/mock" + inventorymock "github.com/block/Version-Guard/pkg/inventory/mock" + "github.com/block/Version-Guard/pkg/policy" + "github.com/block/Version-Guard/pkg/types" +) + +func TestDetector_Detect_EOLVersion(t *testing.T) { + // Setup mock inventory with EOL OpenSearch domain + eolDate := time.Now().AddDate(0, -6, 0) // 6 months ago + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{ + { + ID: "arn:aws:es:us-east-1:123456:domain/test-domain", + Name: "test-domain", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "test-service", + CloudAccountID: "123456", + CloudRegion: "us-east-1", + Brand: "brand-a", + CurrentVersion: "1.3", + Engine: "opensearch", + }, + }, + } + + // Setup mock EOL provider with EOL version + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{ + "opensearch:1.3": { + Version: "1.3", + Engine: "opensearch", + IsEOL: true, + EOLDate: &eolDate, + IsSupported: false, + }, + }, + } + + // Create detector + detector := NewDetector( + mockInventory, + mockEOL, + policy.NewDefaultPolicy(), + ) + + // Run detection + findings, err := detector.Detect(context.Background()) + + // Assertions + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(findings) != 1 { + t.Fatalf("Expected 1 finding, got %d", len(findings)) + } + + finding := findings[0] + + if finding.Status != types.StatusRed { + t.Errorf("Expected RED status for EOL version, got %s", finding.Status) + } + + if finding.ResourceID != "arn:aws:es:us-east-1:123456:domain/test-domain" { + t.Errorf("Unexpected resource ID: %s", finding.ResourceID) + } + + if finding.CurrentVersion != "1.3" { + t.Errorf("Unexpected version: %s", finding.CurrentVersion) + } + + if finding.Engine != "opensearch" { + t.Errorf("Unexpected engine: %s", finding.Engine) + } +} + +func TestDetector_Detect_CurrentVersion(t *testing.T) { + // Setup mock inventory with current OpenSearch domain + eolDate := time.Now().AddDate(2, 0, 0) // 2 years from now + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{ + { + ID: "arn:aws:es:us-west-2:789012:domain/prod-domain", + Name: "prod-domain", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "search-api", + CloudAccountID: "789012", + CloudRegion: "us-west-2", + Brand: "brand-a", + CurrentVersion: "2.11", + Engine: "opensearch", + }, + }, + } + + // Setup mock EOL provider with supported version + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{ + "opensearch:2.11": { + Version: "2.11", + Engine: "opensearch", + EOLDate: &eolDate, + IsSupported: true, + }, + }, + } + + // Create detector + detector := NewDetector( + mockInventory, + mockEOL, + policy.NewDefaultPolicy(), + ) + + // Run detection + findings, err := detector.Detect(context.Background()) + + // Assertions + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(findings) != 1 { + t.Fatalf("Expected 1 finding, got %d", len(findings)) + } + + finding := findings[0] + + if finding.Status != types.StatusGreen { + t.Errorf("Expected GREEN status for current version, got %s", finding.Status) + } +} + +func TestDetector_Detect_MultipleResources(t *testing.T) { + // Setup mock inventory with multiple domains + eolDate := time.Now().AddDate(0, -6, 0) + futureEOL := time.Now().AddDate(2, 0, 0) + + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{ + { + ID: "arn:aws:es:us-east-1:123456:domain/eol-domain", + Name: "eol-domain", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "old-search", + CurrentVersion: "1.3", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:us-east-1:123456:domain/current-domain", + Name: "current-domain", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "new-search", + CurrentVersion: "2.11", + Engine: "opensearch", + }, + }, + } + + // Setup mock EOL provider + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{ + "opensearch:1.3": { + Version: "1.3", + Engine: "opensearch", + IsEOL: true, + EOLDate: &eolDate, + IsSupported: false, + }, + "opensearch:2.11": { + Version: "2.11", + Engine: "opensearch", + EOLDate: &futureEOL, + IsSupported: true, + }, + }, + } + + // Create detector + detector := NewDetector( + mockInventory, + mockEOL, + policy.NewDefaultPolicy(), + ) + + // Run detection + findings, err := detector.Detect(context.Background()) + + // Assertions + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(findings) != 2 { + t.Fatalf("Expected 2 findings, got %d", len(findings)) + } + + // Verify findings contain both RED and GREEN + var redFound, greenFound bool + for _, finding := range findings { + if finding.Status == types.StatusRed { + redFound = true + if finding.ResourceID != "arn:aws:es:us-east-1:123456:domain/eol-domain" { + t.Errorf("Unexpected RED finding resource: %s", finding.ResourceID) + } + } + if finding.Status == types.StatusGreen { + greenFound = true + if finding.ResourceID != "arn:aws:es:us-east-1:123456:domain/current-domain" { + t.Errorf("Unexpected GREEN finding resource: %s", finding.ResourceID) + } + } + } + + if !redFound { + t.Error("Expected to find RED status finding") + } + if !greenFound { + t.Error("Expected to find GREEN status finding") + } +} + +func TestDetector_Detect_EmptyInventory(t *testing.T) { + // Setup empty mock inventory + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{}, + } + + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{}, + } + + // Create detector + detector := NewDetector( + mockInventory, + mockEOL, + policy.NewDefaultPolicy(), + ) + + // Run detection + findings, err := detector.Detect(context.Background()) + + // Assertions + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(findings) != 0 { + t.Errorf("Expected 0 findings for empty inventory, got %d", len(findings)) + } +} + +func TestDetector_Name(t *testing.T) { + detector := NewDetector(nil, nil, nil) + + name := detector.Name() + expected := "opensearch-detector" + + if name != expected { + t.Errorf("Expected name '%s', got '%s'", expected, name) + } +} + +func TestDetector_ResourceType(t *testing.T) { + detector := NewDetector(nil, nil, nil) + + resourceType := detector.ResourceType() + + if resourceType != types.ResourceTypeOpenSearch { + t.Errorf("Expected resource type OPENSEARCH, got %s", resourceType) + } +} diff --git a/pkg/eol/endoflife/provider.go b/pkg/eol/endoflife/provider.go index 4d2d7bd..82d646a 100644 --- a/pkg/eol/endoflife/provider.go +++ b/pkg/eol/endoflife/provider.go @@ -39,6 +39,8 @@ var ProductMapping = map[string]string{ "elasticache-redis": "amazon-elasticache-redis", "valkey": "valkey", "elasticache-valkey": "valkey", + "opensearch": "amazon-opensearch", + "amazon-opensearch": "amazon-opensearch", } // ProductsWithNonStandardSchema lists products that MUST NOT use this generic provider @@ -288,8 +290,18 @@ func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*t // Determine lifecycle status based on dates now := time.Now() - // If we have an EOL date and we're past it, mark as EOL + // If we have an EOL date and we're past it, check extended support before marking EOL. + // Some products (e.g. OpenSearch) use eol for end-of-standard-support and + // extendedSupport for the real end-of-life date. if eolDate != nil && now.After(*eolDate) { + if extendedSupportDate != nil && now.Before(*extendedSupportDate) { + // Past standard support (eol) but still in extended support window + lifecycle.IsSupported = true + lifecycle.IsExtendedSupport = true + lifecycle.IsDeprecated = true + return lifecycle, nil + } + // Past all support (no extended support, or extended support also expired) lifecycle.IsEOL = true lifecycle.IsSupported = false lifecycle.IsDeprecated = true @@ -349,6 +361,25 @@ func normalizeVersion(engine, version string) string { return version } + // Handle OpenSearch versions + if engine == "opensearch" || engine == "amazon-opensearch" { + // Strip common prefixes (case insensitive) + lower := strings.ToLower(version) + if strings.HasPrefix(lower, "opensearch_") { + version = version[len("opensearch_"):] + } else if strings.HasPrefix(lower, "opensearch-") { + version = version[len("opensearch-"):] + } + + // Truncate to major.minor (strip patch version) + // endoflife.date only tracks major.minor cycles (e.g. "2.11" not "2.11.0") + parts := strings.SplitN(version, ".", 3) + if len(parts) >= 3 { + version = parts[0] + "." + parts[1] + } + return version + } + // For other engines, return as-is return version } diff --git a/pkg/eol/endoflife/provider_test.go b/pkg/eol/endoflife/provider_test.go index 1b41f26..48f4dd1 100644 --- a/pkg/eol/endoflife/provider_test.go +++ b/pkg/eol/endoflife/provider_test.go @@ -321,7 +321,7 @@ func TestProvider_Engines(t *testing.T) { // Note: EKS/kubernetes are NOT in this list because they use non-standard schema // and must use dedicated EKSEOLProvider instead - requiredEngines := []string{"postgres", "mysql", "redis"} + requiredEngines := []string{"postgres", "mysql", "redis", "opensearch"} for _, required := range requiredEngines { if !engineMap[required] { t.Errorf("Expected engine %s to be present", required) @@ -520,6 +520,42 @@ func TestNormalizeVersion(t *testing.T) { version: "15.4", want: "15.4", }, + { + name: "opensearch with OpenSearch_ prefix", + engine: "opensearch", + version: "OpenSearch_2.11", + want: "2.11", + }, + { + name: "opensearch with opensearch- prefix", + engine: "opensearch", + version: "opensearch-2.11", + want: "2.11", + }, + { + name: "opensearch strip patch version", + engine: "opensearch", + version: "2.11.0", + want: "2.11", + }, + { + name: "opensearch prefix and patch version", + engine: "opensearch", + version: "OpenSearch_2.11.0", + want: "2.11", + }, + { + name: "opensearch major.minor only", + engine: "opensearch", + version: "2.11", + want: "2.11", + }, + { + name: "opensearch case insensitive prefix", + engine: "opensearch", + version: "OPENSEARCH_1.3", + want: "1.3", + }, } for _, tt := range tests { @@ -532,6 +568,158 @@ func TestNormalizeVersion(t *testing.T) { } } +func TestProvider_GetVersionLifecycle_OpenSearch(t *testing.T) { + // Mock client with test data matching endoflife.date schema for amazon-opensearch. + // OpenSearch uses eol for end-of-standard-support and extendedSupport for real EOL. + mockClient := &MockClient{ + GetProductCyclesFunc: func(ctx context.Context, product string) ([]*ProductCycle, error) { + if product != "amazon-opensearch" { + t.Errorf("Expected product amazon-opensearch, got %s", product) + } + return []*ProductCycle{ + { + // Current version - not yet EOL (eol: false means still supported) + Cycle: "2.17", + ReleaseDate: "2024-11-13", + EOL: "false", + ExtendedSupport: true, + }, + { + // Version in extended support - past eol date but before extendedSupport date + Cycle: "2.5", + ReleaseDate: "2023-03-13", + EOL: "2025-11-07", // Past + ExtendedSupport: "2027-11-07", // Future + }, + { + // EOL version - past both eol and extendedSupport dates + Cycle: "1.0", + ReleaseDate: "2021-09-08", + EOL: "2024-11-07", // Past + ExtendedSupport: "2025-11-07", // Past + }, + }, nil + }, + } + + provider := NewProvider(mockClient, 1*time.Hour) + + tests := []struct { + name string + engine string + version string + wantVersion string + wantSupported bool + wantDeprecated bool + wantEOL bool + wantExtSupport bool + }{ + { + name: "current version 2.17", + engine: "opensearch", + version: "2.17", + wantVersion: "2.17", + wantSupported: true, + wantDeprecated: false, + wantEOL: false, + wantExtSupport: false, + }, + { + name: "version with OpenSearch_ prefix", + engine: "opensearch", + version: "OpenSearch_2.17", + wantVersion: "2.17", + wantSupported: true, + wantDeprecated: false, + wantEOL: false, + wantExtSupport: false, + }, + { + name: "amazon-opensearch engine alias", + engine: "amazon-opensearch", + version: "2.17", + wantVersion: "2.17", + wantSupported: true, + wantDeprecated: false, + wantEOL: false, + wantExtSupport: false, + }, + { + name: "extended support version 2.5", + engine: "opensearch", + version: "2.5", + wantVersion: "2.5", + wantSupported: true, // Still in extended support + wantDeprecated: true, // Past standard support (eol date) + wantEOL: false, // Not yet past extendedSupport date + wantExtSupport: true, + }, + { + name: "eol version 1.0", + engine: "opensearch", + version: "1.0", + wantVersion: "1.0", + wantSupported: false, // Past all support + wantDeprecated: true, + wantEOL: true, // Past extendedSupport date + wantExtSupport: false, + }, + { + name: "version with patch stripped", + engine: "opensearch", + version: "2.5.0", + wantVersion: "2.5", + wantSupported: true, + wantDeprecated: true, + wantEOL: false, + wantExtSupport: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lifecycle, err := provider.GetVersionLifecycle(context.Background(), tt.engine, tt.version) + if err != nil { + t.Fatalf("GetVersionLifecycle() error = %v", err) + } + + if lifecycle.Version != tt.wantVersion { + t.Errorf("Version = %s, want %s", lifecycle.Version, tt.wantVersion) + } + if lifecycle.IsSupported != tt.wantSupported { + t.Errorf("IsSupported = %v, want %v", lifecycle.IsSupported, tt.wantSupported) + } + if lifecycle.IsDeprecated != tt.wantDeprecated { + t.Errorf("IsDeprecated = %v, want %v", lifecycle.IsDeprecated, tt.wantDeprecated) + } + if lifecycle.IsEOL != tt.wantEOL { + t.Errorf("IsEOL = %v, want %v", lifecycle.IsEOL, tt.wantEOL) + } + if lifecycle.IsExtendedSupport != tt.wantExtSupport { + t.Errorf("IsExtendedSupport = %v, want %v", lifecycle.IsExtendedSupport, tt.wantExtSupport) + } + }) + } +} + +func TestProvider_OpenSearchEngineMapping(t *testing.T) { + // Verify that both "opensearch" and "amazon-opensearch" resolve to the same product + provider := NewProvider(&MockClient{}, 1*time.Hour) + + if !provider.supportsEngine("opensearch") { + t.Error("Expected opensearch to be a supported engine") + } + if !provider.supportsEngine("amazon-opensearch") { + t.Error("Expected amazon-opensearch to be a supported engine") + } + + // Both should map to the same product + if ProductMapping["opensearch"] != ProductMapping["amazon-opensearch"] { + t.Errorf("opensearch and amazon-opensearch should map to the same product, got %s and %s", + ProductMapping["opensearch"], ProductMapping["amazon-opensearch"]) + } +} + // TestProvider_InterfaceCompliance verifies that Provider implements eol.Provider interface func TestProvider_InterfaceCompliance(t *testing.T) { var _ interface { diff --git a/pkg/eol/mock/provider.go b/pkg/eol/mock/provider.go index 35eada5..3b45aa8 100644 --- a/pkg/eol/mock/provider.go +++ b/pkg/eol/mock/provider.go @@ -56,5 +56,5 @@ func (m *EOLProvider) Name() string { // Engines returns supported engines func (m *EOLProvider) Engines() []string { - return []string{"aurora-mysql", "aurora-postgresql", "postgres", "mysql"} + return []string{"aurora-mysql", "aurora-postgresql", "postgres", "mysql", "opensearch"} } diff --git a/pkg/inventory/wiz/fixtures_test.go b/pkg/inventory/wiz/fixtures_test.go index add6d01..668f400 100644 --- a/pkg/inventory/wiz/fixtures_test.go +++ b/pkg/inventory/wiz/fixtures_test.go @@ -13,6 +13,8 @@ var WizAPIFixtures = struct { AuroraCSVData string ElastiCacheReport *Report ElastiCacheCSVData string + OpenSearchReport *Report + OpenSearchCSVData string EmptyCSVData string MalformedCSVData string }{ @@ -50,6 +52,22 @@ arn:aws:elasticache:us-east-1:123456789012:cluster:billing-redis-001,billing-red arn:aws:elasticache:us-east-1:123456789012:cluster:analytics-redis-001,analytics-redis-001,elastiCache/Redis/cluster,123456789012,7.1.0,us-east-1,"[{""key"":""app"",""value"":""analytics""},{""key"":""env"",""value"":""production""},{""key"":""brand"",""value"":""brand-b""}]",Redis arn:aws:elasticache:us-west-2:789012345678:cluster:session-memcached-001,session-memcached-001,elastiCache/Memcached/cluster,789012345678,,us-west-2,"[{""key"":""app"",""value"":""session-store""},{""key"":""team"",""value"":""team-a""}]",Memcached arn:aws:elasticache:eu-west-1:345678901234:cluster:user-valkey-001,user-valkey-001,elastiCache/Valkey/cluster,345678901234,,eu-west-1,"[{""key"":""app"",""value"":""user-service""},{""key"":""brand"",""value"":""brand-c""}]",Valkey +`, + + OpenSearchReport: &Report{ + ID: "opensearch-report-id-789", + Name: "OpenSearch Domains Report", + DownloadURL: "https://wiz-api.example.com/reports/opensearch-report-id-789/download", + LastRun: time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC), + }, + + // Realistic Wiz CSV export for OpenSearch domains + // Columns: externalId, name, nativeType, cloudAccount.externalId, versionDetails.version, region, tags, typeFields.kind + OpenSearchCSVData: `externalId,name,nativeType,cloudAccount.externalId,versionDetails.version,region,tags,typeFields.kind +arn:aws:es:us-east-1:123456789012:domain/search-prod,search-prod,opensearch/OpenSearch/domain,123456789012,2.11,us-east-1,"[{""key"":""app"",""value"":""search-platform""},{""key"":""environment"",""value"":""production""},{""key"":""brand"",""value"":""brand-a""}]",OpenSearch +arn:aws:es:us-east-1:123456789012:domain/logging-domain,logging-domain,opensearch/OpenSearch/domain,123456789012,2.3,us-east-1,"[{""key"":""application"",""value"":""logging-service""},{""key"":""env"",""value"":""production""}]",OpenSearch +arn:aws:es:us-west-2:789012345678:domain/analytics-search,analytics-search,opensearch/Elasticsearch/domain,789012345678,1.0,us-west-2,"[{""key"":""app"",""value"":""analytics""},{""key"":""env"",""value"":""production""},{""key"":""brand"",""value"":""brand-b""}]",Elasticsearch +arn:aws:es:eu-west-1:345678901234:domain/audit-domain,audit-domain,opensearch/OpenSearch/domain,345678901234,OpenSearch_2.5,eu-west-1,"[{""key"":""app"",""value"":""audit-logs""},{""key"":""brand"",""value"":""brand-c""}]",OpenSearch `, EmptyCSVData: `externalId,name,nativeType,cloudAccount.externalId,versionDetails.version,region,tags,typeFields.kind diff --git a/pkg/inventory/wiz/opensearch.go b/pkg/inventory/wiz/opensearch.go new file mode 100644 index 0000000..20b039a --- /dev/null +++ b/pkg/inventory/wiz/opensearch.go @@ -0,0 +1,218 @@ +package wiz + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/pkg/errors" + + "github.com/block/Version-Guard/pkg/registry" + "github.com/block/Version-Guard/pkg/types" +) + +// OpenSearchInventorySource fetches OpenSearch domain inventory from Wiz saved reports +type OpenSearchInventorySource struct { + client *Client + reportID string + registryClient registry.Client // Optional: for service attribution when tags are missing + tagConfig *TagConfig // Configurable tag key mappings +} + +// NewOpenSearchInventorySource creates a new Wiz-based OpenSearch inventory source with default tag configuration. +// Use WithTagConfig() to customize tag key mappings. +func NewOpenSearchInventorySource(client *Client, reportID string) *OpenSearchInventorySource { + return &OpenSearchInventorySource{ + client: client, + reportID: reportID, + tagConfig: DefaultTagConfig(), + } +} + +// WithRegistryClient adds optional registry integration for service attribution. +// When tags are missing, the registry will be queried to map AWS account → service. +func (s *OpenSearchInventorySource) WithRegistryClient(registryClient registry.Client) *OpenSearchInventorySource { + s.registryClient = registryClient + return s +} + +// WithTagConfig sets custom tag key mappings for extracting metadata. +// If not called, uses DefaultTagConfig() with standard AWS tag conventions. +func (s *OpenSearchInventorySource) WithTagConfig(config *TagConfig) *OpenSearchInventorySource { + if config != nil { + s.tagConfig = config + } + return s +} + +// Name returns the name of this inventory source +func (s *OpenSearchInventorySource) Name() string { + return "wiz-opensearch" +} + +// CloudProvider returns the cloud provider this source supports +func (s *OpenSearchInventorySource) CloudProvider() types.CloudProvider { + return types.CloudProviderAWS +} + +// ListResources fetches all OpenSearch resources from Wiz +func (s *OpenSearchInventorySource) ListResources(ctx context.Context, resourceType types.ResourceType) ([]*types.Resource, error) { + if resourceType != types.ResourceTypeOpenSearch { + return nil, fmt.Errorf("unsupported resource type: %s (only OPENSEARCH supported)", resourceType) + } + + // Fetch report data + rows, err := s.client.GetReportData(ctx, s.reportID) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch Wiz report data") + } + + if len(rows) < 2 { + // Empty report (only header row) + return []*types.Resource{}, nil + } + + // Skip header row, parse data rows + var resources []*types.Resource + for i, row := range rows[1:] { + if len(row) < colMinRequired { + // Skip malformed rows + continue + } + + // Filter for OpenSearch domain resources only + nativeType := row[colNativeType] + if !isOpenSearchResource(nativeType) { + continue + } + + resource, err := s.parseOpenSearchRow(ctx, row) + if err != nil { + // Log error but continue processing other rows + // TODO: add proper logging + _ = fmt.Sprintf("row %d: failed to parse OpenSearch resource: %v", i+1, err) + continue + } + + if resource != nil { + resources = append(resources, resource) + } + } + + return resources, nil +} + +// GetResource fetches a specific OpenSearch resource by ARN +func (s *OpenSearchInventorySource) GetResource(ctx context.Context, resourceType types.ResourceType, id string) (*types.Resource, error) { + // For Wiz source, we fetch all and filter + resources, err := s.ListResources(ctx, resourceType) + if err != nil { + return nil, err + } + + for _, resource := range resources { + if resource.ID == id { + return resource, nil + } + } + + return nil, fmt.Errorf("resource not found: %s", id) +} + +// parseOpenSearchRow parses a single CSV row into a Resource +func (s *OpenSearchInventorySource) parseOpenSearchRow(ctx context.Context, row []string) (*types.Resource, error) { + resourceARN := row[colARN] + if resourceARN == "" { + return nil, fmt.Errorf("missing ARN") + } + + // Parse ARN + parsedARN, err := arn.Parse(resourceARN) + if err != nil { + return nil, errors.Wrapf(err, "invalid ARN: %s", resourceARN) + } + + // Extract metadata + resourceName := row[colResourceName] + accountID := row[colAWSAccountID] + if accountID == "" { + accountID = parsedARN.AccountID + } + + engine := normalizeOpenSearchKind(row[colEngineKind]) + version := normalizeOpenSearchVersion(row[colEngineVersion]) + region := row[colRegion] + + // Parse tags + tagsJSON := row[colTags] + tags, err := ParseTags(tagsJSON) + if err != nil { + // Non-fatal, just use empty tags + tags = make(map[string]string) + } + + // Extract service name from tags (using configurable tag keys) + service := s.tagConfig.GetAppTag(tags) + if service == "" { + // Try registry lookup by AWS account (if registry is configured) + if s.registryClient != nil { + if serviceInfo, err := s.registryClient.GetServiceByAWSAccount(ctx, accountID, region); err == nil { + service = serviceInfo.ServiceName + } + // Ignore registry errors - fall through to name parsing + } + + // Final fallback: extract from resource name or ARN + if service == "" { + service = extractServiceFromName(resourceName) + } + } + + // Extract brand (using configurable tag keys) + brand := s.tagConfig.GetBrandTag(tags) + + resource := &types.Resource{ + ID: resourceARN, + Name: resourceName, + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: service, + CloudAccountID: accountID, + CloudRegion: region, + Brand: brand, + CurrentVersion: version, + Engine: engine, + Tags: tags, + DiscoveredAt: time.Now(), + } + + return resource, nil +} + +// normalizeOpenSearchKind converts Wiz typeFields.kind values to standard engine names. +// All OpenSearch domain kinds map to "opensearch". +func normalizeOpenSearchKind(kind string) string { + if kind == "" { + return "" + } + return "opensearch" +} + +// normalizeOpenSearchVersion strips the "OpenSearch_" prefix if present. +// Wiz sometimes reports versions as "OpenSearch_2.11" instead of "2.11". +func normalizeOpenSearchVersion(version string) string { + return strings.TrimPrefix(version, "OpenSearch_") +} + +// isOpenSearchResource checks if a Wiz native type represents an OpenSearch domain. +// Wiz nativeType examples: +// - "opensearch/OpenSearch/domain" +// - "opensearch/Elasticsearch/domain" +// +// We only accept domain types, excluding non-versioned types. +func isOpenSearchResource(nativeType string) bool { + lower := strings.ToLower(nativeType) + return strings.Contains(lower, "opensearch") && strings.HasSuffix(lower, "/domain") +} diff --git a/pkg/inventory/wiz/opensearch_test.go b/pkg/inventory/wiz/opensearch_test.go new file mode 100644 index 0000000..e0a62d1 --- /dev/null +++ b/pkg/inventory/wiz/opensearch_test.go @@ -0,0 +1,158 @@ +package wiz + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/block/Version-Guard/pkg/types" +) + +func TestOpenSearchInventorySource_ListResources_Success(t *testing.T) { + ctx := context.Background() + + mockWizClient := new(MockWizClient) + mockWizClient.On("GetAccessToken", mock.Anything).Return(WizAPIFixtures.AccessToken, nil) + mockWizClient.On("GetReport", mock.Anything, mock.Anything, "opensearch-report-id"). + Return(WizAPIFixtures.OpenSearchReport, nil) + mockWizClient.On("DownloadReport", mock.Anything, mock.Anything). + Return(NewMockReadCloser(WizAPIFixtures.OpenSearchCSVData), nil) + + client := NewClient(mockWizClient, time.Hour) + source := NewOpenSearchInventorySource(client, "opensearch-report-id") + + resources, err := source.ListResources(ctx, types.ResourceTypeOpenSearch) + + require.NoError(t, err) + require.Len(t, resources, 4, "Should have 4 OpenSearch domains from CSV") + + // Verify: First resource (OpenSearch 2.11) + r1 := resources[0] + assert.Equal(t, "arn:aws:es:us-east-1:123456789012:domain/search-prod", r1.ID) + assert.Equal(t, "search-prod", r1.Name) + assert.Equal(t, types.ResourceTypeOpenSearch, r1.Type) + assert.Equal(t, types.CloudProviderAWS, r1.CloudProvider) + assert.Equal(t, "search-platform", r1.Service) + assert.Equal(t, "123456789012", r1.CloudAccountID) + assert.Equal(t, "us-east-1", r1.CloudRegion) + assert.Equal(t, "brand-a", r1.Brand) + assert.Equal(t, "2.11", r1.CurrentVersion) + assert.Equal(t, "opensearch", r1.Engine) + + // Verify: Second resource (OpenSearch 2.3) + r2 := resources[1] + assert.Equal(t, "logging-service", r2.Service) + assert.Equal(t, "2.3", r2.CurrentVersion) + assert.Equal(t, "opensearch", r2.Engine) + + // Verify: Third resource (OpenSearch 1.0 - brand-b) + r3 := resources[2] + assert.Equal(t, "analytics", r3.Service) + assert.Equal(t, "brand-b", r3.Brand) + assert.Equal(t, "1.0", r3.CurrentVersion) + + // Verify: Fourth resource (OpenSearch 2.5 with OpenSearch_ prefix stripped) + r4 := resources[3] + assert.Equal(t, "audit-logs", r4.Service) + assert.Equal(t, "brand-c", r4.Brand) + assert.Equal(t, "2.5", r4.CurrentVersion) + assert.Equal(t, "opensearch", r4.Engine) + + // Verify: All resources have discovery timestamp + for _, r := range resources { + assert.False(t, r.DiscoveredAt.IsZero(), "Should have discovery timestamp") + } + + mockWizClient.AssertExpectations(t) +} + +func TestOpenSearchInventorySource_ListResources_EmptyReport(t *testing.T) { + ctx := context.Background() + + mockWizClient := new(MockWizClient) + mockWizClient.On("GetAccessToken", mock.Anything).Return(WizAPIFixtures.AccessToken, nil) + mockWizClient.On("GetReport", mock.Anything, mock.Anything, mock.Anything). + Return(WizAPIFixtures.OpenSearchReport, nil) + mockWizClient.On("DownloadReport", mock.Anything, mock.Anything). + Return(NewMockReadCloser(WizAPIFixtures.EmptyCSVData), nil) + + client := NewClient(mockWizClient, time.Hour) + source := NewOpenSearchInventorySource(client, "empty-report-id") + + resources, err := source.ListResources(ctx, types.ResourceTypeOpenSearch) + + require.NoError(t, err) + assert.Empty(t, resources, "Should have no resources from header-only CSV") + + mockWizClient.AssertExpectations(t) +} + +func TestOpenSearchInventorySource_ListResources_UnsupportedResourceType(t *testing.T) { + ctx := context.Background() + + mockWizClient := new(MockWizClient) + client := NewClient(mockWizClient, time.Hour) + source := NewOpenSearchInventorySource(client, "opensearch-report-id") + + _, err := source.ListResources(ctx, types.ResourceTypeAurora) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported resource type") +} + +func TestOpenSearchInventorySource_Name(t *testing.T) { + mockWizClient := new(MockWizClient) + client := NewClient(mockWizClient, time.Hour) + source := NewOpenSearchInventorySource(client, "opensearch-report-id") + + assert.Equal(t, "wiz-opensearch", source.Name()) +} + +func TestOpenSearchInventorySource_CloudProvider(t *testing.T) { + mockWizClient := new(MockWizClient) + client := NewClient(mockWizClient, time.Hour) + source := NewOpenSearchInventorySource(client, "opensearch-report-id") + + assert.Equal(t, types.CloudProviderAWS, source.CloudProvider()) +} + +func TestNormalizeOpenSearchKind(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"OpenSearch", "opensearch"}, + {"Elasticsearch", "opensearch"}, + {"opensearch", "opensearch"}, + {"OPENSEARCH", "opensearch"}, + {"", ""}, + } + + for _, tc := range tests { + assert.Equal(t, tc.expected, normalizeOpenSearchKind(tc.input), "normalizeOpenSearchKind(%q)", tc.input) + } +} + +func TestIsOpenSearchResource(t *testing.T) { + tests := []struct { + nativeType string + expected bool + }{ + {"opensearch/OpenSearch/domain", true}, + {"opensearch/Elasticsearch/domain", true}, + {"OpenSearch/OpenSearch/domain", true}, + {"opensearch#snapshot", false}, + {"opensearch#user", false}, + {"rds/AmazonAuroraMySQL/cluster", false}, + {"elastiCache/Redis/cluster", false}, + {"", false}, + } + + for _, tc := range tests { + assert.Equal(t, tc.expected, isOpenSearchResource(tc.nativeType), "isOpenSearchResource(%q)", tc.nativeType) + } +} From 48e1487e4856d67f9d9dcbfe2a1918e872a30079 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 13:29:45 -0400 Subject: [PATCH 2/5] fix(lint): struct alignment and dupl nolint for OpenSearch inventory Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/inventory/wiz/opensearch.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/inventory/wiz/opensearch.go b/pkg/inventory/wiz/opensearch.go index 20b039a..5ed2793 100644 --- a/pkg/inventory/wiz/opensearch.go +++ b/pkg/inventory/wiz/opensearch.go @@ -15,10 +15,10 @@ import ( // OpenSearchInventorySource fetches OpenSearch domain inventory from Wiz saved reports type OpenSearchInventorySource struct { + registryClient registry.Client client *Client + tagConfig *TagConfig reportID string - registryClient registry.Client // Optional: for service attribution when tags are missing - tagConfig *TagConfig // Configurable tag key mappings } // NewOpenSearchInventorySource creates a new Wiz-based OpenSearch inventory source with default tag configuration. @@ -58,6 +58,8 @@ func (s *OpenSearchInventorySource) CloudProvider() types.CloudProvider { } // ListResources fetches all OpenSearch resources from Wiz +// +//nolint:dupl // follows established inventory source pattern (aurora, elasticache) func (s *OpenSearchInventorySource) ListResources(ctx context.Context, resourceType types.ResourceType) ([]*types.Resource, error) { if resourceType != types.ResourceTypeOpenSearch { return nil, fmt.Errorf("unsupported resource type: %s (only OPENSEARCH supported)", resourceType) From 4c864401b6f7d8060064699e5827c441e14d5f6b Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 15:37:06 -0400 Subject: [PATCH 3/5] test: add OpenSearch detector integration test Follows the Aurora/EKS pattern with full-flow tests covering all status classifications (RED, YELLOW, GREEN) and summary statistics. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/detector/opensearch/integration_test.go | 426 ++++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 pkg/detector/opensearch/integration_test.go diff --git a/pkg/detector/opensearch/integration_test.go b/pkg/detector/opensearch/integration_test.go new file mode 100644 index 0000000..49beaf6 --- /dev/null +++ b/pkg/detector/opensearch/integration_test.go @@ -0,0 +1,426 @@ +package opensearch + +import ( + "context" + "testing" + "time" + + "github.com/block/Version-Guard/pkg/eol/mock" + inventorymock "github.com/block/Version-Guard/pkg/inventory/mock" + "github.com/block/Version-Guard/pkg/policy" + "github.com/block/Version-Guard/pkg/types" +) + +// TestFullFlow_MultipleResourcesWithDifferentStatuses tests the complete end-to-end flow +// from inventory fetch → EOL lookup → policy classification → finding generation +// +//nolint:gocognit // integration test complexity is acceptable +func TestFullFlow_MultipleResourcesWithDifferentStatuses(t *testing.T) { + // Setup: Create a realistic scenario with multiple OpenSearch domains + // representing different upgrade states + + eolDate := time.Now().AddDate(0, -6, 0) // 6 months ago + approachingEOL := time.Now().AddDate(0, 2, 0) // 2 months from now + futureEOL := time.Now().AddDate(2, 0, 0) // 2 years from now + + // Mock inventory with 5 OpenSearch domains in different states + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{ + { + ID: "arn:aws:es:us-east-1:123456:domain/legacy-search-10", + Name: "legacy-search-10", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "legacy-search", + CloudAccountID: "123456", + CloudRegion: "us-east-1", + Brand: "brand-a", + CurrentVersion: "1.0", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:us-east-1:123456:domain/search-23-extended", + Name: "search-23-extended", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "billing", + CloudAccountID: "123456", + CloudRegion: "us-east-1", + Brand: "brand-a", + CurrentVersion: "2.3", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:us-east-1:123456:domain/search-25-approaching", + Name: "search-25-approaching", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "analytics", + CloudAccountID: "123456", + CloudRegion: "us-east-1", + Brand: "brand-b", + CurrentVersion: "2.5", + Engine: "opensearch", + Tags: map[string]string{ + "app": "analytics", + "env": "production", + "brand": "brand-b", + }, + }, + { + ID: "arn:aws:es:us-west-2:789012:domain/search-211-current", + Name: "search-211-current", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "payments", + CloudAccountID: "789012", + CloudRegion: "us-west-2", + Brand: "brand-a", + CurrentVersion: "2.11", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:eu-west-1:345678:domain/search-13-deprecated", + Name: "search-13-deprecated", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + Service: "user-service", + CloudAccountID: "345678", + CloudRegion: "eu-west-1", + Brand: "brand-c", + CurrentVersion: "1.3", + Engine: "opensearch", + }, + }, + } + + // Mock EOL provider with lifecycle data for each version + deprecationDate := time.Now().AddDate(0, -3, 0) // 3 months ago + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{ + // OpenSearch 1.0 - Past EOL + "opensearch:1.0": { + Version: "1.0", + Engine: "opensearch", + IsEOL: true, + EOLDate: &eolDate, + IsSupported: false, + IsDeprecated: true, + Source: "mock-eol", + FetchedAt: time.Now(), + }, + // OpenSearch 2.3 - Extended support + "opensearch:2.3": { + Version: "2.3", + Engine: "opensearch", + IsExtendedSupport: true, + IsSupported: true, + Source: "mock-eol", + FetchedAt: time.Now(), + }, + // OpenSearch 2.5 - Approaching EOL + "opensearch:2.5": { + Version: "2.5", + Engine: "opensearch", + EOLDate: &approachingEOL, + IsSupported: true, + Source: "mock-eol", + FetchedAt: time.Now(), + }, + // OpenSearch 2.11 - Current + "opensearch:2.11": { + Version: "2.11", + Engine: "opensearch", + EOLDate: &futureEOL, + IsSupported: true, + Source: "mock-eol", + FetchedAt: time.Now(), + }, + // OpenSearch 1.3 - Deprecated + "opensearch:1.3": { + Version: "1.3", + Engine: "opensearch", + IsDeprecated: true, + DeprecationDate: &deprecationDate, + IsSupported: false, + Source: "mock-eol", + FetchedAt: time.Now(), + }, + }, + } + + // Create detector with real policy + detector := NewDetector( + mockInventory, + mockEOL, + policy.NewDefaultPolicy(), + ) + + // Execute: Run the full detection flow + findings, err := detector.Detect(context.Background()) + + // Verify: Check results + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if len(findings) != 5 { + t.Fatalf("Expected 5 findings, got %d", len(findings)) + } + + // Verify each finding by resource ID + findingsByID := make(map[string]*types.Finding) + for _, f := range findings { + findingsByID[f.ResourceID] = f + } + + // Test 1: OpenSearch 1.0 should be RED (EOL) + t.Run("OpenSearch_1.0_EOL", func(t *testing.T) { + finding := findingsByID["arn:aws:es:us-east-1:123456:domain/legacy-search-10"] + if finding == nil { + t.Fatal("Finding not found") + } + + if finding.Status != types.StatusRed { + t.Errorf("Expected RED status, got %s", finding.Status) + } + + if finding.Service != "legacy-search" { + t.Errorf("Expected service 'legacy-search', got '%s'", finding.Service) + } + + if finding.Brand != "brand-a" { + t.Errorf("Expected brand 'brand-a', got '%s'", finding.Brand) + } + + if finding.CurrentVersion != "1.0" { + t.Errorf("Expected version '1.0', got '%s'", finding.CurrentVersion) + } + + if finding.Message == "" { + t.Error("Expected message to be set") + } + + if finding.Recommendation == "" { + t.Error("Expected recommendation to be set") + } + + // Verify message mentions EOL + if !contains(finding.Message, "End-of-Life") && !contains(finding.Message, "deprecated") { + t.Errorf("Expected message to mention EOL or deprecated, got: %s", finding.Message) + } + + // Verify recommendation mentions upgrade + if !contains(finding.Recommendation, "Upgrade") && !contains(finding.Recommendation, "upgrade") { + t.Errorf("Expected recommendation to mention upgrade, got: %s", finding.Recommendation) + } + }) + + // Test 2: OpenSearch 2.3 should be YELLOW (Extended Support) + t.Run("OpenSearch_2.3_Extended_Support", func(t *testing.T) { + finding := findingsByID["arn:aws:es:us-east-1:123456:domain/search-23-extended"] + if finding == nil { + t.Fatal("Finding not found") + } + + if finding.Status != types.StatusYellow { + t.Errorf("Expected YELLOW status, got %s", finding.Status) + } + + if finding.Service != "billing" { + t.Errorf("Expected service 'billing', got '%s'", finding.Service) + } + + // Verify message mentions extended support or cost + if !contains(finding.Message, "extended support") { + t.Errorf("Expected message to mention extended support, got: %s", finding.Message) + } + }) + + // Test 3: OpenSearch 2.5 should be YELLOW (Approaching EOL) + t.Run("OpenSearch_2.5_Approaching_EOL", func(t *testing.T) { + finding := findingsByID["arn:aws:es:us-east-1:123456:domain/search-25-approaching"] + if finding == nil { + t.Fatal("Finding not found") + } + + if finding.Status != types.StatusYellow { + t.Errorf("Expected YELLOW status, got %s", finding.Status) + } + + if finding.Service != "analytics" { + t.Errorf("Expected service 'analytics', got '%s'", finding.Service) + } + + if finding.Brand != "brand-b" { + t.Errorf("Expected brand 'brand-b', got '%s'", finding.Brand) + } + + // Verify message mentions approaching EOL or days + if !contains(finding.Message, "will reach End-of-Life") && !contains(finding.Message, "days") { + t.Errorf("Expected message to mention approaching EOL, got: %s", finding.Message) + } + }) + + // Test 4: OpenSearch 2.11 should be GREEN (Current) + t.Run("OpenSearch_2.11_Current", func(t *testing.T) { + finding := findingsByID["arn:aws:es:us-west-2:789012:domain/search-211-current"] + if finding == nil { + t.Fatal("Finding not found") + } + + if finding.Status != types.StatusGreen { + t.Errorf("Expected GREEN status, got %s", finding.Status) + } + + if finding.Service != "payments" { + t.Errorf("Expected service 'payments', got '%s'", finding.Service) + } + + if finding.CloudRegion != "us-west-2" { + t.Errorf("Expected region 'us-west-2', got '%s'", finding.CloudRegion) + } + + // Verify message is positive + if !contains(finding.Message, "supported") { + t.Errorf("Expected message to mention supported, got: %s", finding.Message) + } + + if finding.Recommendation != "No action required" { + t.Errorf("Expected recommendation 'No action required', got: %s", finding.Recommendation) + } + }) + + // Test 5: OpenSearch 1.3 should be RED (Deprecated) + t.Run("OpenSearch_1.3_Deprecated", func(t *testing.T) { + finding := findingsByID["arn:aws:es:eu-west-1:345678:domain/search-13-deprecated"] + if finding == nil { + t.Fatal("Finding not found") + } + + if finding.Status != types.StatusRed { + t.Errorf("Expected RED status, got %s", finding.Status) + } + + if finding.Service != "user-service" { + t.Errorf("Expected service 'user-service', got '%s'", finding.Service) + } + + if finding.Brand != "brand-c" { + t.Errorf("Expected brand 'brand-c', got '%s'", finding.Brand) + } + + if finding.Engine != "opensearch" { + t.Errorf("Expected engine 'opensearch', got '%s'", finding.Engine) + } + + if finding.CloudRegion != "eu-west-1" { + t.Errorf("Expected region 'eu-west-1', got '%s'", finding.CloudRegion) + } + }) +} + +// TestFullFlow_SummaryStatistics tests that we can generate summary stats from findings +func TestFullFlow_SummaryStatistics(t *testing.T) { + eolDate := time.Now().AddDate(0, -6, 0) + futureEOL := time.Now().AddDate(2, 0, 0) + + mockInventory := &inventorymock.InventorySource{ + Resources: []*types.Resource{ + { + ID: "arn:aws:es:us-east-1:123:domain/domain-1", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + CurrentVersion: "1.0", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:us-east-1:123:domain/domain-2", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + CurrentVersion: "2.3", + Engine: "opensearch", + }, + { + ID: "arn:aws:es:us-east-1:123:domain/domain-3", + Type: types.ResourceTypeOpenSearch, + CloudProvider: types.CloudProviderAWS, + CurrentVersion: "2.11", + Engine: "opensearch", + }, + }, + } + + mockEOL := &mock.EOLProvider{ + Versions: map[string]*types.VersionLifecycle{ + "opensearch:1.0": { + Version: "1.0", Engine: "opensearch", + IsEOL: true, EOLDate: &eolDate, IsSupported: false, + }, + "opensearch:2.3": { + Version: "2.3", Engine: "opensearch", + IsExtendedSupport: true, IsSupported: true, + }, + "opensearch:2.11": { + Version: "2.11", Engine: "opensearch", + EOLDate: &futureEOL, IsSupported: true, + }, + }, + } + + detector := NewDetector(mockInventory, mockEOL, policy.NewDefaultPolicy()) + findings, err := detector.Detect(context.Background()) + + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Calculate summary statistics + var redCount, yellowCount, greenCount int + for _, f := range findings { + switch f.Status { + case types.StatusRed: + redCount++ + case types.StatusYellow: + yellowCount++ + case types.StatusGreen: + greenCount++ + } + } + + // Verify counts + if redCount != 1 { + t.Errorf("Expected 1 RED finding, got %d", redCount) + } + + if yellowCount != 1 { + t.Errorf("Expected 1 YELLOW finding, got %d", yellowCount) + } + + if greenCount != 1 { + t.Errorf("Expected 1 GREEN finding, got %d", greenCount) + } + + // Calculate compliance percentage (GREEN / TOTAL) + totalResources := len(findings) + compliancePercentage := (float64(greenCount) / float64(totalResources)) * 100 + + expectedCompliance := 33.33 + if compliancePercentage < expectedCompliance-1 || compliancePercentage > expectedCompliance+1 { + t.Errorf("Expected compliance ~%.2f%%, got %.2f%%", expectedCompliance, compliancePercentage) + } +} + +// Helper function +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsHelper(s, substr) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 60b96bb98df3fcb8d3518cf41c4c3a9db9c40fb6 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Wed, 15 Apr 2026 09:48:23 -0400 Subject: [PATCH 4/5] refactor: use shared parseWizReport helper for OpenSearch inventory Address review feedback to leverage the shared CSV parsing infrastructure instead of duplicating the column-index logic. Also updates README with currently supported resource types. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 +- pkg/inventory/wiz/opensearch.go | 140 +++++++------------------------- 2 files changed, 30 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index d3c7a29..c9a76b8 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,10 @@ Version Guard implements a **two-stage detection pipeline**: Currently implemented: - **Aurora** (RDS MySQL/PostgreSQL) - AWS RDS EOL API + Wiz inventory - **EKS** (Kubernetes) - AWS EKS API + endoflife.date (hybrid) + Wiz inventory +- **ElastiCache** (Redis/Valkey/Memcached) - endoflife.date + Wiz inventory +- **OpenSearch** - endoflife.date + Wiz inventory Easily extensible to: -- ElastiCache (Redis/Valkey/Memcached) -- OpenSearch - Lambda (Node.js, Python, Java) - Cloud SQL (GCP) - GKE (GCP) diff --git a/pkg/inventory/wiz/opensearch.go b/pkg/inventory/wiz/opensearch.go index 5ed2793..6219518 100644 --- a/pkg/inventory/wiz/opensearch.go +++ b/pkg/inventory/wiz/opensearch.go @@ -4,10 +4,6 @@ import ( "context" "fmt" "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws/arn" - "github.com/pkg/errors" "github.com/block/Version-Guard/pkg/registry" "github.com/block/Version-Guard/pkg/types" @@ -32,7 +28,6 @@ func NewOpenSearchInventorySource(client *Client, reportID string) *OpenSearchIn } // WithRegistryClient adds optional registry integration for service attribution. -// When tags are missing, the registry will be queried to map AWS account → service. func (s *OpenSearchInventorySource) WithRegistryClient(registryClient registry.Client) *OpenSearchInventorySource { s.registryClient = registryClient return s @@ -57,58 +52,37 @@ func (s *OpenSearchInventorySource) CloudProvider() types.CloudProvider { return types.CloudProviderAWS } +var openSearchRequiredColumns = []string{ + colHeaderExternalID, + colHeaderName, + colHeaderNativeType, + colHeaderAccountID, + colHeaderVersion, + colHeaderRegion, + colHeaderTags, + colHeaderEngineKind, +} + // ListResources fetches all OpenSearch resources from Wiz -// -//nolint:dupl // follows established inventory source pattern (aurora, elasticache) func (s *OpenSearchInventorySource) ListResources(ctx context.Context, resourceType types.ResourceType) ([]*types.Resource, error) { if resourceType != types.ResourceTypeOpenSearch { return nil, fmt.Errorf("unsupported resource type: %s (only OPENSEARCH supported)", resourceType) } - // Fetch report data - rows, err := s.client.GetReportData(ctx, s.reportID) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch Wiz report data") - } - - if len(rows) < 2 { - // Empty report (only header row) - return []*types.Resource{}, nil - } - - // Skip header row, parse data rows - var resources []*types.Resource - for i, row := range rows[1:] { - if len(row) < colMinRequired { - // Skip malformed rows - continue - } - - // Filter for OpenSearch domain resources only - nativeType := row[colNativeType] - if !isOpenSearchResource(nativeType) { - continue - } - - resource, err := s.parseOpenSearchRow(ctx, row) - if err != nil { - // Log error but continue processing other rows - // TODO: add proper logging - _ = fmt.Sprintf("row %d: failed to parse OpenSearch resource: %v", i+1, err) - continue - } - - if resource != nil { - resources = append(resources, resource) - } - } - - return resources, nil + return parseWizReport( + ctx, + s.client, + s.reportID, + openSearchRequiredColumns, + func(cols columnIndex, row []string) bool { + return isOpenSearchResource(cols.col(row, colHeaderNativeType)) + }, + s.parseOpenSearchRow, + ) } // GetResource fetches a specific OpenSearch resource by ARN func (s *OpenSearchInventorySource) GetResource(ctx context.Context, resourceType types.ResourceType, id string) (*types.Resource, error) { - // For Wiz source, we fetch all and filter resources, err := s.ListResources(ctx, resourceType) if err != nil { return nil, err @@ -123,73 +97,15 @@ func (s *OpenSearchInventorySource) GetResource(ctx context.Context, resourceTyp return nil, fmt.Errorf("resource not found: %s", id) } -// parseOpenSearchRow parses a single CSV row into a Resource -func (s *OpenSearchInventorySource) parseOpenSearchRow(ctx context.Context, row []string) (*types.Resource, error) { - resourceARN := row[colARN] - if resourceARN == "" { - return nil, fmt.Errorf("missing ARN") - } - - // Parse ARN - parsedARN, err := arn.Parse(resourceARN) +// parseOpenSearchRow parses a single CSV row into a Resource. +// Uses the shared parseAWSResourceRow helper, then normalizes the version +// to strip the "OpenSearch_" prefix that Wiz sometimes includes. +func (s *OpenSearchInventorySource) parseOpenSearchRow(ctx context.Context, cols columnIndex, row []string) (*types.Resource, error) { + resource, err := parseAWSResourceRow(ctx, cols, row, types.ResourceTypeOpenSearch, normalizeOpenSearchKind, s.tagConfig, s.registryClient) if err != nil { - return nil, errors.Wrapf(err, "invalid ARN: %s", resourceARN) - } - - // Extract metadata - resourceName := row[colResourceName] - accountID := row[colAWSAccountID] - if accountID == "" { - accountID = parsedARN.AccountID - } - - engine := normalizeOpenSearchKind(row[colEngineKind]) - version := normalizeOpenSearchVersion(row[colEngineVersion]) - region := row[colRegion] - - // Parse tags - tagsJSON := row[colTags] - tags, err := ParseTags(tagsJSON) - if err != nil { - // Non-fatal, just use empty tags - tags = make(map[string]string) - } - - // Extract service name from tags (using configurable tag keys) - service := s.tagConfig.GetAppTag(tags) - if service == "" { - // Try registry lookup by AWS account (if registry is configured) - if s.registryClient != nil { - if serviceInfo, err := s.registryClient.GetServiceByAWSAccount(ctx, accountID, region); err == nil { - service = serviceInfo.ServiceName - } - // Ignore registry errors - fall through to name parsing - } - - // Final fallback: extract from resource name or ARN - if service == "" { - service = extractServiceFromName(resourceName) - } - } - - // Extract brand (using configurable tag keys) - brand := s.tagConfig.GetBrandTag(tags) - - resource := &types.Resource{ - ID: resourceARN, - Name: resourceName, - Type: types.ResourceTypeOpenSearch, - CloudProvider: types.CloudProviderAWS, - Service: service, - CloudAccountID: accountID, - CloudRegion: region, - Brand: brand, - CurrentVersion: version, - Engine: engine, - Tags: tags, - DiscoveredAt: time.Now(), + return nil, err } - + resource.CurrentVersion = normalizeOpenSearchVersion(resource.CurrentVersion) return resource, nil } From 384413383efb7edadf8cdd909e73b6046dff464c Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Wed, 15 Apr 2026 09:57:04 -0400 Subject: [PATCH 5/5] docs: update ARCHITECTURE.md with ElastiCache and OpenSearch support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark ElastiCache and OpenSearch as implemented (✅) in the architecture docs, add opensearch.go and helpers.go to the repo structure diagram, and list all four inventory source implementations. Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa3c8e5..b9a4b03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,8 +5,8 @@ **Supported Resources**: - ✅ Aurora (RDS MySQL/PostgreSQL) - ✅ EKS (Kubernetes) -- 🔜 ElastiCache (Redis/Valkey/Memcached) - implementation available, needs integration -- 🔜 OpenSearch +- ✅ ElastiCache (Redis/Valkey/Memcached) +- ✅ OpenSearch - 🔜 Lambda runtimes --- @@ -31,7 +31,7 @@ **Vision:** Version Guard is a **cloud-agnostic** version drift detection platform supporting multiple cloud providers. ### Phase 1 (Implemented): AWS -- **Resources**: ✅ Aurora (RDS), ✅ EKS, 🔜 ElastiCache, 🔜 OpenSearch, 🔜 Lambda +- **Resources**: ✅ Aurora (RDS), ✅ EKS, ✅ ElastiCache, ✅ OpenSearch, 🔜 Lambda - **Inventory**: Wiz (cross-cloud) + AWS APIs - **EOL Data**: AWS native APIs (RDS, EKS) + endoflife.date (hybrid enrichment) @@ -116,6 +116,8 @@ Version-Guard/ │ │ │ ├── aurora.go # AWS Aurora inventory │ │ │ ├── elasticache.go # AWS ElastiCache inventory │ │ │ ├── eks.go # AWS EKS inventory +│ │ │ ├── opensearch.go # AWS OpenSearch inventory +│ │ │ ├── helpers.go # Shared CSV parsing helpers │ │ │ └── client.go # Wiz HTTP client │ │ └── mock/ # Mock for tests │ │ @@ -137,8 +139,10 @@ Version-Guard/ │ │ ├── detector.go # Detector interface │ │ ├── aurora/ │ │ │ └── detector.go # Aurora detector -│ │ └── eks/ -│ │ └── detector.go # EKS detector +│ │ ├── eks/ +│ │ │ └── detector.go # EKS detector +│ │ └── opensearch/ +│ │ └── detector.go # OpenSearch detector │ │ │ ├── store/ │ │ ├── store.go # Store interface @@ -205,6 +209,8 @@ type InventorySource interface { **Implementations:** - `wiz.AuroraInventorySource` - Wiz saved reports for Aurora - `wiz.EKSInventorySource` - Wiz saved reports for EKS +- `wiz.ElastiCacheInventorySource` - Wiz saved reports for ElastiCache +- `wiz.OpenSearchInventorySource` - Wiz saved reports for OpenSearch - `mock.MockInventorySource` - For testing **How to extend:**