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
76 changes: 47 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,42 @@ Version Guard helps organizations maintain infrastructure security and complianc

## 🏗️ Architecture

Version Guard implements a **two-stage detection pipeline**:
Version Guard implements a **two-stage detection pipeline**. Manual and scheduled triggers share the same scan pipeline; scheduled triggers go through a small Temporal launcher workflow first so Temporal's schedule-generated workflow IDs do not bypass the singleton scan guard.

```
┌──────────────────────────┐ ┌─────────────────────────────────┐
│ Manual trigger │ │ Temporal Schedule API │
│ POST /scan or CLI │ │ SCHEDULE_* env vars │
└────────────┬─────────────┘ └────────────────┬────────────────┘
│ │
│ ▼
│ ┌──────────────────────────────┐
│ │ ScheduledScanWorkflow │
│ │ launcher only; no scanning │
│ └──────────────┬───────────────┘
│ │
└────────────────────┬───────────────────┘
┌────────────────────────────────────────────────────────────┐
│ SINGLETON SCAN WORKFLOW │
│ workflow ID: version-guard-active-scan │
│ Temporal rejects a second open run with this same ID │
└────────────────────────────┬───────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ STAGE 1: DETECT │
│ Fan-Out: parallel detection per resource type │
│ Inventory (Wiz) + EOL data + classify │
└────────────────────────────┬───────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ STAGE 1: DETECT (Temporal Workflow) │
│ │
│ Fan-Out: Parallel Detection per Resource Type │
│ ┌───────┐ ┌─────────┐ ┌───────┐ │
│ │Aurora │ │ EKS │ │ More │ ... │
│ └───┬───┘ └────┬────┘ └───┬───┘ │
│ └───────────┼───────────┘ │
│ ▼ │
│ Inventory (Wiz) + EOL Data + Classify │
│ │ │
└───────────────────┼────────────────────────────────────────┘
┌───────────────────┼────────────────────────────────────────┐
│ STAGE 2: STORE │
│ ▼ │
│ Create Versioned JSON Snapshot │
│ │ │
│ s3://bucket/snapshots/YYYY/MM/DD/{snapshot-id}.json │
│ s3://bucket/snapshots/latest.json │
│ │
└────────────────────────────────────────────────────────────┘
📤 YOUR CUSTOM EMITTERS
(See "Extending Version Guard")
│ s3://bucket/snapshots/YYYY/MM/DD/{snapshot-id}.json │
│ s3://bucket/snapshots/latest.json │
└────────────────────────────┬───────────────────────────────┘
📤 YOUR CUSTOM EMITTERS
(See "Extending Version Guard")
```

**Key Components:**
Expand Down Expand Up @@ -248,11 +255,13 @@ curl -X POST http://localhost:8081/scan \
**Via Temporal directly:**

```bash
# Temporal CLI (from inside the temporal container if using docker-compose)
# Manual scan through the singleton orchestrator workflow. Use the fixed
# workflow ID so Temporal enforces "only one active scan at a time".
docker compose exec temporal temporal workflow start \
--workflow-id version-guard-active-scan \
--task-queue version-guard-detection \
--type OrchestratorWorkflow \
--input '{}' \
--input '{"ResourceTypes":["aurora-mysql","eks"],"ScanScope":"full"}' \
--address localhost:7233 \
--namespace version-guard-dev

Expand Down Expand Up @@ -284,7 +293,7 @@ Resource Breakdown:

**Verify snapshot creation:**

Snapshots are stored in MinIO (local S3) at `s3://version-guard-snapshots/snapshots/YYYY/MM/DD/{workflow-id}.json`:
Snapshots are stored in MinIO (local S3) at `s3://version-guard-snapshots/snapshots/YYYY/MM/DD/{snapshot-id}.json`. For scheduled runs, the snapshot ID remains the schedule-generated run ID (for example `version-guard-scheduled-scan-2026-06-11T18:00:00Z`) even though the underlying scan workflow uses the fixed singleton ID:

```bash
# List snapshots (from logs)
Expand Down Expand Up @@ -376,7 +385,16 @@ export SCHEDULE_CRON="*/30 * * * *" # Every 30 minutes
export SCHEDULE_JITTER="2m"
```

The schedule uses a create-or-update pattern — safe to restart the server without creating duplicate schedules. If the cron expression changes, the existing schedule is updated automatically.
The public configuration has not changed: `SCHEDULE_ENABLED`, `SCHEDULE_CRON`, `SCHEDULE_ID`, `SCHEDULE_JITTER`, and `TEMPORAL_TASK_QUEUE` are still the only knobs. The schedule uses a create-or-update pattern — safe to restart the server without creating duplicate schedules. If the cron expression changes, the existing schedule is updated automatically.

