diff --git a/cmd/cmd.go b/cmd/cmd.go index e93eb0f..5cc261c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -7,9 +7,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/anchore/anchore-ecs-inventory/ecg" "github.com/anchore/anchore-ecs-inventory/internal/config" "github.com/anchore/anchore-ecs-inventory/internal/logger" + "github.com/anchore/anchore-ecs-inventory/pkg" ) var ( @@ -61,7 +61,7 @@ func initLogging() { logger.InitLogger(logConfig) log = logger.Log - ecg.SetLogger(logger.Log) + pkg.SetLogger(logger.Log) } func logAppConfig() { diff --git a/cmd/root.go b/cmd/root.go index 3beca33..4e56995 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,8 +8,8 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/anchore/anchore-ecs-inventory/ecg" "github.com/anchore/anchore-ecs-inventory/internal/config" + "github.com/anchore/anchore-ecs-inventory/pkg" ) // rootCmd represents the base command when called without any subcommands @@ -43,7 +43,7 @@ var rootCmd = &cobra.Command{ } */ - ecg.PeriodicallyGetInventoryReport(appConfig.PollingIntervalSeconds, appConfig.AnchoreDetails, appConfig.Region) + pkg.PeriodicallyGetInventoryReport(appConfig.PollingIntervalSeconds, appConfig.AnchoreDetails, appConfig.Region) }, } diff --git a/ecg/lib.go b/ecg/lib.go deleted file mode 100644 index b6ec2eb..0000000 --- a/ecg/lib.go +++ /dev/null @@ -1,200 +0,0 @@ -package ecg - -import ( - "encoding/json" - "fmt" - "os" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ecs" - - "github.com/anchore/anchore-ecs-inventory/ecg/connection" - "github.com/anchore/anchore-ecs-inventory/ecg/inventory" - "github.com/anchore/anchore-ecs-inventory/ecg/logger" - "github.com/anchore/anchore-ecs-inventory/ecg/reporter" -) - -var log logger.Logger - -// Output the JSON formatted report to stdout -func reportToStdout(report inventory.Report) error { - enc := json.NewEncoder(os.Stdout) - // prevent > and < from being escaped in the payload - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - if err := enc.Encode(report); err != nil { - return fmt.Errorf("unable to show inventory: %w", err) - } - return nil -} - -func HandleReport(report inventory.Report, anchoreDetails connection.AnchoreInfo) error { - if anchoreDetails.IsValid() { - if err := reporter.Post(report, anchoreDetails); err != nil { - return fmt.Errorf("unable to report Inventory to Anchore: %w", err) - } - } else { - log.Debug("Anchore details not specified, not reporting inventory") - } - - // Encode the report to JSON and output to stdout (maintains same behaviour as when multiple presenters were supported) - return reportToStdout(report) -} - -// PeriodicallyGetInventoryReport periodically retrieve image results and report/output them according to the configuration. -// Note: Errors do not cause the function to exit, since this is periodically running -func PeriodicallyGetInventoryReport(pollingIntervalSeconds int, anchoreDetails connection.AnchoreInfo, region string) { - // Fire off a ticker that reports according to a configurable polling interval - ticker := time.NewTicker(time.Duration(pollingIntervalSeconds) * time.Second) - - for { - report, err := GetInventoryReport(region) - if err != nil { - log.Error("Failed to get Inventory Report", err) - } else { - err := HandleReport(report, anchoreDetails) - if err != nil { - log.Error("Failed to handle Inventory Report", err) - } - } - - // Wait at least as long as the ticker - log.Debugf("Start new gather %s", <-ticker.C) - } -} - -// GetInventoryReport is an atomic method for getting in-use image results, in parallel for multiple clusters -func GetInventoryReport(region string) (inventory.Report, error) { - sessConfig := &aws.Config{} - if region != "" { - sessConfig.Region = aws.String(region) - } - sess, err := session.NewSession(sessConfig) - if err != nil { - log.Error("Failed to create AWS session", err) - } - - err = checkAWSCredentials(sess) - if err != nil { - return inventory.Report{}, err - } - - ecsClient := ecs.New(sess) - - clusters, err := fetchClusters(ecsClient) - if err != nil { - return inventory.Report{}, err - } - - results := []inventory.ReportItem{} - - for _, cluster := range clusters { - log.Debug("Found cluster", "cluster", *cluster) - - // Fetch tasks in cluster - tasks, err := fetchTasksFromCluster(ecsClient, *cluster) - if err != nil { - return inventory.Report{}, err - } - - images := []inventory.ReportImage{} - // Must be at least one task to continue - if len(tasks) == 0 { - log.Debug("No tasks found in cluster", "cluster", *cluster) - } else { - images, err = fetchImagesFromTasks(ecsClient, *cluster, tasks) - if err != nil { - return inventory.Report{}, err - } - } - - results = append(results, inventory.ReportItem{ - Namespace: *cluster, // NOTE The key is Namespace to match the Anchore API but it's actually the cluster ARN - Images: images, - }) - } - // NOTE: clusterName not used for ECS as the clusternARN (used as the namespace in results payload) provides sufficient - // unique location data (account, region, clustername) - return inventory.Report{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Results: results, - ClusterName: "", - InventoryType: "ecs", - }, nil -} - -func SetLogger(logger logger.Logger) { - log = logger -} - -// Check if AWS are present, should be stored in ~/.aws/credentials -func checkAWSCredentials(sess *session.Session) error { - _, err := sess.Config.Credentials.Get() - if err != nil { - // TODO: Add some logs here detailing where to put the credentials - return fmt.Errorf("unable to get AWS credentials: %w", err) - } - return nil -} - -func fetchClusters(client *ecs.ECS) ([]*string, error) { - input := &ecs.ListClustersInput{} - - result, err := client.ListClusters(input) - if err != nil { - return nil, err - } - - return result.ClusterArns, nil -} - -func fetchTasksFromCluster(client *ecs.ECS, cluster string) ([]*string, error) { - input := &ecs.ListTasksInput{ - Cluster: aws.String(cluster), - } - - result, err := client.ListTasks(input) - if err != nil { - return nil, err - } - - return result.TaskArns, nil -} - -func fetchImagesFromTasks(client *ecs.ECS, cluster string, tasks []*string) ([]inventory.ReportImage, error) { - input := &ecs.DescribeTasksInput{ - Cluster: aws.String(cluster), - Tasks: tasks, - } - - results, err := client.DescribeTasks(input) - if err != nil { - return []inventory.ReportImage{}, err - } - - uniqueImages := make(map[string]inventory.ReportImage) - - for _, task := range results.Tasks { - for _, container := range task.Containers { - digest := "" - if container.ImageDigest != nil { - digest = *container.ImageDigest - } - uniqueName := fmt.Sprintf("%s@%s", *container.Image, digest) - uniqueImages[uniqueName] = inventory.ReportImage{ - Tag: *container.Image, - RepoDigest: digest, - } - } - } - - // convert map of unique images to a slice - images := []inventory.ReportImage{} - for _, image := range uniqueImages { - images = append(images, image) - } - - return images, nil -} diff --git a/internal/config/config.go b/internal/config/config.go index d3c78a1..63101ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,8 +20,8 @@ import ( "github.com/spf13/viper" "gopkg.in/yaml.v2" - "github.com/anchore/anchore-ecs-inventory/ecg/connection" "github.com/anchore/anchore-ecs-inventory/internal" + "github.com/anchore/anchore-ecs-inventory/pkg/connection" ) const redacted = "******" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5188def..0440989 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" - "github.com/anchore/anchore-ecs-inventory/ecg/connection" + "github.com/anchore/anchore-ecs-inventory/pkg/connection" ) func TestLoadConfigFromFileCliConfigPath(t *testing.T) { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 30bdf22..3cc95ea 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -44,7 +44,6 @@ func InitLogger(logConfig LogConfig) { var cfg zap.Config level, err := zap.ParseAtomicLevel(logConfig.Level) - if err != nil { log.Printf("Invalid log level: %s, defaulting to `info`", logConfig.Level) level = zap.NewAtomicLevelAt(zap.InfoLevel) diff --git a/ecg/connection/connection.go b/pkg/connection/connection.go similarity index 100% rename from ecg/connection/connection.go rename to pkg/connection/connection.go diff --git a/pkg/inventory/ecs.go b/pkg/inventory/ecs.go new file mode 100644 index 0000000..b3db6ca --- /dev/null +++ b/pkg/inventory/ecs.go @@ -0,0 +1,82 @@ +package inventory + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + + "github.com/anchore/anchore-ecs-inventory/pkg/reporter" +) + +// Check if AWS are present, should be stored in ~/.aws/credentials +func checkAWSCredentials(sess *session.Session) error { + _, err := sess.Config.Credentials.Get() + if err != nil { + // TODO: Add some logs here detailing where to put the credentials + return fmt.Errorf("unable to get AWS credentials: %w", err) + } + return nil +} + +func fetchClusters(client ecsiface.ECSAPI) ([]*string, error) { + input := &ecs.ListClustersInput{} + + result, err := client.ListClusters(input) + if err != nil { + return nil, err + } + + return result.ClusterArns, nil +} + +func fetchTasksFromCluster(client ecsiface.ECSAPI, cluster string) ([]*string, error) { + input := &ecs.ListTasksInput{ + Cluster: aws.String(cluster), + } + + result, err := client.ListTasks(input) + if err != nil { + return nil, err + } + + return result.TaskArns, nil +} + +func fetchImagesFromTasks(client ecsiface.ECSAPI, cluster string, tasks []*string) ([]reporter.ReportImage, error) { + input := &ecs.DescribeTasksInput{ + Cluster: aws.String(cluster), + Tasks: tasks, + } + + results, err := client.DescribeTasks(input) + if err != nil { + return []reporter.ReportImage{}, err + } + + uniqueImages := make(map[string]reporter.ReportImage) + + for _, task := range results.Tasks { + for _, container := range task.Containers { + digest := "" + if container.ImageDigest != nil { + digest = *container.ImageDigest + } + uniqueName := fmt.Sprintf("%s@%s", *container.Image, digest) + uniqueImages[uniqueName] = reporter.ReportImage{ + Tag: *container.Image, + RepoDigest: digest, + } + } + } + + // convert map of unique images to a slice + images := []reporter.ReportImage{} + for _, image := range uniqueImages { + images = append(images, image) + } + + return images, nil +} diff --git a/pkg/inventory/ecs_test.go b/pkg/inventory/ecs_test.go new file mode 100644 index 0000000..8b642a9 --- /dev/null +++ b/pkg/inventory/ecs_test.go @@ -0,0 +1,38 @@ +package inventory + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/stretchr/testify/assert" +) + +func TestFetchClusters(t *testing.T) { + mockSvc := &mockECSClient{} + + clusters, err := fetchClusters(mockSvc) + + assert.NoError(t, err) + assert.Equal(t, 2, len(clusters)) +} + +func TestFetchTasksFromCluster(t *testing.T) { + mockSvc := &mockECSClient{} + + tasks, err := fetchTasksFromCluster(mockSvc, "cluster-1") + + assert.NoError(t, err) + assert.Equal(t, 2, len(tasks)) +} + +func TestFetchImagesFromTasks(t *testing.T) { + mockSvc := &mockECSClient{} + + images, err := fetchImagesFromTasks(mockSvc, "cluster-1", []*string{ + aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-000000000000"), + aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-111111111111"), + }) + + assert.NoError(t, err) + assert.Equal(t, 3, len(images)) +} diff --git a/pkg/inventory/mock_ecs_test.go b/pkg/inventory/mock_ecs_test.go new file mode 100644 index 0000000..a806cff --- /dev/null +++ b/pkg/inventory/mock_ecs_test.go @@ -0,0 +1,66 @@ +package inventory + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" +) + +type mockECSClient struct { + ecsiface.ECSAPI +} + +func (m *mockECSClient) ListClusters(*ecs.ListClustersInput) (*ecs.ListClustersOutput, error) { + return &ecs.ListClustersOutput{ + ClusterArns: []*string{ + aws.String("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1"), + aws.String("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2"), + }, + }, nil +} + +func (m *mockECSClient) ListTasks(*ecs.ListTasksInput) (*ecs.ListTasksOutput, error) { + return &ecs.ListTasksOutput{ + TaskArns: []*string{ + aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-000000000000"), + aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-111111111111"), + }, + }, nil +} + +func (m *mockECSClient) DescribeTasks(*ecs.DescribeTasksInput) (*ecs.DescribeTasksOutput, error) { + return &ecs.DescribeTasksOutput{ + Tasks: []*ecs.Task{ + { + TaskArn: aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-000000000000"), + Containers: []*ecs.Container{ + { + Name: aws.String("container-1"), + Image: aws.String("image-1"), + ImageDigest: aws.String("sha256:1234567890123456789012345678901234567890123456789012345678901111"), + }, + { + Name: aws.String("container-2"), + Image: aws.String("image-2"), + ImageDigest: aws.String("sha256:1234567890123456789012345678901234567890123456789012345678902222"), + }, + }, + }, + { + TaskArn: aws.String("arn:aws:ecs:us-east-1:123456789012:task/cluster-1/12345678-1234-1234-1234-111111111111"), + Containers: []*ecs.Container{ + { + Name: aws.String("container-3"), + Image: aws.String("image-3"), + ImageDigest: aws.String("sha256:1234567890123456789012345678901234567890123456789012345678903333"), + }, + { + Name: aws.String("container-4-(same-image-as-3)"), + Image: aws.String("image-3"), + ImageDigest: aws.String("sha256:1234567890123456789012345678901234567890123456789012345678903333"), + }, + }, + }, + }, + }, nil +} diff --git a/pkg/inventory/report.go b/pkg/inventory/report.go new file mode 100644 index 0000000..2e24ef1 --- /dev/null +++ b/pkg/inventory/report.go @@ -0,0 +1,126 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/ecs/ecsiface" + + "github.com/anchore/anchore-ecs-inventory/internal/logger" + "github.com/anchore/anchore-ecs-inventory/pkg/connection" + "github.com/anchore/anchore-ecs-inventory/pkg/reporter" +) + +func reportToStdout(report reporter.Report) error { + enc := json.NewEncoder(os.Stdout) + // prevent > and < from being escaped in the payload + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("unable to show inventory: %w", err) + } + return nil +} + +func HandleReport(report reporter.Report, anchoreDetails connection.AnchoreInfo) error { + if anchoreDetails.IsValid() { + if err := reporter.Post(report, anchoreDetails); err != nil { + return fmt.Errorf("unable to report Inventory to Anchore: %w", err) + } + } else { + logger.Log.Debug("Anchore details not specified, not reporting inventory") + } + + return reportToStdout(report) +} + +func GetInventoryReportsForRegion(region string, anchoreDetails connection.AnchoreInfo) error { + sessConfig := &aws.Config{} + if region != "" { + sessConfig.Region = aws.String(region) + } + sess, err := session.NewSession(sessConfig) + if err != nil { + logger.Log.Error("Failed to create AWS session", err) + } + + err = checkAWSCredentials(sess) + if err != nil { + return err + } + + ecsClient := ecs.New(sess) + + clusters, err := fetchClusters(ecsClient) + if err != nil { + return err + } + + var wg sync.WaitGroup + wg.Add(len(clusters)) + + for _, cluster := range clusters { + go func(cluster string) { + defer wg.Done() + + ecsClient := ecs.New(sess) + + report, err := GetInventoryReportForCluster(cluster, ecsClient) + if err != nil { + logger.Log.Error("Failed to get inventory report for cluster", err) + } + + // Only report if there are images present in the cluster + if len(report.Results) != 0 { + err = HandleReport(report, anchoreDetails) + if err != nil { + logger.Log.Error("Failed to report inventory for cluster", err) + } + } + }(*cluster) + } + + wg.Wait() + return nil +} + +// GetInventoryReportForCluster is an atomic method for getting in-use image results, for a cluster +func GetInventoryReportForCluster(cluster string, ecsClient ecsiface.ECSAPI) (reporter.Report, error) { + logger.Log.Debug("Found cluster", "cluster", cluster) + + tasks, err := fetchTasksFromCluster(ecsClient, cluster) + if err != nil { + return reporter.Report{}, err + } + + results := []reporter.ReportItem{} + + // Must be at least one task to continue + if len(tasks) == 0 { + logger.Log.Debug("No tasks found in cluster", "cluster", cluster) + } else { + images, err := fetchImagesFromTasks(ecsClient, cluster, tasks) + if err != nil { + return reporter.Report{}, err + } + results = append(results, reporter.ReportItem{ + Namespace: "", // NOTE The key is Namespace to match the Anchore API but it's actually the cluster ARN + Images: images, + }) + } + + // NOTE: clusterName not used for ECS as the clusternARN (used as the namespace in results payload) provides sufficient + // unique location data (account, region, clustername) + return reporter.Report{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Results: results, + ClusterName: cluster, + InventoryType: "ecs", + }, nil +} diff --git a/pkg/inventory/report_test.go b/pkg/inventory/report_test.go new file mode 100644 index 0000000..5ba47db --- /dev/null +++ b/pkg/inventory/report_test.go @@ -0,0 +1,28 @@ +package inventory + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/anchore-ecs-inventory/internal/logger" +) + +func setupLogger() { + // TODO(bradjones) Setting up logging for tests like this isn't great so will change this later + logConfig := logger.LogConfig{ + Level: "debug", + } + logger.InitLogger(logConfig) +} + +func TestGetInventoryReportForCluster(t *testing.T) { + setupLogger() + + mockSvc := &mockECSClient{} + + report, err := GetInventoryReportForCluster("cluster-1", mockSvc) + + assert.NoError(t, err) + assert.Equal(t, 3, len(report.Results[0].Images)) +} diff --git a/pkg/lib.go b/pkg/lib.go new file mode 100644 index 0000000..855c287 --- /dev/null +++ b/pkg/lib.go @@ -0,0 +1,32 @@ +package pkg + +import ( + "time" + + "github.com/anchore/anchore-ecs-inventory/pkg/connection" + "github.com/anchore/anchore-ecs-inventory/pkg/inventory" + "github.com/anchore/anchore-ecs-inventory/pkg/logger" +) + +var log logger.Logger + +// PeriodicallyGetInventoryReport periodically retrieve image results and report/output them according to the configuration. +// Note: Errors do not cause the function to exit, since this is periodically running +func PeriodicallyGetInventoryReport(pollingIntervalSeconds int, anchoreDetails connection.AnchoreInfo, region string) { + // Fire off a ticker that reports according to a configurable polling interval + ticker := time.NewTicker(time.Duration(pollingIntervalSeconds) * time.Second) + + for { + err := inventory.GetInventoryReportsForRegion(region, anchoreDetails) + if err != nil { + log.Error("Failed to get Inventory Reports for region", err) + } + + // Wait at least as long as the ticker + log.Debugf("Start new gather %s", <-ticker.C) + } +} + +func SetLogger(logger logger.Logger) { + log = logger +} diff --git a/ecg/logger/logger.go b/pkg/logger/logger.go similarity index 100% rename from ecg/logger/logger.go rename to pkg/logger/logger.go diff --git a/ecg/inventory/report.go b/pkg/reporter/report.go similarity index 95% rename from ecg/inventory/report.go rename to pkg/reporter/report.go index 5476618..cfe8359 100644 --- a/ecg/inventory/report.go +++ b/pkg/reporter/report.go @@ -1,4 +1,4 @@ -package inventory +package reporter type Report struct { Timestamp string `json:"timestamp,omitempty"` // Should be generated using time.Now.UTC() and formatted according to RFC Y-M-DTH:M:SZ diff --git a/ecg/reporter/reporter.go b/pkg/reporter/reporter.go similarity index 90% rename from ecg/reporter/reporter.go rename to pkg/reporter/reporter.go index 1da8010..e60151a 100644 --- a/ecg/reporter/reporter.go +++ b/pkg/reporter/reporter.go @@ -10,9 +10,8 @@ import ( "net/url" "time" - "github.com/anchore/anchore-ecs-inventory/ecg/connection" - "github.com/anchore/anchore-ecs-inventory/ecg/inventory" "github.com/anchore/anchore-ecs-inventory/internal/logger" + "github.com/anchore/anchore-ecs-inventory/pkg/connection" ) const ReportAPIPath = "v1/enterprise/inventories" @@ -20,7 +19,7 @@ const ReportAPIPath = "v1/enterprise/inventories" // This method does the actual Reporting (via HTTP) to Anchore // //nolint:gosec -func Post(report inventory.Report, anchoreDetails connection.AnchoreInfo) error { +func Post(report Report, anchoreDetails connection.AnchoreInfo) error { logger.Log.Debug("Reporting results to Anchore") tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: anchoreDetails.HTTP.Insecure}, diff --git a/ecg/reporter/reporter_test.go b/pkg/reporter/reporter_test.go similarity index 88% rename from ecg/reporter/reporter_test.go rename to pkg/reporter/reporter_test.go index f399b07..2b0006f 100644 --- a/ecg/reporter/reporter_test.go +++ b/pkg/reporter/reporter_test.go @@ -3,7 +3,7 @@ package reporter import ( "testing" - "github.com/anchore/anchore-ecs-inventory/ecg/connection" + "github.com/anchore/anchore-ecs-inventory/pkg/connection" ) func TestBuildUrl(t *testing.T) { diff --git a/ecg/inventory/reportitem.go b/pkg/reporter/reportitem.go similarity index 84% rename from ecg/inventory/reportitem.go rename to pkg/reporter/reportitem.go index 17ad38d..2df6e5b 100644 --- a/ecg/inventory/reportitem.go +++ b/pkg/reporter/reportitem.go @@ -1,4 +1,4 @@ -package inventory +package reporter import ( "fmt" @@ -6,7 +6,7 @@ import ( // ReportItem represents a cluster and all it's unique images type ReportItem struct { - Namespace string `json:"namespace,omitempty"` // NOTE The key is Namespace to match the Anchore API but it's actually the cluster ARN + Namespace string `json:"namespace,omitempty"` // NOTE The key is Namespace to match the Anchore API but it's actually passed as empty string Images []ReportImage `json:"images"` }