Skip to content
Merged
54 changes: 28 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,44 +45,45 @@ Version Guard implements a **two-stage detection pipeline**:
```

**Key Components:**
- **Inventory Sources**: Wiz (multi-cloud scanning), mock sources for testing
- **EOL Providers**: AWS APIs (RDS, EKS) + endoflife.date (fallback)
- **Detectors**: Resource-specific detection logic (Aurora, EKS currently implemented)
- **Inventory Sources**: [Wiz](https://wiz.io) saved reports for resource discovery (multi-cloud)
- **EOL Data**: [endoflife.date](https://endoflife.date) API — no cloud provider credentials needed
- **Classification**: Red (EOL/deprecated), Yellow (extended support/approaching EOL), Green (current)
- **S3 Snapshots**: Versioned JSON storage for audit trail and downstream consumption
- **gRPC API**: Query interface for compliance dashboards

## ✨ Features

- ✅ **Multi-Cloud Inventory**: Wiz integration for AWS, GCP, Azure resource discovery
- ✅ **Hybrid EOL Data**: AWS native APIs + endoflife.date for comprehensive coverage
- ✅ **Open EOL Data**: All EOL data from [endoflife.date](https://endoflife.date) — no cloud provider credentials needed
- ✅ **Parallel Detection**: Temporal-based workflows for scalable scanning
- ✅ **Versioned Snapshots**: S3 storage with full audit history
- ✅ **gRPC Query API**: 3 endpoints for compliance scoring, finding details, fleet summaries
- ✅ **Local Development**: Full docker-compose setup with MinIO (S3) and Temporal
- ✅ **Extensible Architecture**: Plugin your own emitters for issue tracking, dashboards, notifications

## 📦 Supported Resources

Currently implemented:
- **Aurora** (RDS MySQL/PostgreSQL) - AWS RDS EOL API + Wiz inventory
- **EKS** (Kubernetes) - AWS EKS API + endoflife.date (hybrid) + Wiz inventory

Easily extensible to:
- ElastiCache (Redis/Valkey/Memcached)
- OpenSearch
- Lambda (Node.js, Python, Java)
- Cloud SQL (GCP)
- GKE (GCP)
- Azure resources
| Resource | Inventory | EOL Source | Code | Status |
|----------|-----------|------------|------|--------|
| **EKS** (Kubernetes) | Wiz | [amazon-eks](https://endoflife.date/amazon-eks) | ✅ Implemented | ✅ Working |
| **ElastiCache** (Redis/Valkey) | Wiz | [amazon-elasticache-redis](https://endoflife.date/amazon-elasticache-redis), [valkey](https://endoflife.date/valkey) | ✅ Implemented | ✅ Working |
| **Aurora PostgreSQL** | Wiz | [amazon-aurora-postgresql](https://endoflife.date/amazon-aurora-postgresql) | ✅ Implemented | 🔜 Needs Wiz report with PostgreSQL data |
| **Aurora MySQL** | Wiz | [amazon-aurora-mysql](https://endoflife.date/amazon-aurora-mysql) | ✅ Implemented | 🔜 EOL data pending [endoflife.date#9534](https://github.com/endoflife-date/endoflife.date/pull/9534) |
| **RDS MySQL** | — | [amazon-rds-mysql](https://endoflife.date/amazon-rds-mysql) | ❌ Needs Wiz report | 📋 Planned |
| **RDS PostgreSQL** | — | [amazon-rds-postgresql](https://endoflife.date/amazon-rds-postgresql) | ❌ Needs Wiz report | 📋 Planned |
| **OpenSearch** | — | [amazon-opensearch](https://endoflife.date/amazon-opensearch) | ❌ Needs Wiz report | 📋 Planned |
| **Lambda** | — | [aws-lambda](https://endoflife.date/aws-lambda) | ❌ Needs Wiz report | 📋 Planned |

Adding a new resource type requires:
1. A Wiz saved report + inventory source (~100 lines)
2. One line in `ProductMapping` to map the engine name to endoflife.date

## 🚀 Quick Start

### Prerequisites

- **Go 1.24+**
- **Docker** (for local Temporal server)
- **AWS credentials** (for EOL APIs - optional but recommended)
- **Wiz API access** (optional - falls back to mock data)
- **Docker** (for docker-compose local setup)
- **Wiz API access** (optional — falls back to mock data)

### Installation

Expand Down Expand Up @@ -150,11 +151,13 @@ make dev
### Trigger a Scan

```bash
# Via Temporal CLI
temporal workflow start \
# Via Temporal CLI (from inside the temporal container if using docker-compose)
docker compose exec temporal temporal workflow start \
--task-queue version-guard-detection \
--type VersionGuardOrchestratorWorkflow \
--input '{}'
--type OrchestratorWorkflow \
--input '{}' \
--address localhost:7233 \
--namespace version-guard-dev

