Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WIZ_CACHE_TTL_HOURS=1
# Wiz Report IDs (JSON map of resource ID to Wiz report ID)
# Create reports in Wiz UI and map them to your resource IDs from config/resources.yaml
# Format: {"resource-id-1":"wiz-report-id-1","resource-id-2":"wiz-report-id-2"}
WIZ_REPORT_IDS={"aurora-mysql":"your-aurora-mysql-report-id","eks":"your-eks-report-id","elasticache-redis":"your-elasticache-redis-report-id"}
WIZ_REPORT_IDS={"aurora-mysql":"your-aurora-mysql-report-id","eks":"your-eks-report-id","elasticache-redis":"your-elasticache-redis-report-id","opensearch":"your-opensearch-report-id"}

# ─── AWS Configuration ────────────────────────────────────────────────────────
# Used for EOL data APIs and S3 snapshots
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
- ✅ **ElastiCache (Redis/Valkey/Memcached)** - Production tested

**Planned Resources** (add ~15 lines to `config/resources.yaml`):
- ✅ **OpenSearch** - Production tested (auto-detects legacy Elasticsearch versions)
- 📋 RDS MySQL/PostgreSQL
- 📋 OpenSearch
- 📋 Lambda runtimes

---
Expand All @@ -35,7 +35,7 @@
**Vision:** Version Guard is a **cloud-agnostic** version drift detection platform supporting multiple cloud providers.