Under the hood, the schedule starts `ScheduledScanWorkflow`. Temporal schedules may append the scheduled fire time to that workflow ID (for example `version-guard-scheduled-scan-2026-06-11T08:00:00Z`). That launcher then starts the real scan as child workflow ID `version-guard-active-scan`. Manual scans use the same fixed `version-guard-active-scan` ID directly. Because both paths converge on the same fixed workflow ID, Temporal rejects overlapping scans while still allowing each completed daily schedule to create a unique snapshot.

In short:

- **Config stays the same** — no new env vars, chart values, ports, task queues, or schedule IDs.
- **Manual scans stay the same** — `POST /scan` and the CLI start `version-guard-active-scan`.
- **Scheduled scans keep unique snapshot IDs** — the scheduled launcher ID is passed through as `ScanID`.
- **Only one collector scan can run at a time** — scheduled and manual triggers both contend on `version-guard-active-scan`.

```bash
# Verify the schedule
Expand Down
8 changes: 5 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error {

// Register workflows
w.RegisterWorkflow(detection.DetectionWorkflow)
w.RegisterWorkflow(orchestrator.ScheduledScanWorkflow)
w.RegisterWorkflow(orchestrator.OrchestratorWorkflow)
fmt.Println("✓ Workflows registered (detection, orchestrator)")
fmt.Println("✓ Workflows registered (detection, scheduled scan, orchestrator)")

// Register activities
// Detection workflow activities
Expand Down Expand Up @@ -478,8 +479,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error {
if s.ScheduleEnabled {
fmt.Printf(" Scans will run automatically (schedule: %s)\n", s.ScheduleCron)
}
fmt.Println("\n📖 To trigger a scan manually, use the Temporal UI or CLI:")
fmt.Printf(" temporal workflow start --workflow-id %s --task-queue %s --type %s --input '{}'\n", orchestrator.ActiveScanWorkflowID, s.TemporalTaskQueue, orchestrator.OrchestratorWorkflowType)
fmt.Println("\n📖 To trigger a scan manually, use the HTTP admin API:")
fmt.Printf(" curl -X POST http://localhost:%d/scan\n", s.HTTPPort)
fmt.Printf(" Advanced Temporal users: start %s with workflow ID %s and explicit ResourceTypes input\n", orchestrator.OrchestratorWorkflowType, orchestrator.ActiveScanWorkflowID)
fmt.Println("\n📖 For more information, see the README.md")
fmt.Println("\nPress Ctrl+C to stop...")

Expand Down
9 changes: 5 additions & 4 deletions pkg/schedule/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
Jitter: cfg.Jitter,
},
Action: &client.ScheduleWorkflowAction{
ID: orchestrator.ActiveScanWorkflowID,
Workflow: orchestrator.OrchestratorWorkflow,
ID: orchestrator.ScheduledScanWorkflowID,
Workflow: orchestrator.ScheduledScanWorkflow,
Args: []interface{}{orchestrator.WorkflowInput{
ResourceTypes: cfg.ResourceTypes,
EmitterWebhookURL: cfg.EmitterWebhookURL,
Expand Down Expand Up @@ -149,7 +149,8 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error {
}
input.Description.Schedule.Policy.Overlap = enumspb.SCHEDULE_OVERLAP_POLICY_SKIP
if action, ok := input.Description.Schedule.Action.(*client.ScheduleWorkflowAction); ok {
action.ID = orchestrator.ActiveScanWorkflowID
action.ID = orchestrator.ScheduledScanWorkflowID
action.Workflow = orchestrator.ScheduledScanWorkflow
action.TaskQueue = cfg.TaskQueue
action.Args = []interface{}{orchestrator.WorkflowInput{
ResourceTypes: cfg.ResourceTypes,
Expand Down Expand Up @@ -184,7 +185,7 @@ func scheduleActionMatches(action client.ScheduleAction, cfg *Config) bool {
if !ok {
return false
}
if wfAction.ID != orchestrator.ActiveScanWorkflowID {
if wfAction.ID != orchestrator.ScheduledScanWorkflowID {
return false
}
if wfAction.TaskQueue != cfg.TaskQueue {
Expand Down
61 changes: 58 additions & 3 deletions pkg/schedule/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ func TestEnsureSchedule_CreatesNew(t *testing.T) {
assert.Equal(t, 5*time.Minute, mock.createOpts.Spec.Jitter)
assert.Equal(t, enumspb.SCHEDULE_OVERLAP_POLICY_SKIP, mock.createOpts.Overlap)
action := mock.createOpts.Action.(*client.ScheduleWorkflowAction)
assert.Equal(t, orchestrator.ActiveScanWorkflowID, action.ID)
assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID)
assert.NotNil(t, action.Workflow)
assert.Equal(t, "test-queue", action.TaskQueue)
assert.Equal(t, 2*time.Hour, action.WorkflowExecutionTimeout)
require.Len(t, action.Args, 1)
Expand All @@ -152,7 +153,7 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) {
Jitter: 5 * time.Minute,
},
Action: &client.ScheduleWorkflowAction{
ID: orchestrator.ActiveScanWorkflowID,
ID: orchestrator.ScheduledScanWorkflowID,
TaskQueue: "test-queue",
Args: []interface{}{orchestrator.WorkflowInput{
ResourceTypes: testResourceTypes,
Expand Down Expand Up @@ -181,6 +182,59 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) {
assert.False(t, handle.updateCalled, "Update should not be called when cron and action match")
}

func TestEnsureSchedule_AlreadyExists_OldDirectOrchestratorActionUpdates(t *testing.T) {
handle := &mockScheduleHandle{
id: "test-schedule",
describeOut: &client.ScheduleDescription{
Schedule: client.Schedule{
Policy: &client.SchedulePolicies{Overlap: enumspb.SCHEDULE_OVERLAP_POLICY_SKIP},
Spec: &client.ScheduleSpec{
CronExpressions: []string{"0 */6 * * *"},
Jitter: 5 * time.Minute,
},
Action: &client.ScheduleWorkflowAction{
ID: orchestrator.ActiveScanWorkflowID,
Workflow: orchestrator.OrchestratorWorkflow,
TaskQueue: "test-queue",
Args: []interface{}{orchestrator.WorkflowInput{
ResourceTypes: testResourceTypes,
ScanScope: orchestrator.ScanScopeFull,
}},
},
},
},
}
var captured *client.ScheduleUpdate
handle.updateFn = func(opts client.ScheduleUpdateOptions) {
input := client.ScheduleUpdateInput{Description: *handle.describeOut}
result, err := opts.DoUpdate(input)
require.NoError(t, err)
captured = result
}
mock := &mockCreator{
createErr: temporal.ErrScheduleAlreadyRunning,
handle: handle,
}
mgr := NewManagerWithClient(mock)

err := mgr.EnsureSchedule(context.Background(), Config{
Enabled: true,
ScheduleID: "test-schedule",
CronExpression: "0 */6 * * *",
Jitter: 5 * time.Minute,
TaskQueue: "test-queue",
ResourceTypes: testResourceTypes,
})

require.NoError(t, err)
assert.True(t, handle.updateCalled, "Update should rewrite the pre-hotfix direct orchestrator schedule action")
require.NotNil(t, captured)
action, ok := captured.Schedule.Action.(*client.ScheduleWorkflowAction)
require.True(t, ok)
assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID)
assert.NotNil(t, action.Workflow)
}

// TestEnsureSchedule_AlreadyExists_NewWebhookURL guards the contract that
// setting EMITTER_WEBHOOK_URL on a deployment whose schedule already
// exists must propagate into the schedule's WorkflowInput. Without this
Expand Down Expand Up @@ -236,7 +290,8 @@ func TestEnsureSchedule_AlreadyExists_NewWebhookURL(t *testing.T) {
assert.Equal(t, enumspb.SCHEDULE_OVERLAP_POLICY_SKIP, captured.Schedule.Policy.Overlap)
action, ok := captured.Schedule.Action.(*client.ScheduleWorkflowAction)
require.True(t, ok, "action should be a ScheduleWorkflowAction")
assert.Equal(t, orchestrator.ActiveScanWorkflowID, action.ID)
assert.Equal(t, orchestrator.ScheduledScanWorkflowID, action.ID)
assert.NotNil(t, action.Workflow)
require.Len(t, action.Args, 1)
in, ok := action.Args[0].(orchestrator.WorkflowInput)
require.True(t, ok)
Expand Down
34 changes: 34 additions & 0 deletions pkg/workflow/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,40 @@ func TestOrchestratorWorkflow_RejectsNonSingletonWorkflowID(t *testing.T) {
assert.Contains(t, err.Error(), ActiveScanWorkflowID)
}

func TestScheduledScanWorkflow_StartsSingletonChildWithScheduledScanID(t *testing.T) {
suite := &testsuite.WorkflowTestSuite{}
env := suite.NewTestWorkflowEnvironment()
env.SetStartWorkflowOptions(client.StartWorkflowOptions{ID: ScheduledScanWorkflowID + "-2026-06-11T08:00:00Z"})
env.RegisterWorkflow(ScheduledScanWorkflow)
env.RegisterWorkflow(OrchestratorWorkflow)

var capturedInput WorkflowInput
env.OnWorkflow(OrchestratorWorkflow, mock.Anything, mock.Anything).
Return(func(ctx workflow.Context, in WorkflowInput) (*WorkflowOutput, error) {
capturedInput = in
assert.Equal(t, ActiveScanWorkflowID, workflow.GetInfo(ctx).WorkflowExecution.ID)
return &WorkflowOutput{
ScanID: in.ScanID,
SnapshotID: "snapshot-from-child",
}, nil
})

env.ExecuteWorkflow(ScheduledScanWorkflow, WorkflowInput{
ResourceTypes: []types.ResourceType{types.ResourceTypeAurora},
ScanScope: ScanScopeFull,
})

require.True(t, env.IsWorkflowCompleted())
require.NoError(t, env.GetWorkflowError())
assert.Equal(t, ScheduledScanWorkflowID+"-2026-06-11T08:00:00Z", capturedInput.ScanID)
assert.Equal(t, []types.ResourceType{types.ResourceTypeAurora}, capturedInput.ResourceTypes)

var output WorkflowOutput
require.NoError(t, env.GetWorkflowResult(&output))
assert.Equal(t, capturedInput.ScanID, output.ScanID)
assert.Equal(t, "snapshot-from-child", output.SnapshotID)
}

func TestOrchestratorWorkflow_ScanIDDefaultsToWorkflowID(t *testing.T) {
env := newOrchestratorEnv(t)

Expand Down
38 changes: 38 additions & 0 deletions pkg/workflow/orchestrator/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"

Expand All @@ -24,6 +25,7 @@ const (
OrchestratorWorkflowType = "VersionGuardOrchestratorWorkflow"
TaskQueueName = "version-guard-orchestrator"
ActiveScanWorkflowID = "version-guard-active-scan"
ScheduledScanWorkflowID = "version-guard-scheduled-scan"

ScanScopeFull = "full"
ScanScopeTargeted = "targeted"
Expand Down Expand Up @@ -75,6 +77,42 @@ type ResourceTypeResult struct {
Error string // Empty if successful
}

// ScheduledScanWorkflow is the Temporal schedule entry point. Temporal appends
// the scheduled fire time to workflow IDs for uniqueness, so schedules cannot
// start OrchestratorWorkflow directly while it enforces ActiveScanWorkflowID.
// This launcher may have a timestamp-suffixed ID, but it starts the real scan
// as a child workflow using the fixed singleton ID. That keeps scheduled and
// manual scans mutually exclusive while preserving unique scheduled ScanIDs.
func ScheduledScanWorkflow(ctx workflow.Context, input WorkflowInput) (*WorkflowOutput, error) {
info := workflow.GetInfo(ctx)
if input.ScanID == "" {
input.ScanID = info.WorkflowExecution.ID
}

childOpts := workflow.ChildWorkflowOptions{
WorkflowID: ActiveScanWorkflowID,
WorkflowExecutionTimeout: 2 * time.Hour,
WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
}
childCtx := workflow.WithChildOptions(ctx, childOpts)

var output WorkflowOutput
err := workflow.ExecuteChildWorkflow(childCtx, OrchestratorWorkflow, input).Get(ctx, &output)
if err != nil {
logger := workflow.GetLogger(ctx)
logger.Error("Scheduled scan workflow failed",
"event", "scheduled_scan_workflow_failed",
"scanID", input.ScanID,
"workflowID", info.WorkflowExecution.ID,
"runID", info.WorkflowExecution.RunID,
"childWorkflowID", ActiveScanWorkflowID,
"error", err)
return nil, err
}

return &output, nil
}

// OrchestratorWorkflow is the main workflow that orchestrates the three-stage pipeline:
// Stage 1: Detect - Fan out across resource types in parallel
// Stage 2: Store - Write classified findings to S3 as versioned snapshot
Expand Down
Loading