From 397fdbf19203f13e87ea484f9228adc57a6e8b3d Mon Sep 17 00:00:00 2001 From: O M Date: Mon, 26 Oct 2026 20:45:36 -0400 Subject: [PATCH 1/3] Name the AWS root-volume scan om scan workload aws. Azure and GCP already name the cloud, and the bare command was running the AWS scan. Co-authored-by: Cursor --- CONTRIBUTING.md | 2 +- README.md | 8 +- collectors/README.md | 2 +- deploy/helm/opensourceom/values.yaml | 5 +- docs/ARCHITECTURE.md | 6 +- docs/ROADMAP.md | 2 +- docs/adr/006-aws-root-volume-packages.md | 2 +- .../adr/007-azure-gce-root-volume-packages.md | 4 +- internal/cmd/scan_workload.go | 109 +--------------- internal/cmd/scan_workload_aws.go | 119 ++++++++++++++++++ internal/cmd/scan_workload_test.go | 8 +- internal/workload/aws/identity.go | 2 +- internal/workload/aws/scan.go | 2 +- 13 files changed, 147 insertions(+), 124 deletions(-) create mode 100644 internal/cmd/scan_workload_aws.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3a630c..9019d93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ On a PR you can comment `@coderabbitai review` (incremental) or `@coderabbitai f ## Development guidelines -- Keep collectors **read-only** toward cloud accounts by default. `om scan workload` (and its `azure` and `gcp` subcommands) is the opt-in exception: it may create and delete snapshots in the account, and it stores package identifiers only +- Keep collectors **read-only** toward cloud accounts by default. `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` are the opt-in exception: they may create and delete snapshots in the account, and they store package identifiers only - Document graph schema changes in `docs/` - Prefer small, reviewable PRs - Add tests for graph queries and normalization logic diff --git a/README.md b/README.md index 40ad110..5bcb35b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ OpenSourceOM Core is the platform behind [opensourceom.org](https://opensourceom Traditional scanners flood you with CVEs and misconfigurations. OpenSourceOM connects the dots — showing which findings sit on paths from the internet to your sensitive data and privileged identities. -> **Status:** Phase 3 has shipped. Phase 4 is open: `om scan workload` inventories AWS, Azure, and GCE Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors ([#10](https://github.com/OpenSourceOM/core/issues/10)). See the [roadmap](./docs/ROADMAP.md). +> **Status:** Phase 3 has shipped. Phase 4 is open: `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` inventory Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors ([#10](https://github.com/OpenSourceOM/core/issues/10)). See the [roadmap](./docs/ROADMAP.md). ## Why this exists @@ -94,7 +94,7 @@ go build -o om ./cmd/om export AWS_REGION=us-east-1 ./om scan aws # On an EC2 instance in that account, as root, after the API scan: -# sudo -E ./om scan workload +# sudo -E ./om scan workload aws # On an Azure VM or a GCE instance in that region, as root: # sudo -E ./om scan workload azure # sudo -E ./om scan workload gcp @@ -138,7 +138,7 @@ An API token is created only when an admin asks for one, from the console or wit ```bash ./om scan demo # sample environment, no credentials ./om scan aws -./om scan workload # root on an EC2 worker; Linux root-volume packages +./om scan workload aws # root on an EC2 worker; Linux root-volume packages ./om scan azure # requires AZURE_SUBSCRIPTION_ID + az login ./om scan workload azure # root on an Azure VM; Linux OS-disk packages ./om scan gcp # requires GCP_PROJECT_ID + ADC @@ -170,7 +170,7 @@ Full documentation: [opensourceom.org](https://opensourceom.org) (docs at [opens | **1** | Attack path queries, CVE enrichment, web UI, Azure/GCP collectors | | **2** | CSPM rules, blast radius, K8s connector, exports | | **3** | Plugin SDK, Helm, embedded rule pack, demo graph, inventory-backed CVEs, crown-jewel datastores, attack-path findings, and cloud audit context (CloudTrail, Activity Log, Admin Activity). Shipped. | -| **4** | Package inventory from persistent workload storage. AWS, Azure, and GCE Linux root volumes ship with `om scan workload`. Boot images, container layers, function bundles, and shared filesystems are open. | +| **4** | Package inventory from persistent workload storage. AWS, Azure, and GCE Linux root volumes ship with `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp`. Boot images, container layers, function bundles, and shared filesystems are open. | | **5** | Secret and malware findings on storage Phase 4 already opens. Kind and path only. Open. | Details: [docs/ROADMAP.md](./docs/ROADMAP.md) diff --git a/collectors/README.md b/collectors/README.md index 6b3d043..ae62c97 100644 --- a/collectors/README.md +++ b/collectors/README.md @@ -20,7 +20,7 @@ Cloud and platform ingestion plugins. Each collector normalizes provider APIs in The **demo** collector loads a fixed environment (internet-exposed web tier, production database, public/private buckets, admin vs app identities, public Kubernetes service). Use it to exercise CSPM packs without cloud credentials. -The **AWS** collector emits properties the CIS pack matches on: `open_ingress`, `imdsv2`, `public_ip`, S3 `encryption` / `versioning` / `public_access_block`, and IAM user `mfa` / `unused_access_keys`. It also records RDS and Aurora instances as datastores. S3 bucket tags and the RDS `TagList` copy `sensitivity` or `data-class` onto the datastore. `sensitivity` wins when both are set. The same scan reads CloudTrail management events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. `om scan aws` does not read instance disks. `om scan workload` is the separate command that inventories packages on Linux root volumes. +The **AWS** collector emits properties the CIS pack matches on: `open_ingress`, `imdsv2`, `public_ip`, S3 `encryption` / `versioning` / `public_access_block`, and IAM user `mfa` / `unused_access_keys`. It also records RDS and Aurora instances as datastores. S3 bucket tags and the RDS `TagList` copy `sensitivity` or `data-class` onto the datastore. `sensitivity` wins when both are set. The same scan reads CloudTrail management events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. `om scan aws` does not read instance disks. `om scan workload aws` is the separate command that inventories packages on Linux root volumes. The **Azure** collector records logical SQL servers. `om scan azure` and `om scan gcp` do not read OS disks. `om scan workload azure` and `om scan workload gcp` are the separate commands that inventory packages on those disks. The same scan reads administrative Activity Log events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Entra ID sign-in logs are not part of this scan. The **GCP** collector records Cloud SQL instances. The same scan reads Admin Activity audit logs from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Data Access logs are not part of this scan. A workload gets `CAN_ACCESS` only when a security group, firewall, or VPC path allows it. Azure copies the crown-jewel mark from storage-account and SQL-server tags. GCP copies it from a bucket label or a Cloud SQL user label. The keys are `sensitivity` and `data-class`. A plugin may set `sensitivity` on a datastore directly. diff --git a/deploy/helm/opensourceom/values.yaml b/deploy/helm/opensourceom/values.yaml index c73debf..92907bc 100644 --- a/deploy/helm/opensourceom/values.yaml +++ b/deploy/helm/opensourceom/values.yaml @@ -58,9 +58,10 @@ resources: # collector is enabled and credentials are set (a static key, an existing # Secret, or serviceAccountAuth). Kubernetes is off unless enabled; without # a kubeconfig Secret the chart creates a read-only ServiceAccount for this -# cluster. `om scan demo` and `om scan workload` are not scheduled. +# cluster. `om scan demo` and the workload scans are not scheduled. # The workload scan mounts disks and runs on a VM in the account: EC2 for -# `om scan workload`, an Azure VM for `azure`, and a GCE instance for `gcp`. +# `om scan workload aws`, an Azure VM for `om scan workload azure`, and a +# GCE instance for `om scan workload gcp`. scan: schedule: "0 */6 * * *" successfulJobsHistoryLimit: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7620a91..b494e81 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Architecture -> **Status:** Phase 3 has shipped. The collector plugin SDK, Helm, the embedded rule pack, and cloud audit context from CloudTrail, Activity Log, and Admin Activity logs are in the tree, alongside Phase 2 CSPM rules, blast radius, Kubernetes ingest, and exports. Phase 4 is open: `om scan workload` inventories AWS, Azure, and GCE Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors. +> **Status:** Phase 3 has shipped. The collector plugin SDK, Helm, the embedded rule pack, and cloud audit context from CloudTrail, Activity Log, and Admin Activity logs are in the tree, alongside Phase 2 CSPM rules, blast radius, Kubernetes ingest, and exports. Phase 4 is open: `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` inventory Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors. ## Overview @@ -32,7 +32,7 @@ SPDX-License-Identifier: Apache-2.0 | Component | Location | Notes | |-----------|----------|-------| | **Collectors** | `internal/collectors/` | AWS, Azure, GCP, Kubernetes, demo; RDS, Azure SQL, and Cloud SQL are datastores; AWS emits CIS pack properties and recent CloudTrail management events; Azure emits recent Activity Log events; GCP emits recent Admin Activity audit logs | -| **Workload packages** | `internal/workload/` | `om scan workload` reads AWS, Azure, and GCE Linux root volumes and sets `packages` | +| **Workload packages** | `internal/workload/` | `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` set `packages` from Linux root volumes | | **Plugin SDK** | `sdk/collector`, `internal/plugins/` | External executables; `om scan plugin` | | **Graph store** | `internal/graph/`, `migrations/` | PostgreSQL `nodes` + `edges` | | **Path queries** | `internal/graph/query.go` | Named queries including `internet-to-datastore` and `internet-to-sensitive-datastore` | @@ -87,7 +87,7 @@ Further community rule packs (PCI and additional CIS mappings) stay open for con Phase 4 writes `packages` (CPE 2.3 names and package URLs) from persistent workload storage onto the workload node that enrichment already matches. The sources, in order, are AWS Linux root volumes, Azure VM and GCE root volumes, boot images, container image layers and function bundles, and shared filesystems (EFS, Azure Files, Filestore). The scan is opt-in. `om scan aws`, `om scan azure`, and `om scan gcp` stay read-only API collectors. -`om scan workload` is the AWS root-volume scan. `om scan workload azure` and `om scan workload gcp` are the same walk for Azure OS disks and GCE boot disks. Each runs as root on a worker in that account and region, snapshots the root disk, mounts the copy read-only, and stores package identifiers only. A later API scan keeps `packages`. Windows, LVM, and non-root disks are skipped. Boot images, container layers, function bundles, and shared filesystems are still open. See [ADR 006](./adr/006-aws-root-volume-packages.md) and [ADR 007](./adr/007-azure-gce-root-volume-packages.md). +`om scan workload aws` is the AWS root-volume scan. `om scan workload azure` and `om scan workload gcp` are the same walk for Azure OS disks and GCE boot disks. Each runs as root on a worker in that account and region, snapshots the root disk, mounts the copy read-only, and stores package identifiers only. A later API scan keeps `packages`. Windows, LVM, and non-root disks are skipped. Boot images, container layers, function bundles, and shared filesystems are still open. See [ADR 006](./adr/006-aws-root-volume-packages.md) and [ADR 007](./adr/007-azure-gce-root-volume-packages.md). Phase 5 adds a finding for a secret pattern or a malware signature on storage that Phase 4 reader already opened. The finding stores the kind and the path. Object bodies and database rows are in neither phase. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 527ae9a..6d038f7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,7 +74,7 @@ The path, in order: - [ ] **Container image layers and function bundles** — layers for images already stored on Kubernetes workloads, plus Lambda deployment packages and the Azure and GCP equivalents. No volume attach — [#105](https://github.com/OpenSourceOM/core/issues/105) - [ ] **Shared filesystems** — EFS, Azure Files, and Filestore, when the application is installed there and a root-volume scan would miss it — [#106](https://github.com/OpenSourceOM/core/issues/106) -`om scan workload` reads Linux root volumes in the current AWS account. It runs as root on an EC2 instance in that account, snapshots the root volume, mounts the copy read-only, and writes CPE 2.3 names and package URLs onto `packages`. `om scan workload azure` does the same for managed OS disks from an Azure VM in that region, and `om scan workload gcp` does it for GCE boot disks from an instance in that region. A later `om scan aws`, `om scan azure`, or `om scan gcp` leaves `packages` in place. Windows, LVM, and disks other than the root volume are skipped. +`om scan workload aws` reads Linux root volumes in the current AWS account. It runs as root on an EC2 instance in that account, snapshots the root volume, mounts the copy read-only, and writes CPE 2.3 names and package URLs onto `packages`. `om scan workload azure` does the same for managed OS disks from an Azure VM in that region, and `om scan workload gcp` does it for GCE boot disks from an instance in that region. A later `om scan aws`, `om scan azure`, or `om scan gcp` leaves `packages` in place. Windows, LVM, and disks other than the root volume are skipped. Object bodies and database rows stay out. They are customer data, not the installed software. A public bucket or database is already an exposure finding from the API scan. diff --git a/docs/adr/006-aws-root-volume-packages.md b/docs/adr/006-aws-root-volume-packages.md index ca48b9f..bc412c0 100644 --- a/docs/adr/006-aws-root-volume-packages.md +++ b/docs/adr/006-aws-root-volume-packages.md @@ -17,7 +17,7 @@ Reading a volume means creating a snapshot and attaching a copy. That is a write ## Decision -`om scan workload` is a separate command. It runs as root on an EC2 instance in the account and region being scanned. +`om scan workload aws` is a separate command. It runs as root on an EC2 instance in the account and region being scanned. For each Linux instance with an EBS root volume, the command: diff --git a/docs/adr/007-azure-gce-root-volume-packages.md b/docs/adr/007-azure-gce-root-volume-packages.md index 3e06ebd..8e50dc2 100644 --- a/docs/adr/007-azure-gce-root-volume-packages.md +++ b/docs/adr/007-azure-gce-root-volume-packages.md @@ -11,13 +11,13 @@ SPDX-License-Identifier: Apache-2.0 ## Context -[ADR 006](./006-aws-root-volume-packages.md) defined `packages` and `om scan workload` for AWS Linux root volumes. `om scan azure` and `om scan gcp` still record the virtual machine from the cloud API and do not read the OS disk, so a package installed on an Azure VM or a GCE instance never reaches `packages`. +[ADR 006](./006-aws-root-volume-packages.md) defined `packages` and `om scan workload aws` for AWS Linux root volumes. `om scan azure` and `om scan gcp` still record the virtual machine from the cloud API and do not read the OS disk, so a package installed on an Azure VM or a GCE instance never reaches `packages`. The filesystem walk is the same. The snapshot and attach APIs are not. ## Decision -`om scan workload azure` and `om scan workload gcp` are subcommands of the same opt-in scan. `om scan azure` and `om scan gcp` stay read-only. +`om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` are subcommands of the same opt-in scan. `om scan azure` and `om scan gcp` stay read-only. `om scan workload azure` runs as root on an Azure VM in the subscription. For each Linux VM in that region with a managed OS disk, it creates a snapshot and a disk in the worker's resource group and zone, attaches the disk to the worker, and mounts it read-only. `om scan workload gcp` runs as root on a GCE instance in the project. For each Linux instance in that region with a persistent boot disk, it creates a snapshot, creates a disk in the worker's zone, and attaches that disk read-only. diff --git a/internal/cmd/scan_workload.go b/internal/cmd/scan_workload.go index fe73b12..0699859 100644 --- a/internal/cmd/scan_workload.go +++ b/internal/cmd/scan_workload.go @@ -3,117 +3,16 @@ package cmd -import ( - "fmt" - "os" - "strings" - "time" - - awscol "github.com/OpenSourceOM/core/internal/collectors/aws" - "github.com/OpenSourceOM/core/internal/workload" - awsv "github.com/OpenSourceOM/core/internal/workload/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/spf13/cobra" -) - -var ( - workloadTimeout time.Duration - workloadInstances []string -) +import "github.com/spf13/cobra" var scanWorkloadCmd = &cobra.Command{ Use: "workload", - Short: "Inventory packages on AWS Linux root volumes", - Long: workloadLong(), - RunE: runScanWorkload, -} - -func workloadLong() string { - return `Inventory packages on Linux root volumes in the current AWS account. - -Run it as root on an EC2 instance in that account. The command snapshots each root volume, creates a volume from the snapshot in this instance's availability zone, attaches it, and mounts it read-only. It writes CPE 2.3 names and package URLs onto the workload node from om scan aws, then deletes the snapshot and the volume, including when a read fails. File contents stay on the volume. - -om scan aws stays a read-only API scan. A later om scan aws keeps packages already stored on the workload. Snapshots and volumes are tagged opensourceom:scan=workload, which is how to find one left behind by a killed process. - -This command reads Linux root volumes. Windows instances are skipped. LVM and disks other than the root volume are skipped. Azure OS disks are "om scan workload azure". GCE boot disks are "om scan workload gcp". Boot images, container layers, function bundles, and shared filesystems are later scans. - -IAM actions: - ` + strings.Join(awsv.Permissions, "\n ") -} - -func runScanWorkload(cmd *cobra.Command, args []string) error { - cfg := loadConfig() - ctx := cmd.Context() - worker, err := awsv.LookupWorker(ctx) - if err != nil { - return err - } - if err := awsv.CheckRegion(worker.Region, cfg.AWSRegion); err != nil { - return err - } - if os.Geteuid() != 0 { - return fmt.Errorf("om scan workload mounts the snapshot and must run as root on the worker") - } - - store, err := openGraphStore(ctx, cfg) - if err != nil { - return err - } - defer store.Close() - - collector, err := awscol.NewCollector(ctx, cfg.AWSRegion) - if err != nil { - return err - } - awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.AWSRegion)) - if err != nil { - return fmt.Errorf("load aws config: %w", err) - } - - scanner := &awsv.Scanner{ - API: ec2.NewFromConfig(awsCfg), - Worker: awsv.Worker{ - InstanceID: worker.InstanceID, - AvailabilityZone: worker.AvailabilityZone, - Region: worker.Region, - }, - Only: workloadInstances, - Timeout: workloadTimeout, - Mount: awsv.NewGuest().MountReadOnly, - Walk: workload.Walk, - OnStart: func(instanceID, name string) { - fmt.Printf("%s: snapshotting the root volume\n", workloadLabel(instanceID, name)) - }, - } - wrote := 0 - err = scanner.Scan(ctx, func(res awsv.Result) error { - label := workloadLabel(res.InstanceID, res.Name) - if res.Skipped != "" { - fmt.Printf("%s: skipped (%s)\n", label, res.Skipped) - return nil - } - nodeID := awscol.WorkloadNodeID(collector.AccountID, cfg.AWSRegion, res.InstanceID) - if err := store.SetWorkloadPackages(ctx, nodeID, res.Packages); err != nil { - return err - } - fmt.Printf("%s: %d package identifiers\n", label, len(res.Packages)) - wrote++ - return nil - }) - fmt.Printf("Stored packages on %d workloads.\n", wrote) - return err -} + Short: "Inventory packages on Linux root volumes", + Long: `Inventory packages on Linux root volumes in the current account. -func workloadLabel(instanceID, name string) string { - if name != "" && name != instanceID { - return instanceID + " (" + name + ")" - } - return instanceID +Each cloud is its own command: "om scan workload aws", "om scan workload azure", and "om scan workload gcp". Each runs as root on a worker in that account and region. om scan aws, om scan azure, and om scan gcp stay read-only.`, } func init() { - scanWorkloadCmd.Flags().DurationVar(&workloadTimeout, "timeout", 30*time.Minute, "time limit for each instance") - scanWorkloadCmd.Flags().StringArrayVar(&workloadInstances, "instance", nil, "EC2 instance to scan (repeat to scan several; the default is every instance in the region)") scanCmd.AddCommand(scanWorkloadCmd) } diff --git a/internal/cmd/scan_workload_aws.go b/internal/cmd/scan_workload_aws.go new file mode 100644 index 0000000..f4ae01c --- /dev/null +++ b/internal/cmd/scan_workload_aws.go @@ -0,0 +1,119 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + awscol "github.com/OpenSourceOM/core/internal/collectors/aws" + "github.com/OpenSourceOM/core/internal/workload" + awsv "github.com/OpenSourceOM/core/internal/workload/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/spf13/cobra" +) + +var ( + awsWorkloadTimeout time.Duration + awsWorkloadInstances []string +) + +var scanWorkloadAWSCmd = &cobra.Command{ + Use: "aws", + Short: "Inventory packages on AWS Linux root volumes", + Long: workloadAWSLong(), + RunE: runScanWorkloadAWS, +} + +func workloadAWSLong() string { + return `Inventory packages on Linux root volumes in the current AWS account. + +Run it as root on an EC2 instance in that account. The command snapshots each root volume, creates a volume from the snapshot in this instance's availability zone, attaches it, and mounts it read-only. It writes CPE 2.3 names and package URLs onto the workload node from om scan aws, then deletes the snapshot and the volume, including when a read fails. File contents stay on the volume. + +om scan aws stays a read-only API scan. A later om scan aws keeps packages already stored on the workload. Snapshots and volumes are tagged opensourceom:scan=workload, which is how to find one left behind by a killed process. + +This command reads Linux root volumes. Windows instances are skipped. LVM and disks other than the root volume are skipped. Boot images, container layers, function bundles, and shared filesystems are later scans. + +IAM actions: + ` + strings.Join(awsv.Permissions, "\n ") +} + +func runScanWorkloadAWS(cmd *cobra.Command, args []string) error { + cfg := loadConfig() + ctx := cmd.Context() + worker, err := awsv.LookupWorker(ctx) + if err != nil { + return err + } + if err := awsv.CheckRegion(worker.Region, cfg.AWSRegion); err != nil { + return err + } + if os.Geteuid() != 0 { + return fmt.Errorf("om scan workload aws mounts the snapshot and must run as root on the worker") + } + + store, err := openGraphStore(ctx, cfg) + if err != nil { + return err + } + defer store.Close() + + collector, err := awscol.NewCollector(ctx, cfg.AWSRegion) + if err != nil { + return err + } + awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.AWSRegion)) + if err != nil { + return fmt.Errorf("load aws config: %w", err) + } + + scanner := &awsv.Scanner{ + API: ec2.NewFromConfig(awsCfg), + Worker: awsv.Worker{ + InstanceID: worker.InstanceID, + AvailabilityZone: worker.AvailabilityZone, + Region: worker.Region, + }, + Only: awsWorkloadInstances, + Timeout: awsWorkloadTimeout, + Mount: awsv.NewGuest().MountReadOnly, + Walk: workload.Walk, + OnStart: func(instanceID, name string) { + fmt.Printf("%s: snapshotting the root volume\n", workloadLabel(instanceID, name)) + }, + } + wrote := 0 + err = scanner.Scan(ctx, func(res awsv.Result) error { + label := workloadLabel(res.InstanceID, res.Name) + if res.Skipped != "" { + fmt.Printf("%s: skipped (%s)\n", label, res.Skipped) + return nil + } + nodeID := awscol.WorkloadNodeID(collector.AccountID, cfg.AWSRegion, res.InstanceID) + if err := store.SetWorkloadPackages(ctx, nodeID, res.Packages); err != nil { + return err + } + fmt.Printf("%s: %d package identifiers\n", label, len(res.Packages)) + wrote++ + return nil + }) + fmt.Printf("Stored packages on %d workloads.\n", wrote) + return err +} + +func workloadLabel(instanceID, name string) string { + if name != "" && name != instanceID { + return instanceID + " (" + name + ")" + } + return instanceID +} + +func init() { + scanWorkloadAWSCmd.Flags().DurationVar(&awsWorkloadTimeout, "timeout", 30*time.Minute, "time limit for each instance") + scanWorkloadAWSCmd.Flags().StringArrayVar(&awsWorkloadInstances, "instance", nil, "EC2 instance to scan (repeat to scan several; the default is every instance in the region)") + scanWorkloadCmd.AddCommand(scanWorkloadAWSCmd) +} diff --git a/internal/cmd/scan_workload_test.go b/internal/cmd/scan_workload_test.go index fb433ef..b694fda 100644 --- a/internal/cmd/scan_workload_test.go +++ b/internal/cmd/scan_workload_test.go @@ -24,9 +24,13 @@ func TestScanWorkloadRegistered(t *testing.T) { if workloadCmd == nil { t.Fatal("om scan workload is not registered") } + if workloadCmd.Runnable() { + t.Fatal("om scan workload runs a scan; name the cloud") + } + aws := commandByName(t, workloadCmd, "aws") for _, action := range awsv.Permissions { - if !strings.Contains(workloadCmd.Long, action) { - t.Errorf("help missing %s", action) + if !strings.Contains(aws.Long, action) { + t.Errorf("aws help missing %s", action) } } azure := commandByName(t, workloadCmd, "azure") diff --git a/internal/workload/aws/identity.go b/internal/workload/aws/identity.go index 8a47580..51a6f38 100644 --- a/internal/workload/aws/identity.go +++ b/internal/workload/aws/identity.go @@ -22,7 +22,7 @@ func LookupWorker(ctx context.Context) (Worker, error) { client := imds.NewFromConfig(cfg) id, err := metadata(ctx, client, "instance-id") if err != nil { - return Worker{}, fmt.Errorf("om scan workload runs on an EC2 instance in this account: %w", err) + return Worker{}, fmt.Errorf("om scan workload aws runs on an EC2 instance in this account: %w", err) } az, err := metadata(ctx, client, "placement/availability-zone") if err != nil { diff --git a/internal/workload/aws/scan.go b/internal/workload/aws/scan.go index 128d6b9..9774a66 100644 --- a/internal/workload/aws/scan.go +++ b/internal/workload/aws/scan.go @@ -16,7 +16,7 @@ import ( "github.com/aws/smithy-go" ) -// Permissions are the IAM actions om scan workload adds. +// Permissions are the IAM actions om scan workload aws adds. // kms:CreateGrant and kms:Decrypt are the grant an encrypted root volume needs. var Permissions = []string{ "ec2:DescribeInstances", From 03110423b89190e80ca9b29a68351f427961755e Mon Sep 17 00:00:00 2001 From: O M Date: Mon, 26 Oct 2026 20:49:44 -0400 Subject: [PATCH 2/3] Inventory packages on boot images. Read each AMI, Azure image, and GCE image once and add those package identifiers to instances launched from it, keeping packages installed on an instance after boot. Co-authored-by: Cursor --- CONTRIBUTING.md | 2 +- README.md | 20 +- collectors/README.md | 4 +- deploy/helm/opensourceom/values.yaml | 5 +- docs/ARCHITECTURE.md | 8 +- docs/ROADMAP.md | 4 +- docs/adr/006-aws-root-volume-packages.md | 2 +- .../adr/007-azure-gce-root-volume-packages.md | 2 +- docs/adr/008-boot-image-packages.md | 49 ++ internal/cmd/scan_workload.go | 6 +- internal/cmd/scan_workload_aws.go | 2 +- internal/cmd/scan_workload_azure.go | 2 +- internal/cmd/scan_workload_gcp.go | 2 +- internal/cmd/scan_workload_image.go | 331 +++++++++++ internal/cmd/scan_workload_test.go | 22 + internal/graph/packages.go | 48 ++ internal/graph/packages_test.go | 35 ++ internal/graph/store.go | 18 + internal/graph/store_test.go | 44 ++ internal/workload/aws/image.go | 390 +++++++++++++ internal/workload/aws/image_test.go | 260 +++++++++ internal/workload/aws/scan.go | 3 +- internal/workload/aws/scan_test.go | 33 ++ internal/workload/azure/client.go | 25 +- internal/workload/azure/image.go | 519 ++++++++++++++++++ internal/workload/azure/image_test.go | 302 ++++++++++ internal/workload/azure/scan.go | 8 + internal/workload/gcp/image.go | 482 ++++++++++++++++ internal/workload/gcp/image_test.go | 227 ++++++++ internal/workload/gcp/scan.go | 1 + internal/workload/skip.go | 3 + 31 files changed, 2828 insertions(+), 31 deletions(-) create mode 100644 docs/adr/008-boot-image-packages.md create mode 100644 internal/cmd/scan_workload_image.go create mode 100644 internal/graph/packages.go create mode 100644 internal/graph/packages_test.go create mode 100644 internal/workload/aws/image.go create mode 100644 internal/workload/aws/image_test.go create mode 100644 internal/workload/azure/image.go create mode 100644 internal/workload/azure/image_test.go create mode 100644 internal/workload/gcp/image.go create mode 100644 internal/workload/gcp/image_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9019d93..9180e8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ On a PR you can comment `@coderabbitai review` (incremental) or `@coderabbitai f ## Development guidelines -- Keep collectors **read-only** toward cloud accounts by default. `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` are the opt-in exception: they may create and delete snapshots in the account, and they store package identifiers only +- Keep collectors **read-only** toward cloud accounts by default. `om scan workload` is the opt-in exception: the root-volume commands may create and delete snapshots, the boot-image commands may create and delete a volume or disk from an image, and both store package identifiers only - Document graph schema changes in `docs/` - Prefer small, reviewable PRs - Add tests for graph queries and normalization logic diff --git a/README.md b/README.md index 5bcb35b..f59d710 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ OpenSourceOM Core is the platform behind [opensourceom.org](https://opensourceom Traditional scanners flood you with CVEs and misconfigurations. OpenSourceOM connects the dots — showing which findings sit on paths from the internet to your sensitive data and privileged identities. -> **Status:** Phase 3 has shipped. Phase 4 is open: `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` inventory Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors ([#10](https://github.com/OpenSourceOM/core/issues/10)). See the [roadmap](./docs/ROADMAP.md). +> **Status:** Phase 3 has shipped. Phase 4 is open: `om scan workload` inventories Linux root volumes and boot images. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors ([#10](https://github.com/OpenSourceOM/core/issues/10)). See the [roadmap](./docs/ROADMAP.md). ## Why this exists @@ -95,9 +95,12 @@ export AWS_REGION=us-east-1 ./om scan aws # On an EC2 instance in that account, as root, after the API scan: # sudo -E ./om scan workload aws +# sudo -E ./om scan workload image aws # On an Azure VM or a GCE instance in that region, as root: # sudo -E ./om scan workload azure +# sudo -E ./om scan workload image azure # sudo -E ./om scan workload gcp +# sudo -E ./om scan workload image gcp ./om scan k8s # requires kubeconfig # Or ingest a custom collector (stdout is a graph batch) @@ -138,11 +141,14 @@ An API token is created only when an admin asks for one, from the console or wit ```bash ./om scan demo # sample environment, no credentials ./om scan aws -./om scan workload aws # root on an EC2 worker; Linux root-volume packages -./om scan azure # requires AZURE_SUBSCRIPTION_ID + az login -./om scan workload azure # root on an Azure VM; Linux OS-disk packages -./om scan gcp # requires GCP_PROJECT_ID + ADC -./om scan workload gcp # root on a GCE instance; Linux boot-disk packages +./om scan workload aws # root on an EC2 worker; Linux root-volume packages +./om scan workload image aws # root on an EC2 worker; AMI packages, applied to instances +./om scan azure # requires AZURE_SUBSCRIPTION_ID + az login +./om scan workload azure # root on an Azure VM; Linux OS-disk packages +./om scan workload image azure # root on an Azure VM; boot-image packages, applied to VMs +./om scan gcp # requires GCP_PROJECT_ID + ADC +./om scan workload gcp # root on a GCE instance; Linux boot-disk packages +./om scan workload image gcp # root on a GCE instance; image packages, applied to instances ./om scan k8s # requires kubeconfig or in-cluster credentials ./om scan plugin -- ./my-collector ``` @@ -170,7 +176,7 @@ Full documentation: [opensourceom.org](https://opensourceom.org) (docs at [opens | **1** | Attack path queries, CVE enrichment, web UI, Azure/GCP collectors | | **2** | CSPM rules, blast radius, K8s connector, exports | | **3** | Plugin SDK, Helm, embedded rule pack, demo graph, inventory-backed CVEs, crown-jewel datastores, attack-path findings, and cloud audit context (CloudTrail, Activity Log, Admin Activity). Shipped. | -| **4** | Package inventory from persistent workload storage. AWS, Azure, and GCE Linux root volumes ship with `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp`. Boot images, container layers, function bundles, and shared filesystems are open. | +| **4** | Package inventory from persistent workload storage. Linux root volumes and boot images ship with `om scan workload`. Container layers, function bundles, and shared filesystems are open. | | **5** | Secret and malware findings on storage Phase 4 already opens. Kind and path only. Open. | Details: [docs/ROADMAP.md](./docs/ROADMAP.md) diff --git a/collectors/README.md b/collectors/README.md index ae62c97..a2de956 100644 --- a/collectors/README.md +++ b/collectors/README.md @@ -20,9 +20,9 @@ Cloud and platform ingestion plugins. Each collector normalizes provider APIs in The **demo** collector loads a fixed environment (internet-exposed web tier, production database, public/private buckets, admin vs app identities, public Kubernetes service). Use it to exercise CSPM packs without cloud credentials. -The **AWS** collector emits properties the CIS pack matches on: `open_ingress`, `imdsv2`, `public_ip`, S3 `encryption` / `versioning` / `public_access_block`, and IAM user `mfa` / `unused_access_keys`. It also records RDS and Aurora instances as datastores. S3 bucket tags and the RDS `TagList` copy `sensitivity` or `data-class` onto the datastore. `sensitivity` wins when both are set. The same scan reads CloudTrail management events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. `om scan aws` does not read instance disks. `om scan workload aws` is the separate command that inventories packages on Linux root volumes. +The **AWS** collector emits properties the CIS pack matches on: `open_ingress`, `imdsv2`, `public_ip`, S3 `encryption` / `versioning` / `public_access_block`, and IAM user `mfa` / `unused_access_keys`. It also records RDS and Aurora instances as datastores. S3 bucket tags and the RDS `TagList` copy `sensitivity` or `data-class` onto the datastore. `sensitivity` wins when both are set. The same scan reads CloudTrail management events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. `om scan aws` does not read instance disks. `om scan workload aws` inventories packages on Linux root volumes. `om scan workload image aws` reads each boot image once and applies those packages to instances launched from it. -The **Azure** collector records logical SQL servers. `om scan azure` and `om scan gcp` do not read OS disks. `om scan workload azure` and `om scan workload gcp` are the separate commands that inventory packages on those disks. The same scan reads administrative Activity Log events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Entra ID sign-in logs are not part of this scan. The **GCP** collector records Cloud SQL instances. The same scan reads Admin Activity audit logs from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Data Access logs are not part of this scan. A workload gets `CAN_ACCESS` only when a security group, firewall, or VPC path allows it. Azure copies the crown-jewel mark from storage-account and SQL-server tags. GCP copies it from a bucket label or a Cloud SQL user label. The keys are `sensitivity` and `data-class`. A plugin may set `sensitivity` on a datastore directly. +The **Azure** collector records logical SQL servers. `om scan azure` and `om scan gcp` do not read OS disks. `om scan workload azure` and `om scan workload gcp` inventory packages on those disks. `om scan workload image azure` and `om scan workload image gcp` read each boot image once. The same scan reads administrative Activity Log events from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Entra ID sign-in logs are not part of this scan. The **GCP** collector records Cloud SQL instances. The same scan reads Admin Activity audit logs from the last 24 hours and stores the ones that name an identity and a resource on an exposed path. A failed lookup omits those events. Data Access logs are not part of this scan. A workload gets `CAN_ACCESS` only when a security group, firewall, or VPC path allows it. Azure copies the crown-jewel mark from storage-account and SQL-server tags. GCP copies it from a bucket label or a Cloud SQL user label. The keys are `sensitivity` and `data-class`. A plugin may set `sensitivity` on a datastore directly. The **Kubernetes** collector records Ingress objects and whether a NetworkPolicy selects each pod. A pod is internet-reachable from a LoadBalancer or an Ingress backend unless that policy governs ingress and does not allow the world. NodePort alone is not exposure. diff --git a/deploy/helm/opensourceom/values.yaml b/deploy/helm/opensourceom/values.yaml index 92907bc..a6d0c86 100644 --- a/deploy/helm/opensourceom/values.yaml +++ b/deploy/helm/opensourceom/values.yaml @@ -60,8 +60,9 @@ resources: # a kubeconfig Secret the chart creates a read-only ServiceAccount for this # cluster. `om scan demo` and the workload scans are not scheduled. # The workload scan mounts disks and runs on a VM in the account: EC2 for -# `om scan workload aws`, an Azure VM for `om scan workload azure`, and a -# GCE instance for `om scan workload gcp`. +# `om scan workload aws` and `om scan workload image aws`, an Azure VM for +# `om scan workload azure` and `om scan workload image azure`, and a GCE +# instance for `om scan workload gcp` and `om scan workload image gcp`. scan: schedule: "0 */6 * * *" successfulJobsHistoryLimit: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b494e81..6b1c6a1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Architecture -> **Status:** Phase 3 has shipped. The collector plugin SDK, Helm, the embedded rule pack, and cloud audit context from CloudTrail, Activity Log, and Admin Activity logs are in the tree, alongside Phase 2 CSPM rules, blast radius, Kubernetes ingest, and exports. Phase 4 is open: `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` inventory Linux root volumes. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors. +> **Status:** Phase 3 has shipped. The collector plugin SDK, Helm, the embedded rule pack, and cloud audit context from CloudTrail, Activity Log, and Admin Activity logs are in the tree, alongside Phase 2 CSPM rules, blast radius, Kubernetes ingest, and exports. Phase 4 is open: `om scan workload` inventories Linux root volumes and boot images. Phase 5 (secret and malware findings on that storage) is open. Further rule packs stay open for contributors. ## Overview @@ -32,7 +32,7 @@ SPDX-License-Identifier: Apache-2.0 | Component | Location | Notes | |-----------|----------|-------| | **Collectors** | `internal/collectors/` | AWS, Azure, GCP, Kubernetes, demo; RDS, Azure SQL, and Cloud SQL are datastores; AWS emits CIS pack properties and recent CloudTrail management events; Azure emits recent Activity Log events; GCP emits recent Admin Activity audit logs | -| **Workload packages** | `internal/workload/` | `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` set `packages` from Linux root volumes | +| **Workload packages** | `internal/workload/` | `om scan workload` sets `packages` from Linux root volumes and from boot images | | **Plugin SDK** | `sdk/collector`, `internal/plugins/` | External executables; `om scan plugin` | | **Graph store** | `internal/graph/`, `migrations/` | PostgreSQL `nodes` + `edges` | | **Path queries** | `internal/graph/query.go` | Named queries including `internet-to-datastore` and `internet-to-sensitive-datastore` | @@ -52,7 +52,7 @@ SPDX-License-Identifier: Apache-2.0 Findings link to affected resources via `VIOLATES` edges. CSPM and CVE findings share the same `Finding` node type but use `finding_type` and `rule_id` / `cve_id` properties for provenance. -See [ADR 001](./adr/001-graph-schema-v0.md), [ADR 002](./adr/002-phase1-findings-ui.md), [ADR 003](./adr/003-phase2-cspm-rbac.md), [ADR 004](./adr/004-collector-plugin-sdk.md), [ADR 005](./adr/005-console-admins.md), [ADR 006](./adr/006-aws-root-volume-packages.md), and [ADR 007](./adr/007-azure-gce-root-volume-packages.md). +See [ADR 001](./adr/001-graph-schema-v0.md), [ADR 002](./adr/002-phase1-findings-ui.md), [ADR 003](./adr/003-phase2-cspm-rbac.md), [ADR 004](./adr/004-collector-plugin-sdk.md), [ADR 005](./adr/005-console-admins.md), [ADR 006](./adr/006-aws-root-volume-packages.md), [ADR 007](./adr/007-azure-gce-root-volume-packages.md), and [ADR 008](./adr/008-boot-image-packages.md). ## Technology choices @@ -87,7 +87,7 @@ Further community rule packs (PCI and additional CIS mappings) stay open for con Phase 4 writes `packages` (CPE 2.3 names and package URLs) from persistent workload storage onto the workload node that enrichment already matches. The sources, in order, are AWS Linux root volumes, Azure VM and GCE root volumes, boot images, container image layers and function bundles, and shared filesystems (EFS, Azure Files, Filestore). The scan is opt-in. `om scan aws`, `om scan azure`, and `om scan gcp` stay read-only API collectors. -`om scan workload aws` is the AWS root-volume scan. `om scan workload azure` and `om scan workload gcp` are the same walk for Azure OS disks and GCE boot disks. Each runs as root on a worker in that account and region, snapshots the root disk, mounts the copy read-only, and stores package identifiers only. A later API scan keeps `packages`. Windows, LVM, and non-root disks are skipped. Boot images, container layers, function bundles, and shared filesystems are still open. See [ADR 006](./adr/006-aws-root-volume-packages.md) and [ADR 007](./adr/007-azure-gce-root-volume-packages.md). +`om scan workload aws` is the AWS root-volume scan. `om scan workload azure` and `om scan workload gcp` are the same walk for Azure OS disks and GCE boot disks. `om scan workload image aws`, `om scan workload image azure`, and `om scan workload image gcp` read each boot image once and add those package identifiers to instances launched from it. A package that exists only on an instance disk stays on that instance. Each command runs as root on a worker in that account and region, mounts a copy read-only, and stores package identifiers only. A later API scan keeps `packages`. Windows, LVM, and non-root disks are skipped. Container layers, function bundles, and shared filesystems are still open. See [ADR 006](./adr/006-aws-root-volume-packages.md), [ADR 007](./adr/007-azure-gce-root-volume-packages.md), and [ADR 008](./adr/008-boot-image-packages.md). Phase 5 adds a finding for a secret pattern or a malware signature on storage that Phase 4 reader already opened. The finding stores the kind and the path. Object bodies and database rows are in neither phase. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6d038f7..1974a5e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -70,12 +70,14 @@ The path, in order: - [x] **AWS Linux root volumes** — [#102](https://github.com/OpenSourceOM/core/issues/102) - [x] **Azure VM and GCE root volumes** — the same walker, those snapshot APIs — [#103](https://github.com/OpenSourceOM/core/issues/103) -- [ ] **Boot images** — an AMI and the Azure and GCE equivalents, scanned once and applied to instances launched from that image — [#104](https://github.com/OpenSourceOM/core/issues/104) +- [x] **Boot images** — an AMI and the Azure and GCE equivalents, scanned once and applied to instances launched from that image — [#104](https://github.com/OpenSourceOM/core/issues/104) - [ ] **Container image layers and function bundles** — layers for images already stored on Kubernetes workloads, plus Lambda deployment packages and the Azure and GCP equivalents. No volume attach — [#105](https://github.com/OpenSourceOM/core/issues/105) - [ ] **Shared filesystems** — EFS, Azure Files, and Filestore, when the application is installed there and a root-volume scan would miss it — [#106](https://github.com/OpenSourceOM/core/issues/106) `om scan workload aws` reads Linux root volumes in the current AWS account. It runs as root on an EC2 instance in that account, snapshots the root volume, mounts the copy read-only, and writes CPE 2.3 names and package URLs onto `packages`. `om scan workload azure` does the same for managed OS disks from an Azure VM in that region, and `om scan workload gcp` does it for GCE boot disks from an instance in that region. A later `om scan aws`, `om scan azure`, or `om scan gcp` leaves `packages` in place. Windows, LVM, and disks other than the root volume are skipped. +`om scan workload image aws` reads each AMI once, including an image no instance is running, and adds those package identifiers to every instance launched from it. `om scan workload image azure` and `om scan workload image gcp` do the same for Azure and GCE images. A package found only on an instance's own disk stays on that instance. Marketplace images the account cannot snapshot are skipped. The volume or disk created for the read is deleted. The image's own snapshot is not. + Object bodies and database rows stay out. They are customer data, not the installed software. A public bucket or database is already an exposure finding from the API scan. ## Phase 5 — Findings on inventoried storage diff --git a/docs/adr/006-aws-root-volume-packages.md b/docs/adr/006-aws-root-volume-packages.md index bc412c0..6b0135f 100644 --- a/docs/adr/006-aws-root-volume-packages.md +++ b/docs/adr/006-aws-root-volume-packages.md @@ -32,7 +32,7 @@ An inventory upsert that omits `packages` keeps the list already stored. `om sca The IAM actions are `ec2:CreateSnapshot`, `ec2:AttachVolume`, `ec2:DeleteSnapshot`, `ec2:DeleteVolume`, the describe and tag calls around that lifecycle, and `kms:CreateGrant` plus `kms:Decrypt` (with `kms:DescribeKey`, `kms:GenerateDataKeyWithoutPlaintext`, and `kms:ReEncrypt*`) for an encrypted root volume. `om scan aws` does not gain these actions. -Azure, GCE, boot images, container layers, function bundles, and shared filesystems stay on later Phase 4 issues. Secret and malware findings stay on Phase 5, after this reader exists. +Azure and GCE root volumes are [ADR 007](./007-azure-gce-root-volume-packages.md). Boot images are [ADR 008](./008-boot-image-packages.md). Container layers, function bundles, and shared filesystems stay on later Phase 4 issues. Secret and malware findings stay on Phase 5, after this reader exists. ## Consequences diff --git a/docs/adr/007-azure-gce-root-volume-packages.md b/docs/adr/007-azure-gce-root-volume-packages.md index 8e50dc2..69ffa72 100644 --- a/docs/adr/007-azure-gce-root-volume-packages.md +++ b/docs/adr/007-azure-gce-root-volume-packages.md @@ -29,7 +29,7 @@ Azure copies are tagged `opensourceom:scan=workload` and are created with public The Azure actions are `Microsoft.Compute/snapshots/write`, `Microsoft.Compute/snapshots/delete`, `Microsoft.Compute/disks/write`, `Microsoft.Compute/disks/delete`, `Microsoft.Compute/virtualMachines/attachDetachDataDisks/action`, and `Microsoft.KeyVault/vaults/keys/unwrap/action` (with `keys/wrap/action` when the snapshot keeps the key). The GCP permissions are `compute.disks.createSnapshot`, `compute.snapshots.delete`, `compute.disks.create`, `compute.disks.delete`, `compute.instances.attachDisk`, `compute.instances.detachDisk`, and `cloudkms.cryptoKeyVersions.useToDecrypt` (with `useToEncrypt`). The API collectors do not gain these permissions. -Boot images, container layers, function bundles, and shared filesystems stay on later Phase 4 issues. Secret and malware findings stay on Phase 5. +Boot images are [ADR 008](./008-boot-image-packages.md). Container layers, function bundles, and shared filesystems stay on later Phase 4 issues. Secret and malware findings stay on Phase 5. ## Consequences diff --git a/docs/adr/008-boot-image-packages.md b/docs/adr/008-boot-image-packages.md new file mode 100644 index 0000000..6da271c --- /dev/null +++ b/docs/adr/008-boot-image-packages.md @@ -0,0 +1,49 @@ + + +# ADR 008: Boot-image package inventory + +**Status:** Accepted +**Date:** 2026-10-26 +**Phase:** 4 + +## Context + +[ADR 006](./006-aws-root-volume-packages.md) and [ADR 007](./007-azure-gce-root-volume-packages.md) read each instance's root disk. Many instances boot from one image: an AMI, an Azure image, or a GCE image. That image is where the golden software lives. Scanning only running instances repeats the walk per instance and misses an image that is not currently booted. + +`om enrich cve` already matches `packages`. It does not need a new identifier shape. + +## Decision + +`om scan workload image aws`, `om scan workload image azure`, and `om scan workload image gcp` are subcommands of the same opt-in scan. `om scan aws`, `om scan azure`, and `om scan gcp` stay read-only. The root-volume commands stay as they are. + +Each command runs as root on a worker in that account and region. It reads a boot image once, through the same package walker as a root volume: + +- AWS creates a volume from the AMI's root snapshot. It does not delete that snapshot. +- Azure creates a disk from a managed image, a gallery image, a community gallery image, or a marketplace image id. +- GCE creates a disk from the image. + +The copy is attached read-only, walked, and deleted, including when a later step fails. The graph, logs, and exports store package identifiers. They do not store file contents. + +The image's identifiers are added to `packages` on every existing workload launched from that image. Identifiers already stored stay. A package that exists only on an instance disk is not written onto the image and is not copied to another instance. `om scan workload aws`, `om scan workload azure`, and `om scan workload gcp` still replace `packages` with the packages on that instance's own disk. + +Images the account owns are read even when no instance is running. An image that instances were launched from is read even when another account published it. Marketplace and shared images the account cannot snapshot are skipped, and the command says so. Windows images are skipped. AWS instance-store images are skipped. + +The new AWS actions are `ec2:DescribeImages` and the volume create, attach, and delete calls. They do not include `ec2:CreateSnapshot` or `ec2:DeleteSnapshot`. The new Azure actions are `Microsoft.Compute/images/read`, `Microsoft.Compute/galleries/images/versions/read`, and the disk create, delete, and attach calls. The new GCP permissions are `compute.images.list`, `compute.images.useReadOnly`, `compute.disks.list`, and the disk create, delete, and attach calls. The API collectors do not gain these permissions. + +Container layers, function bundles, and shared filesystems stay on later Phase 4 issues. Secret and malware findings stay on Phase 5. + +## Consequences + +Each command has to run on a VM in the account, in the region being scanned. A process killed with SIGKILL can leave a volume or disk; the same `opensourceom:scan=workload` tag or `opensourceom-scan=workload` label from the root-volume scan is how to find it. The Helm chart does not schedule these commands. + +`om enrich cve` is unchanged. It attaches a CVE only when one of these identifiers matches. An image with no instance in the graph is still read, and its packages are stored when a later run finds a workload launched from it. + +## References + +- [docs/ROADMAP.md](../ROADMAP.md) +- [ADR 006](./006-aws-root-volume-packages.md) +- [ADR 007](./007-azure-gce-root-volume-packages.md) +- [Issue #104](https://github.com/OpenSourceOM/core/issues/104) diff --git a/internal/cmd/scan_workload.go b/internal/cmd/scan_workload.go index 0699859..ff04343 100644 --- a/internal/cmd/scan_workload.go +++ b/internal/cmd/scan_workload.go @@ -7,10 +7,10 @@ import "github.com/spf13/cobra" var scanWorkloadCmd = &cobra.Command{ Use: "workload", - Short: "Inventory packages on Linux root volumes", - Long: `Inventory packages on Linux root volumes in the current account. + Short: "Inventory packages on workload storage", + Long: `Inventory packages on workload storage in the current account. -Each cloud is its own command: "om scan workload aws", "om scan workload azure", and "om scan workload gcp". Each runs as root on a worker in that account and region. om scan aws, om scan azure, and om scan gcp stay read-only.`, +Root volumes are "om scan workload aws", "om scan workload azure", and "om scan workload gcp". Boot images are "om scan workload image aws", "om scan workload image azure", and "om scan workload image gcp". Each runs as root on a worker in that account and region. om scan aws, om scan azure, and om scan gcp stay read-only.`, } func init() { diff --git a/internal/cmd/scan_workload_aws.go b/internal/cmd/scan_workload_aws.go index f4ae01c..ab49176 100644 --- a/internal/cmd/scan_workload_aws.go +++ b/internal/cmd/scan_workload_aws.go @@ -36,7 +36,7 @@ Run it as root on an EC2 instance in that account. The command snapshots each ro om scan aws stays a read-only API scan. A later om scan aws keeps packages already stored on the workload. Snapshots and volumes are tagged opensourceom:scan=workload, which is how to find one left behind by a killed process. -This command reads Linux root volumes. Windows instances are skipped. LVM and disks other than the root volume are skipped. Boot images, container layers, function bundles, and shared filesystems are later scans. +This command reads Linux root volumes. Windows instances are skipped. LVM and disks other than the root volume are skipped. Boot images are "om scan workload image aws". Container layers, function bundles, and shared filesystems are later scans. IAM actions: ` + strings.Join(awsv.Permissions, "\n ") diff --git a/internal/cmd/scan_workload_azure.go b/internal/cmd/scan_workload_azure.go index 2b59e60..763934c 100644 --- a/internal/cmd/scan_workload_azure.go +++ b/internal/cmd/scan_workload_azure.go @@ -34,7 +34,7 @@ Run it as root on an Azure VM in that subscription. The command snapshots each m om scan azure stays a read-only API scan. A later om scan azure keeps packages already stored on the workload. Snapshots and disks are tagged opensourceom:scan=workload, which is how to find one left behind by a killed process. The copies are created in this VM's resource group and cannot be exported. -This command reads Linux OS disks. Windows VMs are skipped. Ephemeral OS disks, unmanaged VHDs, LVM, and data disks are skipped. VMs in another region are skipped; run the command on a VM in that region. +This command reads Linux OS disks. Windows VMs are skipped. Ephemeral OS disks, unmanaged VHDs, LVM, and data disks are skipped. VMs in another region are skipped; run the command on a VM in that region. Boot images are "om scan workload image azure". The graph id matches om scan azure: azure:::workload:. diff --git a/internal/cmd/scan_workload_gcp.go b/internal/cmd/scan_workload_gcp.go index 2c1410d..cf86967 100644 --- a/internal/cmd/scan_workload_gcp.go +++ b/internal/cmd/scan_workload_gcp.go @@ -34,7 +34,7 @@ Run it as root on a GCE instance in that project. The command snapshots each per om scan gcp stays a read-only API scan. A later om scan gcp keeps packages already stored on the workload. Snapshots and disks are labeled opensourceom-scan=workload, which is how to find one left behind by a killed process. Label keys cannot contain a colon. -This command reads Linux boot disks. Windows instances are skipped. Local SSD boot disks, customer-supplied encryption keys, LVM, and extra disks are skipped. Instances in another region are skipped; run the command on an instance in that region. +This command reads Linux boot disks. Windows instances are skipped. Local SSD boot disks, customer-supplied encryption keys, LVM, and extra disks are skipped. Instances in another region are skipped; run the command on an instance in that region. Boot images are "om scan workload image gcp". The graph id matches om scan gcp: gcp:::workload:. diff --git a/internal/cmd/scan_workload_image.go b/internal/cmd/scan_workload_image.go new file mode 100644 index 0000000..c88af8d --- /dev/null +++ b/internal/cmd/scan_workload_image.go @@ -0,0 +1,331 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + awscol "github.com/OpenSourceOM/core/internal/collectors/aws" + azurecol "github.com/OpenSourceOM/core/internal/collectors/azure" + gcpcol "github.com/OpenSourceOM/core/internal/collectors/gcp" + "github.com/OpenSourceOM/core/internal/workload" + awsv "github.com/OpenSourceOM/core/internal/workload/aws" + azurev "github.com/OpenSourceOM/core/internal/workload/azure" + gcpv "github.com/OpenSourceOM/core/internal/workload/gcp" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/spf13/cobra" +) + +var ( + awsImageTimeout time.Duration + awsImageIDs []string + azureImageTimeout time.Duration + azureImageIDs []string + gcpImageTimeout time.Duration + gcpImageIDs []string +) + +var scanWorkloadImageCmd = &cobra.Command{ + Use: "image", + Short: "Inventory packages on boot images", + Long: `Inventory packages on boot images in the current account. + +Each cloud is its own command: "om scan workload image aws", "om scan workload image azure", and "om scan workload image gcp". Each reads a boot image once and writes those package identifiers onto instances launched from it. om scan aws, om scan azure, and om scan gcp stay read-only.`, +} + +var scanWorkloadImageAWSCmd = &cobra.Command{ + Use: "aws", + Short: "Inventory packages on AWS AMIs", + Long: workloadImageAWSLong(), + RunE: runScanWorkloadImageAWS, +} + +var scanWorkloadImageAzureCmd = &cobra.Command{ + Use: "azure", + Short: "Inventory packages on Azure boot images", + Long: workloadImageAzureLong(), + RunE: runScanWorkloadImageAzure, +} + +var scanWorkloadImageGCPCmd = &cobra.Command{ + Use: "gcp", + Short: "Inventory packages on GCE boot images", + Long: workloadImageGCPLong(), + RunE: runScanWorkloadImageGCP, +} + +func workloadImageAWSLong() string { + return `Inventory packages on boot images in the current AWS account. + +Run it as root on an EC2 instance in that account. The command reads each AMI once: images this account owns, including ones no instance is running, and images that instances in this region were launched from. It creates a volume from the AMI root snapshot in this instance's availability zone, attaches it, and mounts it read-only. It adds those CPE 2.3 names and package URLs to packages on every workload launched from that AMI, then deletes the volume, including when a read fails. The AMI snapshot is left in place. File contents stay on the volume. + +A package that exists only on an instance disk stays on that instance. This command does not copy it onto the image or onto another instance. om scan workload aws, which reads the instance disk, replaces packages with what is installed there. om scan aws stays a read-only API scan and keeps packages already stored. Volumes are tagged opensourceom:scan=workload. + +Windows images and instance-store images are skipped. Marketplace images this account cannot snapshot are skipped, and the command says so. Run om scan aws first so the instance nodes exist. + +IAM actions: + ` + strings.Join(awsv.ImagePermissions, "\n ") +} + +func workloadImageAzureLong() string { + return `Inventory packages on boot images in the current Azure subscription. + +Run it as root on an Azure VM in that subscription. The command reads each managed image in this VM's region once, including images no VM is running, and each image a VM in this region was launched from. It creates a disk from the image in this VM's resource group and zone, attaches it, and mounts it read-only. It adds those CPE 2.3 names and package URLs to packages on every workload launched from that image, then deletes the disk, including when a read fails. The image is left in place. File contents stay on the disk. + +A package that exists only on a VM disk stays on that VM. This command does not copy it onto the image or onto another VM. om scan workload azure replaces packages with what is installed on that OS disk. om scan azure stays a read-only API scan and keeps packages already stored. Disks are tagged opensourceom:scan=workload and cannot be exported. + +Windows images are skipped. Marketplace images this subscription cannot snapshot are skipped, and the command says so. Run om scan azure first so the VM nodes exist. + +The graph id matches om scan azure: azure:::workload:. + +Azure RBAC actions: + ` + strings.Join(azurev.ImagePermissions, "\n ") +} + +func workloadImageGCPLong() string { + return `Inventory packages on boot images in the current GCP project. + +Run it as root on a GCE instance in that project. The command reads each project image once, including images no instance in this region is running, and each image an instance in this region was launched from. It creates a disk from the image in this instance's zone, attaches it read-only, and mounts it read-only. It adds those CPE 2.3 names and package URLs to packages on every workload launched from that image, then deletes the disk, including when a read fails. The image is left in place. File contents stay on the disk. + +A package that exists only on an instance disk stays on that instance. This command does not copy it onto the image or onto another instance. om scan workload gcp replaces packages with what is installed on that boot disk. om scan gcp stays a read-only API scan and keeps packages already stored. Disks are labeled opensourceom-scan=workload. Label keys cannot contain a colon. + +Windows images are skipped. Marketplace images this project cannot snapshot are skipped, and the command says so. Run om scan gcp first so the instance nodes exist. + +The graph id matches om scan gcp: gcp:::workload:. + +IAM permissions: + ` + strings.Join(gcpv.ImagePermissions, "\n ") +} + +func runScanWorkloadImageAWS(cmd *cobra.Command, args []string) error { + cfg := loadConfig() + ctx := cmd.Context() + worker, err := awsv.LookupWorker(ctx) + if err != nil { + return err + } + if err := awsv.CheckRegion(worker.Region, cfg.AWSRegion); err != nil { + return err + } + if os.Geteuid() != 0 { + return fmt.Errorf("om scan workload image aws mounts the image and must run as root on the worker") + } + + store, err := openGraphStore(ctx, cfg) + if err != nil { + return err + } + defer store.Close() + + collector, err := awscol.NewCollector(ctx, cfg.AWSRegion) + if err != nil { + return err + } + awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.AWSRegion)) + if err != nil { + return fmt.Errorf("load aws config: %w", err) + } + + scanner := &awsv.Scanner{ + API: ec2.NewFromConfig(awsCfg), + Worker: awsv.Worker{ + InstanceID: worker.InstanceID, + AvailabilityZone: worker.AvailabilityZone, + Region: worker.Region, + }, + Timeout: awsImageTimeout, + Mount: awsv.NewGuest().MountReadOnly, + Walk: workload.Walk, + OnStart: func(imageID, name string) { + fmt.Printf("%s: reading the boot image\n", workloadLabel(imageID, name)) + }, + } + wrote := 0 + err = scanner.ScanImages(ctx, awsImageIDs, func(res awsv.ImageResult) error { + label := workloadLabel(res.ImageID, res.Name) + if res.Skipped != "" { + fmt.Printf("%s: skipped (%s)\n", label, res.Skipped) + return nil + } + if len(res.Instances) == 0 { + fmt.Printf("%s: %d package identifiers, no instances launched from this image\n", label, len(res.Packages)) + return nil + } + var failed []error + for _, inst := range res.Instances { + nodeID := awscol.WorkloadNodeID(collector.AccountID, cfg.AWSRegion, inst.ID) + if err := store.MergeWorkloadPackages(ctx, nodeID, res.Packages); err != nil { + failed = append(failed, err) + continue + } + fmt.Printf("%s: %d package identifiers\n", workloadLabel(inst.ID, inst.Name), len(res.Packages)) + wrote++ + } + return errors.Join(failed...) + }) + fmt.Printf("Stored packages on %d workloads.\n", wrote) + return err +} + +func runScanWorkloadImageAzure(cmd *cobra.Command, args []string) error { + cfg := loadConfig() + if cfg.AzureSubscriptionID == "" { + return fmt.Errorf("AZURE_SUBSCRIPTION_ID is required") + } + ctx := cmd.Context() + worker, err := azurev.LookupWorker(ctx) + if err != nil { + return err + } + if err := azurev.CheckSubscription(worker.SubscriptionID, cfg.AzureSubscriptionID); err != nil { + return err + } + if os.Geteuid() != 0 { + return fmt.Errorf("om scan workload image azure mounts the image and must run as root on the worker") + } + + store, err := openGraphStore(ctx, cfg) + if err != nil { + return err + } + defer store.Close() + + client, err := azurev.NewClient(cfg.AzureSubscriptionID) + if err != nil { + return err + } + scanner := &azurev.ImageScanner{ + API: client, + Worker: azurev.Worker{ + Name: worker.Name, + ResourceGroup: worker.ResourceGroup, + SubscriptionID: worker.SubscriptionID, + Location: worker.Location, + Zone: worker.Zone, + }, + Only: azureImageIDs, + Timeout: azureImageTimeout, + Mount: azurev.NewGuest().MountReadOnly, + Walk: workload.Walk, + OnStart: func(_, name string) { + fmt.Printf("%s: reading the boot image\n", name) + }, + } + wrote := 0 + err = scanner.Scan(ctx, func(res azurev.ImageResult) error { + label := res.Name + if label == "" { + label = res.ID + } + if res.Skipped != "" { + fmt.Printf("%s: skipped (%s)\n", label, res.Skipped) + return nil + } + if len(res.Instances) == 0 { + fmt.Printf("%s: %d package identifiers, no instances launched from this image\n", label, len(res.Packages)) + return nil + } + var failed []error + for _, vm := range res.Instances { + nodeID := azurecol.WorkloadNodeID(cfg.AzureSubscriptionID, cfg.AzureLocation, vm.Name) + if err := store.MergeWorkloadPackages(ctx, nodeID, res.Packages); err != nil { + failed = append(failed, err) + continue + } + fmt.Printf("%s: %d package identifiers\n", groupLabel(vm.ResourceGroup, vm.Name), len(res.Packages)) + wrote++ + } + return errors.Join(failed...) + }) + fmt.Printf("Stored packages on %d workloads.\n", wrote) + return err +} + +func runScanWorkloadImageGCP(cmd *cobra.Command, args []string) error { + cfg := loadConfig() + if cfg.GCPProjectID == "" { + return fmt.Errorf("GCP_PROJECT_ID is required") + } + ctx := cmd.Context() + worker, err := gcpv.LookupWorker(ctx) + if err != nil { + return err + } + if err := gcpv.CheckProject(worker.ProjectID, cfg.GCPProjectID); err != nil { + return err + } + if os.Geteuid() != 0 { + return fmt.Errorf("om scan workload image gcp mounts the image and must run as root on the worker") + } + + store, err := openGraphStore(ctx, cfg) + if err != nil { + return err + } + defer store.Close() + + client, err := gcpv.NewClient(ctx, cfg.GCPProjectID) + if err != nil { + return err + } + scanner := &gcpv.ImageScanner{ + API: client, + Worker: gcpv.Worker{ + Name: worker.Name, + ProjectID: worker.ProjectID, + Zone: worker.Zone, + }, + Only: gcpImageIDs, + Timeout: gcpImageTimeout, + Mount: gcpv.NewGuest().MountReadOnly, + Walk: workload.Walk, + OnStart: func(_, name string) { + fmt.Printf("%s: reading the boot image\n", name) + }, + } + wrote := 0 + err = scanner.Scan(ctx, func(res gcpv.ImageResult) error { + label := res.Name + if label == "" { + label = res.Link + } + if res.Skipped != "" { + fmt.Printf("%s: skipped (%s)\n", label, res.Skipped) + return nil + } + if len(res.Instances) == 0 { + fmt.Printf("%s: %d package identifiers, no instances launched from this image\n", label, len(res.Packages)) + return nil + } + var failed []error + for _, inst := range res.Instances { + nodeID := gcpcol.WorkloadNodeID(cfg.GCPProjectID, inst.Zone, inst.Name) + if err := store.MergeWorkloadPackages(ctx, nodeID, res.Packages); err != nil { + failed = append(failed, err) + continue + } + fmt.Printf("%s: %d package identifiers\n", zoneLabel(inst.Zone, inst.Name), len(res.Packages)) + wrote++ + } + return errors.Join(failed...) + }) + fmt.Printf("Stored packages on %d workloads.\n", wrote) + return err +} + +func init() { + scanWorkloadImageAWSCmd.Flags().DurationVar(&awsImageTimeout, "timeout", 30*time.Minute, "time limit for each image") + scanWorkloadImageAWSCmd.Flags().StringArrayVar(&awsImageIDs, "image", nil, "AMI id or name (repeat to scan several; the default is every owned image and every image an instance in this region uses)") + scanWorkloadImageAzureCmd.Flags().DurationVar(&azureImageTimeout, "timeout", 30*time.Minute, "time limit for each image") + scanWorkloadImageAzureCmd.Flags().StringArrayVar(&azureImageIDs, "image", nil, "image name or resource id (repeat to scan several; the default is every image in this region)") + scanWorkloadImageGCPCmd.Flags().DurationVar(&gcpImageTimeout, "timeout", 30*time.Minute, "time limit for each image") + scanWorkloadImageGCPCmd.Flags().StringArrayVar(&gcpImageIDs, "image", nil, "image name or resource link (repeat to scan several; the default is every project image and every image an instance in this region uses)") + scanWorkloadImageCmd.AddCommand(scanWorkloadImageAWSCmd, scanWorkloadImageAzureCmd, scanWorkloadImageGCPCmd) + scanWorkloadCmd.AddCommand(scanWorkloadImageCmd) +} diff --git a/internal/cmd/scan_workload_test.go b/internal/cmd/scan_workload_test.go index b694fda..b5921cd 100644 --- a/internal/cmd/scan_workload_test.go +++ b/internal/cmd/scan_workload_test.go @@ -45,6 +45,28 @@ func TestScanWorkloadRegistered(t *testing.T) { t.Errorf("gcp help missing %s", action) } } + image := commandByName(t, workloadCmd, "image") + if image.Runnable() { + t.Fatal("om scan workload image runs a scan; name the cloud") + } + awsImage := commandByName(t, image, "aws") + for _, action := range awsv.ImagePermissions { + if !strings.Contains(awsImage.Long, action) { + t.Errorf("image aws help missing %s", action) + } + } + azureImage := commandByName(t, image, "azure") + for _, action := range azurev.ImagePermissions { + if !strings.Contains(azureImage.Long, action) { + t.Errorf("image azure help missing %s", action) + } + } + gcpImage := commandByName(t, image, "gcp") + for _, action := range gcpv.ImagePermissions { + if !strings.Contains(gcpImage.Long, action) { + t.Errorf("image gcp help missing %s", action) + } + } } func commandByName(t *testing.T, parent *cobra.Command, name string) *cobra.Command { diff --git a/internal/graph/packages.go b/internal/graph/packages.go new file mode 100644 index 0000000..1572786 --- /dev/null +++ b/internal/graph/packages.go @@ -0,0 +1,48 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package graph + +import ( + "sort" + "strings" +) + +// UnionPackages returns the sorted unique combination of existing and added. +// An identifier that is only in existing stays. That is how a package installed +// on one instance after boot is kept off the shared boot image. +func UnionPackages(existing, added []string) []string { + seen := map[string]struct{}{} + var out []string + for _, id := range append(append([]string{}, existing...), added...) { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Strings(out) + return out +} + +func packageList(v any) []string { + switch items := v.(type) { + case []string: + return items + case []any: + out := make([]string, 0, len(items)) + for _, item := range items { + s, ok := item.(string) + if ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} diff --git a/internal/graph/packages_test.go b/internal/graph/packages_test.go new file mode 100644 index 0000000..a694a86 --- /dev/null +++ b/internal/graph/packages_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package graph_test + +import ( + "slices" + "testing" + + "github.com/OpenSourceOM/core/internal/graph" +) + +func TestUnionPackagesKeepsInstanceOnlyPackages(t *testing.T) { + image := []string{"pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1"} + instance := []string{ + "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1", + "pkg:deb/ubuntu/nginx@1.18.0-6ubuntu14", + } + got := graph.UnionPackages(instance, image) + want := []string{ + "pkg:deb/ubuntu/nginx@1.18.0-6ubuntu14", + "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1", + } + if !slices.Equal(got, want) { + t.Fatalf("instance packages = %#v", got) + } + + sibling := graph.UnionPackages(nil, image) + if slices.Contains(sibling, "pkg:deb/ubuntu/nginx@1.18.0-6ubuntu14") { + t.Fatalf("sibling received an instance package: %#v", sibling) + } + if !slices.Equal(sibling, image) { + t.Fatalf("sibling = %#v", sibling) + } +} diff --git a/internal/graph/store.go b/internal/graph/store.go index 8b93aec..0c4a7d2 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -6,6 +6,7 @@ package graph import ( "context" "encoding/json" + "errors" "fmt" "github.com/jackc/pgx/v5" @@ -393,6 +394,23 @@ func (s *Store) InternetReachableWorkloadIDs(ctx context.Context) ([]string, err return ids, rows.Err() } +// MergeWorkloadPackages adds package identifiers on an existing workload. +// Identifiers already stored stay, including ones the added list does not contain. +// A later inventory upsert that omits packages keeps the merged list. +func (s *Store) MergeWorkloadPackages(ctx context.Context, id string, packages []string) error { + node, err := s.GetNode(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("workload %s is not in the graph", id) + } + return err + } + if node.Type != NodeWorkload { + return fmt.Errorf("workload %s is not in the graph", id) + } + return s.SetWorkloadPackages(ctx, id, UnionPackages(packageList(node.Properties["packages"]), packages)) +} + // SetWorkloadPackages stores package identifiers on an existing workload. // A later inventory upsert that omits packages keeps this list. func (s *Store) SetWorkloadPackages(ctx context.Context, id string, packages []string) error { diff --git a/internal/graph/store_test.go b/internal/graph/store_test.go index 269d003..3801f93 100644 --- a/internal/graph/store_test.go +++ b/internal/graph/store_test.go @@ -173,6 +173,50 @@ func TestUpsertKeepsPackagesWhenScanOmitsThem(t *testing.T) { } } +func TestMergeWorkloadPackagesKeepsInstanceOnlyPackages(t *testing.T) { + ctx := context.Background() + store := openQueryFixture(t) + const ( + web = "aws:111122223333:us-east-1:workload:i-web" + api = "aws:111122223333:us-east-1:workload:i-api" + image = "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1" + extra = "pkg:deb/ubuntu/nginx@1.18.0-6ubuntu14" + ) + if err := store.UpsertBatch(ctx, graph.Batch{Nodes: []graph.Node{ + { + ID: web, Type: graph.NodeWorkload, Name: "web", Provider: "aws", + Region: "us-east-1", AccountID: "111122223333", + Properties: map[string]any{"image": "ami-golden", "packages": []any{image, extra}}, + }, + { + ID: api, Type: graph.NodeWorkload, Name: "api", Provider: "aws", + Region: "us-east-1", AccountID: "111122223333", + Properties: map[string]any{"image": "ami-golden"}, + }, + }}); err != nil { + t.Fatal(err) + } + if err := store.MergeWorkloadPackages(ctx, web, []string{image}); err != nil { + t.Fatal(err) + } + if err := store.MergeWorkloadPackages(ctx, api, []string{image}); err != nil { + t.Fatal(err) + } + webNode := mustNode(t, ctx, store, web) + if webNode.Properties["image"] != "ami-golden" { + t.Fatalf("image = %#v", webNode.Properties["image"]) + } + if joinPackages(t, webNode.Properties) != extra+" "+image { + t.Fatalf("web packages = %#v", webNode.Properties["packages"]) + } + if joinPackages(t, mustNode(t, ctx, store, api).Properties) != image { + t.Fatalf("api packages = %#v", mustNode(t, ctx, store, api).Properties["packages"]) + } + if err := store.MergeWorkloadPackages(ctx, "missing", []string{image}); err == nil { + t.Fatal("expected a missing workload to fail") + } +} + func mustNode(t *testing.T, ctx context.Context, store *graph.Store, id string) graph.Node { t.Helper() node, err := store.GetNode(ctx, id) diff --git a/internal/workload/aws/image.go b/internal/workload/aws/image.go new file mode 100644 index 0000000..3f29df8 --- /dev/null +++ b/internal/workload/aws/image.go @@ -0,0 +1,390 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package aws + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/OpenSourceOM/core/internal/workload" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/smithy-go" +) + +// ImagePermissions are the IAM actions om scan workload image adds. +// The AMI's own snapshot is not deleted. kms:CreateGrant and kms:Decrypt are +// the grant an encrypted image snapshot needs when the volume is created. +var ImagePermissions = []string{ + "ec2:DescribeInstances", + "ec2:DescribeImages", + "ec2:CreateVolume", + "ec2:DescribeVolumes", + "ec2:AttachVolume", + "ec2:DetachVolume", + "ec2:DeleteVolume", + "ec2:CreateTags", + "kms:CreateGrant", + "kms:Decrypt", + "kms:DescribeKey", + "kms:GenerateDataKeyWithoutPlaintext", + "kms:ReEncryptFrom", + "kms:ReEncryptTo", +} + +// ImageInstance is an EC2 instance launched from one boot image. +type ImageInstance struct { + ID string + Name string +} + +// ImageResult is one AMI after the volume created for the read is deleted. +// Instances lists every instance in the region launched from this image. +// Packages is the image walk only. A package that exists only on an instance +// disk is not included. +type ImageResult struct { + ImageID string + Name string + Packages []string + Skipped string + Instances []ImageInstance +} + +type bootImage struct { + image ec2types.Image + denied bool +} + +// ScanImages reads each boot image once and calls write after the volume +// created for that image is deleted. Owned images are read even when no +// instance is running. A skip leaves packages unchanged. +func (s *Scanner) ScanImages(ctx context.Context, only []string, write func(ImageResult) error) error { + if s.Worker.InstanceID == "" || s.Worker.AvailabilityZone == "" { + return fmt.Errorf("worker instance id and availability zone are required") + } + if s.API == nil || s.Mount == nil || s.Walk == nil { + return fmt.Errorf("workload scan is missing an EC2 client, mount, or package walker") + } + instances, err := s.describe(ctx, nil) + if err != nil { + return err + } + var worker ec2types.Instance + found := false + for _, inst := range instances { + if aws.ToString(inst.InstanceId) == s.Worker.InstanceID { + worker = inst + found = true + break + } + } + if !found { + return fmt.Errorf("worker %s is not in this region", s.Worker.InstanceID) + } + images, err := s.bootImages(ctx, instances, only) + if err != nil { + return err + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 30 * time.Minute + } + var failed []error + for _, img := range images { + ictx, cancel := context.WithTimeout(ctx, timeout) + res, scanErr := s.scanImage(ictx, img, instances, worker) + cancel() + if scanErr != nil { + failed = append(failed, scanErr) + continue + } + if write != nil { + if err := write(res); err != nil { + failed = append(failed, fmt.Errorf("%s: %w", res.ImageID, err)) + } + } + } + return errors.Join(append(failed, s.cleanupErrs...)...) +} + +func (s *Scanner) bootImages(ctx context.Context, instances []ec2types.Instance, only []string) ([]bootImage, error) { + owned, err := s.describeImages(ctx, &ec2.DescribeImagesInput{Owners: []string{"self"}}) + if err != nil { + return nil, fmt.Errorf("describe images: %w", err) + } + byID := map[string]bootImage{} + for _, img := range owned { + id := aws.ToString(img.ImageId) + if id == "" { + continue + } + byID[id] = bootImage{image: img} + } + var missing []string + seen := map[string]bool{} + for _, inst := range instances { + id := aws.ToString(inst.ImageId) + if id == "" || seen[id] { + continue + } + if _, ok := byID[id]; ok { + continue + } + seen[id] = true + missing = append(missing, id) + } + for _, want := range only { + if !strings.HasPrefix(want, "ami-") || seen[want] { + continue + } + if _, ok := byID[want]; ok { + continue + } + seen[want] = true + missing = append(missing, want) + } + for _, id := range missing { + imgs, err := s.describeImages(ctx, &ec2.DescribeImagesInput{ImageIds: []string{id}}) + if err != nil || len(imgs) == 0 { + if err != nil && !cannotSnapshotImage(err) && !isNotFound(err) { + return nil, fmt.Errorf("describe image %s: %w", id, err) + } + byID[id] = bootImage{image: ec2types.Image{ImageId: aws.String(id)}, denied: true} + continue + } + got := aws.ToString(imgs[0].ImageId) + if got == "" { + got = id + } + byID[got] = bootImage{image: imgs[0]} + } + + var images []bootImage + for _, img := range byID { + images = append(images, img) + } + slices.SortFunc(images, func(a, b bootImage) int { + return strings.Compare(aws.ToString(a.image.ImageId), aws.ToString(b.image.ImageId)) + }) + return selectBootImages(images, only) +} + +func selectBootImages(images []bootImage, only []string) ([]bootImage, error) { + if len(only) == 0 { + return images, nil + } + var out []bootImage + for _, want := range only { + var matched []bootImage + for _, img := range images { + if aws.ToString(img.image.ImageId) == want || aws.ToString(img.image.Name) == want { + matched = append(matched, img) + } + } + if len(matched) == 0 { + return nil, fmt.Errorf("image %s is not in this region", want) + } + if len(matched) > 1 { + return nil, fmt.Errorf("image %s matches more than one image; pass the image id", want) + } + out = append(out, matched[0]) + } + return out, nil +} + +func (s *Scanner) describeImages(ctx context.Context, input *ec2.DescribeImagesInput) ([]ec2types.Image, error) { + var out []ec2types.Image + for { + page, err := s.API.DescribeImages(ctx, input) + if err != nil { + return nil, err + } + out = append(out, page.Images...) + if page.NextToken == nil || *page.NextToken == "" { + return out, nil + } + input.NextToken = page.NextToken + } +} + +func (s *Scanner) scanImage(ctx context.Context, item bootImage, instances []ec2types.Instance, worker ec2types.Instance) (ImageResult, error) { + img := item.image + id := aws.ToString(img.ImageId) + res := ImageResult{ImageID: id, Name: aws.ToString(img.Name), Instances: instancesUsing(id, instances)} + if item.denied { + res.Skipped = workload.ErrImageDenied.Error() + return res, nil + } + if imageWindows(img) { + res.Skipped = "Windows" + return res, nil + } + if img.State != "" && img.State != ec2types.ImageStateAvailable { + res.Skipped = "not available" + return res, nil + } + snapID, skip := imageRootSnapshot(img) + if skip != "" { + res.Skipped = skip + return res, nil + } + if s.OnStart != nil { + s.OnStart(id, res.Name) + } + + volID, err := s.createImageVolume(ctx, snapID, id) + if volID != "" { + defer s.deleteVolume(volID) + } + if err != nil { + if cannotSnapshotImage(err) { + res.Skipped = workload.ErrImageDenied.Error() + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", id, err) + } + if err := s.waitVolume(ctx, volID, ec2types.VolumeStateAvailable); err != nil { + return ImageResult{}, fmt.Errorf("%s: %w", id, err) + } + device := freeDevice(worker.BlockDeviceMappings) + if device == "" { + return ImageResult{}, fmt.Errorf("%s: no free device name on the worker", id) + } + _, err = s.API.AttachVolume(ctx, &ec2.AttachVolumeInput{ + Device: aws.String(device), + InstanceId: aws.String(s.Worker.InstanceID), + VolumeId: aws.String(volID), + }) + if err != nil { + return ImageResult{}, fmt.Errorf("%s: attach volume: %w", id, err) + } + defer s.detach(volID) + if err := s.waitAttached(ctx, volID); err != nil { + return ImageResult{}, fmt.Errorf("%s: %w", id, err) + } + + root, unmount, err := s.Mount(ctx, volID) + if err != nil { + if reason, ok := skipReason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", id, err) + } + defer func() { s.note(unmount()) }() + + pkgs, err := s.Walk(root) + if err != nil { + if reason, ok := skipReason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", id, err) + } + res.Packages = pkgs + return res, nil +} + +func (s *Scanner) createImageVolume(ctx context.Context, snapID, imageID string) (string, error) { + out, err := s.API.CreateVolume(ctx, &ec2.CreateVolumeInput{ + AvailabilityZone: aws.String(s.Worker.AvailabilityZone), + SnapshotId: aws.String(snapID), + VolumeType: ec2types.VolumeTypeGp3, + TagSpecifications: []ec2types.TagSpecification{{ + ResourceType: ec2types.ResourceTypeVolume, + Tags: imageVolumeTags(imageID), + }}, + }) + if err != nil { + return "", fmt.Errorf("create volume: %w", err) + } + id := aws.ToString(out.VolumeId) + if id == "" { + return "", fmt.Errorf("create volume returned an empty volume id") + } + return id, nil +} + +func imageVolumeTags(imageID string) []ec2types.Tag { + return []ec2types.Tag{ + {Key: aws.String("opensourceom:scan"), Value: aws.String("workload")}, + {Key: aws.String("opensourceom:image"), Value: aws.String(imageID)}, + {Key: aws.String("Name"), Value: aws.String("opensourceom-workload-" + imageID)}, + } +} + +func instancesUsing(imageID string, instances []ec2types.Instance) []ImageInstance { + var out []ImageInstance + for _, inst := range instances { + if aws.ToString(inst.ImageId) != imageID { + continue + } + out = append(out, ImageInstance{ID: aws.ToString(inst.InstanceId), Name: instanceName(inst)}) + } + slices.SortFunc(out, func(a, b ImageInstance) int { return strings.Compare(a.ID, b.ID) }) + return out +} + +func imageRootSnapshot(img ec2types.Image) (string, string) { + if img.RootDeviceType == ec2types.DeviceTypeInstanceStore { + return "", "instance store root" + } + if img.RootDeviceType != "" && img.RootDeviceType != ec2types.DeviceTypeEbs { + return "", "no EBS root snapshot" + } + root := aws.ToString(img.RootDeviceName) + if root == "" { + return "", "no EBS root snapshot" + } + for _, mapping := range img.BlockDeviceMappings { + if aws.ToString(mapping.DeviceName) != root { + continue + } + if mapping.Ebs == nil { + return "", "no EBS root snapshot" + } + id := aws.ToString(mapping.Ebs.SnapshotId) + if id == "" { + return "", "no EBS root snapshot" + } + return id, "" + } + return "", "no EBS root snapshot" +} + +func imageWindows(img ec2types.Image) bool { + if img.Platform == ec2types.PlatformValuesWindows { + return true + } + return strings.Contains(strings.ToLower(aws.ToString(img.PlatformDetails)), "windows") +} + +func cannotSnapshotImage(err error) bool { + var api smithy.APIError + if errors.As(err, &api) { + switch api.ErrorCode() { + case "UnauthorizedOperation", "OperationNotPermitted", "OptInRequired", "AuthFailure", "InvalidSnapshot.NotFound", "InvalidAMIID.NotFound": + return true + } + } + msg := strings.ToLower(err.Error()) + for _, code := range []string{ + "unauthorizedoperation", + "operationnotpermitted", + "optinrequired", + "authfailure", + "invalidsnapshot.notfound", + "invalidamiid.notfound", + } { + if strings.Contains(msg, code) { + return true + } + } + return false +} diff --git a/internal/workload/aws/image_test.go b/internal/workload/aws/image_test.go new file mode 100644 index 0000000..7ac42c7 --- /dev/null +++ b/internal/workload/aws/image_test.go @@ -0,0 +1,260 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package aws + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/OpenSourceOM/core/internal/workload" + "github.com/aws/aws-sdk-go-v2/aws" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/smithy-go" +) + +func TestImagePermissionsDoNotDeleteTheImageSnapshot(t *testing.T) { + if !slices.Contains(ImagePermissions, "ec2:DescribeImages") || !slices.Contains(ImagePermissions, "ec2:CreateVolume") { + t.Fatalf("permissions = %v", ImagePermissions) + } + for _, action := range []string{"ec2:CreateSnapshot", "ec2:DeleteSnapshot"} { + if slices.Contains(ImagePermissions, action) { + t.Errorf("image scan must not %s", action) + } + } +} + +func TestScanImageOnceAppliesTheSamePackages(t *testing.T) { + fake := newFake(imageInstances()) + fake.images = []ec2types.Image{testAMI("ami-golden", "golden", "snap-golden"), testAMI("ami-worker", "worker", "snap-worker")} + var mounts int + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + mounts++ + dir := t.TempDir() + writeRoot(t, dir) + return dir, func() error { return nil }, nil + }, workload.Walk) + + var got []ImageResult + err := scanner.ScanImages(context.Background(), []string{"ami-golden"}, func(res ImageResult) error { + got = append(got, res) + return nil + }) + if err != nil { + t.Fatal(err) + } + if mounts != 1 || len(got) != 1 || got[0].Skipped != "" { + t.Fatalf("mounts=%d results=%#v", mounts, got) + } + if len(got[0].Instances) != 2 || got[0].Instances[0].ID != "i-api" || got[0].Instances[1].ID != "i-web" { + t.Fatalf("instances = %#v", got[0].Instances) + } + joined := strings.Join(got[0].Packages, "\n") + if !strings.Contains(joined, "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1") || strings.Contains(joined, "nginx") { + t.Fatalf("packages =\n%s", joined) + } + if slices.Contains(fake.log, "CreateSnapshot") || slices.Contains(fake.log, "DeleteSnapshot") { + t.Fatalf("image snapshot was created or deleted: %v", fake.log) + } + if len(fake.volInputs) != 1 || aws.ToString(fake.volInputs[0].SnapshotId) != "snap-golden" { + t.Fatalf("volumes = %#v", fake.volInputs) + } + if tagValue(fake.volInputs[0].TagSpecifications[0].Tags, "opensourceom:image") != "ami-golden" { + t.Fatalf("tags = %#v", fake.volInputs[0].TagSpecifications) + } + if len(fake.vols) != 0 { + t.Fatal("volume left behind") + } +} + +func TestScanImageReadsAnOwnedImageWithNoInstances(t *testing.T) { + fake := newFake(imageInstances()) + fake.images = []ec2types.Image{testAMI("ami-idle", "idle", "snap-idle"), testAMI("ami-worker", "worker", "snap-worker")} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + dir := t.TempDir() + writeRoot(t, dir) + return dir, func() error { return nil }, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.ScanImages(context.Background(), []string{"ami-idle"}, func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || len(got[0].Instances) != 0 || got[0].Skipped != "" || len(got[0].Packages) == 0 { + t.Fatalf("results = %#v", got) + } + if len(fake.volInputs) != 1 || aws.ToString(fake.volInputs[0].SnapshotId) != "snap-idle" { + t.Fatalf("volumes = %#v", fake.volInputs) + } +} + +func TestScanImageReadsASharedImageOnce(t *testing.T) { + instances := imageInstances() + sharedInst := linuxInstance("i-shared", "shared", "vol-shared") + sharedInst.ImageId = aws.String("ami-shared") + fake := newFake(append(instances, sharedInst)) + fake.images = []ec2types.Image{testAMI("ami-worker", "worker", "snap-worker")} + fake.shared = []ec2types.Image{testAMI("ami-shared", "shared", "snap-shared")} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + dir := t.TempDir() + writeRoot(t, dir) + return dir, func() error { return nil }, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.ScanImages(context.Background(), []string{"ami-shared"}, func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || len(got[0].Instances) != 1 || got[0].Instances[0].ID != "i-shared" { + t.Fatalf("results = %#v", got) + } + if len(fake.volInputs) != 1 || aws.ToString(fake.volInputs[0].SnapshotId) != "snap-shared" { + t.Fatalf("volumes = %#v", fake.volInputs) + } +} + +func TestScanImageReportsMarketplaceItCannotSnapshot(t *testing.T) { + instances := imageInstances() + market := linuxInstance("i-market", "market", "vol-market") + market.ImageId = aws.String("ami-market") + fake := newFake(append(instances, market)) + fake.images = []ec2types.Image{testAMI("ami-worker", "worker", "snap-worker")} + fake.shared = []ec2types.Image{testAMI("ami-market", "market", "snap-market")} + fake.denySnap = map[string]error{"snap-market": apiErr{code: "UnauthorizedOperation"}} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("marketplace image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.ScanImages(context.Background(), []string{"ami-market"}, func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != workload.ErrImageDenied.Error() { + t.Fatalf("results = %#v", got) + } + if len(fake.vols) != 0 || slices.Contains(fake.log, "DeleteSnapshot") { + t.Fatalf("log=%v vols=%d", fake.log, len(fake.vols)) + } +} + +func TestScanImageReportsAnImageItCannotDescribe(t *testing.T) { + instances := imageInstances() + hidden := linuxInstance("i-hidden", "hidden", "vol-hidden") + hidden.ImageId = aws.String("ami-hidden") + fake := newFake(append(instances, hidden)) + fake.images = []ec2types.Image{testAMI("ami-worker", "worker", "snap-worker")} + fake.imageErr = map[string]error{"ami-hidden": apiErr{code: "InvalidAMIID.NotFound"}} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("hidden image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.ScanImages(context.Background(), []string{"ami-hidden"}, func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != workload.ErrImageDenied.Error() || len(got[0].Instances) != 1 { + t.Fatalf("results = %#v", got) + } + if slices.Contains(fake.log, "CreateVolume") { + t.Fatal("volume created for an image the account cannot describe") + } +} + +func TestScanImageSkipsWindowsAndInstanceStore(t *testing.T) { + fake := newFake(imageInstances()) + windows := testAMI("ami-win", "win", "snap-win") + windows.Platform = ec2types.PlatformValuesWindows + store := testAMI("ami-store", "store", "") + store.RootDeviceType = ec2types.DeviceTypeInstanceStore + store.BlockDeviceMappings = nil + fake.images = []ec2types.Image{windows, store, testAMI("ami-worker", "worker", "snap-worker")} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("skipped image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.ScanImages(context.Background(), []string{"ami-win", "ami-store"}, func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Skipped != "Windows" || got[1].Skipped != "instance store root" { + t.Fatalf("results = %#v", got) + } + if slices.Contains(fake.log, "CreateVolume") { + t.Fatal("volume created for a skipped image") + } +} + +func TestScanImageDeletesVolumeWhenWalkFails(t *testing.T) { + fake := newFake(imageInstances()) + fake.images = []ec2types.Image{testAMI("ami-golden", "golden", "snap-golden"), testAMI("ami-worker", "worker", "snap-worker")} + scanner := testScanner(t, fake, func(context.Context, string) (string, func() error, error) { + return t.TempDir(), func() error { return nil }, nil + }, func(string) ([]string, error) { + return nil, errString("read rpm database: corrupt") + }) + err := scanner.ScanImages(context.Background(), []string{"ami-golden"}, nil) + if err == nil || !strings.Contains(err.Error(), "corrupt") { + t.Fatalf("err = %v", err) + } + assertOrder(t, fake.log, "CreateVolume", "AttachVolume", "DetachVolume", "DeleteVolume") + if slices.Contains(fake.log, "DeleteSnapshot") || len(fake.vols) != 0 { + t.Fatalf("log=%v vols=%d", fake.log, len(fake.vols)) + } +} + +func imageInstances() []ec2types.Instance { + worker := linuxInstance("i-worker", "worker", "vol-worker") + worker.ImageId = aws.String("ami-worker") + worker.BlockDeviceMappings = append(worker.BlockDeviceMappings, ec2types.InstanceBlockDeviceMapping{ + DeviceName: aws.String("/dev/sdf"), + Ebs: &ec2types.EbsInstanceBlockDevice{VolumeId: aws.String("vol-extra")}, + }) + web := linuxInstance("i-web", "web", "vol-web") + web.ImageId = aws.String("ami-golden") + api := linuxInstance("i-api", "api", "vol-api") + api.ImageId = aws.String("ami-golden") + return []ec2types.Instance{worker, web, api} +} + +func testAMI(id, name, snap string) ec2types.Image { + img := ec2types.Image{ + ImageId: aws.String(id), + Name: aws.String(name), + State: ec2types.ImageStateAvailable, + RootDeviceType: ec2types.DeviceTypeEbs, + RootDeviceName: aws.String("/dev/xvda"), + } + if snap != "" { + img.BlockDeviceMappings = []ec2types.BlockDeviceMapping{{ + DeviceName: aws.String("/dev/xvda"), + Ebs: &ec2types.EbsBlockDevice{SnapshotId: aws.String(snap)}, + }} + } + return img +} + +type apiErr struct{ code string } + +func (e apiErr) Error() string { return e.code } +func (e apiErr) ErrorCode() string { return e.code } +func (e apiErr) ErrorMessage() string { return e.code } +func (e apiErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient } + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/workload/aws/scan.go b/internal/workload/aws/scan.go index 9774a66..9935556 100644 --- a/internal/workload/aws/scan.go +++ b/internal/workload/aws/scan.go @@ -68,6 +68,7 @@ type Scanner struct { type ec2API interface { DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) + DescribeImages(context.Context, *ec2.DescribeImagesInput, ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) CreateSnapshot(context.Context, *ec2.CreateSnapshotInput, ...func(*ec2.Options)) (*ec2.CreateSnapshotOutput, error) DescribeSnapshots(context.Context, *ec2.DescribeSnapshotsInput, ...func(*ec2.Options)) (*ec2.DescribeSnapshotsOutput, error) CreateVolume(context.Context, *ec2.CreateVolumeInput, ...func(*ec2.Options)) (*ec2.CreateVolumeOutput, error) @@ -453,7 +454,7 @@ func isNotFound(err error) bool { return false } switch api.ErrorCode() { - case "InvalidSnapshot.NotFound", "InvalidVolume.NotFound": + case "InvalidSnapshot.NotFound", "InvalidVolume.NotFound", "InvalidAMIID.NotFound", "InvalidAMIID.Malformed": return true default: return false diff --git a/internal/workload/aws/scan_test.go b/internal/workload/aws/scan_test.go index cc1530b..58231f0 100644 --- a/internal/workload/aws/scan_test.go +++ b/internal/workload/aws/scan_test.go @@ -340,6 +340,10 @@ type fakeEC2 struct { attachInputs []*ec2.AttachVolumeInput stuckSnapshot bool delaySnapshot bool + images []ec2types.Image + shared []ec2types.Image + imageErr map[string]error + denySnap map[string]error } func newFake(instances []ec2types.Instance) *fakeEC2 { @@ -407,8 +411,37 @@ func (f *fakeEC2) DescribeSnapshots(_ context.Context, in *ec2.DescribeSnapshots }, nil } +func (f *fakeEC2) DescribeImages(_ context.Context, in *ec2.DescribeImagesInput, _ ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + f.log = append(f.log, "DescribeImages") + if in == nil { + in = &ec2.DescribeImagesInput{} + } + if len(in.ImageIds) > 0 { + for _, id := range in.ImageIds { + if err := f.imageErr[id]; err != nil { + return nil, err + } + } + want := map[string]bool{} + for _, id := range in.ImageIds { + want[id] = true + } + var imgs []ec2types.Image + for _, img := range append(append([]ec2types.Image{}, f.images...), f.shared...) { + if want[aws.ToString(img.ImageId)] { + imgs = append(imgs, img) + } + } + return &ec2.DescribeImagesOutput{Images: imgs}, nil + } + return &ec2.DescribeImagesOutput{Images: f.images}, nil +} + func (f *fakeEC2) CreateVolume(_ context.Context, in *ec2.CreateVolumeInput, _ ...func(*ec2.Options)) (*ec2.CreateVolumeOutput, error) { f.log = append(f.log, "CreateVolume") + if err := f.denySnap[aws.ToString(in.SnapshotId)]; err != nil { + return nil, err + } f.volInputs = append(f.volInputs, in) f.volN++ id := fmt.Sprintf("vol-scan-%d", f.volN) diff --git a/internal/workload/azure/client.go b/internal/workload/azure/client.go index dc07e8e..ce2ae4f 100644 --- a/internal/workload/azure/client.go +++ b/internal/workload/azure/client.go @@ -22,10 +22,11 @@ import ( // Client snapshots managed OS disks and attaches the copy to the worker. type Client struct { - sub string - vms *armcompute.VirtualMachinesClient - snaps *armcompute.SnapshotsClient - disks *armcompute.DisksClient + sub string + vms *armcompute.VirtualMachinesClient + snaps *armcompute.SnapshotsClient + disks *armcompute.DisksClient + images *armcompute.ImagesClient } var _ API = (*Client)(nil) @@ -48,7 +49,11 @@ func NewClient(subscriptionID string) (*Client, error) { if err != nil { return nil, err } - return &Client{sub: subscriptionID, vms: vms, snaps: snaps, disks: disks}, nil + images, err := armcompute.NewImagesClient(subscriptionID, cred, nil) + if err != nil { + return nil, err + } + return &Client{sub: subscriptionID, vms: vms, snaps: snaps, disks: disks, images: images}, nil } func (c *Client) List(ctx context.Context, location string) ([]Machine, error) { @@ -228,6 +233,16 @@ func machineFrom(vm *armcompute.VirtualMachine) (Machine, bool) { } machine.ProvisioningState = value(vm.Properties.ProvisioningState) profile := vm.Properties.StorageProfile + if ref := profile.ImageReference; ref != nil { + key, id, source, publisher, offer, sku, version := imageRefOf(ref) + machine.ImageKey = key + machine.ImageID = id + machine.ImageSource = source + machine.Publisher = publisher + machine.Offer = offer + machine.SKU = sku + machine.ImageVersion = version + } if profile.OSDisk != nil { if profile.OSDisk.OSType != nil { machine.OS = strings.ToLower(string(*profile.OSDisk.OSType)) diff --git a/internal/workload/azure/image.go b/internal/workload/azure/image.go new file mode 100644 index 0000000..ac4d422 --- /dev/null +++ b/internal/workload/azure/image.go @@ -0,0 +1,519 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package azure + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" + "github.com/OpenSourceOM/core/internal/workload" +) + +// ImagePermissions are the Azure RBAC actions om scan workload image azure adds. +// The image itself is not deleted. om scan azure does not gain these actions. +var ImagePermissions = []string{ + "Microsoft.Compute/virtualMachines/read", + "Microsoft.Compute/images/read", + "Microsoft.Compute/galleries/images/versions/read", + "Microsoft.Compute/disks/write", + "Microsoft.Compute/disks/delete", + "Microsoft.Compute/virtualMachines/attachDetachDataDisks/action", +} + +// ImageVM is a virtual machine launched from one boot image. +type ImageVM struct { + Name string + ResourceGroup string +} + +// BootImage is one managed, gallery, community, or marketplace image. +type BootImage struct { + Key string + ID string + Name string + Location string + OS string + Source string + Status string + Instances []ImageVM +} + +// ImageResult is one boot image after the disk created for the read is deleted. +// Packages is the image walk only. +type ImageResult struct { + BootImage + Packages []string + Skipped string +} + +// ImageAPI is the image-disk lifecycle. Create calls wait until the disk is ready. +// A non-empty name is deleted even when create returns an error. +type ImageAPI interface { + ListBootImages(ctx context.Context, worker Worker) ([]BootImage, []int32, error) + CreateDiskFromImage(ctx context.Context, group, location string, image BootImage, zones []string) (string, error) + DeleteDisk(ctx context.Context, group, name string) error + Attach(ctx context.Context, group, vm, diskName string, lun int32) error + Detach(ctx context.Context, group, vm, diskName string) error +} + +var _ ImageAPI = (*Client)(nil) + +// ImageScanner reads each boot image once and deletes the disk it created. +type ImageScanner struct { + API ImageAPI + Worker Worker + Only []string + Timeout time.Duration + Mount func(ctx context.Context, lun string) (root string, cleanup func() error, err error) + Walk func(root string) ([]string, error) + OnStart func(key, name string) + + cleanupErrs []error +} + +// Scan reads each Linux boot image in the worker's region and calls write +// after that image's disk is deleted. A skip leaves packages unchanged. +func (s *ImageScanner) Scan(ctx context.Context, write func(ImageResult) error) error { + if s.Worker.Name == "" || s.Worker.ResourceGroup == "" || s.Worker.Location == "" { + return fmt.Errorf("worker name, resource group, and location are required") + } + if s.API == nil || s.Mount == nil || s.Walk == nil { + return fmt.Errorf("workload scan is missing an Azure client, mount, or package walker") + } + images, luns, err := s.API.ListBootImages(ctx, s.Worker) + if err != nil { + return err + } + images, err = selectBootImages(images, s.Only) + if err != nil { + return err + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 30 * time.Minute + } + var failed []error + for _, img := range images { + ictx, cancel := context.WithTimeout(ctx, timeout) + res, scanErr := s.scanImage(ictx, img, luns) + cancel() + if scanErr != nil { + failed = append(failed, scanErr) + continue + } + if write != nil { + if err := write(res); err != nil { + failed = append(failed, fmt.Errorf("%s: %w", img.Key, err)) + } + } + } + return errors.Join(append(failed, s.cleanupErrs...)...) +} + +func (s *ImageScanner) scanImage(ctx context.Context, img BootImage, luns []int32) (ImageResult, error) { + res := ImageResult{BootImage: img} + if skip := skipBootImage(img); skip != "" { + res.Skipped = skip + return res, nil + } + lun, ok := freeLUN(luns) + if !ok { + return ImageResult{}, fmt.Errorf("%s: no free LUN on the worker", imageLabel(img)) + } + if s.OnStart != nil { + s.OnStart(img.Key, img.Name) + } + group := s.Worker.ResourceGroup + disk, err := s.API.CreateDiskFromImage(ctx, group, normalizeLocation(s.Worker.Location), img, s.Worker.zones()) + if disk != "" { + defer s.deleteDisk(group, disk) + } + if err != nil { + if cannotSnapshotImage(err) { + res.Skipped = workload.ErrImageDenied.Error() + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + if err := s.API.Attach(ctx, group, s.Worker.Name, disk, lun); err != nil { + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + defer s.detach(group, disk) + + root, unmount, err := s.Mount(ctx, fmt.Sprintf("%d", lun)) + if err != nil { + if reason, ok := workload.Reason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + defer func() { s.note(unmount()) }() + + pkgs, err := s.Walk(root) + if err != nil { + if reason, ok := workload.Reason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + res.Packages = pkgs + return res, nil +} + +func (s *ImageScanner) detach(group, disk string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := s.API.Detach(ctx, group, s.Worker.Name, disk); err != nil && !isNotFound(err) { + s.note(fmt.Errorf("detach disk %s: %w", disk, err)) + } +} + +func (s *ImageScanner) deleteDisk(group, name string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := s.API.DeleteDisk(ctx, group, name); err != nil && !isNotFound(err) { + s.note(fmt.Errorf("delete disk %s: %w", name, err)) + } +} + +func (s *ImageScanner) note(err error) { + if err != nil { + s.cleanupErrs = append(s.cleanupErrs, err) + } +} + +func skipBootImage(img BootImage) string { + if strings.EqualFold(img.OS, "windows") { + return "Windows" + } + if img.Status != "" && !strings.EqualFold(img.Status, "Succeeded") { + return "not available" + } + if img.ID == "" { + return workload.ErrImageDenied.Error() + } + return "" +} + +func selectBootImages(images []BootImage, only []string) ([]BootImage, error) { + if len(only) == 0 { + return images, nil + } + var out []BootImage + for _, want := range only { + var matched []BootImage + for _, img := range images { + if strings.EqualFold(img.Key, want) || strings.EqualFold(img.ID, want) || strings.EqualFold(img.Name, want) { + matched = append(matched, img) + } + } + if len(matched) == 0 { + return nil, fmt.Errorf("image %s is not in this region", want) + } + if len(matched) > 1 { + return nil, fmt.Errorf("image %s matches more than one image; pass the image id", want) + } + out = append(out, matched[0]) + } + return out, nil +} + +func imageLabel(img BootImage) string { + if img.Name != "" && !strings.EqualFold(img.Name, img.Key) { + return img.Name + } + if img.ID != "" { + return img.ID + } + return img.Key +} + +// ListBootImages returns managed images in the worker's region, images that +// virtual machines there were launched from, and the worker's data-disk LUNs. +func (c *Client) ListBootImages(ctx context.Context, worker Worker) ([]BootImage, []int32, error) { + if worker.Name == "" || worker.ResourceGroup == "" || worker.Location == "" { + return nil, nil, fmt.Errorf("worker name, resource group, and location are required") + } + machines, err := c.List(ctx, normalizeLocation(worker.Location)) + if err != nil { + return nil, nil, err + } + found, err := findMachine(machines, worker.ResourceGroup+"/"+worker.Name) + if err != nil { + return nil, nil, fmt.Errorf("worker %s/%s is not in %s", worker.ResourceGroup, worker.Name, normalizeLocation(worker.Location)) + } + listed, err := c.listManagedImages(ctx) + if err != nil { + return nil, nil, err + } + return assembleBootImages(worker.Location, c.sub, machines, listed), found.DataLUNs, nil +} + +func (c *Client) listManagedImages(ctx context.Context) ([]BootImage, error) { + if c.images == nil { + return nil, fmt.Errorf("azure image client is not configured") + } + pager := c.images.NewListPager(nil) + var out []BootImage + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list images: %w", err) + } + for _, img := range page.Value { + item, ok := bootImageFrom(img) + if ok { + out = append(out, item) + } + } + } + return out, nil +} + +func bootImageFrom(img *armcompute.Image) (BootImage, bool) { + if img == nil || img.ID == nil || *img.ID == "" { + return BootImage{}, false + } + item := BootImage{ + ID: *img.ID, + Key: strings.ToLower(*img.ID), + Name: value(img.Name), + Location: normalizeLocation(value(img.Location)), + Source: "managed", + } + if img.Properties != nil { + item.Status = value(img.Properties.ProvisioningState) + if img.Properties.StorageProfile != nil && img.Properties.StorageProfile.OSDisk != nil && img.Properties.StorageProfile.OSDisk.OSType != nil { + item.OS = strings.ToLower(string(*img.Properties.StorageProfile.OSDisk.OSType)) + } + } + return item, true +} + +// CreateDiskFromImage creates a managed disk from a boot image in the worker's group. +func (c *Client) CreateDiskFromImage(ctx context.Context, group, location string, image BootImage, zones []string) (string, error) { + name := objectName(image.Name) + poller, err := c.disks.BeginCreateOrUpdate(ctx, group, name, imageDiskBody(location, image, zones), nil) + if err != nil { + return "", fmt.Errorf("create disk: %w", err) + } + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + return name, fmt.Errorf("create disk: %w", err) + } + return name, nil +} + +func imageDiskBody(location string, image BootImage, zones []string) armcompute.Disk { + data := &armcompute.CreationData{CreateOption: ptr(armcompute.DiskCreateOptionFromImage)} + switch image.Source { + case "gallery": + data.GalleryImageReference = &armcompute.ImageDiskReference{ID: ptr(image.ID)} + case "community": + data.ImageReference = &armcompute.ImageDiskReference{CommunityGalleryImageID: ptr(image.ID)} + default: + data.ImageReference = &armcompute.ImageDiskReference{ID: ptr(image.ID)} + } + disk := armcompute.Disk{ + Location: ptr(location), + Tags: imageScanTags(image.Name), + Properties: &armcompute.DiskProperties{ + NetworkAccessPolicy: ptr(armcompute.NetworkAccessPolicyDenyAll), + PublicNetworkAccess: ptr(armcompute.PublicNetworkAccessDisabled), + CreationData: data, + }, + } + if len(zones) > 0 { + disk.Zones = make([]*string, len(zones)) + for i, zone := range zones { + disk.Zones[i] = ptr(zone) + } + } + return disk +} + +func imageScanTags(name string) map[string]*string { + if len(name) > 256 { + name = name[:256] + } + return map[string]*string{ + "opensourceom:scan": ptr("workload"), + "opensourceom:image": ptr(name), + } +} + +func imageRefOf(ref *armcompute.ImageReference) (key, id, source, publisher, offer, sku, version string) { + if ref == nil { + return "", "", "", "", "", "", "" + } + if v := value(ref.ID); v != "" { + source = "managed" + if strings.Contains(strings.ToLower(v), "/galleries/") { + source = "gallery" + } + return strings.ToLower(v), v, source, "", "", "", "" + } + if v := value(ref.SharedGalleryImageID); v != "" { + return strings.ToLower(v), v, "gallery", "", "", "", "" + } + if v := value(ref.CommunityGalleryImageID); v != "" { + return strings.ToLower(v), v, "community", "", "", "", "" + } + publisher = value(ref.Publisher) + offer = value(ref.Offer) + sku = value(ref.SKU) + version = value(ref.Version) + if exact := value(ref.ExactVersion); exact != "" { + version = exact + } + if publisher == "" { + return "", "", "", "", "", "", "" + } + key = strings.ToLower(publisher + "|" + offer + "|" + sku + "|" + version) + return key, "", "marketplace", publisher, offer, sku, version +} + +func marketplaceImageID(subscription, location, publisher, offer, sku, version string) string { + return "/subscriptions/" + subscription + + "/providers/Microsoft.Compute/locations/" + normalizeLocation(location) + + "/publishers/" + publisher + + "/artifacttypes/vmimage/offers/" + offer + + "/skus/" + sku + + "/versions/" + version +} + +func assembleBootImages(location, subscription string, machines []Machine, listed []BootImage) []BootImage { + location = normalizeLocation(location) + by := map[string]BootImage{} + var keys []string + touch := func(key string) { + if _, ok := by[key]; ok { + return + } + by[key] = BootImage{Key: key} + keys = append(keys, key) + } + for _, src := range listed { + key := strings.ToLower(src.ID) + if key == "" { + key = src.Key + } + if key == "" { + continue + } + touch(key) + img := by[key] + img.ID = src.ID + img.Name = src.Name + img.Location = normalizeLocation(src.Location) + img.OS = src.OS + img.Source = src.Source + if img.Source == "" { + img.Source = "managed" + } + img.Status = src.Status + img.Key = key + by[key] = img + } + + windows := map[string]bool{} + seen := map[string]int{} + for _, vm := range machines { + if vm.Location != "" && !sameLocation(vm.Location, location) { + continue + } + if vm.ImageKey == "" { + continue + } + touch(vm.ImageKey) + img := by[vm.ImageKey] + if img.Source == "" { + img.Source = vm.ImageSource + } + if img.ID == "" { + img.ID = vm.ImageID + } + if img.Source == "marketplace" && img.ID == "" && subscription != "" && vm.Publisher != "" && vm.ImageVersion != "" { + img.ID = marketplaceImageID(subscription, location, vm.Publisher, vm.Offer, vm.SKU, vm.ImageVersion) + } + if img.Name == "" { + img.Name = vm.Offer + if img.Name == "" { + img.Name = vm.Name + } + } + if img.Location == "" { + img.Location = location + } + if img.OS == "" && vm.OS != "" && !strings.EqualFold(vm.OS, "windows") { + img.OS = vm.OS + } + if !hasVM(img.Instances, vm.ResourceGroup, vm.Name) { + img.Instances = append(img.Instances, ImageVM{Name: vm.Name, ResourceGroup: vm.ResourceGroup}) + } + if seen[vm.ImageKey] == 0 { + windows[vm.ImageKey] = strings.EqualFold(vm.OS, "windows") + } else if !strings.EqualFold(vm.OS, "windows") { + windows[vm.ImageKey] = false + } + seen[vm.ImageKey]++ + by[vm.ImageKey] = img + } + + var out []BootImage + for _, key := range keys { + img := by[key] + if img.OS == "" && windows[key] && seen[key] > 0 { + img.OS = "windows" + } + if img.Location != "" && !sameLocation(img.Location, location) && len(img.Instances) == 0 { + continue + } + slices.SortFunc(img.Instances, func(a, b ImageVM) int { + if c := strings.Compare(strings.ToLower(a.ResourceGroup), strings.ToLower(b.ResourceGroup)); c != 0 { + return c + } + return strings.Compare(a.Name, b.Name) + }) + out = append(out, img) + } + slices.SortFunc(out, func(a, b BootImage) int { return strings.Compare(a.Key, b.Key) }) + return out +} + +func hasVM(vms []ImageVM, group, name string) bool { + for _, vm := range vms { + if strings.EqualFold(vm.ResourceGroup, group) && vm.Name == name { + return true + } + } + return false +} + +func cannotSnapshotImage(err error) bool { + if err == nil { + return false + } + var resp *azcore.ResponseError + if errors.As(err, &resp) { + switch resp.StatusCode { + case http.StatusForbidden, http.StatusConflict: + return true + } + code := strings.ToLower(resp.ErrorCode) + if strings.Contains(code, "authorization") || strings.Contains(code, "marketplace") { + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "authorizationfailed") || strings.Contains(msg, "marketplace") || strings.Contains(msg, "not purchased") +} diff --git a/internal/workload/azure/image_test.go b/internal/workload/azure/image_test.go new file mode 100644 index 0000000..59119b3 --- /dev/null +++ b/internal/workload/azure/image_test.go @@ -0,0 +1,302 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package azure + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" + "github.com/OpenSourceOM/core/internal/workload" +) + +func TestMachineFromImageReference(t *testing.T) { + id := "/subscriptions/sub/resourceGroups/App/providers/Microsoft.Compute/virtualMachines/web" + imageID := "/subscriptions/sub/resourceGroups/App/providers/Microsoft.Compute/images/golden" + galleryID := "/subscriptions/sub/resourceGroups/App/providers/Microsoft.Compute/galleries/g/images/golden/versions/1" + vm := &armcompute.VirtualMachine{ + Name: ptr("web"), + ID: ptr(id), + Location: ptr("eastus"), + Properties: &armcompute.VirtualMachineProperties{ + StorageProfile: &armcompute.StorageProfile{ + ImageReference: &armcompute.ImageReference{ID: ptr(imageID)}, + OSDisk: &armcompute.OSDisk{OSType: ptr(armcompute.OperatingSystemTypesLinux)}, + }, + }, + } + got, ok := machineFrom(vm) + if !ok || got.ImageID != imageID || got.ImageSource != "managed" || got.ImageKey != strings.ToLower(imageID) { + t.Fatalf("machine = %#v ok=%v", got, ok) + } + vm.Properties.StorageProfile.ImageReference.ID = ptr(galleryID) + got, _ = machineFrom(vm) + if got.ImageSource != "gallery" || got.ImageID != galleryID { + t.Fatalf("gallery = %#v", got) + } +} + +func TestImagePermissionsDoNotSnapshot(t *testing.T) { + if !slices.Contains(ImagePermissions, "Microsoft.Compute/images/read") || !slices.Contains(ImagePermissions, "Microsoft.Compute/disks/write") { + t.Fatalf("permissions = %v", ImagePermissions) + } + for _, action := range ImagePermissions { + if strings.Contains(action, "snapshots") { + t.Errorf("image scan must not use %s", action) + } + } +} + +func TestAssembleBootImagesOncePerImage(t *testing.T) { + imageID := "/subscriptions/sub/resourceGroups/app/providers/Microsoft.Compute/images/golden" + listed := []BootImage{{ + ID: imageID, Name: "golden", Location: "eastus", OS: "linux", Source: "managed", Status: "Succeeded", + }} + machines := []Machine{ + {Name: "web", ResourceGroup: "app", Location: "eastus", OS: "linux", ImageKey: strings.ToLower(imageID), ImageID: imageID, ImageSource: "managed"}, + {Name: "api", ResourceGroup: "app", Location: "eastus", OS: "linux", ImageKey: strings.ToLower(imageID), ImageID: imageID, ImageSource: "managed"}, + {Name: "other", ResourceGroup: "app", Location: "westus", OS: "linux", ImageKey: "other"}, + } + got := assembleBootImages("eastus", "sub", machines, listed) + if len(got) != 1 || len(got[0].Instances) != 2 { + t.Fatalf("images = %#v", got) + } + if got[0].Instances[0].Name != "api" || got[0].Instances[1].Name != "web" { + t.Fatalf("instances = %#v", got[0].Instances) + } +} + +func TestAssembleBootImagesKeepsAnUnusedImageAndBuildsMarketplaceID(t *testing.T) { + idle := "/subscriptions/sub/resourceGroups/app/providers/Microsoft.Compute/images/idle" + west := "/subscriptions/sub/resourceGroups/app/providers/Microsoft.Compute/images/west" + listed := []BootImage{ + {ID: idle, Name: "idle", Location: "eastus", Source: "managed", Status: "Succeeded"}, + {ID: west, Name: "west", Location: "westus", Source: "managed", Status: "Succeeded"}, + } + machines := []Machine{{ + Name: "web", ResourceGroup: "app", Location: "eastus", OS: "linux", + ImageKey: "canonical|0001-com-ubuntu-server-jammy|22_04-lts-gen2|22.04.202410020", + ImageSource: "marketplace", Publisher: "canonical", Offer: "0001-com-ubuntu-server-jammy", + SKU: "22_04-lts-gen2", ImageVersion: "22.04.202410020", + }} + got := assembleBootImages("eastus", "sub", machines, listed) + if len(got) != 2 { + t.Fatalf("images = %#v", got) + } + var idleSeen, marketSeen bool + for _, img := range got { + if img.Name == "west" { + t.Fatal("image in another region with no instances was kept") + } + if img.Name == "idle" && len(img.Instances) == 0 { + idleSeen = true + } + if img.Source == "marketplace" { + marketSeen = true + if !strings.Contains(img.ID, "/publishers/canonical/") || !strings.Contains(img.ID, "/versions/22.04.202410020") { + t.Fatalf("marketplace id = %s", img.ID) + } + if len(img.Instances) != 1 || img.Instances[0].Name != "web" { + t.Fatalf("marketplace instances = %#v", img.Instances) + } + } + } + if !idleSeen || !marketSeen { + t.Fatalf("images = %#v", got) + } +} + +func TestAssembleBootImagesMarksWindows(t *testing.T) { + got := assembleBootImages("eastus", "sub", []Machine{{ + Name: "win", ResourceGroup: "app", Location: "eastus", OS: "windows", + ImageKey: "win-image", ImageID: "/subscriptions/sub/resourceGroups/app/providers/Microsoft.Compute/images/win", + ImageSource: "managed", + }}, nil) + if len(got) != 1 || got[0].OS != "windows" { + t.Fatalf("images = %#v", got) + } +} + +func TestImageDiskBodyUsesTheImageAndNotASnapshot(t *testing.T) { + managed := imageDiskBody("eastus", BootImage{ID: "/subscriptions/sub/images/golden", Name: "golden", Source: "managed"}, []string{"1"}) + if managed.Properties.CreationData.CreateOption == nil || *managed.Properties.CreationData.CreateOption != armcompute.DiskCreateOptionFromImage { + t.Fatalf("create option = %v", managed.Properties.CreationData.CreateOption) + } + if managed.Properties.CreationData.SourceResourceID != nil { + t.Fatal("disk copies a snapshot") + } + if managed.Properties.CreationData.ImageReference == nil || value(managed.Properties.CreationData.ImageReference.ID) == "" { + t.Fatal("missing image reference") + } + if managed.Properties.NetworkAccessPolicy == nil || *managed.Properties.NetworkAccessPolicy != armcompute.NetworkAccessPolicyDenyAll { + t.Fatal("disk can be exported") + } + gallery := imageDiskBody("eastus", BootImage{ID: "/subscriptions/sub/galleries/g/images/golden/versions/1", Name: "golden", Source: "gallery"}, nil) + if gallery.Properties.CreationData.GalleryImageReference == nil { + t.Fatal("gallery image was stored as a managed image") + } +} + +func TestScanImageOnceAppliesTheSamePackages(t *testing.T) { + imageID := "/subscriptions/sub/resourceGroups/app/providers/Microsoft.Compute/images/golden" + fake := &fakeImages{images: []BootImage{{ + Key: strings.ToLower(imageID), ID: imageID, Name: "golden", Location: "eastus", OS: "linux", Source: "managed", Status: "Succeeded", + Instances: []ImageVM{{Name: "api", ResourceGroup: "app"}, {Name: "web", ResourceGroup: "app"}}, + }}} + var mounts int + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + mounts++ + dir := t.TempDir() + writeRoot(t, dir) + return dir, func() error { return nil }, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if mounts != 1 || len(got) != 1 || len(got[0].Instances) != 2 || got[0].Skipped != "" { + t.Fatalf("mounts=%d results=%#v", mounts, got) + } + joined := strings.Join(got[0].Packages, "\n") + if !strings.Contains(joined, "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1") || strings.Contains(joined, "nginx") { + t.Fatalf("packages =\n%s", joined) + } + if len(fake.creates) != 1 || slices.Contains(fake.log, "CreateSnapshot") { + t.Fatalf("log=%v creates=%d", fake.log, len(fake.creates)) + } + if len(fake.live) != 0 { + t.Fatal("disk left behind") + } +} + +func TestScanImageReportsMarketplaceItCannotSnapshot(t *testing.T) { + fake := &fakeImages{images: []BootImage{{ + Key: "canonical|ubuntu|22_04|1", ID: "/subscriptions/sub/providers/Microsoft.Compute/locations/eastus/publishers/canonical/artifacttypes/vmimage/offers/ubuntu/skus/22_04/versions/1", + Name: "ubuntu", Source: "marketplace", + Instances: []ImageVM{{Name: "web", ResourceGroup: "app"}}, + }}, deny: "marketplace"} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("marketplace image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != workload.ErrImageDenied.Error() { + t.Fatalf("results = %#v", got) + } + if slices.Contains(fake.log, "Attach") || len(fake.live) != 0 { + t.Fatalf("log=%v live=%d", fake.log, len(fake.live)) + } +} + +func TestScanImageSkipsWindows(t *testing.T) { + fake := &fakeImages{images: []BootImage{{ + Key: "win", ID: "/subscriptions/sub/images/win", Name: "win", OS: "windows", Source: "managed", + }}} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("windows image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != "Windows" || slices.Contains(fake.log, "CreateDisk") { + t.Fatalf("results=%#v log=%v", got, fake.log) + } +} + +func TestScanImageDeletesDiskWhenWalkFails(t *testing.T) { + fake := &fakeImages{images: []BootImage{{ + Key: "golden", ID: "/subscriptions/sub/images/golden", Name: "golden", Source: "managed", Status: "Succeeded", + }}} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + return t.TempDir(), func() error { return nil }, nil + }, func(string) ([]string, error) { + return nil, errors.New("read rpm database: corrupt") + }) + err := scanner.Scan(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "corrupt") { + t.Fatalf("err = %v", err) + } + assertOrder(t, fake.log, "CreateDisk", "Attach", "Detach", "DeleteDisk") + if len(fake.live) != 0 { + t.Fatal("disk left behind") + } +} + +func testImageScanner(t *testing.T, fake *fakeImages, mount func(context.Context, string) (string, func() error, error), walk func(string) ([]string, error)) *ImageScanner { + t.Helper() + return &ImageScanner{ + API: fake, + Worker: Worker{ + Name: "worker", + ResourceGroup: "scan", + Location: "eastus", + Zone: "1", + }, + Mount: mount, + Walk: walk, + } +} + +type fakeImages struct { + images []BootImage + luns []int32 + log []string + live map[string]bool + deny string + creates []BootImage + diskN int +} + +func (f *fakeImages) ListBootImages(context.Context, Worker) ([]BootImage, []int32, error) { + f.log = append(f.log, "ListBootImages") + return f.images, f.luns, nil +} + +func (f *fakeImages) CreateDiskFromImage(_ context.Context, _, _ string, image BootImage, _ []string) (string, error) { + f.log = append(f.log, "CreateDisk") + f.creates = append(f.creates, image) + if f.deny != "" && image.Source == f.deny { + return "", errors.New("AuthorizationFailed: marketplace plan was not purchased") + } + if f.live == nil { + f.live = map[string]bool{} + } + f.diskN++ + name := "disk" + f.live[name] = true + return name, nil +} + +func (f *fakeImages) DeleteDisk(_ context.Context, _, name string) error { + f.log = append(f.log, "DeleteDisk") + delete(f.live, name) + return nil +} + +func (f *fakeImages) Attach(context.Context, string, string, string, int32) error { + f.log = append(f.log, "Attach") + return nil +} + +func (f *fakeImages) Detach(context.Context, string, string, string) error { + f.log = append(f.log, "Detach") + return nil +} diff --git a/internal/workload/azure/scan.go b/internal/workload/azure/scan.go index c07d82d..ddd7532 100644 --- a/internal/workload/azure/scan.go +++ b/internal/workload/azure/scan.go @@ -51,6 +51,14 @@ type Machine struct { Ephemeral bool ProvisioningState string DataLUNs []int32 + // ImageKey is shared by instances launched from one boot image. + ImageKey string + ImageID string + ImageSource string + Publisher string + Offer string + SKU string + ImageVersion string } // Result is one VM after the snapshot and disk are deleted. diff --git a/internal/workload/gcp/image.go b/internal/workload/gcp/image.go new file mode 100644 index 0000000..856d980 --- /dev/null +++ b/internal/workload/gcp/image.go @@ -0,0 +1,482 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/OpenSourceOM/core/internal/workload" + "google.golang.org/api/compute/v1" + "google.golang.org/api/googleapi" +) + +// ImagePermissions are the IAM permissions om scan workload image gcp adds. +// The image itself is not deleted. useToDecrypt is the unwrap a +// customer-managed key needs when the disk keeps that key. om scan gcp does +// not gain these permissions. +var ImagePermissions = []string{ + "compute.instances.list", + "compute.disks.list", + "compute.images.list", + "compute.images.useReadOnly", + "compute.disks.create", + "compute.disks.delete", + "compute.disks.useReadOnly", + "compute.instances.attachDisk", + "compute.instances.detachDisk", + "compute.zoneOperations.get", + "cloudkms.cryptoKeyVersions.useToDecrypt", + "cloudkms.cryptoKeyVersions.useToEncrypt", +} + +// ImageVM is a GCE instance launched from one boot image. +type ImageVM struct { + Name string + Zone string +} + +// BootImage is one project image, or an image another project published that +// an instance in this region was launched from. +type BootImage struct { + Link string + Name string + OS string + Status string + Instances []ImageVM +} + +// ImageResult is one boot image after the disk created for the read is deleted. +// Packages is the image walk only. +type ImageResult struct { + BootImage + Packages []string + Skipped string +} + +// ImageAPI is the image-disk lifecycle. Create calls wait until the disk is ready. +// A non-empty name is deleted even when create returns an error. +type ImageAPI interface { + ListBootImages(ctx context.Context, worker Worker) ([]BootImage, []string, error) + CreateDiskFromImage(ctx context.Context, zone, imageLink, name string) (string, error) + DeleteDisk(ctx context.Context, zone, name string) error + Attach(ctx context.Context, zone, instance, diskName, device string) error + Detach(ctx context.Context, zone, instance, device string) error +} + +var _ ImageAPI = (*Client)(nil) + +// ImageScanner reads each boot image once and deletes the disk it created. +type ImageScanner struct { + API ImageAPI + Worker Worker + Only []string + Timeout time.Duration + Mount func(ctx context.Context, device string) (root string, cleanup func() error, err error) + Walk func(root string) ([]string, error) + OnStart func(link, name string) + + cleanupErrs []error +} + +// Scan reads each Linux boot image used in the worker's region, plus project +// images that no instance is running, and calls write after that image's disk +// is deleted. A skip leaves packages unchanged. +func (s *ImageScanner) Scan(ctx context.Context, write func(ImageResult) error) error { + if s.Worker.Name == "" || s.Worker.Zone == "" { + return fmt.Errorf("worker name and zone are required") + } + if s.API == nil || s.Mount == nil || s.Walk == nil { + return fmt.Errorf("workload scan is missing a Compute client, mount, or package walker") + } + images, devices, err := s.API.ListBootImages(ctx, s.Worker) + if err != nil { + return err + } + images, err = selectBootImages(images, s.Only) + if err != nil { + return err + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 30 * time.Minute + } + var failed []error + for _, img := range images { + ictx, cancel := context.WithTimeout(ctx, timeout) + res, scanErr := s.scanImage(ictx, img, devices) + cancel() + if scanErr != nil { + failed = append(failed, scanErr) + continue + } + if write != nil { + if err := write(res); err != nil { + failed = append(failed, fmt.Errorf("%s: %w", img.Link, err)) + } + } + } + return errors.Join(append(failed, s.cleanupErrs...)...) +} + +func (s *ImageScanner) scanImage(ctx context.Context, img BootImage, devices []string) (ImageResult, error) { + res := ImageResult{BootImage: img} + if skip := skipBootImage(img); skip != "" { + res.Skipped = skip + return res, nil + } + device := freeDevice(devices) + if device == "" { + return ImageResult{}, fmt.Errorf("%s: no free device name on the worker", imageLabel(img)) + } + if s.OnStart != nil { + s.OnStart(img.Link, img.Name) + } + disk, err := s.API.CreateDiskFromImage(ctx, s.Worker.Zone, img.Link, img.Name) + if disk != "" { + defer s.deleteDisk(s.Worker.Zone, disk) + } + if err != nil { + if cannotSnapshotImage(err) { + res.Skipped = workload.ErrImageDenied.Error() + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + if err := s.API.Attach(ctx, s.Worker.Zone, s.Worker.Name, disk, device); err != nil { + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + defer s.detach(s.Worker.Zone, device) + + root, unmount, err := s.Mount(ctx, device) + if err != nil { + if reason, ok := workload.Reason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + defer func() { s.note(unmount()) }() + + pkgs, err := s.Walk(root) + if err != nil { + if reason, ok := workload.Reason(err); ok { + res.Skipped = reason + return res, nil + } + return ImageResult{}, fmt.Errorf("%s: %w", imageLabel(img), err) + } + res.Packages = pkgs + return res, nil +} + +func (s *ImageScanner) detach(zone, device string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := s.API.Detach(ctx, zone, s.Worker.Name, device); err != nil && !isNotFound(err) { + s.note(fmt.Errorf("detach disk %s: %w", device, err)) + } +} + +func (s *ImageScanner) deleteDisk(zone, name string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := s.API.DeleteDisk(ctx, zone, name); err != nil && !isNotFound(err) { + s.note(fmt.Errorf("delete disk %s: %w", name, err)) + } +} + +func (s *ImageScanner) note(err error) { + if err != nil { + s.cleanupErrs = append(s.cleanupErrs, err) + } +} + +func skipBootImage(img BootImage) string { + if strings.EqualFold(img.OS, "windows") { + return "Windows" + } + if img.Status != "" && !strings.EqualFold(img.Status, "READY") { + return "not available" + } + if img.Link == "" { + return workload.ErrImageDenied.Error() + } + return "" +} + +func selectBootImages(images []BootImage, only []string) ([]BootImage, error) { + if len(only) == 0 { + return images, nil + } + var out []BootImage + for _, want := range only { + var matched []BootImage + for _, img := range images { + if img.Link == want || imageKey(img.Link) == imageKey(want) || img.Name == want || lastSegment(img.Link) == want { + matched = append(matched, img) + } + } + if len(matched) == 0 { + return nil, fmt.Errorf("image %s is not in this region", want) + } + if len(matched) > 1 { + return nil, fmt.Errorf("image %s matches more than one image; pass the image id", want) + } + out = append(out, matched[0]) + } + return out, nil +} + +func imageLabel(img BootImage) string { + if img.Name != "" && img.Name != img.Link { + return img.Name + } + if img.Link != "" { + return img.Link + } + return img.Name +} + +// ListBootImages returns project images and images that instances in the +// worker's region were launched from, plus the worker's device names. +func (c *Client) ListBootImages(ctx context.Context, worker Worker) ([]BootImage, []string, error) { + if worker.Name == "" || worker.Zone == "" { + return nil, nil, fmt.Errorf("worker name and zone are required") + } + instances, err := c.List(ctx, worker.Region()) + if err != nil { + return nil, nil, err + } + sources, err := c.diskImages(ctx, worker.Region()) + if err != nil { + return nil, nil, err + } + for i := range instances { + instances[i].SourceImage = sources[imageKey(instances[i].BootSource)] + } + listed, err := c.projectImages(ctx) + if err != nil { + return nil, nil, err + } + found, err := findInstance(instances, worker.Zone+"/"+worker.Name) + if err != nil { + return nil, nil, fmt.Errorf("worker %s/%s is not in %s", worker.Zone, worker.Name, worker.Region()) + } + return assembleGCE(listed, instances), found.Devices, nil +} + +func (c *Client) projectImages(ctx context.Context) ([]BootImage, error) { + var out []BootImage + err := c.svc.Images.List(c.project).Pages(ctx, func(list *compute.ImageList) error { + for _, img := range list.Items { + item, ok := bootImageFrom(c.project, img) + if ok { + out = append(out, item) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list images: %w", err) + } + return out, nil +} + +func bootImageFrom(project string, img *compute.Image) (BootImage, bool) { + if img == nil || img.Name == "" { + return BootImage{}, false + } + link := img.SelfLink + if link == "" { + link = "projects/" + project + "/global/images/" + img.Name + } + item := BootImage{Link: imageKey(link), Name: img.Name, Status: img.Status} + if gceImageWindows(img) { + item.OS = "windows" + } + return item, true +} + +func gceImageWindows(img *compute.Image) bool { + if img == nil { + return false + } + for _, feature := range img.GuestOsFeatures { + if feature != nil && strings.EqualFold(feature.Type, "WINDOWS") { + return true + } + } + for _, license := range img.Licenses { + if strings.Contains(strings.ToLower(license), "windows") { + return true + } + } + return false +} + +func (c *Client) diskImages(ctx context.Context, region string) (map[string]string, error) { + out := map[string]string{} + err := c.svc.Disks.AggregatedList(c.project).Pages(ctx, func(resp *compute.DiskAggregatedList) error { + for zonePath, scoped := range resp.Items { + zone := lastSegment(zonePath) + if regionOf(zone) != region { + continue + } + for _, disk := range scoped.Disks { + if disk == nil || disk.SourceImage == "" { + continue + } + src := imageKey(disk.SourceImage) + if disk.SelfLink != "" { + out[imageKey(disk.SelfLink)] = src + } + if disk.Name != "" && zone != "" { + out[imageKey("projects/"+c.project+"/zones/"+zone+"/disks/"+disk.Name)] = src + } + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("list disks: %w", err) + } + return out, nil +} + +// CreateDiskFromImage creates a disk from a boot image in the worker's zone. +func (c *Client) CreateDiskFromImage(ctx context.Context, zone, imageLink, name string) (string, error) { + diskName := objectName(name) + op, err := c.svc.Disks.Insert(c.project, zone, imageDiskBody(diskName, zone, imageLink)).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("create disk: %w", err) + } + if err := c.wait(ctx, op); err != nil { + return diskName, fmt.Errorf("create disk: %w", err) + } + return diskName, nil +} + +func imageDiskBody(name, zone, imageLink string) *compute.Disk { + return &compute.Disk{ + Name: name, + Description: "OpenSourceOM workload package inventory", + SourceImage: imageKey(imageLink), + Type: "zones/" + zone + "/diskTypes/pd-balanced", + Labels: imageLabels(imageLink), + } +} + +func imageLabels(imageLink string) map[string]string { + return map[string]string{ + "opensourceom-scan": "workload", + "opensourceom-image": labelValue(lastSegment(imageLink)), + } +} + +func imageKey(link string) string { + link = strings.TrimSpace(link) + link = strings.TrimPrefix(link, "https://www.googleapis.com/compute/v1/") + link = strings.TrimPrefix(link, "http://www.googleapis.com/compute/v1/") + return strings.Trim(link, "/") +} + +func assembleGCE(listed []BootImage, instances []Instance) []BootImage { + by := map[string]BootImage{} + var keys []string + put := func(img BootImage) string { + key := imageKey(img.Link) + if key == "" { + key = img.Name + } + img.Link = key + prev, ok := by[key] + if !ok { + if img.Name == "" { + img.Name = lastSegment(key) + } + by[key] = img + keys = append(keys, key) + return key + } + if prev.Name == "" || prev.Name == lastSegment(key) { + if img.Name != "" { + prev.Name = img.Name + } + } + if img.OS != "" { + prev.OS = img.OS + } + if img.Status != "" && prev.Status == "" { + prev.Status = img.Status + } + by[key] = prev + return key + } + for _, img := range listed { + put(img) + } + windows := map[string]bool{} + count := map[string]int{} + for _, inst := range instances { + key := imageKey(inst.SourceImage) + if key == "" { + continue + } + put(BootImage{Link: key}) + img := by[key] + if !hasVM(img.Instances, inst.Zone, inst.Name) { + img.Instances = append(img.Instances, ImageVM{Name: inst.Name, Zone: inst.Zone}) + } + by[key] = img + if count[key] == 0 { + windows[key] = inst.Windows + } else if !inst.Windows { + windows[key] = false + } + count[key]++ + } + var out []BootImage + for _, key := range keys { + img := by[key] + if img.OS == "" && windows[key] && count[key] > 0 { + img.OS = "windows" + } + slices.SortFunc(img.Instances, func(a, b ImageVM) int { + if c := strings.Compare(a.Zone, b.Zone); c != 0 { + return c + } + return strings.Compare(a.Name, b.Name) + }) + out = append(out, img) + } + slices.SortFunc(out, func(a, b BootImage) int { return strings.Compare(a.Link, b.Link) }) + return out +} + +func hasVM(vms []ImageVM, zone, name string) bool { + for _, vm := range vms { + if vm.Zone == zone && vm.Name == name { + return true + } + } + return false +} + +func cannotSnapshotImage(err error) bool { + if err == nil { + return false + } + var gerr *googleapi.Error + if errors.As(err, &gerr) && (gerr.Code == http.StatusForbidden || gerr.Code == http.StatusConflict) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "forbidden") || strings.Contains(msg, "marketplace") || strings.Contains(msg, "not authorized") +} diff --git a/internal/workload/gcp/image_test.go b/internal/workload/gcp/image_test.go new file mode 100644 index 0000000..c7a0aa5 --- /dev/null +++ b/internal/workload/gcp/image_test.go @@ -0,0 +1,227 @@ +// Copyright 2026 OpenSourceOM +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + + "github.com/OpenSourceOM/core/internal/workload" + "google.golang.org/api/compute/v1" +) + +func TestImagePermissionsDoNotSnapshot(t *testing.T) { + if !slices.Contains(ImagePermissions, "compute.images.useReadOnly") || !slices.Contains(ImagePermissions, "compute.disks.create") { + t.Fatalf("permissions = %v", ImagePermissions) + } + for _, action := range ImagePermissions { + if strings.Contains(action, "snapshot") { + t.Errorf("image scan must not use %s", action) + } + } +} + +func TestAssembleGCEOncePerImage(t *testing.T) { + link := "projects/p/global/images/golden" + listed := []BootImage{{Link: link, Name: "golden", Status: "READY"}, {Link: "projects/p/global/images/idle", Name: "idle", Status: "READY"}} + instances := []Instance{ + {Name: "web", Zone: "us-central1-a", SourceImage: "https://www.googleapis.com/compute/v1/" + link}, + {Name: "api", Zone: "us-central1-b", SourceImage: link}, + {Name: "deb", Zone: "us-central1-a", SourceImage: "projects/debian-cloud/global/images/debian-12"}, + {Name: "win", Zone: "us-central1-a", Windows: true, SourceImage: "projects/windows-cloud/global/images/windows-2022"}, + } + got := assembleGCE(listed, instances) + byLink := map[string]BootImage{} + for _, img := range got { + byLink[img.Link] = img + } + golden := byLink[link] + if len(golden.Instances) != 2 || golden.Instances[0].Name != "web" || golden.Instances[1].Name != "api" { + t.Fatalf("golden = %#v", golden) + } + if _, ok := byLink["projects/p/global/images/idle"]; !ok || len(byLink["projects/p/global/images/idle"].Instances) != 0 { + t.Fatal("unused project image was dropped") + } + if len(byLink["projects/debian-cloud/global/images/debian-12"].Instances) != 1 { + t.Fatal("foreign image was not kept") + } + if byLink["projects/windows-cloud/global/images/windows-2022"].OS != "windows" { + t.Fatal("windows image was not marked") + } +} + +func TestImageDiskBodyUsesTheImageAndNotASnapshot(t *testing.T) { + body := imageDiskBody("omwl-abc", "us-central1-a", "https://www.googleapis.com/compute/v1/projects/p/global/images/golden") + if body.SourceSnapshot != "" { + t.Fatal("disk copies a snapshot") + } + if body.SourceImage != "projects/p/global/images/golden" { + t.Fatalf("source = %s", body.SourceImage) + } + if body.Labels["opensourceom-scan"] != "workload" || body.Labels["opensourceom-image"] != "golden" { + t.Fatalf("labels = %#v", body.Labels) + } +} + +func TestGCEImageWindows(t *testing.T) { + if !gceImageWindows(&compute.Image{GuestOsFeatures: []*compute.GuestOsFeature{{Type: "WINDOWS"}}}) { + t.Fatal("guest os feature was ignored") + } + if gceImageWindows(&compute.Image{Name: "debian"}) { + t.Fatal("linux image marked windows") + } +} + +func TestScanImageOnceAppliesTheSamePackages(t *testing.T) { + link := "projects/p/global/images/golden" + fake := &fakeImages{images: []BootImage{{ + Link: link, Name: "golden", Status: "READY", + Instances: []ImageVM{{Name: "api", Zone: "us-central1-b"}, {Name: "web", Zone: "us-central1-a"}}, + }}, devices: []string{"boot"}} + var mounts int + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + mounts++ + dir := t.TempDir() + writeRoot(t, dir) + return dir, func() error { return nil }, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if mounts != 1 || len(got) != 1 || len(got[0].Instances) != 2 || got[0].Skipped != "" { + t.Fatalf("mounts=%d results=%#v", mounts, got) + } + joined := strings.Join(got[0].Packages, "\n") + if !strings.Contains(joined, "pkg:deb/ubuntu/openssl@1.1.1n-0ubuntu1") || strings.Contains(joined, "nginx") { + t.Fatalf("packages =\n%s", joined) + } + if len(fake.links) != 1 || fake.links[0] != link || slices.Contains(fake.log, "CreateSnapshot") { + t.Fatalf("log=%v links=%v", fake.log, fake.links) + } + if len(fake.live) != 0 { + t.Fatal("disk left behind") + } +} + +func TestScanImageReportsMarketplaceItCannotSnapshot(t *testing.T) { + fake := &fakeImages{images: []BootImage{{ + Link: "projects/marketplace/global/images/paid", Name: "paid", Status: "READY", + Instances: []ImageVM{{Name: "web", Zone: "us-central1-a"}}, + }}, deny: true} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("marketplace image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != workload.ErrImageDenied.Error() { + t.Fatalf("results = %#v", got) + } + if slices.Contains(fake.log, "Attach") || len(fake.live) != 0 { + t.Fatalf("log=%v", fake.log) + } +} + +func TestScanImageSkipsWindows(t *testing.T) { + fake := &fakeImages{images: []BootImage{{Link: "projects/p/global/images/win", Name: "win", OS: "windows", Status: "READY"}}} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + t.Fatal("windows image was mounted") + return "", nil, nil + }, workload.Walk) + var got []ImageResult + if err := scanner.Scan(context.Background(), func(res ImageResult) error { + got = append(got, res) + return nil + }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Skipped != "Windows" || slices.Contains(fake.log, "CreateDisk") { + t.Fatalf("results=%#v log=%v", got, fake.log) + } +} + +func TestScanImageDeletesDiskWhenWalkFails(t *testing.T) { + fake := &fakeImages{images: []BootImage{{Link: "projects/p/global/images/golden", Name: "golden", Status: "READY"}}} + scanner := testImageScanner(t, fake, func(context.Context, string) (string, func() error, error) { + return t.TempDir(), func() error { return nil }, nil + }, func(string) ([]string, error) { + return nil, errors.New("read rpm database: corrupt") + }) + err := scanner.Scan(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "corrupt") { + t.Fatalf("err = %v", err) + } + assertOrder(t, fake.log, "CreateDisk", "Attach", "Detach", "DeleteDisk") + if len(fake.live) != 0 { + t.Fatal("disk left behind") + } +} + +func testImageScanner(t *testing.T, fake *fakeImages, mount func(context.Context, string) (string, func() error, error), walk func(string) ([]string, error)) *ImageScanner { + t.Helper() + return &ImageScanner{ + API: fake, + Worker: Worker{ + Name: "worker", + Zone: "us-central1-a", + }, + Mount: mount, + Walk: walk, + } +} + +type fakeImages struct { + images []BootImage + devices []string + log []string + live map[string]bool + deny bool + links []string +} + +func (f *fakeImages) ListBootImages(context.Context, Worker) ([]BootImage, []string, error) { + f.log = append(f.log, "ListBootImages") + return f.images, f.devices, nil +} + +func (f *fakeImages) CreateDiskFromImage(_ context.Context, _, imageLink, _ string) (string, error) { + f.log = append(f.log, "CreateDisk") + f.links = append(f.links, imageLink) + if f.deny { + return "", errors.New("googleapi: Error 403: marketplace image, forbidden") + } + if f.live == nil { + f.live = map[string]bool{} + } + f.live["disk"] = true + return "disk", nil +} + +func (f *fakeImages) DeleteDisk(_ context.Context, _, name string) error { + f.log = append(f.log, "DeleteDisk") + delete(f.live, name) + return nil +} + +func (f *fakeImages) Attach(context.Context, string, string, string, string) error { + f.log = append(f.log, "Attach") + return nil +} + +func (f *fakeImages) Detach(context.Context, string, string, string) error { + f.log = append(f.log, "Detach") + return nil +} diff --git a/internal/workload/gcp/scan.go b/internal/workload/gcp/scan.go index 62f1e56..f17e9a8 100644 --- a/internal/workload/gcp/scan.go +++ b/internal/workload/gcp/scan.go @@ -54,6 +54,7 @@ type Instance struct { Status string Windows bool BootSource string + SourceImage string Scratch bool CustomerKey bool Devices []string diff --git a/internal/workload/skip.go b/internal/workload/skip.go index cf3f416..4eddfd0 100644 --- a/internal/workload/skip.go +++ b/internal/workload/skip.go @@ -26,4 +26,7 @@ const ( ErrNoPackages Skip = "no package database" // ErrLVM means the root volume is an LVM physical volume. ErrLVM Skip = "LVM root volumes are not scanned" + // ErrImageDenied means the account cannot copy this boot image. + // Marketplace and shared images the account is not allowed to snapshot use it. + ErrImageDenied Skip = "account cannot snapshot this image" ) From 15dfbd99abbebfa59a005787bf4d28c35dec74d9 Mon Sep 17 00:00:00 2001 From: O M Date: Mon, 26 Oct 2026 20:56:48 -0400 Subject: [PATCH 3/3] Mark workload package inventory since 0.4.1 as 0.5.0. om scan workload reads Linux root volumes, and om scan workload image reads boot images, on AWS, Azure, and GCE. Co-authored-by: Cursor --- .github/workflows/publish-image.yml | 2 +- README.md | 4 ++-- deploy/helm/opensourceom/Chart.yaml | 4 ++-- deploy/helm/opensourceom/templates/NOTES.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 46a47d9..f69f239 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -8,7 +8,7 @@ on: tag: description: Git tag to build and push required: true - default: v0.4.0 + default: v0.5.0 type: string permissions: diff --git a/README.md b/README.md index f59d710..ab36397 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,10 @@ The CLI does not read `.env`. It defaults `POSTGRES_HOST` to `localhost`, which **Kubernetes:** ```bash -docker build -t ghcr.io/opensourceom/core:0.4.0 . +docker build -t ghcr.io/opensourceom/core:0.5.0 . helm install om deploy/helm/opensourceom \ --set postgres.password='change-me' \ - --set image.tag=0.4.0 + --set image.tag=0.5.0 ``` Collectors stay off until you enable one and supply credentials, an existing Secret, or `serviceAccountAuth`. The CronJob runs `om scan` against the same Postgres the API reads. `om scan demo` stays a one-shot command. See `scan` in `deploy/helm/opensourceom/values.yaml`. diff --git a/deploy/helm/opensourceom/Chart.yaml b/deploy/helm/opensourceom/Chart.yaml index e03a8a1..f241ba4 100644 --- a/deploy/helm/opensourceom/Chart.yaml +++ b/deploy/helm/opensourceom/Chart.yaml @@ -4,5 +4,5 @@ apiVersion: v2 name: opensourceom description: Self-hosted OpenSourceOM Core API, optional Postgres, and optional scheduled collectors type: application -version: 0.4.0 -appVersion: "0.4.0" +version: 0.5.0 +appVersion: "0.5.0" diff --git a/deploy/helm/opensourceom/templates/NOTES.txt b/deploy/helm/opensourceom/templates/NOTES.txt index 7e278ef..0833720 100644 --- a/deploy/helm/opensourceom/templates/NOTES.txt +++ b/deploy/helm/opensourceom/templates/NOTES.txt @@ -1,6 +1,6 @@ 1. Build and load an image (the default repository is a placeholder until you publish one): - docker build -t ghcr.io/opensourceom/core:0.4.0 . + docker build -t ghcr.io/opensourceom/core:0.5.0 . 2. Install with required secrets: @@ -8,7 +8,7 @@ --set api.secret='change-me' \ --set postgres.password='change-me' \ --set image.repository=ghcr.io/opensourceom/core \ - --set image.tag=0.4.0 + --set image.tag=0.5.0 3. Open the console via the ClusterIP service or `kubectl port-forward svc/om-opensourceom 8080:8080`.