### Phase 1 (Implemented): AWS
- **Resources**: ✅ Aurora MySQL (production tested), ✅ Aurora PostgreSQL (config ready), ✅ EKS (production tested), ✅ ElastiCache (production tested), 📋 RDS, 📋 OpenSearch, 📋 Lambda
- **Resources**: ✅ Aurora MySQL (production tested), ✅ Aurora PostgreSQL (config ready), ✅ EKS (production tested), ✅ ElastiCache (production tested), ✅ OpenSearch (production tested), 📋 RDS, 📋 Lambda
- **Inventory**: Wiz saved reports (primary) + Custom sources (extensible)
- **EOL Data**: endoflife.date API (404 graceful degradation for products not yet listed)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ Version Guard uses a **config-driven approach** - resources are defined in `conf
| **ElastiCache** (Redis/Valkey/Memcached) | Wiz | [amazon-elasticache-redis](https://endoflife.date/amazon-elasticache-redis), [valkey](https://endoflife.date/valkey) | ✅ Production tested |
| **Aurora MySQL** | Wiz | [amazon-aurora-mysql](https://endoflife.date/amazon-aurora-mysql) | ⚠️ Production tested, EOL data pending [endoflife.date#9534](https://github.com/endoflife-date/endoflife.date/pull/9534) |
| **Aurora PostgreSQL** | Wiz | [amazon-aurora-postgresql](https://endoflife.date/amazon-aurora-postgresql) | 🔜 Config ready, needs Wiz report ID |
| **OpenSearch** | Wiz | [amazon-opensearch](https://endoflife.date/amazon-opensearch), [elasticsearch](https://endoflife.date/elasticsearch) | ✅ Production tested |
| **RDS MySQL** | — | [amazon-rds-mysql](https://endoflife.date/amazon-rds-mysql) | 📋 Planned (add to config) |
| **RDS PostgreSQL** | — | [amazon-rds-postgresql](https://endoflife.date/amazon-rds-postgresql) | 📋 Planned (add to config) |
| **OpenSearch** | — | [amazon-opensearch](https://endoflife.date/amazon-opensearch) | 📋 Planned (add to config) |
| **Lambda** | — | [aws-lambda](https://endoflife.date/aws-lambda) | 📋 Planned (add to config) |

**Adding a new resource type requires:**
Expand Down
17 changes: 17 additions & 0 deletions config/resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ resources:
product: redis
schema: standard

- id: opensearch
type: opensearch
cloud_provider: aws
inventory:
source: wiz
native_type_pattern: "elasticSearchService|OpenSearch Domain"
field_mappings:
version: "versionDetails.version"
region: "region"
account_id: "cloudAccount.externalId"
name: "name"
external_id: "externalId"
eol:
provider: endoflife-date
product: amazon-opensearch
schema: standard

# Add more resources using the add-version-guard-resource skill
#
# Configuration:
Expand Down
3 changes: 3 additions & 0 deletions pkg/eol/endoflife/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ var ProductMapping = map[string]string{
"elasticache-redis": "amazon-elasticache-redis",
"valkey": "valkey",
"elasticache-valkey": "valkey",

"opensearch": "amazon-opensearch",
"elasticsearch": "elasticsearch",
}

const (
Expand Down
52 changes: 46 additions & 6 deletions pkg/inventory/wiz/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import (
)

const (
resourceTypeEKS = "eks"
resourceTypeEKS = "eks"
resourceTypeOpenSearch = "opensearch"
)

// contextKey is a custom type for context keys to avoid collisions
Expand Down Expand Up @@ -152,10 +153,22 @@ func (s *GenericInventorySource) getRequiredColumns() []string {
return columns
}

// matchesNativeTypePattern checks if nativeType matches the configured pattern
// matchesNativeTypePattern checks if nativeType matches the configured pattern.
// Supports exact match, wildcard patterns (e.g., "elastiCache/*/cluster"),
// and pipe-delimited alternatives (e.g., "elasticSearchService|OpenSearch Domain").
func (s *GenericInventorySource) matchesNativeTypePattern(nativeType string) bool {
pattern := s.config.Inventory.NativeTypePattern

// Handle pipe-delimited alternatives (e.g., "typeA|typeB")
if strings.Contains(pattern, "|") {
for _, alt := range strings.Split(pattern, "|") {
if nativeType == alt {
return true
}
}
return false
}

// Handle wildcard patterns (e.g., "elastiCache/*/cluster")
if strings.Contains(pattern, "*") {
parts := strings.Split(pattern, "/")
Expand Down Expand Up @@ -194,10 +207,7 @@ func (s *GenericInventorySource) parseResourceRow(
name = externalID // Fallback to ID if name is empty
}

accountID, err := cols.require(row, colHeaderAccountID)
if err != nil {
return nil, err
}
accountID := cols.col(row, colHeaderAccountID)

region := cols.col(row, colHeaderRegion)

Expand All @@ -221,6 +231,12 @@ func (s *GenericInventorySource) parseResourceRow(
// Normalize engine
engine = normalizeEngine(engine, s.config.Type)

// OpenSearch-specific: normalize version and detect legacy Elasticsearch
if s.config.Type == resourceTypeOpenSearch {
version = normalizeOpenSearchVersion(version)
engine = detectOpenSearchEngine(version)
}

// Parse tags to extract service, brand
tagsJSON := cols.col(row, colHeaderTags)
tags, err := ParseTags(tagsJSON)
Expand Down Expand Up @@ -287,6 +303,30 @@ func getReportIDFromMap(resourceID string) (string, error) {
return reportID, nil
}

// normalizeOpenSearchVersion strips engine prefixes from OpenSearch/Elasticsearch
// version strings (e.g., "OpenSearch_2.13" → "2.13", "Elasticsearch_7.10" → "7.10").
func normalizeOpenSearchVersion(version string) string {
version = strings.TrimPrefix(version, "OpenSearch_")
version = strings.TrimPrefix(version, "Elasticsearch_")
return version
}

// detectOpenSearchEngine returns "elasticsearch" for legacy Elasticsearch versions
// (5.x, 6.x, 7.x) and "opensearch" for OpenSearch versions (1.x, 2.x, 3.x+).
// OpenSearch forked from Elasticsearch 7.10, so versions ≤7.x are Elasticsearch.
func detectOpenSearchEngine(version string) string {
if version == "" {
return resourceTypeOpenSearch
}
major := strings.SplitN(version, ".", 2)[0]
switch major {
case "5", "6", "7":
return "elasticsearch"
default:
return resourceTypeOpenSearch
}
}

// normalizeEngine normalizes engine names based on resource type
func normalizeEngine(engine, resourceType string) string {
engine = strings.ToLower(strings.TrimSpace(engine))
Expand Down
73 changes: 68 additions & 5 deletions pkg/inventory/wiz/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,75 @@ import (
// Built from the header row of a Wiz saved report CSV.
type columnIndex map[string]int

// columnAliases maps canonical column names to alternative names found in
// different Wiz report schemas. When a canonical name is not present in the
// CSV header, the alias is tried as a fallback. This handles schema
// differences such as the DB_SERVER schema used by OpenSearch reports.
var columnAliases = map[string]string{
"versionDetails.version": "version",
"region": "regionLocation",
"cloudAccount.externalId": "cloudPlatform",
"name": "Name",
}

// buildColumnIndex creates a columnIndex from a CSV header row.
// It also registers unprefixed aliases for columns with dotted prefixes
// (e.g., "DB_SERVER.externalId" is also stored as "externalId") so that
// field mappings work across different Wiz report schemas.
func buildColumnIndex(header []string) columnIndex {
idx := make(columnIndex, len(header))
idx := make(columnIndex, len(header)*2)
for i, name := range header {
idx[name] = i
// Strip prefix: "DB_SERVER.externalId" → "externalId"
if dotIdx := strings.LastIndex(name, "."); dotIdx >= 0 {
unprefixed := name[dotIdx+1:]
if _, exists := idx[unprefixed]; !exists {
idx[unprefixed] = i
}
}
}
return idx
}

// col returns the value of the named column from a CSV row.
// Returns "" if the column is not in the header or the row is too short.
// Falls back to columnAliases if the canonical name is not found.
func (ci columnIndex) col(row []string, name string) string {
i, ok := ci[name]
if !ok || i >= len(row) {
if !ok {
// Try alias fallback
if alias, hasAlias := columnAliases[name]; hasAlias {
i, ok = ci[alias]
}
if !ok {
return ""
}
}
if i >= len(row) {
return ""
}
return row[i]
}

// hasColumn returns true if the named column (or an alias for it) exists in the index.
func (ci columnIndex) hasColumn(name string) bool {
if _, ok := ci[name]; ok {
return true
}
if alias, hasAlias := columnAliases[name]; hasAlias {
if _, ok := ci[alias]; ok {
return true
}
}
return false
}

// require returns the value of the named column, or an error if it is missing
// from the header or empty in the row.
func (ci columnIndex) require(row []string, name string) (string, error) {
v := ci.col(row, name)
if v == "" {
if _, ok := ci[name]; !ok {
if !ci.hasColumn(name) {
return "", fmt.Errorf("column %q not found in CSV header", name)
}
return "", fmt.Errorf("missing value for column %q", name)
Expand Down Expand Up @@ -113,18 +157,31 @@ func parseWizReport(
// Build column index from header row
cols := buildColumnIndex(rows[0])

// Validate that all required columns are present
// Validate that all required columns are present (using alias-aware lookup)
for _, name := range requiredColumns {
if _, ok := cols[name]; !ok {
if !cols.hasColumn(name) {
return nil, fmt.Errorf("required column %q not found in CSV header (have: %v)", name, rows[0])
}
}

totalDataRows := len(rows) - 1
logger.InfoContext(ctx, "processing Wiz report",
"total_rows", totalDataRows,
"report_id", reportID)

// Parse data rows (skip header)
var resources []*types.Resource
var filteredCount int
var filteredNativeTypes []string
for i, row := range rows[1:] {
// Apply resource type filter
if !filterRow(cols, row) {
filteredCount++
if len(filteredNativeTypes) < 3 {
if nt := cols.col(row, colHeaderNativeType); nt != "" {
filteredNativeTypes = append(filteredNativeTypes, nt)
}
}
continue
}

Expand All @@ -143,6 +200,12 @@ func parseWizReport(
}
}

logger.InfoContext(ctx, "Wiz report processing complete",
"matched", len(resources),
"filtered", filteredCount,
"total_rows", totalDataRows,
"sample_filtered_types", filteredNativeTypes)

return resources, nil
}

Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/orchestrator/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func OrchestratorWorkflow(ctx workflow.Context, input WorkflowInput) (*WorkflowO
"aurora",
"elasticache",
"eks",
"opensearch",
}
}

Expand Down
Loading