# Or via the Temporal Web UI at http://localhost:8233 → Start Workflow
```
Expand Down Expand Up @@ -196,7 +199,7 @@ Version Guard is configured via environment variables or CLI flags:
| `TEMPORAL_NAMESPACE` | Temporal namespace | `version-guard-dev` |
| `GRPC_PORT` | gRPC service port | `8080` |
| `S3_BUCKET` | S3 bucket for snapshots | `version-guard-snapshots` |
| `AWS_REGION` | AWS region for EOL APIs | `us-west-2` |
| `AWS_REGION` | AWS region (for S3 snapshots) | `us-west-2` |
| `WIZ_CLIENT_ID_SECRET` | Wiz client ID (optional) | - |
| `WIZ_CLIENT_SECRET_SECRET` | Wiz client secret (optional) | - |
| `TAG_APP_KEYS` | Comma-separated AWS tag keys for app/service | `app,application,service` |
Expand Down Expand Up @@ -338,8 +341,7 @@ Version Guard is maintained by Block, Inc. and the open-source community.
Special thanks to:
- [Temporal](https://temporal.io) for the workflow orchestration framework
- [Wiz](https://wiz.io) for multi-cloud security scanning
- [endoflife.date](https://endoflife.date) for EOL data API
- AWS for native EOL APIs (RDS, EKS)
- [endoflife.date](https://endoflife.date) for open EOL data

---

Expand Down
4 changes: 0 additions & 4 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error {
// Orchestrator workflow activities
if snapshotStore != nil {
orchestratorActivities := orchestrator.NewActivities(st, snapshotStore)
w.RegisterActivityWithOptions(orchestratorActivities.RetrieveFindings, activity.RegisterOptions{Name: orchestrator.RetrieveFindingsActivityName})
w.RegisterActivityWithOptions(orchestratorActivities.CreateSnapshot, activity.RegisterOptions{Name: orchestrator.CreateSnapshotActivityName})
fmt.Println("✓ Orchestrator activities registered (with S3)")
} else {
// Without S3, we can still retrieve findings but can't create snapshots
orchestratorActivities := orchestrator.NewActivities(st, nil)
w.RegisterActivityWithOptions(orchestratorActivities.RetrieveFindings, activity.RegisterOptions{Name: orchestrator.RetrieveFindingsActivityName})
fmt.Println("⚠️ Orchestrator snapshot activity not registered (no S3 store)")
}

Expand Down
8 changes: 5 additions & 3 deletions pkg/eol/aws/eks.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,11 @@ func enrichFromEndOfLife(ctx context.Context, version *EKSVersion, client endofl
}

// End of standard support from EOL field (EKS NON-STANDARD mapping!)
if version.EndOfStandardDate == nil && cycle.EOL != "" && cycle.EOL != "false" {
if eolDate, err := time.Parse("2006-01-02", cycle.EOL); err == nil {
version.EndOfStandardDate = &eolDate
if version.EndOfStandardDate == nil {
if eolStr, ok := cycle.EOL.(string); ok && eolStr != "" && eolStr != "false" {
if eolDate, err := time.Parse("2006-01-02", eolStr); err == nil {
version.EndOfStandardDate = &eolDate
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/eol/endoflife/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ type Client interface {
type ProductCycle struct {
Cycle string `json:"cycle"` // Version identifier (e.g., "1.31")
ReleaseDate string `json:"releaseDate"` // Release date (YYYY-MM-DD)
Support string `json:"support"` // End of standard support (YYYY-MM-DD or boolean)
EOL string `json:"eol"` // End of life date (YYYY-MM-DD or boolean)
Support any `json:"support"` // End of standard support (YYYY-MM-DD or boolean)
EOL any `json:"eol"` // End of life date (YYYY-MM-DD or boolean)
ExtendedSupport any `json:"extendedSupport"` // Extended support availability (boolean or date)
LTS bool `json:"lts"` // Long-term support flag
Latest string `json:"latest"` // Latest patch version
Expand Down
115 changes: 57 additions & 58 deletions pkg/eol/endoflife/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,26 @@ import (
"github.com/block/Version-Guard/pkg/types"
)

// ProductMapping maps internal engine names to endoflife.date product identifiers
//
// WARNING: This provider uses STANDARD endoflife.date field semantics:
// - cycle.EOL → true end of life date
// - cycle.Support → end of standard support date
//
// Some AWS products (e.g., EKS) use NON-STANDARD schemas on endoflife.date
// and MUST use dedicated providers (e.g., EKSEOLProvider) instead of this generic provider.
// These products are listed here but blocked by ProductsWithNonStandardSchema below.
// ProductMapping maps engine names to endoflife.date product identifiers.
// All EOL data comes from endoflife.date — no cloud provider APIs needed.
var ProductMapping = map[string]string{
// EKS entries are mapped but BLOCKED by ProductsWithNonStandardSchema
// because EKS uses non-standard schema where cycle.EOL means "end of standard support"
// not "true end of life". Use pkg/eol/aws.EKSEOLProvider instead.
"kubernetes": "amazon-eks",
"k8s": "amazon-eks",
"eks": "amazon-eks",

"postgres": "amazon-rds-postgresql",
"postgresql": "amazon-rds-postgresql",
"mysql": "amazon-rds-mysql",
"aurora-mysql": "amazon-rds-mysql",
"aurora-postgresql": "amazon-rds-postgresql",
"postgres": "amazon-rds-postgresql",
"postgresql": "amazon-rds-postgresql",
"mysql": "amazon-rds-mysql",
"aurora-postgresql": "amazon-aurora-postgresql",
// TODO(endoflife.date#9534): aurora-mysql → amazon-aurora-mysql once the
// endoflife.date PR is merged. Until then, aurora-mysql returns UNKNOWN.
"aurora-mysql": "amazon-aurora-mysql",
"redis": "amazon-elasticache-redis",
"elasticache-redis": "amazon-elasticache-redis",
"valkey": "valkey",
"elasticache-valkey": "valkey",
}

// ProductsWithNonStandardSchema lists products that MUST NOT use this generic provider
// because they use non-standard field semantics on endoflife.date.
// The provider will return an error if these products are requested.
var ProductsWithNonStandardSchema = []string{
"amazon-eks", // cycle.EOL = end of standard support (NOT true EOL!)
}

const (
providerName = "endoflife-date-api"
falseBool = "false"
Expand Down Expand Up @@ -114,12 +99,23 @@ func (p *Provider) GetVersionLifecycle(ctx context.Context, engine, version stri
return nil, err
}

// Find the specific version
// Find the specific version — try exact match first, then prefix match.
// endoflife.date uses major.minor cycles (e.g., "8.0", "7") while Wiz
// reports full versions (e.g., "8.0.35", "7.1.0").
var bestMatch *types.VersionLifecycle
bestMatchLen := 0
for _, v := range versions {
normalizedV := normalizeVersion(engine, v.Version)
if normalizedV == version {
return v, nil
}
if strings.HasPrefix(version, normalizedV+".") && len(normalizedV) > bestMatchLen {
bestMatch = v
bestMatchLen = len(normalizedV)
}
}
if bestMatch != nil {
return bestMatch, nil
}

// Version not found - return unknown lifecycle (empty Version signals missing data)
Expand Down Expand Up @@ -152,17 +148,6 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types
return nil, fmt.Errorf("unsupported engine: %s", engine)
}

// Guard against products with non-standard schemas
// These products interpret endoflife.date fields differently and need dedicated providers
for _, blockedProduct := range ProductsWithNonStandardSchema {
if product == blockedProduct {
return nil, fmt.Errorf(
"engine %s (product: %s) uses non-standard endoflife.date schema and cannot use generic provider; use dedicated provider instead (e.g., EKSEOLProvider)",
engine, product,
)
}
}

// Use product as cache key
cacheKey := product

Expand Down Expand Up @@ -221,15 +206,11 @@ func (p *Provider) ListAllVersions(ctx context.Context, engine string) ([]*types

// convertCycle converts a ProductCycle to our VersionLifecycle type
//
// Field Mapping (STANDARD endoflife.date schema):
// Field mapping:
// - cycle.ReleaseDate → ReleaseDate
// - cycle.Support → DeprecationDate (end of standard support)
// - cycle.EOL → EOLDate (true end of life)
// - cycle.EOL → EOLDate
// - cycle.ExtendedSupport → ExtendedSupportEnd
//
// WARNING: This assumes STANDARD field semantics. Products with non-standard schemas
// (e.g., amazon-eks where cycle.EOL means "end of standard support", not true EOL)
// should be blocked by ListAllVersions and use dedicated providers instead.
func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*types.VersionLifecycle, error) {
version := cycle.Cycle

Expand All @@ -249,17 +230,17 @@ func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*t

// Parse EOL date (STANDARD semantics: true end of life)
var eolDate *time.Time
if cycle.EOL != "" && cycle.EOL != falseBool {
if parsed, err := parseDate(cycle.EOL); err == nil {
if dateStr := anyToDateString(cycle.EOL); dateStr != "" {
if parsed, err := parseDate(dateStr); err == nil {
eolDate = &parsed
lifecycle.EOLDate = eolDate
}
}

// Parse support date (STANDARD semantics: end of standard support)
var supportDate *time.Time
if cycle.Support != "" && cycle.Support != falseBool {
if parsed, err := parseDate(cycle.Support); err == nil {
if dateStr := anyToDateString(cycle.Support); dateStr != "" {
if parsed, err := parseDate(dateStr); err == nil {
supportDate = &parsed
lifecycle.DeprecationDate = supportDate
}
Expand All @@ -285,38 +266,47 @@ func (p *Provider) convertCycle(engine, product string, cycle *ProductCycle) (*t
}
}

// Determine lifecycle status based on dates
// Determine lifecycle status based on dates.
// For products like EKS, the "support" field is absent and "eol" means
// end of standard support while "extendedSupport" is the true end of life.
// We treat eolDate as the standard support boundary when supportDate is nil.
now := time.Now()

// If we have an EOL date and we're past it, mark as EOL
if eolDate != nil && now.After(*eolDate) {
lifecycle.IsEOL = true
lifecycle.IsSupported = false
lifecycle.IsDeprecated = true
return lifecycle, nil
// Resolve the effective standard-support-end date
standardEnd := supportDate
if standardEnd == nil {
standardEnd = eolDate
}

// If we have extended support end and we're past standard support
if extendedSupportDate != nil && supportDate != nil && now.After(*supportDate) {
// Check extended support window first — must come before the EOL check
// so that resources in extended support get YELLOW, not RED.
if extendedSupportDate != nil && standardEnd != nil && now.After(*standardEnd) {
if now.Before(*extendedSupportDate) {
// In extended support window
lifecycle.IsSupported = true
lifecycle.IsExtendedSupport = true
lifecycle.IsDeprecated = true
} else {
// Past extended support
// Past extended support — truly EOL
lifecycle.IsEOL = true
lifecycle.IsSupported = false
lifecycle.IsDeprecated = true
}
return lifecycle, nil
}

// If we're past support date but no extended support info
// Past EOL with no extended support available
if eolDate != nil && now.After(*eolDate) {
lifecycle.IsEOL = true
lifecycle.IsSupported = false
lifecycle.IsDeprecated = true
return lifecycle, nil
}

// Past standard support but no extended support info
if supportDate != nil && now.After(*supportDate) {
lifecycle.IsDeprecated = true
lifecycle.IsSupported = false
// If we have EOL date, use it; otherwise mark as deprecated but not EOL
if eolDate != nil && now.Before(*eolDate) {
lifecycle.IsEOL = false
}
Expand Down Expand Up @@ -353,6 +343,15 @@ func normalizeVersion(engine, version string) string {
return version
}

// anyToDateString extracts a date string from an any-typed field.
// endoflife.date returns EOL/Support as either a date string or a boolean.
func anyToDateString(v any) string {
if val, ok := v.(string); ok && val != "" && val != "false" && val != "true" {
return val
}
return ""
}

// parseDate parses date strings from endoflife.date API
// Supports formats: YYYY-MM-DD, boolean "true"/"false"
func parseDate(dateStr string) (time.Time, error) {
Expand Down
Loading
Loading