Skip to content
Open
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
11 changes: 6 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,11 +474,12 @@ s3://your-bucket/snapshots/

Snapshots are produced via Go's `encoding/json` defaults. Top-level fields on
`Snapshot` and `SnapshotSummary` carry explicit `snake_case` JSON tags
(see [pkg/types/snapshot.go](./pkg/types/snapshot.go)); per-`Finding` fields
have no JSON tags and therefore serialize as PascalCase. The `findings_by_type`
(see [pkg/types/snapshot.go](./pkg/types/snapshot.go)); most per-`Finding`
fields serialize as PascalCase. Required `lifecycle_details` is the intentionally
snake_case exception for downstream enrichment metadata. The `findings_by_type`
map is keyed by the resource **config ID** (e.g. `aurora-mysql`, `eks`,
`elasticache-redis`) — the uppercase `ResourceType` constants in
`pkg/types/resource.go` are test fixtures only.
`elasticache-redis`) — the uppercase `ResourceType` constants in `pkg/types/resource.go`
are test fixtures only.

```json
{
Expand All @@ -489,7 +490,7 @@ map is keyed by the resource **config ID** (e.g. `aurora-mysql`, `eks`,
"scan_end_time": "2026-04-09T12:34:56Z",
"scan_duration_sec": 2096,
"findings_by_type": {
"aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "..."}],
"aurora-mysql": [{"ResourceID": "...", "Status": "RED", "Message": "...", "lifecycle_details": {"standard_support_end": "2024-02-29T00:00:00Z", "is_extended_support": true}}],
"eks": [{"ResourceID": "...", "Status": "YELLOW", "Message": "..."}]
},
"summary": {
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,10 +505,11 @@ s3://your-bucket/snapshots/latest.json

Snapshots are produced via Go's `encoding/json` defaults. Top-level fields on
`Snapshot` and `SnapshotSummary` carry explicit `snake_case` tags (see
[pkg/types/snapshot.go](./pkg/types/snapshot.go)); per-`Finding` fields have no
JSON tags and therefore serialize as PascalCase. The `findings_by_type` map is
keyed by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the
`ResourceType` constants used in tests.
[pkg/types/snapshot.go](./pkg/types/snapshot.go)); most per-`Finding` fields
serialize as PascalCase. Required `lifecycle_details` is the intentionally
snake_case exception for downstream enrichment metadata. The `findings_by_type` map is keyed
by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the `ResourceType`
constants used in tests.

```json
{
Expand All @@ -528,6 +529,18 @@ keyed by the resource config ID (e.g. `aurora-mysql`, `eks`), not by the
"Engine": "aurora-mysql",
"Status": "RED",
"Message": "Running deprecated version 5.6.10a (EOL: 2024-02-29)",
"lifecycle_details": {
"standard_support_end": "2024-02-29T00:00:00Z",
"extended_support_end": "2027-02-28T00:00:00Z",
"eol_date": "2027-02-28T00:00:00Z",
"version": "5.7",
"engine": "mysql",
"source": "endoflife-date-api",
"is_supported": true,
"is_deprecated": true,
"is_extended_support": true,
"is_eol": false
},
"DetectedAt": "2026-04-09T12:34:56Z",
"UpdatedAt": "2026-04-09T12:34:56Z"
}
Expand Down
23 changes: 23 additions & 0 deletions pkg/snapshot/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ func TestBuilder_JSONWireShape(t *testing.T) {
// downstream tools have been told to drop, so the test fails fast on
// regressions.
func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) {
standardSupportEnd := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC)
extendedSupportEnd := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC)
snap := NewBuilder().
AddFindings(types.ResourceTypeAurora, []*types.Finding{
{
Expand All @@ -169,6 +171,17 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) {
"account_id": "123456789012",
"region": "us-east-1",
},
LifecycleDetails: types.LifecycleDetails{
StandardSupportEnd: &standardSupportEnd,
ExtendedSupportEnd: &extendedSupportEnd,
EOLDate: &extendedSupportEnd,
Version: "13",
Engine: "aurora-postgresql",
Source: "endoflife-date-api",
IsSupported: true,
IsDeprecated: true,
IsExtendedSupport: true,
},
},
}).
Build()
Expand Down Expand Up @@ -205,4 +218,14 @@ func TestBuilder_CurrentSchemaBreakWireShape(t *testing.T) {
assert.Equal(t, "c1", extra["name"])
assert.Equal(t, "123456789012", extra["account_id"])
assert.Equal(t, "us-east-1", extra["region"])

lifecycleDetails, ok := finding["lifecycle_details"].(map[string]any)
require.True(t, ok, "v3 finding JSON must carry lifecycle_details for downstream enrichment")
assert.Equal(t, "2024-02-29T00:00:00Z", lifecycleDetails["standard_support_end"])
assert.Equal(t, "2027-02-28T00:00:00Z", lifecycleDetails["extended_support_end"])
assert.Equal(t, "2027-02-28T00:00:00Z", lifecycleDetails["eol_date"])
assert.Equal(t, "13", lifecycleDetails["version"])
assert.Equal(t, "aurora-postgresql", lifecycleDetails["engine"])
assert.Equal(t, "endoflife-date-api", lifecycleDetails["source"])
assert.Equal(t, true, lifecycleDetails["is_extended_support"])
}
7 changes: 4 additions & 3 deletions pkg/snapshot/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import (
const (
// SnapshotSchemaVersion is the current schema version for snapshots.
//
// v3 (current): removed Finding.Recommendation from the snapshot
// schema; remediation guidance belongs in curated docs, not in
// generated findings.
// v3 (current): removed Finding.Recommendation from the snapshot schema;
// remediation guidance belongs in curated docs, not in generated
// findings. Finding.lifecycle_details is required metadata added for
// downstream enrichment and remains backward-compatible within v3.
// v2 (deprecated): tightened the typed Finding surface to only the
// fields the system itself requires (identity, EOL keys, service,
// classification metadata, tags). Top-level resource_name,
Expand Down
46 changes: 46 additions & 0 deletions pkg/types/lifecycle_details.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package types

import "time"

// LifecycleDetails preserves structured lifecycle data on findings so
// downstream enrichment can reason about support windows without re-fetching EOL data.
type LifecycleDetails struct {
StandardSupportEnd *time.Time `json:"standard_support_end,omitempty"`
EOLDate *time.Time `json:"eol_date,omitempty"`
ExtendedSupportEnd *time.Time `json:"extended_support_end,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
FetchedAt time.Time `json:"fetched_at,omitempty"`
Version string `json:"version,omitempty"`
Engine string `json:"engine,omitempty"`
Source string `json:"source,omitempty"`
IsSupported bool `json:"is_supported"`
IsDeprecated bool `json:"is_deprecated"`
IsExtendedSupport bool `json:"is_extended_support"`
IsEOL bool `json:"is_eol"`
}

// LifecycleDetailsFromVersionLifecycle converts EOL provider output into
// finding-level lifecycle metadata.
func LifecycleDetailsFromVersionLifecycle(lifecycle *VersionLifecycle) LifecycleDetails {
if lifecycle == nil {
return LifecycleDetails{}
}
standardSupportEnd := lifecycle.DeprecationDate
if standardSupportEnd == nil && lifecycle.ExtendedSupportEnd != nil {
standardSupportEnd = lifecycle.EOLDate
}
return LifecycleDetails{
ReleaseDate: lifecycle.ReleaseDate,
StandardSupportEnd: standardSupportEnd,
EOLDate: lifecycle.EOLDate,
ExtendedSupportEnd: lifecycle.ExtendedSupportEnd,
FetchedAt: lifecycle.FetchedAt,
Version: lifecycle.Version,
Engine: lifecycle.Engine,
Source: lifecycle.Source,
IsSupported: lifecycle.IsSupported,
IsDeprecated: lifecycle.IsDeprecated,
IsExtendedSupport: lifecycle.IsExtendedSupport,
IsEOL: lifecycle.IsEOL,
}
}
3 changes: 3 additions & 0 deletions pkg/types/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ type Finding struct {
// attributes without a schema change.
Extra map[string]string `json:",omitempty"`

// LifecycleDetails preserves structured lifecycle status for downstream enrichment.
LifecycleDetails LifecycleDetails `json:"lifecycle_details"`

// EOLDate is when the current version reaches End-of-Life
EOLDate *time.Time

Expand Down
23 changes: 12 additions & 11 deletions pkg/workflow/detection/activities.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,18 @@ func (a *Activities) DetectDrift(ctx context.Context, input DetectInput) (*Detec
// Create finding. Name, account, and region (when configured) are
// part of resource.Extra and propagate through verbatim.
finding := &types.Finding{
ResourceID: resource.ID,
ResourceType: resource.Type,
Service: resource.Service,
CloudProvider: resource.CloudProvider,
CurrentVersion: resource.CurrentVersion,
Engine: resource.Engine,
Status: status,
Message: message,
EOLDate: lifecycle.EOLDate,
Tags: resource.Tags,
Extra: resource.Extra,
ResourceID: resource.ID,
ResourceType: resource.Type,
Service: resource.Service,
CloudProvider: resource.CloudProvider,
CurrentVersion: resource.CurrentVersion,
Engine: resource.Engine,
Status: status,
Message: message,
EOLDate: lifecycle.EOLDate,
Tags: resource.Tags,
Extra: resource.Extra,
LifecycleDetails: types.LifecycleDetailsFromVersionLifecycle(lifecycle),
}

findings = append(findings, finding)
Expand Down
56 changes: 56 additions & 0 deletions pkg/workflow/detection/activities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package detection

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -332,6 +333,61 @@ func TestDetectDrift_PropagatesExtra(t *testing.T) {
assert.Equal(t, "engineering-prod", detect.Findings[0].Extra["cost_center"])
}

func TestDetectDrift_PropagatesLifecycleDetails(t *testing.T) {
standardSupportEnd := time.Date(2024, time.February, 29, 0, 0, 0, 0, time.UTC)
extendedSupportEnd := time.Date(2027, time.February, 28, 0, 0, 0, 0, time.UTC)
fetchedAt := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC)
resources := []*types.Resource{
{
ID: "arn:aws:rds:us-east-1:123:db:mysql-57",
Type: types.ResourceType("rds-mysql"),
CloudProvider: types.CloudProviderAWS,
Engine: "mysql",
CurrentVersion: "5.7.44",
},
}
lifecycles := map[string]*types.VersionLifecycle{
"mysql:5.7.44": {
Version: "5.7",
Engine: "mysql",
Source: "endoflife-date-api",
DeprecationDate: &standardSupportEnd,
ExtendedSupportEnd: &extendedSupportEnd,
EOLDate: &extendedSupportEnd,
FetchedAt: fetchedAt,
IsSupported: true,
IsDeprecated: true,
IsExtendedSupport: true,
},
}
act := newTestActivities(resources, lifecycles)
env := newActivityEnv()
env.RegisterActivity(act.DetectDrift)

result, err := env.ExecuteActivity(act.DetectDrift, DetectInput{
Resources: resources,
VersionLifecycles: lifecycles,
})
require.NoError(t, err)

var detect DetectResult
require.NoError(t, result.Get(&detect))
require.Len(t, detect.Findings, 1)

details := detect.Findings[0].LifecycleDetails
assert.Equal(t, "5.7", details.Version)
assert.Equal(t, "mysql", details.Engine)
assert.Equal(t, "endoflife-date-api", details.Source)
require.NotNil(t, details.StandardSupportEnd)
require.NotNil(t, details.ExtendedSupportEnd)
require.NotNil(t, details.EOLDate)
assert.Equal(t, standardSupportEnd, *details.StandardSupportEnd)
assert.Equal(t, extendedSupportEnd, *details.ExtendedSupportEnd)
assert.Equal(t, extendedSupportEnd, *details.EOLDate)
assert.Equal(t, fetchedAt, details.FetchedAt)
assert.True(t, details.IsExtendedSupport)
}

func TestDetectDrift_UnknownVersion(t *testing.T) {
resources := []*types.Resource{
{ID: "r1", Engine: "aurora-mysql", CurrentVersion: "99.0.0", Type: types.ResourceTypeAurora},
Expand Down