From 14f7d9e2e57aba90b3cb5df71dc379d640b93675 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 14:45:48 +0900 Subject: [PATCH 01/23] o --- Makefile | 2 +- awsiface/iface.go | 5 + cli/cage/commands/command.go | 8 +- cli/cage/commands/scan.go | 59 +++++++++++ cli/cage/main.go | 3 + go.mod | 9 +- go.sum | 18 ++-- key/keys.go | 1 + mocks/mock_awsiface/iface.go | 187 +++++++++++++++++++++++---------- mocks/mock_rollout/executor.go | 10 +- mocks/mock_task/factory.go | 12 ++- mocks/mock_task/task.go | 12 ++- mocks/mock_taskset/taskset.go | 10 +- mocks/mock_types/iface.go | 32 +++++- mocks/mock_upgrade/upgrade.go | 10 +- scan/ecr.go | 107 +++++++++++++++++++ scan/ecs.go | 113 ++++++++++++++++++++ scan/printer.go | 78 ++++++++++++++ scan/scanner.go | 56 ++++++++++ scan/types.go | 11 ++ 20 files changed, 654 insertions(+), 89 deletions(-) create mode 100644 cli/cage/commands/scan.go create mode 100644 scan/ecr.go create mode 100644 scan/ecs.go create mode 100644 scan/printer.go create mode 100644 scan/scanner.go create mode 100644 scan/types.go diff --git a/Makefile b/Makefile index d16753d..9cff6df 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -MOCKGEN := go run go.uber.org/mock/mockgen +MOCKGEN := mockgen .PHONY: test test: go test ./... -coverprofile=coverage.txt -covermode=count diff --git a/awsiface/iface.go b/awsiface/iface.go index 94cd650..f106afb 100644 --- a/awsiface/iface.go +++ b/awsiface/iface.go @@ -4,6 +4,7 @@ import ( "context" "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecs" elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" ) @@ -23,6 +24,10 @@ type ( StopTask(ctx context.Context, params *ecs.StopTaskInput, optFns ...func(*ecs.Options)) (*ecs.StopTaskOutput, error) DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) } + EcrClient interface { + BatchGetImage(ctx context.Context, params *ecr.BatchGetImageInput, optFns ...func(*ecr.Options)) (*ecr.BatchGetImageOutput, error) + DescribeImageScanFindings(ctx context.Context, params *ecr.DescribeImageScanFindingsInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) + } AlbClient interface { DescribeTargetGroups(ctx context.Context, params *elbv2.DescribeTargetGroupsInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTargetGroupsOutput, error) DescribeTargetHealth(ctx context.Context, params *elbv2.DescribeTargetHealthInput, optFns ...func(*elbv2.Options)) (*elbv2.DescribeTargetHealthOutput, error) diff --git a/cli/cage/commands/command.go b/cli/cage/commands/command.go index c8c162e..4e5551b 100644 --- a/cli/cage/commands/command.go +++ b/cli/cage/commands/command.go @@ -26,7 +26,7 @@ func NewCageCommands( } } -type cageCliProvier = func(envars *env.Envars) (types.Cage, error) +type cageCliProvier = func(e *env.Envars) (types.Cage, error) func (c *CageCommands) requireArgs( ctx *cli.Context, @@ -70,7 +70,11 @@ func (c *CageCommands) setupCage( if err := env.EnsureEnvars(envars); err != nil { return nil, err } - cagecli, err := c.cageCliProvier(envars) + di, err := c.diProvider(envars) + if err != nil { + return nil, err + } + cagecli, err := c.cageCliProvier(di) if err != nil { return nil, err } diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/scan.go new file mode 100644 index 0000000..fcd09f1 --- /dev/null +++ b/cli/cage/commands/scan.go @@ -0,0 +1,59 @@ +package commands + +import ( + "context" + "errors" + + "github.com/loilo-inc/canarycage/awsiface" + "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/scan" + "github.com/urfave/cli/v2" +) + +func (c *CageCommands) Scan( + ecscli awsiface.EcsClient, + ecrcli awsiface.EcrClient, +) *cli.Command { + var region string + var cluster string + var service string + return &cli.Command{ + Name: "scan", + Usage: "Scan ECR image vulnerabilities for the given ECS service", + Args: true, + ArgsUsage: "", + Flags: []cli.Flag{ + RegionFlag(®ion), + ClusterFlag(&cluster), + ServiceFlag(&service), + }, + Action: func(ctx *cli.Context) error { + dir, _, err := c.requireArgs(ctx, 0, 1) + if err != nil { + return err + } + if dir != "" { + srv, err := env.LoadServiceDefinition(dir) + if err != nil { + return err + } + if srv.ServiceName == nil || srv.Cluster == nil { + return errors.New("service.json must contain ServiceName and Cluster") + } + service = *srv.ServiceName + cluster = *srv.Cluster + } else if cluster == "" || service == "" { + return errors.New("either directory argument or both --cluster and --service flags must be provided") + } + scanner := scan.NewScanner(ecscli, ecrcli) + result, err := scanner.Scan(context.Background(), cluster, service) + if err != nil { + return err + } + logger := scan.DefaultLogger() + printer := scan.NewPrinter(logger) + printer.Print(result) + return nil + }, + } +} diff --git a/cli/cage/main.go b/cli/cage/main.go index 5dcb4e4..f88e057 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" cage "github.com/loilo-inc/canarycage" @@ -44,6 +45,7 @@ func main() { cmds.RollOut(&envars), cmds.Run(&envars), cmds.Upgrade(upgrade.NewUpgrader(version)), + cmds.Scan(&envars), } app.Flags = []cli.Flag{ &cli.BoolFlag{ @@ -68,6 +70,7 @@ func provideCageCli(envars *env.Envars) (types.Cage, error) { d := di.NewDomain(func(b *di.B) { b.Set(key.Env, envars) b.Set(key.EcsCli, ecs.NewFromConfig(conf)) + b.Set(key.EcrCli, ecr.NewFromConfig(conf)) b.Set(key.Ec2Cli, ec2.NewFromConfig(conf)) b.Set(key.AlbCli, elasticloadbalancingv2.NewFromConfig(conf)) b.Set(key.TaskFactory, task.NewFactory(b.Future())) diff --git a/go.mod b/go.mod index c6e09a6..97f0e3e 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.25.5 require ( github.com/apex/log v1.9.0 - github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.6 github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0 + github.com/aws/aws-sdk-go-v2/service/ecr v1.55.1 github.com/aws/aws-sdk-go-v2/service/ecs v1.70.0 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.5 github.com/google/go-github/v62 v62.0.0 @@ -15,6 +16,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 go.uber.org/mock v0.6.0 + golang.org/x/sync v0.16.0 ) require ( @@ -26,8 +28,8 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect @@ -42,7 +44,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - golang.org/x/sync v0.19.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5d225bc..257a196 100644 --- a/go.sum +++ b/go.sum @@ -6,22 +6,24 @@ github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDw github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0 h1:o7eJKe6VYAnqERPlLAvDW5VKXV6eTKv1oxTpMoDP378= github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0/go.mod h1:Wg68QRgy2gEGGdmTPU/UbVpdv8sM14bUZmF64KFwAsY= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.1 h1:B7f9R99lCF83XlolTg6d6Lvghyto+/VU83ZrneAVfK8= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.1/go.mod h1:cpYRXx5BkmS3mwWRKPbWSPKmyAUNL7aLWAPiiinwk/U= github.com/aws/aws-sdk-go-v2/service/ecs v1.70.0 h1:IZpZatHsscdOKjwmDXC6idsCXmm3F/obutAUNjnX+OM= github.com/aws/aws-sdk-go-v2/service/ecs v1.70.0/go.mod h1:LQMlcWBoiFVD3vUVEz42ST0yTiaDujv2dRE6sXt1yPE= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.54.5 h1:JjKuK9zbAVv6X44ia/OZrRS8ngOx3QfvtQTN0poJdPw= @@ -119,8 +121,8 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/key/keys.go b/key/keys.go index 7b32dda..749c054 100644 --- a/key/keys.go +++ b/key/keys.go @@ -4,6 +4,7 @@ type DepsKey string const ( EcsCli DepsKey = "ecs" + EcrCli DepsKey = "ecr" Ec2Cli DepsKey = "ec2" AlbCli DepsKey = "alb" Env DepsKey = "env" diff --git a/mocks/mock_awsiface/iface.go b/mocks/mock_awsiface/iface.go index a7dc97d..b3fdbda 100644 --- a/mocks/mock_awsiface/iface.go +++ b/mocks/mock_awsiface/iface.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./awsiface/iface.go +// +// Generated by this command: +// +// mockgen -source=./awsiface/iface.go +// // Package mock_awsiface is a generated GoMock package. package mock_awsiface @@ -9,6 +14,7 @@ import ( reflect "reflect" ec2 "github.com/aws/aws-sdk-go-v2/service/ec2" + ecr "github.com/aws/aws-sdk-go-v2/service/ecr" ecs "github.com/aws/aws-sdk-go-v2/service/ecs" elasticloadbalancingv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" gomock "go.uber.org/mock/gomock" @@ -18,6 +24,7 @@ import ( type MockEcsClient struct { ctrl *gomock.Controller recorder *MockEcsClientMockRecorder + isgomock struct{} } // MockEcsClientMockRecorder is the mock recorder for MockEcsClient. @@ -40,7 +47,7 @@ func (m *MockEcsClient) EXPECT() *MockEcsClientMockRecorder { // CreateService mocks base method. func (m *MockEcsClient) CreateService(ctx context.Context, params *ecs.CreateServiceInput, optFns ...func(*ecs.Options)) (*ecs.CreateServiceOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -51,16 +58,16 @@ func (m *MockEcsClient) CreateService(ctx context.Context, params *ecs.CreateSer } // CreateService indicates an expected call of CreateService. -func (mr *MockEcsClientMockRecorder) CreateService(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) CreateService(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockEcsClient)(nil).CreateService), varargs...) } // DeleteService mocks base method. func (m *MockEcsClient) DeleteService(ctx context.Context, params *ecs.DeleteServiceInput, optFns ...func(*ecs.Options)) (*ecs.DeleteServiceOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -71,16 +78,16 @@ func (m *MockEcsClient) DeleteService(ctx context.Context, params *ecs.DeleteSer } // DeleteService indicates an expected call of DeleteService. -func (mr *MockEcsClientMockRecorder) DeleteService(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) DeleteService(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockEcsClient)(nil).DeleteService), varargs...) } // DescribeContainerInstances mocks base method. func (m *MockEcsClient) DescribeContainerInstances(ctx context.Context, params *ecs.DescribeContainerInstancesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeContainerInstancesOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -91,16 +98,16 @@ func (m *MockEcsClient) DescribeContainerInstances(ctx context.Context, params * } // DescribeContainerInstances indicates an expected call of DescribeContainerInstances. -func (mr *MockEcsClientMockRecorder) DescribeContainerInstances(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) DescribeContainerInstances(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeContainerInstances", reflect.TypeOf((*MockEcsClient)(nil).DescribeContainerInstances), varargs...) } // DescribeServices mocks base method. func (m *MockEcsClient) DescribeServices(ctx context.Context, params *ecs.DescribeServicesInput, optFns ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -111,16 +118,16 @@ func (m *MockEcsClient) DescribeServices(ctx context.Context, params *ecs.Descri } // DescribeServices indicates an expected call of DescribeServices. -func (mr *MockEcsClientMockRecorder) DescribeServices(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) DescribeServices(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeServices", reflect.TypeOf((*MockEcsClient)(nil).DescribeServices), varargs...) } // DescribeTaskDefinition mocks base method. func (m *MockEcsClient) DescribeTaskDefinition(ctx context.Context, params *ecs.DescribeTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -131,16 +138,16 @@ func (m *MockEcsClient) DescribeTaskDefinition(ctx context.Context, params *ecs. } // DescribeTaskDefinition indicates an expected call of DescribeTaskDefinition. -func (mr *MockEcsClientMockRecorder) DescribeTaskDefinition(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) DescribeTaskDefinition(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeTaskDefinition", reflect.TypeOf((*MockEcsClient)(nil).DescribeTaskDefinition), varargs...) } // DescribeTasks mocks base method. func (m *MockEcsClient) DescribeTasks(ctx context.Context, params *ecs.DescribeTasksInput, optFns ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -151,16 +158,16 @@ func (m *MockEcsClient) DescribeTasks(ctx context.Context, params *ecs.DescribeT } // DescribeTasks indicates an expected call of DescribeTasks. -func (mr *MockEcsClientMockRecorder) DescribeTasks(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) DescribeTasks(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeTasks", reflect.TypeOf((*MockEcsClient)(nil).DescribeTasks), varargs...) } // ListTasks mocks base method. func (m *MockEcsClient) ListTasks(ctx context.Context, params *ecs.ListTasksInput, optFns ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -171,16 +178,16 @@ func (m *MockEcsClient) ListTasks(ctx context.Context, params *ecs.ListTasksInpu } // ListTasks indicates an expected call of ListTasks. -func (mr *MockEcsClientMockRecorder) ListTasks(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) ListTasks(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTasks", reflect.TypeOf((*MockEcsClient)(nil).ListTasks), varargs...) } // RegisterTaskDefinition mocks base method. func (m *MockEcsClient) RegisterTaskDefinition(ctx context.Context, params *ecs.RegisterTaskDefinitionInput, optFns ...func(*ecs.Options)) (*ecs.RegisterTaskDefinitionOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -191,16 +198,16 @@ func (m *MockEcsClient) RegisterTaskDefinition(ctx context.Context, params *ecs. } // RegisterTaskDefinition indicates an expected call of RegisterTaskDefinition. -func (mr *MockEcsClientMockRecorder) RegisterTaskDefinition(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) RegisterTaskDefinition(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterTaskDefinition", reflect.TypeOf((*MockEcsClient)(nil).RegisterTaskDefinition), varargs...) } // RunTask mocks base method. func (m *MockEcsClient) RunTask(ctx context.Context, params *ecs.RunTaskInput, optFns ...func(*ecs.Options)) (*ecs.RunTaskOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -211,16 +218,16 @@ func (m *MockEcsClient) RunTask(ctx context.Context, params *ecs.RunTaskInput, o } // RunTask indicates an expected call of RunTask. -func (mr *MockEcsClientMockRecorder) RunTask(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) RunTask(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunTask", reflect.TypeOf((*MockEcsClient)(nil).RunTask), varargs...) } // StartTask mocks base method. func (m *MockEcsClient) StartTask(ctx context.Context, params *ecs.StartTaskInput, optFns ...func(*ecs.Options)) (*ecs.StartTaskOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -231,16 +238,16 @@ func (m *MockEcsClient) StartTask(ctx context.Context, params *ecs.StartTaskInpu } // StartTask indicates an expected call of StartTask. -func (mr *MockEcsClientMockRecorder) StartTask(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) StartTask(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartTask", reflect.TypeOf((*MockEcsClient)(nil).StartTask), varargs...) } // StopTask mocks base method. func (m *MockEcsClient) StopTask(ctx context.Context, params *ecs.StopTaskInput, optFns ...func(*ecs.Options)) (*ecs.StopTaskOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -251,16 +258,16 @@ func (m *MockEcsClient) StopTask(ctx context.Context, params *ecs.StopTaskInput, } // StopTask indicates an expected call of StopTask. -func (mr *MockEcsClientMockRecorder) StopTask(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) StopTask(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopTask", reflect.TypeOf((*MockEcsClient)(nil).StopTask), varargs...) } // UpdateService mocks base method. func (m *MockEcsClient) UpdateService(ctx context.Context, params *ecs.UpdateServiceInput, optFns ...func(*ecs.Options)) (*ecs.UpdateServiceOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -271,16 +278,81 @@ func (m *MockEcsClient) UpdateService(ctx context.Context, params *ecs.UpdateSer } // UpdateService indicates an expected call of UpdateService. -func (mr *MockEcsClientMockRecorder) UpdateService(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEcsClientMockRecorder) UpdateService(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockEcsClient)(nil).UpdateService), varargs...) } +// MockEcrClient is a mock of EcrClient interface. +type MockEcrClient struct { + ctrl *gomock.Controller + recorder *MockEcrClientMockRecorder + isgomock struct{} +} + +// MockEcrClientMockRecorder is the mock recorder for MockEcrClient. +type MockEcrClientMockRecorder struct { + mock *MockEcrClient +} + +// NewMockEcrClient creates a new mock instance. +func NewMockEcrClient(ctrl *gomock.Controller) *MockEcrClient { + mock := &MockEcrClient{ctrl: ctrl} + mock.recorder = &MockEcrClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEcrClient) EXPECT() *MockEcrClientMockRecorder { + return m.recorder +} + +// BatchGetImage mocks base method. +func (m *MockEcrClient) BatchGetImage(ctx context.Context, params *ecr.BatchGetImageInput, optFns ...func(*ecr.Options)) (*ecr.BatchGetImageOutput, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, params} + for _, a := range optFns { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "BatchGetImage", varargs...) + ret0, _ := ret[0].(*ecr.BatchGetImageOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BatchGetImage indicates an expected call of BatchGetImage. +func (mr *MockEcrClientMockRecorder) BatchGetImage(ctx, params any, optFns ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, params}, optFns...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetImage", reflect.TypeOf((*MockEcrClient)(nil).BatchGetImage), varargs...) +} + +// DescribeImageScanFindings mocks base method. +func (m *MockEcrClient) DescribeImageScanFindings(ctx context.Context, params *ecr.DescribeImageScanFindingsInput, optFns ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, params} + for _, a := range optFns { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DescribeImageScanFindings", varargs...) + ret0, _ := ret[0].(*ecr.DescribeImageScanFindingsOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeImageScanFindings indicates an expected call of DescribeImageScanFindings. +func (mr *MockEcrClientMockRecorder) DescribeImageScanFindings(ctx, params any, optFns ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, params}, optFns...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeImageScanFindings", reflect.TypeOf((*MockEcrClient)(nil).DescribeImageScanFindings), varargs...) +} + // MockAlbClient is a mock of AlbClient interface. type MockAlbClient struct { ctrl *gomock.Controller recorder *MockAlbClientMockRecorder + isgomock struct{} } // MockAlbClientMockRecorder is the mock recorder for MockAlbClient. @@ -303,7 +375,7 @@ func (m *MockAlbClient) EXPECT() *MockAlbClientMockRecorder { // DeregisterTargets mocks base method. func (m *MockAlbClient) DeregisterTargets(ctx context.Context, params *elasticloadbalancingv2.DeregisterTargetsInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DeregisterTargetsOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -314,16 +386,16 @@ func (m *MockAlbClient) DeregisterTargets(ctx context.Context, params *elasticlo } // DeregisterTargets indicates an expected call of DeregisterTargets. -func (mr *MockAlbClientMockRecorder) DeregisterTargets(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockAlbClientMockRecorder) DeregisterTargets(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeregisterTargets", reflect.TypeOf((*MockAlbClient)(nil).DeregisterTargets), varargs...) } // DescribeTargetGroupAttributes mocks base method. func (m *MockAlbClient) DescribeTargetGroupAttributes(ctx context.Context, params *elasticloadbalancingv2.DescribeTargetGroupAttributesInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTargetGroupAttributesOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -334,16 +406,16 @@ func (m *MockAlbClient) DescribeTargetGroupAttributes(ctx context.Context, param } // DescribeTargetGroupAttributes indicates an expected call of DescribeTargetGroupAttributes. -func (mr *MockAlbClientMockRecorder) DescribeTargetGroupAttributes(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockAlbClientMockRecorder) DescribeTargetGroupAttributes(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeTargetGroupAttributes", reflect.TypeOf((*MockAlbClient)(nil).DescribeTargetGroupAttributes), varargs...) } // DescribeTargetGroups mocks base method. func (m *MockAlbClient) DescribeTargetGroups(ctx context.Context, params *elasticloadbalancingv2.DescribeTargetGroupsInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTargetGroupsOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -354,16 +426,16 @@ func (m *MockAlbClient) DescribeTargetGroups(ctx context.Context, params *elasti } // DescribeTargetGroups indicates an expected call of DescribeTargetGroups. -func (mr *MockAlbClientMockRecorder) DescribeTargetGroups(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockAlbClientMockRecorder) DescribeTargetGroups(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeTargetGroups", reflect.TypeOf((*MockAlbClient)(nil).DescribeTargetGroups), varargs...) } // DescribeTargetHealth mocks base method. func (m *MockAlbClient) DescribeTargetHealth(ctx context.Context, params *elasticloadbalancingv2.DescribeTargetHealthInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTargetHealthOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -374,16 +446,16 @@ func (m *MockAlbClient) DescribeTargetHealth(ctx context.Context, params *elasti } // DescribeTargetHealth indicates an expected call of DescribeTargetHealth. -func (mr *MockAlbClientMockRecorder) DescribeTargetHealth(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockAlbClientMockRecorder) DescribeTargetHealth(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeTargetHealth", reflect.TypeOf((*MockAlbClient)(nil).DescribeTargetHealth), varargs...) } // RegisterTargets mocks base method. func (m *MockAlbClient) RegisterTargets(ctx context.Context, params *elasticloadbalancingv2.RegisterTargetsInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.RegisterTargetsOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -394,9 +466,9 @@ func (m *MockAlbClient) RegisterTargets(ctx context.Context, params *elasticload } // RegisterTargets indicates an expected call of RegisterTargets. -func (mr *MockAlbClientMockRecorder) RegisterTargets(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockAlbClientMockRecorder) RegisterTargets(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterTargets", reflect.TypeOf((*MockAlbClient)(nil).RegisterTargets), varargs...) } @@ -404,6 +476,7 @@ func (mr *MockAlbClientMockRecorder) RegisterTargets(ctx, params interface{}, op type MockEc2Client struct { ctrl *gomock.Controller recorder *MockEc2ClientMockRecorder + isgomock struct{} } // MockEc2ClientMockRecorder is the mock recorder for MockEc2Client. @@ -426,7 +499,7 @@ func (m *MockEc2Client) EXPECT() *MockEc2ClientMockRecorder { // DescribeInstances mocks base method. func (m *MockEc2Client) DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -437,16 +510,16 @@ func (m *MockEc2Client) DescribeInstances(ctx context.Context, params *ec2.Descr } // DescribeInstances indicates an expected call of DescribeInstances. -func (mr *MockEc2ClientMockRecorder) DescribeInstances(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEc2ClientMockRecorder) DescribeInstances(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeInstances", reflect.TypeOf((*MockEc2Client)(nil).DescribeInstances), varargs...) } // DescribeSubnets mocks base method. func (m *MockEc2Client) DescribeSubnets(ctx context.Context, params *ec2.DescribeSubnetsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { m.ctrl.T.Helper() - varargs := []interface{}{ctx, params} + varargs := []any{ctx, params} for _, a := range optFns { varargs = append(varargs, a) } @@ -457,8 +530,8 @@ func (m *MockEc2Client) DescribeSubnets(ctx context.Context, params *ec2.Describ } // DescribeSubnets indicates an expected call of DescribeSubnets. -func (mr *MockEc2ClientMockRecorder) DescribeSubnets(ctx, params interface{}, optFns ...interface{}) *gomock.Call { +func (mr *MockEc2ClientMockRecorder) DescribeSubnets(ctx, params any, optFns ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{ctx, params}, optFns...) + varargs := append([]any{ctx, params}, optFns...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeSubnets", reflect.TypeOf((*MockEc2Client)(nil).DescribeSubnets), varargs...) } diff --git a/mocks/mock_rollout/executor.go b/mocks/mock_rollout/executor.go index 4ee039e..f86d285 100644 --- a/mocks/mock_rollout/executor.go +++ b/mocks/mock_rollout/executor.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./rollout/executor.go +// +// Generated by this command: +// +// mockgen -source=./rollout/executor.go +// // Package mock_rollout is a generated GoMock package. package mock_rollout @@ -8,14 +13,15 @@ import ( context "context" reflect "reflect" - gomock "go.uber.org/mock/gomock" types "github.com/loilo-inc/canarycage/types" + gomock "go.uber.org/mock/gomock" ) // MockExecutor is a mock of Executor interface. type MockExecutor struct { ctrl *gomock.Controller recorder *MockExecutorMockRecorder + isgomock struct{} } // MockExecutorMockRecorder is the mock recorder for MockExecutor. @@ -44,7 +50,7 @@ func (m *MockExecutor) RollOut(ctx context.Context, input *types.RollOutInput) e } // RollOut indicates an expected call of RollOut. -func (mr *MockExecutorMockRecorder) RollOut(ctx, input interface{}) *gomock.Call { +func (mr *MockExecutorMockRecorder) RollOut(ctx, input any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollOut", reflect.TypeOf((*MockExecutor)(nil).RollOut), ctx, input) } diff --git a/mocks/mock_task/factory.go b/mocks/mock_task/factory.go index eb99313..08cdfa0 100644 --- a/mocks/mock_task/factory.go +++ b/mocks/mock_task/factory.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./task/factory.go +// +// Generated by this command: +// +// mockgen -source=./task/factory.go +// // Package mock_task is a generated GoMock package. package mock_task @@ -8,14 +13,15 @@ import ( reflect "reflect" types "github.com/aws/aws-sdk-go-v2/service/ecs/types" - gomock "go.uber.org/mock/gomock" task "github.com/loilo-inc/canarycage/task" + gomock "go.uber.org/mock/gomock" ) // MockFactory is a mock of Factory interface. type MockFactory struct { ctrl *gomock.Controller recorder *MockFactoryMockRecorder + isgomock struct{} } // MockFactoryMockRecorder is the mock recorder for MockFactory. @@ -44,7 +50,7 @@ func (m *MockFactory) NewAlbTask(input *task.Input, lb *types.LoadBalancer) task } // NewAlbTask indicates an expected call of NewAlbTask. -func (mr *MockFactoryMockRecorder) NewAlbTask(input, lb interface{}) *gomock.Call { +func (mr *MockFactoryMockRecorder) NewAlbTask(input, lb any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewAlbTask", reflect.TypeOf((*MockFactory)(nil).NewAlbTask), input, lb) } @@ -58,7 +64,7 @@ func (m *MockFactory) NewSimpleTask(input *task.Input) task.Task { } // NewSimpleTask indicates an expected call of NewSimpleTask. -func (mr *MockFactoryMockRecorder) NewSimpleTask(input interface{}) *gomock.Call { +func (mr *MockFactoryMockRecorder) NewSimpleTask(input any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewSimpleTask", reflect.TypeOf((*MockFactory)(nil).NewSimpleTask), input) } diff --git a/mocks/mock_task/task.go b/mocks/mock_task/task.go index d991706..ff4490a 100644 --- a/mocks/mock_task/task.go +++ b/mocks/mock_task/task.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./task/task.go +// +// Generated by this command: +// +// mockgen -source=./task/task.go +// // Package mock_task is a generated GoMock package. package mock_task @@ -15,6 +20,7 @@ import ( type MockTask struct { ctrl *gomock.Controller recorder *MockTaskMockRecorder + isgomock struct{} } // MockTaskMockRecorder is the mock recorder for MockTask. @@ -43,7 +49,7 @@ func (m *MockTask) Start(ctx context.Context) error { } // Start indicates an expected call of Start. -func (mr *MockTaskMockRecorder) Start(ctx interface{}) *gomock.Call { +func (mr *MockTaskMockRecorder) Start(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockTask)(nil).Start), ctx) } @@ -57,7 +63,7 @@ func (m *MockTask) Stop(ctx context.Context) error { } // Stop indicates an expected call of Stop. -func (mr *MockTaskMockRecorder) Stop(ctx interface{}) *gomock.Call { +func (mr *MockTaskMockRecorder) Stop(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockTask)(nil).Stop), ctx) } @@ -71,7 +77,7 @@ func (m *MockTask) Wait(ctx context.Context) error { } // Wait indicates an expected call of Wait. -func (mr *MockTaskMockRecorder) Wait(ctx interface{}) *gomock.Call { +func (mr *MockTaskMockRecorder) Wait(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockTask)(nil).Wait), ctx) } diff --git a/mocks/mock_taskset/taskset.go b/mocks/mock_taskset/taskset.go index 3cd1520..057f771 100644 --- a/mocks/mock_taskset/taskset.go +++ b/mocks/mock_taskset/taskset.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./taskset/taskset.go +// +// Generated by this command: +// +// mockgen -source=./taskset/taskset.go +// // Package mock_taskset is a generated GoMock package. package mock_taskset @@ -15,6 +20,7 @@ import ( type MockSet struct { ctrl *gomock.Controller recorder *MockSetMockRecorder + isgomock struct{} } // MockSetMockRecorder is the mock recorder for MockSet. @@ -43,7 +49,7 @@ func (m *MockSet) Cleanup(ctx context.Context) error { } // Cleanup indicates an expected call of Cleanup. -func (mr *MockSetMockRecorder) Cleanup(ctx interface{}) *gomock.Call { +func (mr *MockSetMockRecorder) Cleanup(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockSet)(nil).Cleanup), ctx) } @@ -57,7 +63,7 @@ func (m *MockSet) Exec(ctx context.Context) error { } // Exec indicates an expected call of Exec. -func (mr *MockSetMockRecorder) Exec(ctx interface{}) *gomock.Call { +func (mr *MockSetMockRecorder) Exec(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockSet)(nil).Exec), ctx) } diff --git a/mocks/mock_types/iface.go b/mocks/mock_types/iface.go index a119993..983cf51 100644 --- a/mocks/mock_types/iface.go +++ b/mocks/mock_types/iface.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./types/iface.go +// +// Generated by this command: +// +// mockgen -source=./types/iface.go +// // Package mock_types is a generated GoMock package. package mock_types @@ -9,14 +14,15 @@ import ( reflect "reflect" time "time" - gomock "go.uber.org/mock/gomock" types "github.com/loilo-inc/canarycage/types" + gomock "go.uber.org/mock/gomock" ) // MockCage is a mock of Cage interface. type MockCage struct { ctrl *gomock.Controller recorder *MockCageMockRecorder + isgomock struct{} } // MockCageMockRecorder is the mock recorder for MockCage. @@ -46,7 +52,7 @@ func (m *MockCage) RollOut(ctx context.Context, input *types.RollOutInput) (*typ } // RollOut indicates an expected call of RollOut. -func (mr *MockCageMockRecorder) RollOut(ctx, input interface{}) *gomock.Call { +func (mr *MockCageMockRecorder) RollOut(ctx, input any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollOut", reflect.TypeOf((*MockCage)(nil).RollOut), ctx, input) } @@ -61,11 +67,26 @@ func (m *MockCage) Run(ctx context.Context, input *types.RunInput) (*types.RunRe } // Run indicates an expected call of Run. -func (mr *MockCageMockRecorder) Run(ctx, input interface{}) *gomock.Call { +func (mr *MockCageMockRecorder) Run(ctx, input any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockCage)(nil).Run), ctx, input) } +// Scan mocks base method. +func (m *MockCage) Scan(ctx context.Context) (*types.ScanResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan", ctx) + ret0, _ := ret[0].(*types.ScanResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Scan indicates an expected call of Scan. +func (mr *MockCageMockRecorder) Scan(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockCage)(nil).Scan), ctx) +} + // Up mocks base method. func (m *MockCage) Up(ctx context.Context) (*types.UpResult, error) { m.ctrl.T.Helper() @@ -76,7 +97,7 @@ func (m *MockCage) Up(ctx context.Context) (*types.UpResult, error) { } // Up indicates an expected call of Up. -func (mr *MockCageMockRecorder) Up(ctx interface{}) *gomock.Call { +func (mr *MockCageMockRecorder) Up(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockCage)(nil).Up), ctx) } @@ -85,6 +106,7 @@ func (mr *MockCageMockRecorder) Up(ctx interface{}) *gomock.Call { type MockTime struct { ctrl *gomock.Controller recorder *MockTimeMockRecorder + isgomock struct{} } // MockTimeMockRecorder is the mock recorder for MockTime. @@ -113,7 +135,7 @@ func (m *MockTime) NewTimer(arg0 time.Duration) *time.Timer { } // NewTimer indicates an expected call of NewTimer. -func (mr *MockTimeMockRecorder) NewTimer(arg0 interface{}) *gomock.Call { +func (mr *MockTimeMockRecorder) NewTimer(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewTimer", reflect.TypeOf((*MockTime)(nil).NewTimer), arg0) } diff --git a/mocks/mock_upgrade/upgrade.go b/mocks/mock_upgrade/upgrade.go index 70de7a7..6f4faff 100644 --- a/mocks/mock_upgrade/upgrade.go +++ b/mocks/mock_upgrade/upgrade.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./cli/cage/upgrade/upgrade.go +// +// Generated by this command: +// +// mockgen -source=./cli/cage/upgrade/upgrade.go +// // Package mock_upgrade is a generated GoMock package. package mock_upgrade @@ -7,14 +12,15 @@ package mock_upgrade import ( reflect "reflect" - gomock "go.uber.org/mock/gomock" upgrade "github.com/loilo-inc/canarycage/cli/cage/upgrade" + gomock "go.uber.org/mock/gomock" ) // MockUpgrader is a mock of Upgrader interface. type MockUpgrader struct { ctrl *gomock.Controller recorder *MockUpgraderMockRecorder + isgomock struct{} } // MockUpgraderMockRecorder is the mock recorder for MockUpgrader. @@ -43,7 +49,7 @@ func (m *MockUpgrader) Upgrade(p *upgrade.Input) error { } // Upgrade indicates an expected call of Upgrade. -func (mr *MockUpgraderMockRecorder) Upgrade(p interface{}) *gomock.Call { +func (mr *MockUpgraderMockRecorder) Upgrade(p any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upgrade", reflect.TypeOf((*MockUpgrader)(nil).Upgrade), p) } diff --git a/scan/ecr.go b/scan/ecr.go new file mode 100644 index 0000000..18d20e4 --- /dev/null +++ b/scan/ecr.go @@ -0,0 +1,107 @@ +package scan + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/awsiface" +) + +const dockerManifestListMediaType = "application/vnd.docker.distribution.manifest.list.v2+json" + +type ecrTool struct { + Ecr awsiface.EcrClient +} + +type EcrTool interface { + GetActualImageIdentifier(ctx context.Context, info *ImageInfo) (*ecrtypes.ImageIdentifier, error) + GetImageScanFindings(ctx context.Context, info *ImageInfo, imageID *ecrtypes.ImageIdentifier) (*ecrtypes.ImageScanFindings, error) +} + +func newEcrTool(ecrClient awsiface.EcrClient) EcrTool { + return &ecrTool{Ecr: ecrClient} +} + +func (t *ecrTool) GetActualImageIdentifier(ctx context.Context, info *ImageInfo) (*ecrtypes.ImageIdentifier, error) { + res, err := t.Ecr.BatchGetImage(ctx, &ecr.BatchGetImageInput{ + RepositoryName: aws.String(info.Repository), + ImageIds: []ecrtypes.ImageIdentifier{{ImageTag: aws.String(info.Tag)}}, + }) + if err != nil { + return nil, err + } + if len(res.Images) == 0 || res.Images[0].ImageManifest == nil { + return nil, fmt.Errorf("image manifest not found for %s:%s", info.Repository, info.Tag) + } + + var manifest dockerSchema + if err := json.Unmarshal([]byte(*res.Images[0].ImageManifest), &manifest); err != nil { + return nil, fmt.Errorf("parse image manifest for %s:%s: %w", info.Repository, info.Tag, err) + } + + if manifest.MediaType == dockerManifestListMediaType { + for _, candidate := range manifest.Manifests { + if candidate.Platform == nil { + continue + } + if toCPUArchitecture(candidate.Platform.Architecture) == info.PlatformArch { + return &ecrtypes.ImageIdentifier{ImageDigest: aws.String(candidate.Digest)}, nil + } + } + return nil, fmt.Errorf("no image found for architecture: %s in %s:%s", info.PlatformArch, info.Repository, info.Tag) + } + + return &ecrtypes.ImageIdentifier{ImageTag: aws.String(info.Tag)}, nil +} + +func (t *ecrTool) GetImageScanFindings(ctx context.Context, info *ImageInfo, imageID *ecrtypes.ImageIdentifier) (*ecrtypes.ImageScanFindings, error) { + res, err := t.Ecr.DescribeImageScanFindings(ctx, &ecr.DescribeImageScanFindingsInput{ + RepositoryName: aws.String(info.Repository), + ImageId: imageID, + }) + if err != nil { + return nil, err + } + if res.ImageScanFindings == nil { + return nil, fmt.Errorf("image scan findings missing for %s:%s", info.Repository, info.Tag) + } + return res.ImageScanFindings, nil +} + +var _ awsiface.EcrClient = (*ecr.Client)(nil) + +type dockerSchema struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []dockerManifest `json:"manifests,omitempty"` + Config *dockerManifest `json:"config,omitempty"` + Layers []dockerManifest `json:"layers,omitempty"` +} + +type dockerManifest struct { + MediaType string `json:"mediaType"` + Size int64 `json:"size"` + Digest string `json:"digest"` + Platform *dockerPlatform `json:"platform,omitempty"` +} + +type dockerPlatform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` +} + +func toCPUArchitecture(arch string) ecstypes.CPUArchitecture { + switch arch { + case "amd64": + return ecstypes.CPUArchitectureX8664 + case "arm64": + return ecstypes.CPUArchitectureArm64 + default: + return "" + } +} diff --git a/scan/ecs.go b/scan/ecs.go new file mode 100644 index 0000000..7602c41 --- /dev/null +++ b/scan/ecs.go @@ -0,0 +1,113 @@ +package scan + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/awsiface" +) + +type ecsTool struct { + Ecs awsiface.EcsClient +} + +type EcsTool interface { + GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]*ImageInfo, error) +} + +func newEcsTool(ecsClient awsiface.EcsClient) EcsTool { + return &ecsTool{Ecs: ecsClient} +} + +func (t *ecsTool) GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]*ImageInfo, error) { + res, err := t.Ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ + Cluster: &cluster, + Services: []string{service}, + }) + if err != nil { + return nil, err + } + if len(res.Services) == 0 || res.Services[0].TaskDefinition == nil { + return nil, fmt.Errorf("service not found: %s/%s", cluster, service) + } + taskDefinition := *res.Services[0].TaskDefinition + + tdRes, err := t.Ecs.DescribeTaskDefinition(ctx, &ecs.DescribeTaskDefinitionInput{ + TaskDefinition: &taskDefinition, + }) + if err != nil { + return nil, err + } + td := tdRes.TaskDefinition + if td == nil { + return nil, fmt.Errorf("task definition not found: %s", taskDefinition) + } + + arch := ecstypes.CPUArchitectureX8664 + if td.RuntimePlatform != nil && td.RuntimePlatform.CpuArchitecture != "" { + arch = td.RuntimePlatform.CpuArchitecture + } + + if len(td.ContainerDefinitions) == 0 { + return nil, fmt.Errorf("no container definitions found for task definition: %s", taskDefinition) + } + + images := make([]*ImageInfo, 0, len(td.ContainerDefinitions)) + for _, cd := range td.ContainerDefinitions { + if cd.Name == nil || cd.Image == nil { + return nil, fmt.Errorf("container definition is missing name or image: %s", taskDefinition) + } + parsed := ParseImageInfo(*cd.Image) + images = append(images, &ImageInfo{ + ContainerName: *cd.Name, + PlatformArch: arch, + Registry: parsed.Registry, + Repository: parsed.Repository, + Tag: parsed.Tag, + }) + } + + return images, nil +} + +type ParsedImageInfo struct { + Registry string + Repository string + Tag string +} + +func ParseImageInfo(image string) ParsedImageInfo { + parts := strings.Split(image, "/") + if len(parts) == 1 { + repository, tag := splitRepoTag(image) + return ParsedImageInfo{Repository: repository, Tag: tag} + } + + registry := parts[0] + repoAndTag := strings.Join(parts[1:], "/") + repository, tag := splitRepoTag(repoAndTag) + return ParsedImageInfo{Registry: registry, Repository: repository, Tag: tag} +} + +func splitRepoTag(value string) (string, string) { + repository := value + tag := "latest" + if strings.Contains(value, ":") { + parts := strings.SplitN(value, ":", 2) + repository = parts[0] + if parts[1] != "" { + tag = parts[1] + } + } + return repository, tag +} + +var ecrURLPattern = regexp.MustCompile(`^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) + +func IsEcr(registry string) bool { + return ecrURLPattern.MatchString(registry) +} diff --git a/scan/printer.go b/scan/printer.go new file mode 100644 index 0000000..24b9b2f --- /dev/null +++ b/scan/printer.go @@ -0,0 +1,78 @@ +package scan + +import "fmt" + +type printer struct { + logger Logger +} +type Logger interface { + Printf(format string, args ...any) +} +type Printer interface { + Print(result []*ScanResult) +} + +func DefaultLogger() Logger { + return &defaultLogger{} +} + +type defaultLogger struct{} + +func (l *defaultLogger) Printf(format string, args ...any) { + fmt.Printf(format, args...) +} + +func NewPrinter(logger Logger) Printer { + return &printer{logger: logger} +} + +func (p *printer) Print(result []*ScanResult) { + // |image|status|critical|high|medium|low|info|error| + fmtStr := "|%-40s|%-10s|%-8d|%-5d|%-6d|%-4d|%-4d|%-5d|\n" + p.logger.Printf(fmtStr, "IMAGE", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNKNOWN") + for _, r := range result { + if r.Err != nil { + p.logger.Printf(fmtStr, formatImageLabel(r.ImageInfo), "ERROR", 0, 0, 0, 0, 0, 1) + continue + } + findings := r.ImageScanFindings + var critical, high, medium, low, info, unclassified int32 + for _, f := range findings.Findings { + switch f.Severity { + case "CRITICAL": + critical++ + case "HIGH": + high++ + case "MEDIUM": + medium++ + case "LOW": + low++ + case "INFORMATIONAL": + info++ + case "UNCLASSIFIED": + unclassified++ + } + } + status := "OK" + if len(findings.Findings) == 0 { + status = "NONE" + } else if critical > 0 || high > 0 { + status = "VULNERABLE" + } + p.logger.Printf( + fmtStr, + formatImageLabel(r.ImageInfo), + status, + critical, + high, + medium, + low, + info, + unclassified, + ) + } +} + +func formatImageLabel(info *ImageInfo) string { + return fmt.Sprintf("%s (%s:%s)", info.Registry, info.Repository, info.Tag) +} diff --git a/scan/scanner.go b/scan/scanner.go new file mode 100644 index 0000000..091b072 --- /dev/null +++ b/scan/scanner.go @@ -0,0 +1,56 @@ +package scan + +import ( + "context" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/loilo-inc/canarycage/awsiface" +) + +type scanner struct { + ecs awsiface.EcsClient + ecr awsiface.EcrClient +} + +type Scanner interface { + Scan(ctx context.Context, cluster string, service string) ([]*ScanResult, error) +} + +type ScanResult struct { + ImageInfo *ImageInfo + ImageScanFindings *ecrtypes.ImageScanFindings + Err error +} + +func NewScanner(ecs awsiface.EcsClient, ecr awsiface.EcrClient) Scanner { + return &scanner{ecs: ecs, ecr: ecr} +} + +func (s *scanner) Scan( + ctx context.Context, + cluster string, + service string, +) (results []*ScanResult, err error) { + ecsTool := newEcsTool(s.ecs) + ecrTool := newEcrTool(s.ecr) + var imageInfos []*ImageInfo + if imageInfos, err = ecsTool.GetServiceImageInfos(ctx, cluster, service); err != nil { + return nil, err + } + findingsList := make([]*ScanResult, len(imageInfos)) + for i, info := range imageInfos { + findingsList[i] = scanImage(ctx, ecrTool, info) + findingsList[i].ImageInfo = imageInfos[i] + } + return findingsList, nil +} + +func scanImage(ctx context.Context, ecrTool EcrTool, info *ImageInfo) *ScanResult { + if imageID, err := ecrTool.GetActualImageIdentifier(ctx, info); err != nil { + return &ScanResult{Err: err} + } else if findings, err := ecrTool.GetImageScanFindings(ctx, info, imageID); err != nil { + return &ScanResult{Err: err} + } else { + return &ScanResult{ImageScanFindings: findings} + } +} diff --git a/scan/types.go b/scan/types.go new file mode 100644 index 0000000..c57909a --- /dev/null +++ b/scan/types.go @@ -0,0 +1,11 @@ +package scan + +import "github.com/aws/aws-sdk-go-v2/service/ecs/types" + +type ImageInfo struct { + Registry string + ContainerName string + PlatformArch types.CPUArchitecture + Repository string + Tag string +} From 3073a8fc2073e3176ae80cdb3599dd8ebe43c2aa Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 15:44:02 +0900 Subject: [PATCH 02/23] a --- Makefile | 4 +- cli/cage/{commands => cageapp}/flags.go | 6 ++- cli/cage/cageapp/provider.go | 60 ++++++++++++++++++++++ cli/cage/commands/command.go | 25 +++------ cli/cage/commands/command_test.go | 19 ++++--- cli/cage/commands/rollout.go | 35 +++++++------ cli/cage/commands/run.go | 25 +++++---- cli/cage/commands/scan.go | 26 ++++++---- cli/cage/commands/up.go | 29 ++++++----- cli/cage/commands/upgrade.go | 4 +- cli/cage/commands/upgrade_test.go | 6 +-- cli/cage/main.go | 51 ++++--------------- cli/cage/prompt/prompt.go | 4 -- {scan => cli/cage/scan}/ecr.go | 0 {scan => cli/cage/scan}/ecs.go | 0 {scan => cli/cage/scan}/printer.go | 0 cli/cage/scan/scancmd/scan.go | 68 +++++++++++++++++++++++++ {scan => cli/cage/scan}/scanner.go | 0 {scan => cli/cage/scan}/types.go | 0 env/env.go | 1 - mocks/mock_types/iface.go | 15 ------ 21 files changed, 232 insertions(+), 146 deletions(-) rename cli/cage/{commands => cageapp}/flags.go (98%) create mode 100644 cli/cage/cageapp/provider.go rename {scan => cli/cage/scan}/ecr.go (100%) rename {scan => cli/cage/scan}/ecs.go (100%) rename {scan => cli/cage/scan}/printer.go (100%) create mode 100644 cli/cage/scan/scancmd/scan.go rename {scan => cli/cage/scan}/scanner.go (100%) rename {scan => cli/cage/scan}/types.go (100%) diff --git a/Makefile b/Makefile index 9cff6df..a715b98 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ mocks/mock_awsiface/iface.go: awsiface/iface.go $(MOCKGEN) -source=./awsiface/iface.go > mocks/mock_awsiface/iface.go mocks/mock_types/iface.go: types/iface.go $(MOCKGEN) -source=./types/iface.go > mocks/mock_types/iface.go -mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go - $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go +mocks/mock_upgrade/upgrade.go: upgrade/upgrade.go + $(MOCKGEN) -source=./upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go mocks/mock_task/task.go: task/task.go $(MOCKGEN) -source=./task/task.go > mocks/mock_task/task.go mocks/mock_taskset/taskset.go: taskset/taskset.go diff --git a/cli/cage/commands/flags.go b/cli/cage/cageapp/flags.go similarity index 98% rename from cli/cage/commands/flags.go rename to cli/cage/cageapp/flags.go index eb991bb..9ddb427 100644 --- a/cli/cage/commands/flags.go +++ b/cli/cage/cageapp/flags.go @@ -1,10 +1,14 @@ -package commands +package cageapp import ( "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" ) +type Flag struct { + CI bool +} + func RegionFlag(dest *string) *cli.StringFlag { return &cli.StringFlag{ Name: "region", diff --git a/cli/cage/cageapp/provider.go b/cli/cage/cageapp/provider.go new file mode 100644 index 0000000..a6d07ff --- /dev/null +++ b/cli/cage/cageapp/provider.go @@ -0,0 +1,60 @@ +package cageapp + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" + cage "github.com/loilo-inc/canarycage" + "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/task" + "github.com/loilo-inc/canarycage/timeout" + "github.com/loilo-inc/canarycage/types" + "github.com/loilo-inc/logos/di" + "golang.org/x/xerrors" +) + +func ProvideCageCli(envars *env.Envars) (types.Cage, error) { + conf, err := loadAwsConfig(envars.Region) + if err != nil { + return nil, err + } + d := di.NewDomain(func(b *di.B) { + b.Set(key.Env, envars) + b.Set(key.EcsCli, ecs.NewFromConfig(conf)) + b.Set(key.EcrCli, ecr.NewFromConfig(conf)) + b.Set(key.Ec2Cli, ec2.NewFromConfig(conf)) + b.Set(key.AlbCli, elasticloadbalancingv2.NewFromConfig(conf)) + b.Set(key.TaskFactory, task.NewFactory(b.Future())) + b.Set(key.Time, &timeout.Time{}) + }) + cagecli := cage.NewCage(d) + return cagecli, nil +} + +func ProvideScanDI(region string) (*di.D, error) { + conf, err := loadAwsConfig(region) + if err != nil { + return nil, err + } + d := di.NewDomain(func(b *di.B) { + b.Set(key.EcsCli, ecs.NewFromConfig(conf)) + b.Set(key.EcrCli, ecr.NewFromConfig(conf)) + }) + return d, nil +} + +func loadAwsConfig(region string) (aws.Config, error) { + conf, err := config.LoadDefaultConfig( + context.Background(), + config.WithRegion(region)) + if err != nil { + return aws.Config{}, xerrors.Errorf("failed to load aws config: %w", err) + } + return conf, nil +} diff --git a/cli/cage/commands/command.go b/cli/cage/commands/command.go index 4e5551b..48f409a 100644 --- a/cli/cage/commands/command.go +++ b/cli/cage/commands/command.go @@ -1,10 +1,7 @@ package commands import ( - "io" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/loilo-inc/canarycage/cli/cage/prompt" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" @@ -12,23 +9,19 @@ import ( ) type CageCommands struct { - Prompt *prompt.Prompter - cageCliProvier cageCliProvier + cageCliProvider cageCliProvider } func NewCageCommands( - stdin io.Reader, - cageCliProvier cageCliProvier, + cageCliProvider cageCliProvider, ) *CageCommands { - return &CageCommands{ - Prompt: prompt.NewPrompter(stdin), - cageCliProvier: cageCliProvier, - } + cmds := &CageCommands{cageCliProvider: cageCliProvider} + return cmds } -type cageCliProvier = func(e *env.Envars) (types.Cage, error) +type cageCliProvider = func(e *env.Envars) (types.Cage, error) -func (c *CageCommands) requireArgs( +func RequireArgs( ctx *cli.Context, minArgs int, maxArgs int, @@ -70,11 +63,7 @@ func (c *CageCommands) setupCage( if err := env.EnsureEnvars(envars); err != nil { return nil, err } - di, err := c.diProvider(envars) - if err != nil { - return nil, err - } - cagecli, err := c.cageCliProvier(di) + cagecli, err := c.cageCliProvider(envars) if err != nil { return nil, err } diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 346b084..8c65e6d 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -2,9 +2,9 @@ package commands import ( "fmt" - "strings" "testing" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/mocks/mock_types" "github.com/loilo-inc/canarycage/test" @@ -22,17 +22,16 @@ func TestCommands(t *testing.T) { stdinTask := fmt.Sprintf("%s\n%s\n%s\n", region, cluster, "yes") setup := func(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { ctrl := gomock.NewController(t) - stdin := strings.NewReader(input) cagecli := mock_types.NewMockCage(ctrl) + flag := &cageapp.Flag{} app := cli.NewApp() - cmds := NewCageCommands(stdin, func(envars *env.Envars) (types.Cage, error) { + cmds := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return cagecli, nil }) - envars := env.Envars{CI: input == ""} app.Commands = []*cli.Command{ - cmds.Up(&envars), - cmds.RollOut(&envars), - cmds.Run(&envars), + cmds.Up(flag), + cmds.RollOut(flag), + cmds.Run(flag), } return app, cagecli } @@ -108,7 +107,7 @@ func TestSetupCage(t *testing.T) { t.Run("basic", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2"} cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(nil, func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return cageCli, nil }) v, err := cmd.setupCage(envars, "../../../fixtures") @@ -124,7 +123,7 @@ func TestSetupCage(t *testing.T) { t.Run("should skip load task definition if --taskDefinitionArn provided", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2", TaskDefinitionArn: "arn"} cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(nil, func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return cageCli, nil }) v, err := cmd.setupCage(envars, "../../../fixtures") @@ -139,7 +138,7 @@ func TestSetupCage(t *testing.T) { }) t.Run("should error if error returned from NewCage", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2"} - cmd := NewCageCommands(nil, func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return nil, test.Err }) _, err := cmd.setupCage(envars, "../../../fixtures") diff --git a/cli/cage/commands/rollout.go b/cli/cage/commands/rollout.go index 0b2151c..e31b223 100644 --- a/cli/cage/commands/rollout.go +++ b/cli/cage/commands/rollout.go @@ -2,16 +2,18 @@ package commands import ( "context" + "os" "github.com/apex/log" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/prompt" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" ) -func (c *CageCommands) RollOut( - envars *env.Envars, -) *cli.Command { +func (c *CageCommands) RollOut(flag *cageapp.Flag) *cli.Command { + envars := &env.Envars{} var updateServiceConf bool return &cli.Command{ Name: "rollout", @@ -20,11 +22,11 @@ func (c *CageCommands) RollOut( Args: true, ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ - RegionFlag(&envars.Region), - ClusterFlag(&envars.Cluster), - ServiceFlag(&envars.Service), - TaskDefinitionArnFlag(&envars.TaskDefinitionArn), - CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), + cageapp.RegionFlag(&envars.Region), + cageapp.ClusterFlag(&envars.Cluster), + cageapp.ServiceFlag(&envars.Service), + cageapp.TaskDefinitionArnFlag(&envars.TaskDefinitionArn), + cageapp.CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), &cli.StringFlag{ Name: "canaryInstanceArn", EnvVars: []string{env.CanaryInstanceArnKey}, @@ -37,13 +39,13 @@ func (c *CageCommands) RollOut( Usage: "Update service configurations except for task definiton. Default is false.", Destination: &updateServiceConf, }, - TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), - TaskHealthCheckWaitFlag(&envars.CanaryTaskHealthCheckWait), - TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), - ServiceStableWaitFlag(&envars.ServiceStableWait), + cageapp.TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), + cageapp.TaskHealthCheckWaitFlag(&envars.CanaryTaskHealthCheckWait), + cageapp.TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), + cageapp.ServiceStableWaitFlag(&envars.ServiceStableWait), }, Action: func(ctx *cli.Context) error { - dir, _, err := c.requireArgs(ctx, 1, 1) + dir, _, err := RequireArgs(ctx, 1, 1) if err != nil { return err } @@ -51,8 +53,11 @@ func (c *CageCommands) RollOut( if err != nil { return err } - if err := c.Prompt.ConfirmService(envars); err != nil { - return err + if !flag.CI { + prompter := prompt.NewPrompter(os.Stdin) + if err := prompter.ConfirmService(envars); err != nil { + return err + } } result, err := cagecli.RollOut(context.Background(), &types.RollOutInput{UpdateService: updateServiceConf}) if err != nil { diff --git a/cli/cage/commands/run.go b/cli/cage/commands/run.go index c830f5b..52efbdc 100644 --- a/cli/cage/commands/run.go +++ b/cli/cage/commands/run.go @@ -2,17 +2,19 @@ package commands import ( "context" + "os" "github.com/apex/log" ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/prompt" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" ) -func (c *CageCommands) Run( - envars *env.Envars, -) *cli.Command { +func (c *CageCommands) Run(flag *cageapp.Flag) *cli.Command { + envars := &env.Envars{} return &cli.Command{ Name: "run", Usage: "run task with specified task definition", @@ -20,13 +22,13 @@ func (c *CageCommands) Run( Args: true, ArgsUsage: " ...", Flags: []cli.Flag{ - RegionFlag(&envars.Region), - ClusterFlag(&envars.Cluster), - TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), - TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), + cageapp.RegionFlag(&envars.Region), + cageapp.ClusterFlag(&envars.Cluster), + cageapp.TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), + cageapp.TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), }, Action: func(ctx *cli.Context) error { - dir, rest, err := c.requireArgs(ctx, 3, 100) + dir, rest, err := RequireArgs(ctx, 3, 100) if err != nil { return err } @@ -34,8 +36,11 @@ func (c *CageCommands) Run( if err != nil { return err } - if err := c.Prompt.ConfirmTask(envars); err != nil { - return err + if !flag.CI { + prompter := prompt.NewPrompter(os.Stdin) + if err := prompter.ConfirmTask(envars); err != nil { + return err + } } container := rest[0] commands := rest[1:] diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/scan.go index fcd09f1..313956d 100644 --- a/cli/cage/commands/scan.go +++ b/cli/cage/commands/scan.go @@ -5,15 +5,17 @@ import ( "errors" "github.com/loilo-inc/canarycage/awsiface" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/scan" "github.com/loilo-inc/canarycage/env" - "github.com/loilo-inc/canarycage/scan" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/logos/di" "github.com/urfave/cli/v2" ) -func (c *CageCommands) Scan( - ecscli awsiface.EcsClient, - ecrcli awsiface.EcrClient, -) *cli.Command { +type diProvider = func(region string) (*di.D, error) + +func Scan(diProvider diProvider) *cli.Command { var region string var cluster string var service string @@ -23,12 +25,12 @@ func (c *CageCommands) Scan( Args: true, ArgsUsage: "", Flags: []cli.Flag{ - RegionFlag(®ion), - ClusterFlag(&cluster), - ServiceFlag(&service), + cageapp.RegionFlag(®ion), + cageapp.ClusterFlag(&cluster), + cageapp.ServiceFlag(&service), }, Action: func(ctx *cli.Context) error { - dir, _, err := c.requireArgs(ctx, 0, 1) + dir, _, err := RequireArgs(ctx, 0, 1) if err != nil { return err } @@ -45,6 +47,12 @@ func (c *CageCommands) Scan( } else if cluster == "" || service == "" { return errors.New("either directory argument or both --cluster and --service flags must be provided") } + d, err := diProvider(region) + if err != nil { + return err + } + ecscli := d.Get(key.EcsCli).(awsiface.EcsClient) + ecrcli := d.Get(key.EcrCli).(awsiface.EcrClient) scanner := scan.NewScanner(ecscli, ecrcli) result, err := scanner.Scan(context.Background(), cluster, service) if err != nil { diff --git a/cli/cage/commands/up.go b/cli/cage/commands/up.go index 3ecd906..8aaa889 100644 --- a/cli/cage/commands/up.go +++ b/cli/cage/commands/up.go @@ -2,14 +2,16 @@ package commands import ( "context" + "os" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/prompt" "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" ) -func (c *CageCommands) Up( - envars *env.Envars, -) *cli.Command { +func (c *CageCommands) Up(flag *cageapp.Flag) *cli.Command { + envars := &env.Envars{} return &cli.Command{ Name: "up", Usage: "create new ECS service with specified task definition", @@ -17,15 +19,15 @@ func (c *CageCommands) Up( Args: true, ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ - RegionFlag(&envars.Region), - ClusterFlag(&envars.Cluster), - ServiceFlag(&envars.Service), - TaskDefinitionArnFlag(&envars.TaskDefinitionArn), - CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), - ServiceStableWaitFlag(&envars.ServiceStableWait), + cageapp.RegionFlag(&envars.Region), + cageapp.ClusterFlag(&envars.Cluster), + cageapp.ServiceFlag(&envars.Service), + cageapp.TaskDefinitionArnFlag(&envars.TaskDefinitionArn), + cageapp.CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), + cageapp.ServiceStableWaitFlag(&envars.ServiceStableWait), }, Action: func(ctx *cli.Context) error { - dir, _, err := c.requireArgs(ctx, 1, 1) + dir, _, err := RequireArgs(ctx, 1, 1) if err != nil { return err } @@ -33,8 +35,11 @@ func (c *CageCommands) Up( if err != nil { return err } - if err := c.Prompt.ConfirmService(envars); err != nil { - return err + if !flag.CI { + prompter := prompt.NewPrompter(os.Stdin) + if err := prompter.ConfirmService(envars); err != nil { + return err + } } _, err = cagecli.Up(context.Background()) return err diff --git a/cli/cage/commands/upgrade.go b/cli/cage/commands/upgrade.go index f590226..08aa3ba 100644 --- a/cli/cage/commands/upgrade.go +++ b/cli/cage/commands/upgrade.go @@ -5,9 +5,7 @@ import ( "github.com/urfave/cli/v2" ) -func (c *CageCommands) Upgrade( - upgrader upgrade.Upgrader, -) *cli.Command { +func Upgrade(upgrader upgrade.Upgrader) *cli.Command { var preRelease bool return &cli.Command{ Name: "upgrade", diff --git a/cli/cage/commands/upgrade_test.go b/cli/cage/commands/upgrade_test.go index e7122a8..5921891 100644 --- a/cli/cage/commands/upgrade_test.go +++ b/cli/cage/commands/upgrade_test.go @@ -19,9 +19,8 @@ func TestUpgrade(t *testing.T) { u.EXPECT().Upgrade( gomock.Eq(&upgrade.Input{}), ).Return(nil) - cmds := commands.NewCageCommands(nil, nil) app.Commands = []*cli.Command{ - cmds.Upgrade(u), + commands.Upgrade(u), } err := app.Run([]string{"cage", "upgrade"}) assert.NoError(t, err) @@ -33,9 +32,8 @@ func TestUpgrade(t *testing.T) { u.EXPECT().Upgrade( gomock.Eq(&upgrade.Input{PreRelease: true}), ).Return(nil) - cmds := commands.NewCageCommands(nil, nil) app.Commands = []*cli.Command{ - cmds.Upgrade(u), + commands.Upgrade(u), } err := app.Run([]string{"cage", "upgrade", "--pre-release"}) assert.NoError(t, err) diff --git a/cli/cage/main.go b/cli/cage/main.go index f88e057..1f01900 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -1,27 +1,14 @@ package main import ( - "context" "fmt" "log" "os" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/aws/aws-sdk-go-v2/service/ecr" - "github.com/aws/aws-sdk-go-v2/service/ecs" - "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" - cage "github.com/loilo-inc/canarycage" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/commands" "github.com/loilo-inc/canarycage/cli/cage/upgrade" - "github.com/loilo-inc/canarycage/env" - "github.com/loilo-inc/canarycage/key" - "github.com/loilo-inc/canarycage/task" - "github.com/loilo-inc/canarycage/timeout" - "github.com/loilo-inc/canarycage/types" - "github.com/loilo-inc/logos/di" "github.com/urfave/cli/v2" - "golang.org/x/xerrors" ) // set by goreleaser @@ -32,50 +19,30 @@ var ( ) func main() { + flag := &cageapp.Flag{} app := cli.NewApp() app.Name = "canarycage" app.HelpName = "cage" app.Version = fmt.Sprintf("%s (commit: %s, date: %s)", version, commit, date) app.Usage = "A deployment tool for AWS ECS" app.Description = "A deployment tool for AWS ECS" - envars := env.Envars{} - cmds := commands.NewCageCommands(os.Stdin, provideCageCli) + cmds := commands.NewCageCommands(cageapp.ProvideCageCli) app.Commands = []*cli.Command{ - cmds.Up(&envars), - cmds.RollOut(&envars), - cmds.Run(&envars), - cmds.Upgrade(upgrade.NewUpgrader(version)), - cmds.Scan(&envars), + cmds.Up(flag), + cmds.RollOut(flag), + cmds.Run(flag), + commands.Upgrade(upgrade.NewUpgrader(version)), + commands.Scan(cageapp.ProvideScanDI), } app.Flags = []cli.Flag{ &cli.BoolFlag{ Name: "ci", Usage: "CI mode. Skip all confirmations and use default values.", EnvVars: []string{"CI"}, - Destination: &envars.CI, + Destination: &flag.CI, }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } - -func provideCageCli(envars *env.Envars) (types.Cage, error) { - conf, err := config.LoadDefaultConfig( - context.Background(), - config.WithRegion(envars.Region)) - if err != nil { - return nil, xerrors.Errorf("failed to load aws config: %w", err) - } - d := di.NewDomain(func(b *di.B) { - b.Set(key.Env, envars) - b.Set(key.EcsCli, ecs.NewFromConfig(conf)) - b.Set(key.EcrCli, ecr.NewFromConfig(conf)) - b.Set(key.Ec2Cli, ec2.NewFromConfig(conf)) - b.Set(key.AlbCli, elasticloadbalancingv2.NewFromConfig(conf)) - b.Set(key.TaskFactory, task.NewFactory(b.Future())) - b.Set(key.Time, &timeout.Time{}) - }) - cagecli := cage.NewCage(d) - return cagecli, nil -} diff --git a/cli/cage/prompt/prompt.go b/cli/cage/prompt/prompt.go index 05b8e84..bfeb458 100644 --- a/cli/cage/prompt/prompt.go +++ b/cli/cage/prompt/prompt.go @@ -47,10 +47,6 @@ func (s *Prompter) confirmStackChange( envars *env.Envars, service bool, ) error { - // Skip confirmation if running in CI - if envars.CI { - return nil - } if err := s.Confirm("region", envars.Region); err != nil { return err } diff --git a/scan/ecr.go b/cli/cage/scan/ecr.go similarity index 100% rename from scan/ecr.go rename to cli/cage/scan/ecr.go diff --git a/scan/ecs.go b/cli/cage/scan/ecs.go similarity index 100% rename from scan/ecs.go rename to cli/cage/scan/ecs.go diff --git a/scan/printer.go b/cli/cage/scan/printer.go similarity index 100% rename from scan/printer.go rename to cli/cage/scan/printer.go diff --git a/cli/cage/scan/scancmd/scan.go b/cli/cage/scan/scancmd/scan.go new file mode 100644 index 0000000..8a16e0c --- /dev/null +++ b/cli/cage/scan/scancmd/scan.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/loilo-inc/canarycage/awsiface" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/commands" + "github.com/loilo-inc/canarycage/cli/cage/scan" + "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/logos/di" + "github.com/urfave/cli/v2" +) + +type diProvider = func(region string) (*di.D, error) + +func Command(diProvider diProvider) *cli.Command { + var region string + var cluster string + var service string + return &cli.Command{ + Name: "scan", + Usage: "Scan ECR image vulnerabilities for the given ECS service", + Args: true, + ArgsUsage: "", + Flags: []cli.Flag{ + cageapp.RegionFlag(®ion), + cageapp.ClusterFlag(&cluster), + cageapp.ServiceFlag(&service), + }, + Action: func(ctx *cli.Context) error { + dir, _, err := commands.RequireArgs(ctx, 0, 1) + if err != nil { + return err + } + if dir != "" { + srv, err := env.LoadServiceDefinition(dir) + if err != nil { + return err + } + if srv.ServiceName == nil || srv.Cluster == nil { + return errors.New("service.json must contain ServiceName and Cluster") + } + service = *srv.ServiceName + cluster = *srv.Cluster + } else if cluster == "" || service == "" { + return errors.New("either directory argument or both --cluster and --service flags must be provided") + } + d, err := diProvider(region) + if err != nil { + return err + } + ecscli := d.Get(key.EcsCli).(awsiface.EcsClient) + ecrcli := d.Get(key.EcrCli).(awsiface.EcrClient) + scanner := scan.NewScanner(ecscli, ecrcli) + result, err := scanner.Scan(context.Background(), cluster, service) + if err != nil { + return err + } + logger := scan.DefaultLogger() + printer := scan.NewPrinter(logger) + printer.Print(result) + return nil + }, + } +} diff --git a/scan/scanner.go b/cli/cage/scan/scanner.go similarity index 100% rename from scan/scanner.go rename to cli/cage/scan/scanner.go diff --git a/scan/types.go b/cli/cage/scan/types.go similarity index 100% rename from scan/types.go rename to cli/cage/scan/types.go diff --git a/env/env.go b/env/env.go index 0922204..a078612 100644 --- a/env/env.go +++ b/env/env.go @@ -13,7 +13,6 @@ import ( type Envars struct { _ struct{} `type:"struct"` - CI bool `json:"ci" type:"bool"` Region string `json:"region" type:"string"` Cluster string `json:"cluster" type:"string" required:"true"` Service string `json:"service" type:"string" required:"true"` diff --git a/mocks/mock_types/iface.go b/mocks/mock_types/iface.go index 983cf51..ce06076 100644 --- a/mocks/mock_types/iface.go +++ b/mocks/mock_types/iface.go @@ -72,21 +72,6 @@ func (mr *MockCageMockRecorder) Run(ctx, input any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockCage)(nil).Run), ctx, input) } -// Scan mocks base method. -func (m *MockCage) Scan(ctx context.Context) (*types.ScanResult, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Scan", ctx) - ret0, _ := ret[0].(*types.ScanResult) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Scan indicates an expected call of Scan. -func (mr *MockCageMockRecorder) Scan(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockCage)(nil).Scan), ctx) -} - // Up mocks base method. func (m *MockCage) Up(ctx context.Context) (*types.UpResult, error) { m.ctrl.T.Helper() From 544e3b6d233a8be029ac6a0d8d923c3c0567119b Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 16:04:46 +0900 Subject: [PATCH 03/23] a --- cli/cage/cageapp/provider.go | 2 ++ cli/cage/scan/printer.go | 41 +++++++++++++++------ cli/cage/scan/scancmd/scan.go | 68 ----------------------------------- cli/cage/scan/scanner.go | 2 ++ 4 files changed, 34 insertions(+), 79 deletions(-) delete mode 100644 cli/cage/scan/scancmd/scan.go diff --git a/cli/cage/cageapp/provider.go b/cli/cage/cageapp/provider.go index a6d07ff..7667170 100644 --- a/cli/cage/cageapp/provider.go +++ b/cli/cage/cageapp/provider.go @@ -3,6 +3,7 @@ package cageapp import ( "context" + "github.com/apex/log" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" @@ -38,6 +39,7 @@ func ProvideCageCli(envars *env.Envars) (types.Cage, error) { } func ProvideScanDI(region string) (*di.D, error) { + log.Infof("Initializing AWS clients for region: %s", region) conf, err := loadAwsConfig(region) if err != nil { return nil, err diff --git a/cli/cage/scan/printer.go b/cli/cage/scan/printer.go index 24b9b2f..1a90ee5 100644 --- a/cli/cage/scan/printer.go +++ b/cli/cage/scan/printer.go @@ -27,16 +27,22 @@ func NewPrinter(logger Logger) Printer { } func (p *printer) Print(result []*ScanResult) { - // |image|status|critical|high|medium|low|info|error| - fmtStr := "|%-40s|%-10s|%-8d|%-5d|%-6d|%-4d|%-4d|%-5d|\n" - p.logger.Printf(fmtStr, "IMAGE", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "UNKNOWN") + containerMax, imageMax := MaxHeaderWidth(result) + // |container|status|critical|high|medium|low|info|image| + headerFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8s|%%-5s|%%-6s|%%-4s|%%-4s|%%-%ds|\n", containerMax, imageMax) + p.logger.Printf(headerFmt, "CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE") + bodyFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8d|%%-5d|%%-6d|%%-4d|%%-4d|%%-%ds|\n", containerMax, imageMax) for _, r := range result { if r.Err != nil { - p.logger.Printf(fmtStr, formatImageLabel(r.ImageInfo), "ERROR", 0, 0, 0, 0, 0, 1) + p.logger.Printf(bodyFmt, + r.ImageInfo.ContainerName, + "ERROR", 0, 0, 0, 0, 0, 1, + formatImageLabel(r.ImageInfo), + ) continue } findings := r.ImageScanFindings - var critical, high, medium, low, info, unclassified int32 + var critical, high, medium, low, info int32 for _, f := range findings.Findings { switch f.Severity { case "CRITICAL": @@ -49,8 +55,6 @@ func (p *printer) Print(result []*ScanResult) { low++ case "INFORMATIONAL": info++ - case "UNCLASSIFIED": - unclassified++ } } status := "OK" @@ -60,19 +64,34 @@ func (p *printer) Print(result []*ScanResult) { status = "VULNERABLE" } p.logger.Printf( - fmtStr, - formatImageLabel(r.ImageInfo), + bodyFmt, + r.ImageInfo.ContainerName, status, critical, high, medium, low, info, - unclassified, + formatImageLabel(r.ImageInfo), ) } } func formatImageLabel(info *ImageInfo) string { - return fmt.Sprintf("%s (%s:%s)", info.Registry, info.Repository, info.Tag) + return fmt.Sprintf("%s/%s:%s", info.Registry, info.Repository, info.Tag) +} + +func MaxHeaderWidth(imageInfos []*ScanResult) (int, int) { + containerMax := len("CONTAINER") + imageMax := len("IMAGE") + for _, info := range imageInfos { + if l := len(info.ImageInfo.ContainerName); l > containerMax { + containerMax = l + } + imageLabel := formatImageLabel(info.ImageInfo) + if l := len(imageLabel); l > imageMax { + imageMax = l + } + } + return containerMax, imageMax } diff --git a/cli/cage/scan/scancmd/scan.go b/cli/cage/scan/scancmd/scan.go deleted file mode 100644 index 8a16e0c..0000000 --- a/cli/cage/scan/scancmd/scan.go +++ /dev/null @@ -1,68 +0,0 @@ -package cmd - -import ( - "context" - "errors" - - "github.com/loilo-inc/canarycage/awsiface" - "github.com/loilo-inc/canarycage/cli/cage/cageapp" - "github.com/loilo-inc/canarycage/cli/cage/commands" - "github.com/loilo-inc/canarycage/cli/cage/scan" - "github.com/loilo-inc/canarycage/env" - "github.com/loilo-inc/canarycage/key" - "github.com/loilo-inc/logos/di" - "github.com/urfave/cli/v2" -) - -type diProvider = func(region string) (*di.D, error) - -func Command(diProvider diProvider) *cli.Command { - var region string - var cluster string - var service string - return &cli.Command{ - Name: "scan", - Usage: "Scan ECR image vulnerabilities for the given ECS service", - Args: true, - ArgsUsage: "", - Flags: []cli.Flag{ - cageapp.RegionFlag(®ion), - cageapp.ClusterFlag(&cluster), - cageapp.ServiceFlag(&service), - }, - Action: func(ctx *cli.Context) error { - dir, _, err := commands.RequireArgs(ctx, 0, 1) - if err != nil { - return err - } - if dir != "" { - srv, err := env.LoadServiceDefinition(dir) - if err != nil { - return err - } - if srv.ServiceName == nil || srv.Cluster == nil { - return errors.New("service.json must contain ServiceName and Cluster") - } - service = *srv.ServiceName - cluster = *srv.Cluster - } else if cluster == "" || service == "" { - return errors.New("either directory argument or both --cluster and --service flags must be provided") - } - d, err := diProvider(region) - if err != nil { - return err - } - ecscli := d.Get(key.EcsCli).(awsiface.EcsClient) - ecrcli := d.Get(key.EcrCli).(awsiface.EcrClient) - scanner := scan.NewScanner(ecscli, ecrcli) - result, err := scanner.Scan(context.Background(), cluster, service) - if err != nil { - return err - } - logger := scan.DefaultLogger() - printer := scan.NewPrinter(logger) - printer.Print(result) - return nil - }, - } -} diff --git a/cli/cage/scan/scanner.go b/cli/cage/scan/scanner.go index 091b072..111086b 100644 --- a/cli/cage/scan/scanner.go +++ b/cli/cage/scan/scanner.go @@ -3,6 +3,7 @@ package scan import ( "context" + "github.com/apex/log" ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/loilo-inc/canarycage/awsiface" ) @@ -33,6 +34,7 @@ func (s *scanner) Scan( ) (results []*ScanResult, err error) { ecsTool := newEcsTool(s.ecs) ecrTool := newEcrTool(s.ecr) + log.Infof("Scanning ECR image vulnerabilities for ECS service %s/%s", cluster, service) var imageInfos []*ImageInfo if imageInfos, err = ecsTool.GetServiceImageInfos(ctx, cluster, service); err != nil { return nil, err From f1b9b858cdbd9a1542c99c56ce34096baab01df6 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 17:01:11 +0900 Subject: [PATCH 04/23] a --- cli/cage/cageapp/flags.go | 7 +- cli/cage/cageapp/provider.go | 2 - cli/cage/commands/command_test.go | 24 +- cli/cage/commands/rollout.go | 7 +- cli/cage/commands/run.go | 7 +- cli/cage/commands/up.go | 7 +- cli/cage/main.go | 2 +- cli/cage/scan/ecr_test.go | 373 ++++++++++++++++++++++++++++++ cli/cage/scan/ecs_test.go | 290 +++++++++++++++++++++++ cli/cage/scan/printer_test.go | 202 ++++++++++++++++ cli/cage/scan/scanner.go | 7 - cli/cage/scan/scanner_test.go | 166 +++++++++++++ cli/cage/scan/types.go | 11 +- 13 files changed, 1072 insertions(+), 33 deletions(-) create mode 100644 cli/cage/scan/ecr_test.go create mode 100644 cli/cage/scan/ecs_test.go create mode 100644 cli/cage/scan/printer_test.go create mode 100644 cli/cage/scan/scanner_test.go diff --git a/cli/cage/cageapp/flags.go b/cli/cage/cageapp/flags.go index 9ddb427..551f434 100644 --- a/cli/cage/cageapp/flags.go +++ b/cli/cage/cageapp/flags.go @@ -1,12 +1,15 @@ package cageapp import ( + "io" + "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" ) -type Flag struct { - CI bool +type App struct { + CI bool + Stdin io.Reader } func RegionFlag(dest *string) *cli.StringFlag { diff --git a/cli/cage/cageapp/provider.go b/cli/cage/cageapp/provider.go index 7667170..a6d07ff 100644 --- a/cli/cage/cageapp/provider.go +++ b/cli/cage/cageapp/provider.go @@ -3,7 +3,6 @@ package cageapp import ( "context" - "github.com/apex/log" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" @@ -39,7 +38,6 @@ func ProvideCageCli(envars *env.Envars) (types.Cage, error) { } func ProvideScanDI(region string) (*di.D, error) { - log.Infof("Initializing AWS clients for region: %s", region) conf, err := loadAwsConfig(region) if err != nil { return nil, err diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 8c65e6d..445cf0d 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "strings" "testing" "github.com/loilo-inc/canarycage/cli/cage/cageapp" @@ -23,15 +24,22 @@ func TestCommands(t *testing.T) { setup := func(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { ctrl := gomock.NewController(t) cagecli := mock_types.NewMockCage(ctrl) - flag := &cageapp.Flag{} + cageapp := &cageapp.App{Stdin: strings.NewReader(input)} app := cli.NewApp() cmds := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return cagecli, nil }) app.Commands = []*cli.Command{ - cmds.Up(flag), - cmds.RollOut(flag), - cmds.Run(flag), + cmds.Up(cageapp), + cmds.RollOut(cageapp), + cmds.Run(cageapp), + } + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "ci", + Destination: &cageapp.CI, + Value: false, + }, } return app, cagecli } @@ -45,10 +53,10 @@ func TestCommands(t *testing.T) { t.Run("basic/ci", func(t *testing.T) { app, cagecli := setup(t, "") cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) - err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) + err := app.Run([]string{"cage", "--ci", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) - t.Run("basic/udate-service", func(t *testing.T) { + t.Run("basic/update-service", func(t *testing.T) { app, cagecli := setup(t, stdinService) cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{UpdateService: true}).Return(&types.RollOutResult{}, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "--updateService", "../../../fixtures"}) @@ -71,7 +79,7 @@ func TestCommands(t *testing.T) { t.Run("basic/ci", func(t *testing.T) { app, cagecli := setup(t, "") cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) - err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) + err := app.Run([]string{"cage", "--ci", "up", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("error", func(t *testing.T) { @@ -91,7 +99,7 @@ func TestCommands(t *testing.T) { t.Run("basic/ci", func(t *testing.T) { app, cagecli := setup(t, "") cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) - err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) + err := app.Run([]string{"cage", "--ci", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) assert.NoError(t, err) }) t.Run("error", func(t *testing.T) { diff --git a/cli/cage/commands/rollout.go b/cli/cage/commands/rollout.go index e31b223..ce67e84 100644 --- a/cli/cage/commands/rollout.go +++ b/cli/cage/commands/rollout.go @@ -2,7 +2,6 @@ package commands import ( "context" - "os" "github.com/apex/log" "github.com/loilo-inc/canarycage/cli/cage/cageapp" @@ -12,7 +11,7 @@ import ( "github.com/urfave/cli/v2" ) -func (c *CageCommands) RollOut(flag *cageapp.Flag) *cli.Command { +func (c *CageCommands) RollOut(app *cageapp.App) *cli.Command { envars := &env.Envars{} var updateServiceConf bool return &cli.Command{ @@ -53,8 +52,8 @@ func (c *CageCommands) RollOut(flag *cageapp.Flag) *cli.Command { if err != nil { return err } - if !flag.CI { - prompter := prompt.NewPrompter(os.Stdin) + if !app.CI { + prompter := prompt.NewPrompter(app.Stdin) if err := prompter.ConfirmService(envars); err != nil { return err } diff --git a/cli/cage/commands/run.go b/cli/cage/commands/run.go index 52efbdc..2726819 100644 --- a/cli/cage/commands/run.go +++ b/cli/cage/commands/run.go @@ -2,7 +2,6 @@ package commands import ( "context" - "os" "github.com/apex/log" ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" @@ -13,7 +12,7 @@ import ( "github.com/urfave/cli/v2" ) -func (c *CageCommands) Run(flag *cageapp.Flag) *cli.Command { +func (c *CageCommands) Run(app *cageapp.App) *cli.Command { envars := &env.Envars{} return &cli.Command{ Name: "run", @@ -36,8 +35,8 @@ func (c *CageCommands) Run(flag *cageapp.Flag) *cli.Command { if err != nil { return err } - if !flag.CI { - prompter := prompt.NewPrompter(os.Stdin) + if !app.CI { + prompter := prompt.NewPrompter(app.Stdin) if err := prompter.ConfirmTask(envars); err != nil { return err } diff --git a/cli/cage/commands/up.go b/cli/cage/commands/up.go index 8aaa889..0769fe7 100644 --- a/cli/cage/commands/up.go +++ b/cli/cage/commands/up.go @@ -2,7 +2,6 @@ package commands import ( "context" - "os" "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/prompt" @@ -10,7 +9,7 @@ import ( "github.com/urfave/cli/v2" ) -func (c *CageCommands) Up(flag *cageapp.Flag) *cli.Command { +func (c *CageCommands) Up(app *cageapp.App) *cli.Command { envars := &env.Envars{} return &cli.Command{ Name: "up", @@ -35,8 +34,8 @@ func (c *CageCommands) Up(flag *cageapp.Flag) *cli.Command { if err != nil { return err } - if !flag.CI { - prompter := prompt.NewPrompter(os.Stdin) + if !app.CI { + prompter := prompt.NewPrompter(app.Stdin) if err := prompter.ConfirmService(envars); err != nil { return err } diff --git a/cli/cage/main.go b/cli/cage/main.go index 1f01900..5022e46 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -19,7 +19,7 @@ var ( ) func main() { - flag := &cageapp.Flag{} + flag := &cageapp.App{} app := cli.NewApp() app.Name = "canarycage" app.HelpName = "cage" diff --git a/cli/cage/scan/ecr_test.go b/cli/cage/scan/ecr_test.go new file mode 100644 index 0000000..0252dca --- /dev/null +++ b/cli/cage/scan/ecr_test.go @@ -0,0 +1,373 @@ +package scan + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/mocks/mock_awsiface" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetActualImageIdentifier(t *testing.T) { + ctx := context.Background() + + t.Run("single architecture image returns tag", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureX8664, + } + + manifest := dockerSchema{ + SchemaVersion: 2, + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + } + manifestJSON, _ := json.Marshal(manifest) + manifestStr := string(manifestJSON) + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: &manifestStr}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "v1.0.0", *result.ImageTag) + }) + + t.Run("multi-arch image returns digest for matching architecture", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureArm64, + } + + manifest := dockerSchema{ + SchemaVersion: 2, + MediaType: dockerManifestListMediaType, + Manifests: []dockerManifest{ + { + Digest: "sha256:amd64digest", + Platform: &dockerPlatform{ + Architecture: "amd64", + OS: "linux", + }, + }, + { + Digest: "sha256:arm64digest", + Platform: &dockerPlatform{ + Architecture: "arm64", + OS: "linux", + }, + }, + }, + } + manifestJSON, _ := json.Marshal(manifest) + manifestStr := string(manifestJSON) + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: &manifestStr}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "sha256:arm64digest", *result.ImageDigest) + }) + + t.Run("multi-arch image with no matching architecture returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureArm64, + } + + manifest := dockerSchema{ + SchemaVersion: 2, + MediaType: dockerManifestListMediaType, + Manifests: []dockerManifest{ + { + Digest: "sha256:amd64digest", + Platform: &dockerPlatform{ + Architecture: "amd64", + OS: "linux", + }, + }, + }, + } + manifestJSON, _ := json.Marshal(manifest) + manifestStr := string(manifestJSON) + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: &manifestStr}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "no image found for architecture") + }) + + t.Run("BatchGetImage error returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureX8664, + } + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(nil, errors.New("API error")) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "API error") + }) + + t.Run("empty images returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureX8664, + } + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{}, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "image manifest not found") + }) + + t.Run("nil manifest returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureX8664, + } + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: nil}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "image manifest not found") + }) + + t.Run("invalid JSON manifest returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureX8664, + } + + invalidJSON := "invalid json" + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: &invalidJSON}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "parse image manifest") + }) + + t.Run("multi-arch image skips manifests without platform", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + PlatformArch: ecstypes.CPUArchitectureArm64, + } + + manifest := dockerSchema{ + SchemaVersion: 2, + MediaType: dockerManifestListMediaType, + Manifests: []dockerManifest{ + { + Digest: "sha256:noplatform", + Platform: nil, + }, + { + Digest: "sha256:arm64digest", + Platform: &dockerPlatform{ + Architecture: "arm64", + OS: "linux", + }, + }, + }, + } + manifestJSON, _ := json.Marshal(manifest) + manifestStr := string(manifestJSON) + + mockClient.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})).Return(&ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{ + {ImageManifest: &manifestStr}, + }, + }, nil) + + result, err := tool.GetActualImageIdentifier(ctx, info) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "sha256:arm64digest", *result.ImageDigest) + }) +} +func TestGetImageScanFindings(t *testing.T) { + ctx := context.Background() + + t.Run("successfully returns scan findings", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + } + imageID := &ecrtypes.ImageIdentifier{ + ImageDigest: aws.String("sha256:abc123"), + } + + expectedFindings := &ecrtypes.ImageScanFindings{ + FindingSeverityCounts: map[string]int32{ + "CRITICAL": 1, + "HIGH": 2, + }, + } + + mockClient.EXPECT().DescribeImageScanFindings( + ctx, gomock.AssignableToTypeOf(&ecr.DescribeImageScanFindingsInput{})). + DoAndReturn(func(ctx context.Context, + input *ecr.DescribeImageScanFindingsInput, + opts ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + assert.Equal(t, "my-repo", *input.RepositoryName) + assert.Equal(t, "sha256:abc123", *input.ImageId.ImageDigest) + return &ecr.DescribeImageScanFindingsOutput{ImageScanFindings: expectedFindings}, nil + }) + + result, err := tool.GetImageScanFindings(ctx, info, imageID) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, expectedFindings, result) + }) + + t.Run("DescribeImageScanFindings error returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + } + imageID := &ecrtypes.ImageIdentifier{ + ImageTag: aws.String("v1.0.0"), + } + + mockClient.EXPECT().DescribeImageScanFindings(ctx, gomock.AssignableToTypeOf(&ecr.DescribeImageScanFindingsInput{})).Return(nil, errors.New("API error")) + + result, err := tool.GetImageScanFindings(ctx, info, imageID) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "API error") + }) + + t.Run("nil ImageScanFindings returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcrClient(ctrl) + tool := newEcrTool(mockClient) + + info := &ImageInfo{ + Repository: "my-repo", + Tag: "v1.0.0", + } + imageID := &ecrtypes.ImageIdentifier{ + ImageTag: aws.String("v1.0.0"), + } + + mockClient.EXPECT().DescribeImageScanFindings(ctx, gomock.AssignableToTypeOf(&ecr.DescribeImageScanFindingsInput{})).Return(&ecr.DescribeImageScanFindingsOutput{ + ImageScanFindings: nil, + }, nil) + + result, err := tool.GetImageScanFindings(ctx, info, imageID) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "image scan findings missing for my-repo:v1.0.0") + }) +} + +func TestToCPUArchitecture(t *testing.T) { + t.Run("amd64 maps to x86_64", func(t *testing.T) { + assert.Equal(t, ecstypes.CPUArchitectureX8664, toCPUArchitecture("amd64")) + }) + + t.Run("arm64 maps to arm64", func(t *testing.T) { + assert.Equal(t, ecstypes.CPUArchitectureArm64, toCPUArchitecture("arm64")) + }) + + t.Run("unknown architecture maps to empty", func(t *testing.T) { + assert.Equal(t, ecstypes.CPUArchitecture(""), toCPUArchitecture("riscv")) + }) +} diff --git a/cli/cage/scan/ecs_test.go b/cli/cage/scan/ecs_test.go new file mode 100644 index 0000000..e68e1ad --- /dev/null +++ b/cli/cage/scan/ecs_test.go @@ -0,0 +1,290 @@ +package scan + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/mocks/mock_awsiface" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetServiceImageInfos(t *testing.T) { + ctx := context.Background() + + t.Run("returns image infos with default architecture", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + DoAndReturn(func(ctx context.Context, input *ecs.DescribeServicesInput, opts ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { + assert.Equal(t, "cluster-a", *input.Cluster) + assert.Equal(t, []string{"service-a"}, input.Services) + return &ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil + }) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + DoAndReturn(func(ctx context.Context, input *ecs.DescribeTaskDefinitionInput, opts ...func(*ecs.Options)) (*ecs.DescribeTaskDefinitionOutput, error) { + assert.Equal(t, "td:1", *input.TaskDefinition) + return &ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecstypes.TaskDefinition{ + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: aws.String("app"), + Image: aws.String("123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo:1.2.3"), + }, + { + Name: aws.String("sidecar"), + Image: aws.String("nginx:latest"), + }, + }, + }, + }, nil + }) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.NoError(t, err) + if assert.Len(t, result, 2) { + assert.Equal(t, "app", result[0].ContainerName) + assert.Equal(t, ecstypes.CPUArchitectureX8664, result[0].PlatformArch) + assert.Equal(t, "123456789012.dkr.ecr.us-west-2.amazonaws.com", result[0].Registry) + assert.Equal(t, "my-repo", result[0].Repository) + assert.Equal(t, "1.2.3", result[0].Tag) + assert.Equal(t, "sidecar", result[1].ContainerName) + assert.Equal(t, "", result[1].Registry) + assert.Equal(t, "nginx", result[1].Repository) + assert.Equal(t, "latest", result[1].Tag) + } + }) + + t.Run("uses runtime platform architecture when provided", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:2")}}, + }, nil) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(&ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecstypes.TaskDefinition{ + RuntimePlatform: &ecstypes.RuntimePlatform{CpuArchitecture: ecstypes.CPUArchitectureArm64}, + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: aws.String("app"), + Image: aws.String("repo:v2"), + }, + }, + }, + }, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.NoError(t, err) + if assert.Len(t, result, 1) { + assert.Equal(t, ecstypes.CPUArchitectureArm64, result[0].PlatformArch) + } + }) + + t.Run("DescribeServices error returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(nil, errors.New("API error")) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "API error") + }) + + t.Run("service not found returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{Services: []ecstypes.Service{}}, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "service not found") + }) + + t.Run("missing task definition returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: nil}}, + }, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "service not found") + }) + + t.Run("DescribeTaskDefinition error returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(nil, errors.New("API error")) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "API error") + }) + + t.Run("nil task definition returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(&ecs.DescribeTaskDefinitionOutput{TaskDefinition: nil}, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "task definition not found") + }) + + t.Run("no container definitions returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(&ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecstypes.TaskDefinition{ + ContainerDefinitions: []ecstypes.ContainerDefinition{}, + }, + }, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "no container definitions") + }) + + t.Run("container definition missing name or image returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := mock_awsiface.NewMockEcsClient(ctrl) + tool := newEcsTool(mockClient) + + mockClient.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil) + + mockClient.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(&ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecstypes.TaskDefinition{ + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: nil, + Image: aws.String("repo:v1"), + }, + }, + }, + }, nil) + + result, err := tool.GetServiceImageInfos(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "container definition is missing name or image") + }) +} + +func TestParseImageInfo(t *testing.T) { + t.Run("parses registry, repository, and tag", func(t *testing.T) { + parsed := ParseImageInfo("123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo:1.2.3") + assert.Equal(t, "123456789012.dkr.ecr.us-west-2.amazonaws.com", parsed.Registry) + assert.Equal(t, "my-repo", parsed.Repository) + assert.Equal(t, "1.2.3", parsed.Tag) + }) + + t.Run("parses repository without registry", func(t *testing.T) { + parsed := ParseImageInfo("nginx:latest") + assert.Equal(t, "", parsed.Registry) + assert.Equal(t, "nginx", parsed.Repository) + assert.Equal(t, "latest", parsed.Tag) + }) + + t.Run("defaults tag to latest", func(t *testing.T) { + parsed := ParseImageInfo("nginx") + assert.Equal(t, "nginx", parsed.Repository) + assert.Equal(t, "latest", parsed.Tag) + }) +} + +func TestSplitRepoTag(t *testing.T) { + t.Run("returns latest when tag is missing", func(t *testing.T) { + repo, tag := splitRepoTag("repo") + assert.Equal(t, "repo", repo) + assert.Equal(t, "latest", tag) + }) + + t.Run("returns tag when present", func(t *testing.T) { + repo, tag := splitRepoTag("repo:v1") + assert.Equal(t, "repo", repo) + assert.Equal(t, "v1", tag) + }) + + t.Run("returns latest when tag is empty", func(t *testing.T) { + repo, tag := splitRepoTag("repo:") + assert.Equal(t, "repo", repo) + assert.Equal(t, "latest", tag) + }) +} + +func TestIsEcr(t *testing.T) { + t.Run("matches valid ecr registry", func(t *testing.T) { + assert.True(t, IsEcr("123456789012.dkr.ecr.us-west-2.amazonaws.com")) + }) + + t.Run("rejects non-ecr registry", func(t *testing.T) { + assert.False(t, IsEcr("example.com")) + }) +} diff --git a/cli/cage/scan/printer_test.go b/cli/cage/scan/printer_test.go new file mode 100644 index 0000000..10852fc --- /dev/null +++ b/cli/cage/scan/printer_test.go @@ -0,0 +1,202 @@ +package scan_test + +import ( + "fmt" + "strings" + "testing" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/loilo-inc/canarycage/cli/cage/scan" +) + +type mockLogger struct { + logs []string +} + +func (m *mockLogger) Printf(format string, args ...any) { + m.logs = append(m.logs, fmt.Sprintf(format, args...)) +} + +func TestPrinter_Print(t *testing.T) { + tests := []struct { + name string + results []*scan.ScanResult + expectedLines int + expectedStatus []string + expectedCounts [][]int32 + }{ + { + name: "single result with no findings", + results: []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container1", + Registry: "registry.io", + Repository: "myapp", + Tag: "v1.0", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{}, + }, + }, + }, + expectedLines: 2, // header + 1 body + expectedStatus: []string{"NONE"}, + expectedCounts: [][]int32{{0, 0, 0, 0, 0}}, + }, + { + name: "single result with vulnerabilities", + results: []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container2", + Registry: "registry.io", + Repository: "myapp", + Tag: "v2.0", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: "CRITICAL"}, + {Severity: "HIGH"}, + {Severity: "MEDIUM"}, + {Severity: "LOW"}, + {Severity: "INFORMATIONAL"}, + }, + }, + }, + }, + expectedLines: 2, + expectedStatus: []string{"VULNERABLE"}, + expectedCounts: [][]int32{{1, 1, 1, 1, 1}}, + }, + { + name: "result with error", + results: []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container3", + Registry: "registry.io", + Repository: "myapp", + Tag: "v3.0", + }, + Err: fmt.Errorf("scan failed"), + }, + }, + expectedLines: 2, + expectedStatus: []string{"ERROR"}, + expectedCounts: [][]int32{{0, 0, 0, 0, 0}}, + }, + { + name: "multiple results mixed", + results: []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container4", + Registry: "registry.io", + Repository: "app1", + Tag: "v1", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: "CRITICAL"}, + {Severity: "CRITICAL"}, + }, + }, + }, + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container5", + Registry: "registry.io", + Repository: "app2", + Tag: "v2", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{}, + }, + }, + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container6", + Registry: "registry.io", + Repository: "app3", + Tag: "v3", + }, + Err: fmt.Errorf("error"), + }, + }, + expectedLines: 4, // header + 3 bodies + expectedStatus: []string{"VULNERABLE", "NONE", "ERROR"}, + expectedCounts: [][]int32{{2, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}}, + }, + { + name: "result with only low severity", + results: []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "container7", + Registry: "registry.io", + Repository: "app", + Tag: "v1", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: "LOW"}, + {Severity: "INFORMATIONAL"}, + }, + }, + }, + }, + expectedLines: 2, + expectedStatus: []string{"OK"}, + expectedCounts: [][]int32{{0, 0, 0, 1, 1}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := &mockLogger{logs: []string{}} + printer := scan.NewPrinter(logger) + + printer.Print(tt.results) + + if len(logger.logs) != tt.expectedLines { + t.Errorf("expected %d log lines, got %d", tt.expectedLines, len(logger.logs)) + } + + // Check header is present + if len(logger.logs) > 0 { + header := logger.logs[0] + if !strings.Contains(header, "CONTAINER") || !strings.Contains(header, "STATUS") { + t.Errorf("expected header to contain CONTAINER and STATUS, got: %s", header) + } + } + + // Check statuses + for i, expectedStatus := range tt.expectedStatus { + bodyLine := logger.logs[i+1] + if !strings.Contains(bodyLine, expectedStatus) { + t.Errorf("expected line %d to contain status %s, got: %s", i+1, expectedStatus, bodyLine) + } + } + + // Check container names + for i, result := range tt.results { + bodyLine := logger.logs[i+1] + if !strings.Contains(bodyLine, result.ImageInfo.ContainerName) { + t.Errorf("expected line %d to contain container name %s, got: %s", i+1, result.ImageInfo.ContainerName, bodyLine) + } + } + }) + } +} + +func TestPrinter_Print_EmptyResults(t *testing.T) { + logger := &mockLogger{logs: []string{}} + printer := scan.NewPrinter(logger) + + printer.Print([]*scan.ScanResult{}) + + if len(logger.logs) != 1 { + t.Errorf("expected 1 log line (header only), got %d", len(logger.logs)) + } +} diff --git a/cli/cage/scan/scanner.go b/cli/cage/scan/scanner.go index 111086b..82ed983 100644 --- a/cli/cage/scan/scanner.go +++ b/cli/cage/scan/scanner.go @@ -4,7 +4,6 @@ import ( "context" "github.com/apex/log" - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/loilo-inc/canarycage/awsiface" ) @@ -17,12 +16,6 @@ type Scanner interface { Scan(ctx context.Context, cluster string, service string) ([]*ScanResult, error) } -type ScanResult struct { - ImageInfo *ImageInfo - ImageScanFindings *ecrtypes.ImageScanFindings - Err error -} - func NewScanner(ecs awsiface.EcsClient, ecr awsiface.EcrClient) Scanner { return &scanner{ecs: ecs, ecr: ecr} } diff --git a/cli/cage/scan/scanner_test.go b/cli/cage/scan/scanner_test.go new file mode 100644 index 0000000..e7acb1d --- /dev/null +++ b/cli/cage/scan/scanner_test.go @@ -0,0 +1,166 @@ +package scan + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/loilo-inc/canarycage/mocks/mock_awsiface" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +type stubEcrTool struct { + imageID *ecrtypes.ImageIdentifier + findings *ecrtypes.ImageScanFindings + errID error + errScan error +} + +func (s *stubEcrTool) GetActualImageIdentifier(ctx context.Context, info *ImageInfo) (*ecrtypes.ImageIdentifier, error) { + if s.errID != nil { + return nil, s.errID + } + return s.imageID, nil +} + +func (s *stubEcrTool) GetImageScanFindings(ctx context.Context, info *ImageInfo, imageID *ecrtypes.ImageIdentifier) (*ecrtypes.ImageScanFindings, error) { + if s.errScan != nil { + return nil, s.errScan + } + return s.findings, nil +} + +func TestScanner_Scan(t *testing.T) { + ctx := context.Background() + + t.Run("returns scan results for each image", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockEcs := mock_awsiface.NewMockEcsClient(ctrl) + mockEcr := mock_awsiface.NewMockEcrClient(ctrl) + scanner := NewScanner(mockEcs, mockEcr) + + mockEcs.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(&ecs.DescribeServicesOutput{ + Services: []ecstypes.Service{{TaskDefinition: aws.String("td:1")}}, + }, nil) + + mockEcs.EXPECT().DescribeTaskDefinition(ctx, gomock.AssignableToTypeOf(&ecs.DescribeTaskDefinitionInput{})). + Return(&ecs.DescribeTaskDefinitionOutput{ + TaskDefinition: &ecstypes.TaskDefinition{ + ContainerDefinitions: []ecstypes.ContainerDefinition{ + { + Name: aws.String("app"), + Image: aws.String("123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo:1.2.3"), + }, + { + Name: aws.String("sidecar"), + Image: aws.String("nginx:latest"), + }, + }, + }, + }, nil) + + manifestJSON, _ := json.Marshal(dockerSchema{ + SchemaVersion: 2, + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + }) + manifestStr := string(manifestJSON) + + mockEcr.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})). + DoAndReturn(func(ctx context.Context, input *ecr.BatchGetImageInput, opts ...func(*ecr.Options)) (*ecr.BatchGetImageOutput, error) { + assert.Equal(t, "my-repo", *input.RepositoryName) + assert.Equal(t, "1.2.3", *input.ImageIds[0].ImageTag) + return &ecr.BatchGetImageOutput{ + Images: []ecrtypes.Image{{ImageManifest: &manifestStr}}, + }, nil + }) + + mockEcr.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})). + DoAndReturn(func(ctx context.Context, input *ecr.BatchGetImageInput, opts ...func(*ecr.Options)) (*ecr.BatchGetImageOutput, error) { + assert.Equal(t, "nginx", *input.RepositoryName) + assert.Equal(t, "latest", *input.ImageIds[0].ImageTag) + return nil, errors.New("batch error") + }) + + mockEcr.EXPECT().DescribeImageScanFindings(ctx, gomock.AssignableToTypeOf(&ecr.DescribeImageScanFindingsInput{})). + DoAndReturn(func(ctx context.Context, input *ecr.DescribeImageScanFindingsInput, opts ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { + assert.Equal(t, "my-repo", *input.RepositoryName) + assert.Equal(t, "1.2.3", *input.ImageId.ImageTag) + return &ecr.DescribeImageScanFindingsOutput{ + ImageScanFindings: &ecrtypes.ImageScanFindings{}, + }, nil + }) + + results, err := scanner.Scan(ctx, "cluster-a", "service-a") + + assert.NoError(t, err) + if assert.Len(t, results, 2) { + assert.Equal(t, "app", results[0].ImageInfo.ContainerName) + assert.NoError(t, results[0].Err) + assert.Equal(t, "sidecar", results[1].ImageInfo.ContainerName) + assert.Error(t, results[1].Err) + } + }) + + t.Run("ecs error returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockEcs := mock_awsiface.NewMockEcsClient(ctrl) + mockEcr := mock_awsiface.NewMockEcrClient(ctrl) + scanner := NewScanner(mockEcs, mockEcr) + + mockEcs.EXPECT().DescribeServices(ctx, gomock.AssignableToTypeOf(&ecs.DescribeServicesInput{})). + Return(nil, errors.New("ecs error")) + + results, err := scanner.Scan(ctx, "cluster-a", "service-a") + + assert.Error(t, err) + assert.Nil(t, results) + }) +} + +func TestScanImage(t *testing.T) { + ctx := context.Background() + + t.Run("GetActualImageIdentifier error returns error", func(t *testing.T) { + tool := &stubEcrTool{ + errID: errors.New("id error"), + } + + result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + + assert.Error(t, result.Err) + assert.Contains(t, result.Err.Error(), "id error") + }) + + t.Run("GetImageScanFindings error returns error", func(t *testing.T) { + tool := &stubEcrTool{ + imageID: &ecrtypes.ImageIdentifier{ImageTag: aws.String("v1")}, + errScan: errors.New("scan error"), + } + + result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + + assert.Error(t, result.Err) + assert.Contains(t, result.Err.Error(), "scan error") + }) + + t.Run("success returns findings", func(t *testing.T) { + findings := &ecrtypes.ImageScanFindings{} + tool := &stubEcrTool{ + imageID: &ecrtypes.ImageIdentifier{ImageTag: aws.String("v1")}, + findings: findings, + } + + result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + + assert.NoError(t, result.Err) + assert.Equal(t, findings, result.ImageScanFindings) + }) +} diff --git a/cli/cage/scan/types.go b/cli/cage/scan/types.go index c57909a..2dcea34 100644 --- a/cli/cage/scan/types.go +++ b/cli/cage/scan/types.go @@ -1,6 +1,9 @@ package scan -import "github.com/aws/aws-sdk-go-v2/service/ecs/types" +import ( + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/aws/aws-sdk-go-v2/service/ecs/types" +) type ImageInfo struct { Registry string @@ -9,3 +12,9 @@ type ImageInfo struct { Repository string Tag string } + +type ScanResult struct { + ImageInfo *ImageInfo + ImageScanFindings *ecrtypes.ImageScanFindings + Err error +} From adacf58c3e329310a5630fda33c1138b9543768b Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 17:04:01 +0900 Subject: [PATCH 05/23] Update scan.go --- cli/cage/commands/scan.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/scan.go index 313956d..a242fe9 100644 --- a/cli/cage/commands/scan.go +++ b/cli/cage/commands/scan.go @@ -22,7 +22,6 @@ func Scan(diProvider diProvider) *cli.Command { return &cli.Command{ Name: "scan", Usage: "Scan ECR image vulnerabilities for the given ECS service", - Args: true, ArgsUsage: "", Flags: []cli.Flag{ cageapp.RegionFlag(®ion), From 97f4461277707fa90915cde8c23a435a429758c4 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:14:05 +0900 Subject: [PATCH 06/23] test --- Makefile | 13 ++- cli/cage/cageapp/provider.go | 9 +- cli/cage/cageapp/provider_test.go | 81 ++++++++++++++++++ cli/cage/commands/a_test.go | 40 +++++++++ cli/cage/commands/command_test.go | 100 ---------------------- cli/cage/commands/rollout_test.go | 42 ++++++++++ cli/cage/commands/run_test.go | 36 ++++++++ cli/cage/commands/scan.go | 8 +- cli/cage/commands/scan_test.go | 134 ++++++++++++++++++++++++++++++ cli/cage/commands/up_test.go | 36 ++++++++ cli/cage/scan/printer.go | 24 ++---- key/keys.go | 2 + logger/logger.go | 22 +++++ logger/logger_test.go | 17 ++++ mocks/mock_logger/logger.go | 57 +++++++++++++ mocks/mock_scan/scanner.go | 57 +++++++++++++ 16 files changed, 552 insertions(+), 126 deletions(-) create mode 100644 cli/cage/cageapp/provider_test.go create mode 100644 cli/cage/commands/a_test.go create mode 100644 cli/cage/commands/rollout_test.go create mode 100644 cli/cage/commands/run_test.go create mode 100644 cli/cage/commands/scan_test.go create mode 100644 cli/cage/commands/up_test.go create mode 100644 logger/logger.go create mode 100644 logger/logger_test.go create mode 100644 mocks/mock_logger/logger.go create mode 100644 mocks/mock_scan/scanner.go diff --git a/Makefile b/Makefile index a715b98..fcb32c9 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +# go install go.uber.org/mock/mockgen@latest MOCKGEN := mockgen .PHONY: test test: @@ -13,16 +14,20 @@ mocks: go.sum \ mocks/mock_awsiface/iface.go \ mocks/mock_types/iface.go \ mocks/mock_upgrade/upgrade.go \ + mocks/mock_scan/scanner.go \ mocks/mock_task/task.go \ mocks/mock_taskset/taskset.go \ mocks/mock_task/factory.go \ - mocks/mock_rollout/executor.go + mocks/mock_rollout/executor.go \ + mocks/mock_logger/logger.go mocks/mock_awsiface/iface.go: awsiface/iface.go $(MOCKGEN) -source=./awsiface/iface.go > mocks/mock_awsiface/iface.go mocks/mock_types/iface.go: types/iface.go $(MOCKGEN) -source=./types/iface.go > mocks/mock_types/iface.go -mocks/mock_upgrade/upgrade.go: upgrade/upgrade.go - $(MOCKGEN) -source=./upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go +mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go + $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go +mocks/mock_scan/scanner.go: cli/cage/scan/scanner.go + $(MOCKGEN) -source=./cli/cage/scan/scanner.go > mocks/mock_scan/scanner.go mocks/mock_task/task.go: task/task.go $(MOCKGEN) -source=./task/task.go > mocks/mock_task/task.go mocks/mock_taskset/taskset.go: taskset/taskset.go @@ -31,4 +36,6 @@ mocks/mock_task/factory.go: task/factory.go $(MOCKGEN) -source=./task/factory.go > mocks/mock_task/factory.go mocks/mock_rollout/executor.go: rollout/executor.go $(MOCKGEN) -source=./rollout/executor.go > mocks/mock_rollout/executor.go +mocks/mock_logger/logger.go: logger/logger.go + $(MOCKGEN) -source=./logger/logger.go > mocks/mock_logger/logger.go .PHONY: mocks diff --git a/cli/cage/cageapp/provider.go b/cli/cage/cageapp/provider.go index a6d07ff..d987a6b 100644 --- a/cli/cage/cageapp/provider.go +++ b/cli/cage/cageapp/provider.go @@ -2,6 +2,7 @@ package cageapp import ( "context" + "os" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -10,8 +11,10 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" cage "github.com/loilo-inc/canarycage" + "github.com/loilo-inc/canarycage/cli/cage/scan" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/logger" "github.com/loilo-inc/canarycage/task" "github.com/loilo-inc/canarycage/timeout" "github.com/loilo-inc/canarycage/types" @@ -43,8 +46,10 @@ func ProvideScanDI(region string) (*di.D, error) { return nil, err } d := di.NewDomain(func(b *di.B) { - b.Set(key.EcsCli, ecs.NewFromConfig(conf)) - b.Set(key.EcrCli, ecr.NewFromConfig(conf)) + ecsCli := ecs.NewFromConfig(conf) + ecrCli := ecr.NewFromConfig(conf) + b.Set(key.Scanner, scan.NewScanner(ecsCli, ecrCli)) + b.Set(key.Logger, logger.DefaultLogger(os.Stdout)) }) return d, nil } diff --git a/cli/cage/cageapp/provider_test.go b/cli/cage/cageapp/provider_test.go new file mode 100644 index 0000000..ea4d263 --- /dev/null +++ b/cli/cage/cageapp/provider_test.go @@ -0,0 +1,81 @@ +package cageapp_test + +import ( + "testing" + + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/env" + "github.com/stretchr/testify/assert" +) + +func TestProvideCageCli(t *testing.T) { + t.Run("successfully creates cage cli with valid region", func(t *testing.T) { + envars := &env.Envars{ + Region: "us-east-1", + } + + cage, err := cageapp.ProvideCageCli(envars) + assert.NoError(t, err) + assert.NotNil(t, cage) + }) + + t.Run("returns error with invalid region", func(t *testing.T) { + envars := &env.Envars{ + Region: "", + } + + cage, err := cageapp.ProvideCageCli(envars) + if err != nil { + assert.Nil(t, cage, "expected cage to be nil when error occurs") + return + } + assert.NotNil(t, cage, "expected cage to be non-nil when no error") + }) + + t.Run("handles nil envars", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + return + } + }() + + cage, err := cageapp.ProvideCageCli(nil) + if err == nil { + assert.NotNil(t, cage, "expected cage to be non-nil when no error") + } + }) +} + +func TestProvideScanDI(t *testing.T) { + t.Run("successfully creates scan DI with valid region", func(t *testing.T) { + region := "us-east-1" + + d, err := cageapp.ProvideScanDI(region) + assert.NoError(t, err) + assert.NotNil(t, d) + }) + + t.Run("returns error with invalid region", func(t *testing.T) { + region := "" + + d, err := cageapp.ProvideScanDI(region) + if err != nil { + assert.Nil(t, d, "expected DI domain to be nil when error occurs") + return + } + assert.NotNil(t, d, "expected DI domain to be non-nil when no error") + }) + + t.Run("creates DI domain with different regions", func(t *testing.T) { + regions := []string{"us-west-2", "eu-west-1", "ap-northeast-1"} + + for _, region := range regions { + d, err := cageapp.ProvideScanDI(region) + if err != nil { + t.Logf("region %s returned error: %v", region, err) + continue + } + assert.NotNil(t, d, "expected DI domain to be non-nil for region %s", region) + } + }) +} diff --git a/cli/cage/commands/a_test.go b/cli/cage/commands/a_test.go new file mode 100644 index 0000000..cbea587 --- /dev/null +++ b/cli/cage/commands/a_test.go @@ -0,0 +1,40 @@ +package commands_test + +import ( + "strings" + "testing" + + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/cli/cage/commands" + "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/mocks/mock_types" + "github.com/loilo-inc/canarycage/types" + "github.com/urfave/cli/v2" + "go.uber.org/mock/gomock" +) + +var stdinService = "ap-notheast-1\ncluster\nservice\nyes\n" +var stdinTask = "ap-notheast-1\ncluster\nyes\n" + +func setup(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { + ctrl := gomock.NewController(t) + cagecli := mock_types.NewMockCage(ctrl) + cageapp := &cageapp.App{Stdin: strings.NewReader(input)} + app := cli.NewApp() + cmds := commands.NewCageCommands(func(envars *env.Envars) (types.Cage, error) { + return cagecli, nil + }) + app.Commands = []*cli.Command{ + cmds.Up(cageapp), + cmds.RollOut(cageapp), + cmds.Run(cageapp), + } + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "ci", + Destination: &cageapp.CI, + Value: false, + }, + } + return app, cagecli +} diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 445cf0d..261ea69 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -1,116 +1,16 @@ package commands import ( - "fmt" - "strings" "testing" - "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/mocks/mock_types" "github.com/loilo-inc/canarycage/test" "github.com/loilo-inc/canarycage/types" "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v2" "go.uber.org/mock/gomock" ) -func TestCommands(t *testing.T) { - region := "ap-notheast-1" - cluster := "cluster" - service := "service" - stdinService := fmt.Sprintf("%s\n%s\n%s\n%s\n", region, cluster, service, "yes") - stdinTask := fmt.Sprintf("%s\n%s\n%s\n", region, cluster, "yes") - setup := func(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { - ctrl := gomock.NewController(t) - cagecli := mock_types.NewMockCage(ctrl) - cageapp := &cageapp.App{Stdin: strings.NewReader(input)} - app := cli.NewApp() - cmds := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { - return cagecli, nil - }) - app.Commands = []*cli.Command{ - cmds.Up(cageapp), - cmds.RollOut(cageapp), - cmds.Run(cageapp), - } - app.Flags = []cli.Flag{ - &cli.BoolFlag{ - Name: "ci", - Destination: &cageapp.CI, - Value: false, - }, - } - return app, cagecli - } - t.Run("rollout", func(t *testing.T) { - t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) - err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") - cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) - err := app.Run([]string{"cage", "--ci", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("basic/update-service", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{UpdateService: true}).Return(&types.RollOutResult{}, nil) - err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "--updateService", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, fmt.Errorf("error")) - err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.EqualError(t, err, "error") - }) - }) - t.Run("up", func(t *testing.T) { - t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) - err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") - cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) - err := app.Run([]string{"cage", "--ci", "up", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.NoError(t, err) - }) - t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinService) - cagecli.EXPECT().Up(gomock.Any()).Return(nil, fmt.Errorf("error")) - err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) - assert.EqualError(t, err, "error") - }) - }) - t.Run("run", func(t *testing.T) { - t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinTask) - cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) - err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) - assert.NoError(t, err) - }) - t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") - cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) - err := app.Run([]string{"cage", "--ci", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) - assert.NoError(t, err) - }) - t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinTask) - cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) - err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) - assert.EqualError(t, err, "error") - }) - }) -} - func TestSetupCage(t *testing.T) { t.Run("basic", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2"} diff --git a/cli/cage/commands/rollout_test.go b/cli/cage/commands/rollout_test.go new file mode 100644 index 0000000..a505ce2 --- /dev/null +++ b/cli/cage/commands/rollout_test.go @@ -0,0 +1,42 @@ +package commands_test + +import ( + "fmt" + "testing" + + "github.com/loilo-inc/canarycage/types" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestRollOut(t *testing.T) { + t.Run("basic", func(t *testing.T) { + app, cagecli := setup(t, stdinService) + cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) + err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("basic/ci", func(t *testing.T) { + app, cagecli := setup(t, "") + cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) + err := app.Run([]string{"cage", "--ci", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("basic/update-service", func(t *testing.T) { + app, cagecli := setup(t, stdinService) + cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{UpdateService: true}).Return(&types.RollOutResult{}, nil) + err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "--updateService", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("missing args", func(t *testing.T) { + app, _ := setup(t, "") + err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1"}) + assert.EqualError(t, err, "invalid number of arguments. expected at least 1") + }) + t.Run("error", func(t *testing.T) { + app, cagecli := setup(t, stdinService) + cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, fmt.Errorf("error")) + err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.EqualError(t, err, "error") + }) +} diff --git a/cli/cage/commands/run_test.go b/cli/cage/commands/run_test.go new file mode 100644 index 0000000..79b150e --- /dev/null +++ b/cli/cage/commands/run_test.go @@ -0,0 +1,36 @@ +package commands_test + +import ( + "fmt" + "testing" + + "github.com/loilo-inc/canarycage/types" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestRun(t *testing.T) { + t.Run("basic", func(t *testing.T) { + app, cagecli := setup(t, stdinTask) + cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) + err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) + assert.NoError(t, err) + }) + t.Run("basic/ci", func(t *testing.T) { + app, cagecli := setup(t, "") + cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) + err := app.Run([]string{"cage", "--ci", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) + assert.NoError(t, err) + }) + t.Run("missing args", func(t *testing.T) { + app, _ := setup(t, "") + err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1"}) + assert.EqualError(t, err, "invalid number of arguments. expected at least 3") + }) + t.Run("error", func(t *testing.T) { + app, cagecli := setup(t, stdinTask) + cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) + err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) + assert.EqualError(t, err, "error") + }) +} diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/scan.go index a242fe9..49060e7 100644 --- a/cli/cage/commands/scan.go +++ b/cli/cage/commands/scan.go @@ -4,11 +4,11 @@ import ( "context" "errors" - "github.com/loilo-inc/canarycage/awsiface" "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/scan" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/logger" "github.com/loilo-inc/logos/di" "github.com/urfave/cli/v2" ) @@ -50,14 +50,12 @@ func Scan(diProvider diProvider) *cli.Command { if err != nil { return err } - ecscli := d.Get(key.EcsCli).(awsiface.EcsClient) - ecrcli := d.Get(key.EcrCli).(awsiface.EcrClient) - scanner := scan.NewScanner(ecscli, ecrcli) + scanner := d.Get(key.Scanner).(scan.Scanner) result, err := scanner.Scan(context.Background(), cluster, service) if err != nil { return err } - logger := scan.DefaultLogger() + logger := d.Get(key.Logger).(logger.Logger) printer := scan.NewPrinter(logger) printer.Print(result) return nil diff --git a/cli/cage/commands/scan_test.go b/cli/cage/commands/scan_test.go new file mode 100644 index 0000000..7847701 --- /dev/null +++ b/cli/cage/commands/scan_test.go @@ -0,0 +1,134 @@ +package commands + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + + "github.com/loilo-inc/canarycage/cli/cage/scan" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/mocks/mock_logger" + "github.com/loilo-inc/canarycage/mocks/mock_scan" + "github.com/loilo-inc/logos/di" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + "go.uber.org/mock/gomock" +) + +func TestScan(t *testing.T) { + t.Run("returns error when both directory and flags are missing", func(t *testing.T) { + app := setupScanApp(t, nil) + + err := app.Run([]string{"cage", "scan", "--region", "us-east-1"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") + }) + + t.Run("returns error when only cluster flag is provided", func(t *testing.T) { + app := setupScanApp(t, nil) + + err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "--cluster", "test-cluster"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") + }) + + t.Run("returns error when only service flag is provided", func(t *testing.T) { + app := setupScanApp(t, nil) + + err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "--service", "test-service"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") + }) + + t.Run("returns error when diProvider fails", func(t *testing.T) { + expectedErr := errors.New("di provider error") + app := setupScanApp(t, func(region string) (*di.D, error) { + return nil, expectedErr + }) + + err := app.Run([]string{ + "cage", "scan", "--region", "us-east-1", "--cluster", "test-cluster", "--service", "test-service", + }) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) + }) +} + +func TestScan_Success(t *testing.T) { + setup := func(t *testing.T) *cli.App { + t.Helper() + ctrl := gomock.NewController(t) + mockScanner := mock_scan.NewMockScanner(ctrl) + mockLogger := mock_logger.NewMockLogger(ctrl) + d := di.NewDomain(func(b *di.B) { + b.Set(key.Scanner, mockScanner) + b.Set(key.Logger, mockLogger) + }) + + app := setupScanApp(t, func(region string) (*di.D, error) { + assert.Equal(t, "us-east-1", region) + return d, nil + }) + + mockScanner.EXPECT(). + Scan(gomock.Any(), "cluster", "service"). // from fixtures/service.json + Return(makeScanResult(), nil) + + mockLogger.EXPECT().Printf( + gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), + ).Times(2) + + return app + } + t.Run("executes scan with directory argument", func(t *testing.T) { + app := setup(t) + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("executes scan with flags", func(t *testing.T) { + app := setup(t) + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", + "--cluster", "cluster", + "--service", "service"}) + assert.NoError(t, err) + }) +} + +func setupScanApp(t *testing.T, diProvider func(region string) (*di.D, error)) *cli.App { + t.Helper() + app := cli.NewApp() + app.Name = "cage" + app.Commands = []*cli.Command{ + Scan(diProvider), + } + return app +} + +func makeScanResult() []*scan.ScanResult { + return []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + Repository: "test-repo", + Tag: "latest", + Registry: "dockerhub.io", + ContainerName: "web-app", + PlatformArch: "amd64", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-1234"), + Description: aws.String("Test vulnerability description"), + Severity: ecrtypes.FindingSeverityHigh, + }, + }, + }, + }, + } +} diff --git a/cli/cage/commands/up_test.go b/cli/cage/commands/up_test.go new file mode 100644 index 0000000..d91b2a7 --- /dev/null +++ b/cli/cage/commands/up_test.go @@ -0,0 +1,36 @@ +package commands_test + +import ( + "fmt" + "testing" + + "github.com/loilo-inc/canarycage/types" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestUp(t *testing.T) { + t.Run("basic", func(t *testing.T) { + app, cagecli := setup(t, stdinService) + cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) + err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("basic/ci", func(t *testing.T) { + app, cagecli := setup(t, "") + cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) + err := app.Run([]string{"cage", "--ci", "up", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("missing args", func(t *testing.T) { + app, _ := setup(t, "") + err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1"}) + assert.EqualError(t, err, "invalid number of arguments. expected at least 1") + }) + t.Run("error", func(t *testing.T) { + app, cagecli := setup(t, stdinService) + cagecli.EXPECT().Up(gomock.Any()).Return(nil, fmt.Errorf("error")) + err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.EqualError(t, err, "error") + }) +} diff --git a/cli/cage/scan/printer.go b/cli/cage/scan/printer.go index 1a90ee5..8b24d72 100644 --- a/cli/cage/scan/printer.go +++ b/cli/cage/scan/printer.go @@ -1,28 +1,20 @@ package scan -import "fmt" +import ( + "fmt" + + "github.com/loilo-inc/canarycage/logger" +) type printer struct { - logger Logger -} -type Logger interface { - Printf(format string, args ...any) + logger logger.Logger } + type Printer interface { Print(result []*ScanResult) } -func DefaultLogger() Logger { - return &defaultLogger{} -} - -type defaultLogger struct{} - -func (l *defaultLogger) Printf(format string, args ...any) { - fmt.Printf(format, args...) -} - -func NewPrinter(logger Logger) Printer { +func NewPrinter(logger logger.Logger) Printer { return &printer{logger: logger} } diff --git a/key/keys.go b/key/keys.go index 749c054..c65a919 100644 --- a/key/keys.go +++ b/key/keys.go @@ -7,6 +7,8 @@ const ( EcrCli DepsKey = "ecr" Ec2Cli DepsKey = "ec2" AlbCli DepsKey = "alb" + Logger DepsKey = "logger" + Scanner DepsKey = "scanner" Env DepsKey = "env" Time DepsKey = "time" TaskFactory DepsKey = "task-factory" diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..95b56a1 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,22 @@ +package logger + +import ( + "fmt" + "io" +) + +type Logger interface { + Printf(format string, args ...any) +} + +func DefaultLogger(stdout io.Writer) Logger { + return &defaultLogger{stdout: stdout} +} + +type defaultLogger struct { + stdout io.Writer +} + +func (l *defaultLogger) Printf(format string, args ...any) { + fmt.Fprintf(l.stdout, format, args...) +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 0000000..3307c7c --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,17 @@ +package logger_test + +import ( + "bytes" + "testing" + + "github.com/loilo-inc/canarycage/logger" + "github.com/stretchr/testify/assert" +) + +func TestDefaultLogger_Printf(t *testing.T) { + var bin bytes.Buffer + logger := logger.DefaultLogger(&bin) + logger.Printf("Hello, %s!", "world") + output := bin.String() + assert.Equal(t, "Hello, world!", output) +} diff --git a/mocks/mock_logger/logger.go b/mocks/mock_logger/logger.go new file mode 100644 index 0000000..f1610ad --- /dev/null +++ b/mocks/mock_logger/logger.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./logger/logger.go +// +// Generated by this command: +// +// mockgen -source=./logger/logger.go +// + +// Package mock_logger is a generated GoMock package. +package mock_logger + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockLogger is a mock of Logger interface. +type MockLogger struct { + ctrl *gomock.Controller + recorder *MockLoggerMockRecorder + isgomock struct{} +} + +// MockLoggerMockRecorder is the mock recorder for MockLogger. +type MockLoggerMockRecorder struct { + mock *MockLogger +} + +// NewMockLogger creates a new mock instance. +func NewMockLogger(ctrl *gomock.Controller) *MockLogger { + mock := &MockLogger{ctrl: ctrl} + mock.recorder = &MockLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { + return m.recorder +} + +// Printf mocks base method. +func (m *MockLogger) Printf(format string, args ...any) { + m.ctrl.T.Helper() + varargs := []any{format} + for _, a := range args { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Printf", varargs...) +} + +// Printf indicates an expected call of Printf. +func (mr *MockLoggerMockRecorder) Printf(format any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{format}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Printf", reflect.TypeOf((*MockLogger)(nil).Printf), varargs...) +} diff --git a/mocks/mock_scan/scanner.go b/mocks/mock_scan/scanner.go new file mode 100644 index 0000000..15f599f --- /dev/null +++ b/mocks/mock_scan/scanner.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./cli/cage/scan/scanner.go +// +// Generated by this command: +// +// mockgen -source=./cli/cage/scan/scanner.go +// + +// Package mock_scan is a generated GoMock package. +package mock_scan + +import ( + context "context" + reflect "reflect" + + scan "github.com/loilo-inc/canarycage/cli/cage/scan" + gomock "go.uber.org/mock/gomock" +) + +// MockScanner is a mock of Scanner interface. +type MockScanner struct { + ctrl *gomock.Controller + recorder *MockScannerMockRecorder + isgomock struct{} +} + +// MockScannerMockRecorder is the mock recorder for MockScanner. +type MockScannerMockRecorder struct { + mock *MockScanner +} + +// NewMockScanner creates a new mock instance. +func NewMockScanner(ctrl *gomock.Controller) *MockScanner { + mock := &MockScanner{ctrl: ctrl} + mock.recorder = &MockScannerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockScanner) EXPECT() *MockScannerMockRecorder { + return m.recorder +} + +// Scan mocks base method. +func (m *MockScanner) Scan(ctx context.Context, cluster, service string) ([]*scan.ScanResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan", ctx, cluster, service) + ret0, _ := ret[0].([]*scan.ScanResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Scan indicates an expected call of Scan. +func (mr *MockScannerMockRecorder) Scan(ctx, cluster, service any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockScanner)(nil).Scan), ctx, cluster, service) +} From 15261806bc1602d78f0d7e82fe353abe92a9d961 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:18:58 +0900 Subject: [PATCH 07/23] Update printer.go --- cli/cage/scan/printer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cage/scan/printer.go b/cli/cage/scan/printer.go index 8b24d72..03cd393 100644 --- a/cli/cage/scan/printer.go +++ b/cli/cage/scan/printer.go @@ -28,7 +28,7 @@ func (p *printer) Print(result []*ScanResult) { if r.Err != nil { p.logger.Printf(bodyFmt, r.ImageInfo.ContainerName, - "ERROR", 0, 0, 0, 0, 0, 1, + "ERROR", 0, 0, 0, 0, 0, formatImageLabel(r.ImageInfo), ) continue From 7b54f9d558e78234dbbb7d218525bbb5e7678179 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:23:54 +0900 Subject: [PATCH 08/23] Update cli/cage/scan/printer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/cage/scan/printer.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/cage/scan/printer.go b/cli/cage/scan/printer.go index 8b24d72..d25f797 100644 --- a/cli/cage/scan/printer.go +++ b/cli/cage/scan/printer.go @@ -54,6 +54,8 @@ func (p *printer) Print(result []*ScanResult) { status = "NONE" } else if critical > 0 || high > 0 { status = "VULNERABLE" + } else if medium > 0 { + status = "WARNING" } p.logger.Printf( bodyFmt, From 822c10a5d831939bbdd0bb9c26e435c02459ac91 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:24:13 +0900 Subject: [PATCH 09/23] test --- cli/cage/commands/scan.go | 3 +++ cli/cage/commands/scan_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/scan.go index 49060e7..39613fa 100644 --- a/cli/cage/commands/scan.go +++ b/cli/cage/commands/scan.go @@ -33,6 +33,9 @@ func Scan(diProvider diProvider) *cli.Command { if err != nil { return err } + if region == "" { + return errors.New("--region flag is required") + } if dir != "" { srv, err := env.LoadServiceDefinition(dir) if err != nil { diff --git a/cli/cage/commands/scan_test.go b/cli/cage/commands/scan_test.go index 7847701..9e2f41d 100644 --- a/cli/cage/commands/scan_test.go +++ b/cli/cage/commands/scan_test.go @@ -18,6 +18,12 @@ import ( ) func TestScan(t *testing.T) { + t.Run("returns error when region is missing", func(t *testing.T) { + app := setupScanApp(t, nil) + err := app.Run([]string{"cage", "scan", "--region", ""}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--region flag is required") + }) t.Run("returns error when both directory and flags are missing", func(t *testing.T) { app := setupScanApp(t, nil) From bc34201a1a8e7b66379f7f4920f01cb1b45b936f Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:37:34 +0900 Subject: [PATCH 10/23] Update printer_test.go --- cli/cage/scan/printer_test.go | 162 +++++++++++++--------------------- 1 file changed, 62 insertions(+), 100 deletions(-) diff --git a/cli/cage/scan/printer_test.go b/cli/cage/scan/printer_test.go index 10852fc..56066da 100644 --- a/cli/cage/scan/printer_test.go +++ b/cli/cage/scan/printer_test.go @@ -2,11 +2,11 @@ package scan_test import ( "fmt" - "strings" "testing" ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/loilo-inc/canarycage/cli/cage/scan" + "github.com/stretchr/testify/assert" ) type mockLogger struct { @@ -17,6 +17,13 @@ func (m *mockLogger) Printf(format string, args ...any) { m.logs = append(m.logs, fmt.Sprintf(format, args...)) } +var imageInfo = &scan.ImageInfo{ + ContainerName: "test-container", + Registry: "test-registry", + Repository: "test-repo", + Tag: "latest", +} + func TestPrinter_Print(t *testing.T) { tests := []struct { name string @@ -26,60 +33,38 @@ func TestPrinter_Print(t *testing.T) { expectedCounts [][]int32 }{ { - name: "single result with no findings", - results: []*scan.ScanResult{ - { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container1", - Registry: "registry.io", - Repository: "myapp", - Tag: "v1.0", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{}, - }, - }, - }, + name: "single result with no findings", + results: makeScanResult(), expectedLines: 2, // header + 1 body expectedStatus: []string{"NONE"}, expectedCounts: [][]int32{{0, 0, 0, 0, 0}}, }, { name: "single result with vulnerabilities", - results: []*scan.ScanResult{ - { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container2", - Registry: "registry.io", - Repository: "myapp", - Tag: "v2.0", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{ - {Severity: "CRITICAL"}, - {Severity: "HIGH"}, - {Severity: "MEDIUM"}, - {Severity: "LOW"}, - {Severity: "INFORMATIONAL"}, - }, - }, - }, - }, + results: makeScanResult( + "CRITICAL", + "HIGH", + "MEDIUM", + "LOW", + "INFORMATIONAL", + ), expectedLines: 2, expectedStatus: []string{"VULNERABLE"}, expectedCounts: [][]int32{{1, 1, 1, 1, 1}}, }, + { + name: "single result with only medium severity", + results: makeScanResult("MEDIUM", "MEDIUM"), + expectedLines: 2, + expectedStatus: []string{"WARNING"}, + expectedCounts: [][]int32{{0, 0, 2, 0, 0}}, + }, { name: "result with error", results: []*scan.ScanResult{ { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container3", - Registry: "registry.io", - Repository: "myapp", - Tag: "v3.0", - }, - Err: fmt.Errorf("scan failed"), + ImageInfo: imageInfo, + Err: fmt.Errorf("scan failed"), }, }, expectedLines: 2, @@ -89,63 +74,24 @@ func TestPrinter_Print(t *testing.T) { { name: "multiple results mixed", results: []*scan.ScanResult{ + makeScanResult("CRITICAL", "HIGH")[0], { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container4", - Registry: "registry.io", - Repository: "app1", - Tag: "v1", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{ - {Severity: "CRITICAL"}, - {Severity: "CRITICAL"}, - }, - }, - }, - { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container5", - Registry: "registry.io", - Repository: "app2", - Tag: "v2", - }, + ImageInfo: imageInfo, ImageScanFindings: &ecrtypes.ImageScanFindings{ Findings: []ecrtypes.ImageScanFinding{}, }, }, { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container6", - Registry: "registry.io", - Repository: "app3", - Tag: "v3", - }, - Err: fmt.Errorf("error"), + ImageInfo: imageInfo, + Err: fmt.Errorf("error"), }, }, expectedLines: 4, // header + 3 bodies expectedStatus: []string{"VULNERABLE", "NONE", "ERROR"}, - expectedCounts: [][]int32{{2, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}}, }, { - name: "result with only low severity", - results: []*scan.ScanResult{ - { - ImageInfo: &scan.ImageInfo{ - ContainerName: "container7", - Registry: "registry.io", - Repository: "app", - Tag: "v1", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{ - {Severity: "LOW"}, - {Severity: "INFORMATIONAL"}, - }, - }, - }, - }, + name: "result with only low severity", + results: makeScanResult("LOW", "INFORMATIONAL"), expectedLines: 2, expectedStatus: []string{"OK"}, expectedCounts: [][]int32{{0, 0, 0, 1, 1}}, @@ -159,32 +105,25 @@ func TestPrinter_Print(t *testing.T) { printer.Print(tt.results) - if len(logger.logs) != tt.expectedLines { - t.Errorf("expected %d log lines, got %d", tt.expectedLines, len(logger.logs)) - } + assert.Equal(t, tt.expectedLines, len(logger.logs), "unexpected number of log lines") // Check header is present if len(logger.logs) > 0 { header := logger.logs[0] - if !strings.Contains(header, "CONTAINER") || !strings.Contains(header, "STATUS") { - t.Errorf("expected header to contain CONTAINER and STATUS, got: %s", header) - } + assert.Contains(t, header, "CONTAINER", "header should contain CONTAINER") + assert.Contains(t, header, "STATUS", "header should contain STATUS") } // Check statuses for i, expectedStatus := range tt.expectedStatus { bodyLine := logger.logs[i+1] - if !strings.Contains(bodyLine, expectedStatus) { - t.Errorf("expected line %d to contain status %s, got: %s", i+1, expectedStatus, bodyLine) - } + assert.Contains(t, bodyLine, expectedStatus, "line should contain expected status") } // Check container names for i, result := range tt.results { bodyLine := logger.logs[i+1] - if !strings.Contains(bodyLine, result.ImageInfo.ContainerName) { - t.Errorf("expected line %d to contain container name %s, got: %s", i+1, result.ImageInfo.ContainerName, bodyLine) - } + assert.Contains(t, bodyLine, result.ImageInfo.ContainerName, "line should contain container name") } }) } @@ -196,7 +135,30 @@ func TestPrinter_Print_EmptyResults(t *testing.T) { printer.Print([]*scan.ScanResult{}) - if len(logger.logs) != 1 { - t.Errorf("expected 1 log line (header only), got %d", len(logger.logs)) + assert.Equal(t, 1, len(logger.logs), "expected header only") +} + +func makeScanResult( + list ...ecrtypes.FindingSeverity) []*scan.ScanResult { + return []*scan.ScanResult{ + { + ImageInfo: &scan.ImageInfo{ + ContainerName: "test-container", + Registry: "test-registry", + Repository: "test-repo", + Tag: "latest", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: makeFindings(list), + }, + }, + } +} + +func makeFindings(severities []ecrtypes.FindingSeverity) []ecrtypes.ImageScanFinding { + findings := make([]ecrtypes.ImageScanFinding, len(severities)) + for i, sev := range severities { + findings[i] = ecrtypes.ImageScanFinding{Severity: sev} } + return findings } From fa5cd6749079e6c891591fe258f4a09cf7709032 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Mon, 19 Jan 2026 18:58:33 +0900 Subject: [PATCH 11/23] test --- cli/cage/commands/a_test.go | 12 +++-- cli/cage/commands/rollout_test.go | 16 ++++-- cli/cage/commands/run_test.go | 14 +++-- cli/cage/commands/scan_test.go | 86 +++++++++++++++++++++---------- cli/cage/commands/up_test.go | 14 +++-- 5 files changed, 98 insertions(+), 44 deletions(-) diff --git a/cli/cage/commands/a_test.go b/cli/cage/commands/a_test.go index cbea587..e41b938 100644 --- a/cli/cage/commands/a_test.go +++ b/cli/cage/commands/a_test.go @@ -1,7 +1,7 @@ package commands_test import ( - "strings" + "io" "testing" "github.com/loilo-inc/canarycage/cli/cage/cageapp" @@ -16,10 +16,10 @@ import ( var stdinService = "ap-notheast-1\ncluster\nservice\nyes\n" var stdinTask = "ap-notheast-1\ncluster\nyes\n" -func setup(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { +func setup(t *testing.T, input io.Reader) (*cli.App, *mock_types.MockCage) { ctrl := gomock.NewController(t) cagecli := mock_types.NewMockCage(ctrl) - cageapp := &cageapp.App{Stdin: strings.NewReader(input)} + cageapp := &cageapp.App{Stdin: input} app := cli.NewApp() cmds := commands.NewCageCommands(func(envars *env.Envars) (types.Cage, error) { return cagecli, nil @@ -38,3 +38,9 @@ func setup(t *testing.T, input string) (*cli.App, *mock_types.MockCage) { } return app, cagecli } + +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, io.EOF +} diff --git a/cli/cage/commands/rollout_test.go b/cli/cage/commands/rollout_test.go index a505ce2..4201f4f 100644 --- a/cli/cage/commands/rollout_test.go +++ b/cli/cage/commands/rollout_test.go @@ -2,6 +2,7 @@ package commands_test import ( "fmt" + "strings" "testing" "github.com/loilo-inc/canarycage/types" @@ -11,30 +12,35 @@ import ( func TestRollOut(t *testing.T) { t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinService) + app, cagecli := setup(t, strings.NewReader(stdinService)) cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") + app, cagecli := setup(t, nil) cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, nil) err := app.Run([]string{"cage", "--ci", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("basic/update-service", func(t *testing.T) { - app, cagecli := setup(t, stdinService) + app, cagecli := setup(t, strings.NewReader(stdinService)) cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{UpdateService: true}).Return(&types.RollOutResult{}, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "--updateService", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("missing args", func(t *testing.T) { - app, _ := setup(t, "") + app, _ := setup(t, nil) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1"}) assert.EqualError(t, err, "invalid number of arguments. expected at least 1") }) + t.Run("reading stdin error", func(t *testing.T) { + app, _ := setup(t, &errorReader{}) + err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.EqualError(t, err, "failed to read from stdin: EOF") + }) t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinService) + app, cagecli := setup(t, strings.NewReader(stdinService)) cagecli.EXPECT().RollOut(gomock.Any(), &types.RollOutInput{}).Return(&types.RollOutResult{}, fmt.Errorf("error")) err := app.Run([]string{"cage", "rollout", "--region", "ap-notheast-1", "../../../fixtures"}) assert.EqualError(t, err, "error") diff --git a/cli/cage/commands/run_test.go b/cli/cage/commands/run_test.go index 79b150e..0ab397c 100644 --- a/cli/cage/commands/run_test.go +++ b/cli/cage/commands/run_test.go @@ -2,6 +2,7 @@ package commands_test import ( "fmt" + "strings" "testing" "github.com/loilo-inc/canarycage/types" @@ -11,24 +12,29 @@ import ( func TestRun(t *testing.T) { t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinTask) + app, cagecli := setup(t, strings.NewReader(stdinTask)) cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) assert.NoError(t, err) }) t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") + app, cagecli := setup(t, nil) cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&types.RunResult{}, nil) err := app.Run([]string{"cage", "--ci", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) assert.NoError(t, err) }) t.Run("missing args", func(t *testing.T) { - app, _ := setup(t, "") + app, _ := setup(t, nil) err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1"}) assert.EqualError(t, err, "invalid number of arguments. expected at least 3") }) + t.Run("reading stdin error", func(t *testing.T) { + app, _ := setup(t, &errorReader{}) + err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) + assert.EqualError(t, err, "failed to read from stdin: EOF") + }) t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinTask) + app, cagecli := setup(t, strings.NewReader(stdinTask)) cagecli.EXPECT().Run(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("error")) err := app.Run([]string{"cage", "run", "--region", "ap-notheast-1", "../../../fixtures", "container", "exec"}) assert.EqualError(t, err, "error") diff --git a/cli/cage/commands/scan_test.go b/cli/cage/commands/scan_test.go index 9e2f41d..70a3f41 100644 --- a/cli/cage/commands/scan_test.go +++ b/cli/cage/commands/scan_test.go @@ -24,6 +24,12 @@ func TestScan(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "--region flag is required") }) + t.Run("return errors when too many arguments", func(t *testing.T) { + app := setupScanApp(t, nil) + err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "arg1", "arg2"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid number of arguments. expected at most 1") + }) t.Run("returns error when both directory and flags are missing", func(t *testing.T) { app := setupScanApp(t, nil) @@ -60,10 +66,7 @@ func TestScan(t *testing.T) { assert.Error(t, err) assert.Equal(t, expectedErr, err) }) -} - -func TestScan_Success(t *testing.T) { - setup := func(t *testing.T) *cli.App { + setupBase := func(t *testing.T) (*cli.App, *mock_scan.MockScanner, *mock_logger.MockLogger) { t.Helper() ctrl := gomock.NewController(t) mockScanner := mock_scan.NewMockScanner(ctrl) @@ -77,32 +80,59 @@ func TestScan_Success(t *testing.T) { assert.Equal(t, "us-east-1", region) return d, nil }) - - mockScanner.EXPECT(). - Scan(gomock.Any(), "cluster", "service"). // from fixtures/service.json - Return(makeScanResult(), nil) - - mockLogger.EXPECT().Printf( - gomock.Any(), gomock.Any(), gomock.Any(), - gomock.Any(), gomock.Any(), gomock.Any(), - gomock.Any(), gomock.Any(), gomock.Any(), - ).Times(2) - - return app + return app, mockScanner, mockLogger } - t.Run("executes scan with directory argument", func(t *testing.T) { - app := setup(t) - err := app.Run([]string{"cage", "scan", - "--region", "us-east-1", "../../../fixtures"}) - assert.NoError(t, err) + t.Run("Succcess", func(t *testing.T) { + setup := func(t *testing.T) *cli.App { + t.Helper() + app, mockScanner, mockLogger := setupBase(t) + mockScanner.EXPECT(). + Scan(gomock.Any(), "cluster", "service"). // from fixtures/service.json + Return(makeScanResult(), nil) + + mockLogger.EXPECT().Printf( + gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Any(), gomock.Any(), gomock.Any(), + ).Times(2) + return app + } + t.Run("executes scan with directory argument", func(t *testing.T) { + app := setup(t) + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", "../../../fixtures"}) + assert.NoError(t, err) + }) + t.Run("executes scan with flags", func(t *testing.T) { + app := setup(t) + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", + "--cluster", "cluster", + "--service", "service"}) + assert.NoError(t, err) + }) }) - t.Run("executes scan with flags", func(t *testing.T) { - app := setup(t) - err := app.Run([]string{"cage", "scan", - "--region", "us-east-1", - "--cluster", "cluster", - "--service", "service"}) - assert.NoError(t, err) + t.Run("Error", func(t *testing.T) { + t.Run("error on scanner.Scan()", func(t *testing.T) { + app, mockScanner, _ := setupBase(t) + mockScanner.EXPECT(). + Scan(gomock.Any(), "cluster", "service"). + Return(nil, errors.New("scan error")) + + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", + "--cluster", "cluster", + "--service", "service"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "scan error") + }) + t.Run("error on loading service definition", func(t *testing.T) { + app := setupScanApp(t, nil) + err := app.Run([]string{"cage", "scan", + "--region", "us-east-1", "../../../fixtures/invalid-service"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no 'service.json' found") + }) }) } diff --git a/cli/cage/commands/up_test.go b/cli/cage/commands/up_test.go index d91b2a7..f6e81d4 100644 --- a/cli/cage/commands/up_test.go +++ b/cli/cage/commands/up_test.go @@ -2,6 +2,7 @@ package commands_test import ( "fmt" + "strings" "testing" "github.com/loilo-inc/canarycage/types" @@ -11,24 +12,29 @@ import ( func TestUp(t *testing.T) { t.Run("basic", func(t *testing.T) { - app, cagecli := setup(t, stdinService) + app, cagecli := setup(t, strings.NewReader(stdinService)) cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("basic/ci", func(t *testing.T) { - app, cagecli := setup(t, "") + app, cagecli := setup(t, nil) cagecli.EXPECT().Up(gomock.Any()).Return(&types.UpResult{}, nil) err := app.Run([]string{"cage", "--ci", "up", "--region", "ap-notheast-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("missing args", func(t *testing.T) { - app, _ := setup(t, "") + app, _ := setup(t, nil) err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1"}) assert.EqualError(t, err, "invalid number of arguments. expected at least 1") }) + t.Run("reading stdin error", func(t *testing.T) { + app, _ := setup(t, &errorReader{}) + err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) + assert.EqualError(t, err, "failed to read from stdin: EOF") + }) t.Run("error", func(t *testing.T) { - app, cagecli := setup(t, stdinService) + app, cagecli := setup(t, strings.NewReader(stdinService)) cagecli.EXPECT().Up(gomock.Any()).Return(nil, fmt.Errorf("error")) err := app.Run([]string{"cage", "up", "--region", "ap-notheast-1", "../../../fixtures"}) assert.EqualError(t, err, "error") From 268af2b938c75448f2c4379a7c40895a7060cd0f Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Tue, 20 Jan 2026 11:46:25 +0900 Subject: [PATCH 12/23] tets --- Makefile | 4 +-- cli/cage/scan/scanner.go | 9 +++-- cli/cage/scan/scanner_test.go | 17 +++------ cli/cage/scan/types.go | 11 ++++++ cli/cage/scan/types_test.go | 67 +++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 cli/cage/scan/types_test.go diff --git a/Makefile b/Makefile index fcb32c9..281691c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -# go install go.uber.org/mock/mockgen@latest -MOCKGEN := mockgen +# go.modのバージョンを使うと、missing go.sum entry for module providing package...エラーが出る +MOCKGEN := go run go.uber.org/mock/mockgen@v0.6.0 .PHONY: test test: go test ./... -coverprofile=coverage.txt -covermode=count diff --git a/cli/cage/scan/scanner.go b/cli/cage/scan/scanner.go index 82ed983..0fff1ce 100644 --- a/cli/cage/scan/scanner.go +++ b/cli/cage/scan/scanner.go @@ -2,6 +2,7 @@ package scan import ( "context" + "fmt" "github.com/apex/log" "github.com/loilo-inc/canarycage/awsiface" @@ -34,8 +35,12 @@ func (s *scanner) Scan( } findingsList := make([]*ScanResult, len(imageInfos)) for i, info := range imageInfos { - findingsList[i] = scanImage(ctx, ecrTool, info) - findingsList[i].ImageInfo = imageInfos[i] + if info.IsECRImage() { + findingsList[i] = scanImage(ctx, ecrTool, info) + findingsList[i].ImageInfo = imageInfos[i] + } else { + findingsList[i] = &ScanResult{ImageInfo: info, Err: fmt.Errorf("non-ECR image")} + } } return findingsList, nil } diff --git a/cli/cage/scan/scanner_test.go b/cli/cage/scan/scanner_test.go index e7acb1d..7c24333 100644 --- a/cli/cage/scan/scanner_test.go +++ b/cli/cage/scan/scanner_test.go @@ -82,13 +82,6 @@ func TestScanner_Scan(t *testing.T) { }, nil }) - mockEcr.EXPECT().BatchGetImage(ctx, gomock.AssignableToTypeOf(&ecr.BatchGetImageInput{})). - DoAndReturn(func(ctx context.Context, input *ecr.BatchGetImageInput, opts ...func(*ecr.Options)) (*ecr.BatchGetImageOutput, error) { - assert.Equal(t, "nginx", *input.RepositoryName) - assert.Equal(t, "latest", *input.ImageIds[0].ImageTag) - return nil, errors.New("batch error") - }) - mockEcr.EXPECT().DescribeImageScanFindings(ctx, gomock.AssignableToTypeOf(&ecr.DescribeImageScanFindingsInput{})). DoAndReturn(func(ctx context.Context, input *ecr.DescribeImageScanFindingsInput, opts ...func(*ecr.Options)) (*ecr.DescribeImageScanFindingsOutput, error) { assert.Equal(t, "my-repo", *input.RepositoryName) @@ -105,7 +98,7 @@ func TestScanner_Scan(t *testing.T) { assert.Equal(t, "app", results[0].ImageInfo.ContainerName) assert.NoError(t, results[0].Err) assert.Equal(t, "sidecar", results[1].ImageInfo.ContainerName) - assert.Error(t, results[1].Err) + assert.EqualError(t, results[1].Err, "non-ECR image") } }) @@ -120,7 +113,7 @@ func TestScanner_Scan(t *testing.T) { results, err := scanner.Scan(ctx, "cluster-a", "service-a") - assert.Error(t, err) + assert.EqualError(t, err, "ecs error") assert.Nil(t, results) }) } @@ -135,8 +128,7 @@ func TestScanImage(t *testing.T) { result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) - assert.Error(t, result.Err) - assert.Contains(t, result.Err.Error(), "id error") + assert.EqualError(t, result.Err, "id error") }) t.Run("GetImageScanFindings error returns error", func(t *testing.T) { @@ -147,8 +139,7 @@ func TestScanImage(t *testing.T) { result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) - assert.Error(t, result.Err) - assert.Contains(t, result.Err.Error(), "scan error") + assert.EqualError(t, result.Err, "scan error") }) t.Run("success returns findings", func(t *testing.T) { diff --git a/cli/cage/scan/types.go b/cli/cage/scan/types.go index 2dcea34..7e40355 100644 --- a/cli/cage/scan/types.go +++ b/cli/cage/scan/types.go @@ -1,6 +1,8 @@ package scan import ( + "regexp" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/aws/aws-sdk-go-v2/service/ecs/types" ) @@ -13,6 +15,15 @@ type ImageInfo struct { Tag string } +func (i *ImageInfo) IsECRImage() bool { + return i.Registry == "public.ecr.aws" || i.registryHasECRSuffix() +} + +func (i *ImageInfo) registryHasECRSuffix() bool { + pat := regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) + return pat.MatchString(i.Registry) +} + type ScanResult struct { ImageInfo *ImageInfo ImageScanFindings *ecrtypes.ImageScanFindings diff --git a/cli/cage/scan/types_test.go b/cli/cage/scan/types_test.go new file mode 100644 index 0000000..3e6ef9a --- /dev/null +++ b/cli/cage/scan/types_test.go @@ -0,0 +1,67 @@ +package scan_test + +import ( + "testing" + + "github.com/loilo-inc/canarycage/cli/cage/scan" +) + +func TestImageInfo_IsECRImage(t *testing.T) { + tests := []struct { + name string + registry string + want bool + }{ + { + name: "public ECR registry", + registry: "public.ecr.aws", + want: true, + }, + { + name: "private ECR registry with standard suffix", + registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + want: true, + }, + { + name: "private ECR registry with different region", + registry: "123456789012.dkr.ecr.eu-west-1.amazonaws.com", + want: true, + }, + { + name: "Docker Hub registry", + registry: "docker.io", + want: false, + }, + { + name: "empty registry", + registry: "", + want: false, + }, + { + name: "non-ECR AWS registry", + registry: "amazonaws.com", + want: false, + }, + { + name: "registry with partial ECR suffix", + registry: "example.com", + want: false, + }, + { + name: "registry with ECR substring but not suffix", + registry: ".dkr.ecr.amazonaws.com.example.com", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := &scan.ImageInfo{ + Registry: tt.registry, + } + if got := i.IsECRImage(); got != tt.want { + t.Errorf("ImageInfo.IsECRImage() = %v, want %v", got, tt.want) + } + }) + } +} From 92965c93a89285ecfec6565cba128f7b24ffa443 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Tue, 20 Jan 2026 12:07:15 +0900 Subject: [PATCH 13/23] a --- cli/cage/cageapp/flags.go | 5 +++++ cli/cage/cageapp/flags_test.go | 17 +++++++++++++++++ cli/cage/main.go | 10 +++++----- cli/cage/scan/ecs.go | 7 ------- cli/cage/scan/ecs_test.go | 10 ---------- cli/cage/scan/types.go | 5 +++-- 6 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 cli/cage/cageapp/flags_test.go diff --git a/cli/cage/cageapp/flags.go b/cli/cage/cageapp/flags.go index 551f434..2a07409 100644 --- a/cli/cage/cageapp/flags.go +++ b/cli/cage/cageapp/flags.go @@ -2,6 +2,7 @@ package cageapp import ( "io" + "os" "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" @@ -12,6 +13,10 @@ type App struct { Stdin io.Reader } +func NewApp() *App { + return &App{Stdin: os.Stdin} +} + func RegionFlag(dest *string) *cli.StringFlag { return &cli.StringFlag{ Name: "region", diff --git a/cli/cage/cageapp/flags_test.go b/cli/cage/cageapp/flags_test.go new file mode 100644 index 0000000..1f02f1c --- /dev/null +++ b/cli/cage/cageapp/flags_test.go @@ -0,0 +1,17 @@ +package cageapp + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewApp(t *testing.T) { + app := NewApp() + assert := assert.New(t) + + assert.NotNil(app, "NewApp() returned nil") + assert.Equal(os.Stdin, app.Stdin, "expected Stdin to be os.Stdin") + assert.False(app.CI, "expected CI to be false") +} diff --git a/cli/cage/main.go b/cli/cage/main.go index 5022e46..e69828a 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -19,7 +19,7 @@ var ( ) func main() { - flag := &cageapp.App{} + appConf := cageapp.NewApp() app := cli.NewApp() app.Name = "canarycage" app.HelpName = "cage" @@ -28,9 +28,9 @@ func main() { app.Description = "A deployment tool for AWS ECS" cmds := commands.NewCageCommands(cageapp.ProvideCageCli) app.Commands = []*cli.Command{ - cmds.Up(flag), - cmds.RollOut(flag), - cmds.Run(flag), + cmds.Up(appConf), + cmds.RollOut(appConf), + cmds.Run(appConf), commands.Upgrade(upgrade.NewUpgrader(version)), commands.Scan(cageapp.ProvideScanDI), } @@ -39,7 +39,7 @@ func main() { Name: "ci", Usage: "CI mode. Skip all confirmations and use default values.", EnvVars: []string{"CI"}, - Destination: &flag.CI, + Destination: &appConf.CI, }, } if err := app.Run(os.Args); err != nil { diff --git a/cli/cage/scan/ecs.go b/cli/cage/scan/ecs.go index 7602c41..ccb928c 100644 --- a/cli/cage/scan/ecs.go +++ b/cli/cage/scan/ecs.go @@ -3,7 +3,6 @@ package scan import ( "context" "fmt" - "regexp" "strings" "github.com/aws/aws-sdk-go-v2/service/ecs" @@ -105,9 +104,3 @@ func splitRepoTag(value string) (string, string) { } return repository, tag } - -var ecrURLPattern = regexp.MustCompile(`^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) - -func IsEcr(registry string) bool { - return ecrURLPattern.MatchString(registry) -} diff --git a/cli/cage/scan/ecs_test.go b/cli/cage/scan/ecs_test.go index e68e1ad..0e1d3a6 100644 --- a/cli/cage/scan/ecs_test.go +++ b/cli/cage/scan/ecs_test.go @@ -278,13 +278,3 @@ func TestSplitRepoTag(t *testing.T) { assert.Equal(t, "latest", tag) }) } - -func TestIsEcr(t *testing.T) { - t.Run("matches valid ecr registry", func(t *testing.T) { - assert.True(t, IsEcr("123456789012.dkr.ecr.us-west-2.amazonaws.com")) - }) - - t.Run("rejects non-ecr registry", func(t *testing.T) { - assert.False(t, IsEcr("example.com")) - }) -} diff --git a/cli/cage/scan/types.go b/cli/cage/scan/types.go index 7e40355..c8863f0 100644 --- a/cli/cage/scan/types.go +++ b/cli/cage/scan/types.go @@ -19,9 +19,10 @@ func (i *ImageInfo) IsECRImage() bool { return i.Registry == "public.ecr.aws" || i.registryHasECRSuffix() } +var ecrURLPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) + func (i *ImageInfo) registryHasECRSuffix() bool { - pat := regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) - return pat.MatchString(i.Registry) + return ecrURLPattern.MatchString(i.Registry) } type ScanResult struct { From 732c6a0b7b96cae49ed25e868a285a72fc6fe60e Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Tue, 20 Jan 2026 16:22:08 +0900 Subject: [PATCH 14/23] w --- Makefile | 4 +- cli/cage/audit/aggregator.go | 153 +++++++++++ cli/cage/audit/aggregator_test.go | 73 +++++ cli/cage/audit/command.go | 65 +++++ cli/cage/audit/command_test.go | 86 ++++++ cli/cage/audit/deps.go | 32 +++ cli/cage/audit/deps_test.go | 41 +++ cli/cage/{scan => audit}/ecr.go | 2 +- cli/cage/{scan => audit}/ecr_test.go | 2 +- cli/cage/{scan => audit}/ecs.go | 2 +- cli/cage/{scan => audit}/ecs_test.go | 2 +- cli/cage/audit/printer.go | 103 ++++++++ cli/cage/audit/printer_test.go | 185 +++++++++++++ cli/cage/{scan => audit}/scanner.go | 15 +- cli/cage/{scan => audit}/scanner_test.go | 2 +- cli/cage/audit/types.go | 95 +++++++ cli/cage/audit/types_test.go | 250 ++++++++++++++++++ cli/cage/cageapp/flags.go | 5 +- cli/cage/cageapp/provider_test.go | 81 ------ cli/cage/commands/{scan.go => audit.go} | 33 ++- .../commands/{scan_test.go => audit_test.go} | 82 +++--- cli/cage/{cageapp => commands}/provider.go | 36 +-- cli/cage/commands/provider_test.go | 46 ++++ .../commands/{a_test.go => tools_test.go} | 0 cli/cage/main.go | 11 +- cli/cage/scan/printer.go | 91 ------- cli/cage/scan/printer_test.go | 164 ------------ cli/cage/scan/types.go | 32 --- cli/cage/scan/types_test.go | 67 ----- cli/color/color.go | 59 +++++ logger/spinner.go | 19 ++ logger/spinner_test.go | 35 +++ mocks/{mock_scan => mock_audit}/scanner.go | 14 +- test/fake_timer.go | 20 +- 34 files changed, 1355 insertions(+), 552 deletions(-) create mode 100644 cli/cage/audit/aggregator.go create mode 100644 cli/cage/audit/aggregator_test.go create mode 100644 cli/cage/audit/command.go create mode 100644 cli/cage/audit/command_test.go create mode 100644 cli/cage/audit/deps.go create mode 100644 cli/cage/audit/deps_test.go rename cli/cage/{scan => audit}/ecr.go (99%) rename cli/cage/{scan => audit}/ecr_test.go (99%) rename cli/cage/{scan => audit}/ecs.go (99%) rename cli/cage/{scan => audit}/ecs_test.go (99%) create mode 100644 cli/cage/audit/printer.go create mode 100644 cli/cage/audit/printer_test.go rename cli/cage/{scan => audit}/scanner.go (76%) rename cli/cage/{scan => audit}/scanner_test.go (99%) create mode 100644 cli/cage/audit/types.go create mode 100644 cli/cage/audit/types_test.go delete mode 100644 cli/cage/cageapp/provider_test.go rename cli/cage/commands/{scan.go => audit.go} (63%) rename cli/cage/commands/{scan_test.go => audit_test.go} (62%) rename cli/cage/{cageapp => commands}/provider.go (58%) create mode 100644 cli/cage/commands/provider_test.go rename cli/cage/commands/{a_test.go => tools_test.go} (100%) delete mode 100644 cli/cage/scan/printer.go delete mode 100644 cli/cage/scan/printer_test.go delete mode 100644 cli/cage/scan/types.go delete mode 100644 cli/cage/scan/types_test.go create mode 100644 cli/color/color.go create mode 100644 logger/spinner.go create mode 100644 logger/spinner_test.go rename mocks/{mock_scan => mock_audit}/scanner.go (81%) diff --git a/Makefile b/Makefile index 281691c..22d5976 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,8 @@ mocks/mock_types/iface.go: types/iface.go $(MOCKGEN) -source=./types/iface.go > mocks/mock_types/iface.go mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go -mocks/mock_scan/scanner.go: cli/cage/scan/scanner.go - $(MOCKGEN) -source=./cli/cage/scan/scanner.go > mocks/mock_scan/scanner.go +mocks/mock_scan/scanner.go: cli/cage/audit/scanner.go + $(MOCKGEN) -source=./cli/cage/audit/scanner.go > mocks/mock_audit/scanner.go mocks/mock_task/task.go: task/task.go $(MOCKGEN) -source=./task/task.go > mocks/mock_task/task.go mocks/mock_taskset/taskset.go: taskset/taskset.go diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go new file mode 100644 index 0000000..92cd9d6 --- /dev/null +++ b/cli/cage/audit/aggregator.go @@ -0,0 +1,153 @@ +package audit + +import ( + "fmt" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/loilo-inc/canarycage/cli/color" +) + +type aggregater struct { + cves map[string]ecrtypes.ImageScanFinding + cveToSeverity map[string]string + // container name to summaries + summaries map[string][]*ScanResultSummary +} + +func NewAggregater() *aggregater { + return &aggregater{ + cves: make(map[string]ecrtypes.ImageScanFinding), + cveToSeverity: make(map[string]string), + summaries: make(map[string][]*ScanResultSummary)} +} + +func (a *aggregater) Add(r *ScanResult) { + container := r.ContainerName() + if r.Err != nil { + a.summaries[container] = append(a.summaries[container], &ScanResultSummary{ + ContainerName: container, + Status: "ERROR", + }) + return + } else if r.ImageScanFindings == nil { + a.summaries[container] = append(a.summaries[container], &ScanResultSummary{ + ContainerName: container, + Status: "N/A", + }) + return + } + summary := summaryScanResult(r) + a.summaries[container] = append(a.summaries[container], summary) + for _, f := range r.ImageScanFindings.Findings { + if _, exists := a.cves[*f.Name]; !exists { + a.cves[*f.Name] = f + a.cveToSeverity[*f.Name] = string(f.Severity) + } + } +} + +type AggregateResult struct { + CriticalCount int32 + HighCount int32 + MediumCount int32 + LowCount int32 + InfoCount int32 + TotalCount int32 + HighestSeverity ecrtypes.FindingSeverity +} + +func (a *aggregater) SummarizeTotal() *AggregateResult { + result := &AggregateResult{} + highestServity := ecrtypes.FindingSeverityInformational + for cve := range a.cves { + severity := a.cveToSeverity[cve] + switch severity { + case string(ecrtypes.FindingSeverityCritical): + result.CriticalCount++ + case string(ecrtypes.FindingSeverityHigh): + result.HighCount++ + case string(ecrtypes.FindingSeverityMedium): + result.MediumCount++ + case string(ecrtypes.FindingSeverityLow): + result.LowCount++ + case string(ecrtypes.FindingSeverityInformational): + result.InfoCount++ + } + } + if result.CriticalCount > 0 { + highestServity = ecrtypes.FindingSeverityCritical + } else if result.HighCount > 0 { + highestServity = ecrtypes.FindingSeverityHigh + } else if result.MediumCount > 0 { + highestServity = ecrtypes.FindingSeverityMedium + } else { + highestServity = ecrtypes.FindingSeverityLow + } + result.HighestSeverity = highestServity + result.TotalCount = int32(len(a.cves)) + return result +} + +type SeverityCount struct { + Severity ecrtypes.FindingSeverity + Count int +} + +func (a *AggregateResult) SeverityCounts() []SeverityCount { + return []SeverityCount{ + {Severity: ecrtypes.FindingSeverityInformational, Count: int(a.InfoCount)}, + {Severity: ecrtypes.FindingSeverityLow, Count: int(a.LowCount)}, + {Severity: ecrtypes.FindingSeverityMedium, Count: int(a.MediumCount)}, + {Severity: ecrtypes.FindingSeverityHigh, Count: int(a.HighCount)}, + {Severity: ecrtypes.FindingSeverityCritical, Count: int(a.CriticalCount)}, + } +} + +func (a *aggregater) TotalCVECount() int { + return len(a.cves) +} + +func (a *aggregater) CriticalCves() []ecrtypes.ImageScanFinding { + return a.filterCvesBySeverity(ecrtypes.FindingSeverityCritical) +} + +func (a *aggregater) HighCves() []ecrtypes.ImageScanFinding { + return a.filterCvesBySeverity(ecrtypes.FindingSeverityHigh) +} + +func (a *aggregater) MediumCves() []ecrtypes.ImageScanFinding { + return a.filterCvesBySeverity(ecrtypes.FindingSeverityMedium) +} + +func (a *aggregater) filterCvesBySeverity(severity ecrtypes.FindingSeverity) []ecrtypes.ImageScanFinding { + var cves []ecrtypes.ImageScanFinding + for cve, sev := range a.cveToSeverity { + if sev == string(severity) { + cves = append(cves, a.cves[cve]) + } + } + return cves +} + +type severityPrinter struct { + noColor bool + severity ecrtypes.FindingSeverity + color color.Color +} + +func (s *severityPrinter) Sprintf(format string, a ...any) string { + switch s.severity { + case ecrtypes.FindingSeverityCritical: + return s.color.Magentaf(format, a...) + case ecrtypes.FindingSeverityHigh: + return s.color.Redf(format, a...) + case ecrtypes.FindingSeverityMedium: + return s.color.Yellowf(format, a...) + default: + return fmt.Sprintf(format, a...) + } +} + +func (s *severityPrinter) BSprintf(format string, a ...any) string { + return s.color.Boldf("%s", s.Sprintf(format, a...)) +} diff --git a/cli/cage/audit/aggregator_test.go b/cli/cage/audit/aggregator_test.go new file mode 100644 index 0000000..c4c65ea --- /dev/null +++ b/cli/cage/audit/aggregator_test.go @@ -0,0 +1,73 @@ +package audit + +import ( + "testing" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/stretchr/testify/assert" +) + +func TestSeverityPrinter_Sprintf(t *testing.T) { + tests := []struct { + name string + severity ecrtypes.FindingSeverity + format string + args []any + want string + }{ + { + name: "critical severity formats with magenta", + severity: ecrtypes.FindingSeverityCritical, + format: "test %s", + args: []any{"critical"}, + want: "\x1b[35mtest critical\x1b[0m", + }, + { + name: "high severity formats with red", + severity: ecrtypes.FindingSeverityHigh, + format: "test %s", + args: []any{"high"}, + want: "\x1b[31mtest high\x1b[0m", + }, + { + name: "medium severity formats with yellow", + severity: ecrtypes.FindingSeverityMedium, + format: "test %s", + args: []any{"medium"}, + want: "\x1b[33mtest medium\x1b[0m", + }, + { + name: "low severity formats without color", + severity: ecrtypes.FindingSeverityLow, + format: "test %s", + args: []any{"low"}, + want: "test low", + }, + { + name: "informational severity formats without color", + severity: ecrtypes.FindingSeverityInformational, + format: "test %s", + args: []any{"info"}, + want: "test info", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &severityPrinter{ + severity: tt.severity, + } + got := s.Sprintf(tt.format, tt.args...) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSeverityPrinter_BSprintf(t *testing.T) { + s := &severityPrinter{ + severity: ecrtypes.FindingSeverityCritical, + } + got := s.BSprintf("test %s", "critical") + want := "\x1b[1m\x1b[35mtest critical\x1b[0m\x1b[0m" + assert.Equal(t, want, got) +} diff --git a/cli/cage/audit/command.go b/cli/cage/audit/command.go new file mode 100644 index 0000000..7a4fe97 --- /dev/null +++ b/cli/cage/audit/command.go @@ -0,0 +1,65 @@ +package audit + +import ( + "context" + "time" + + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/logger" + "github.com/loilo-inc/canarycage/types" + "github.com/loilo-inc/logos/di" +) + +type command struct { + di *di.D + app *cageapp.App + logDetail bool + spinInterval time.Duration +} + +func NewCommand(di *di.D, app *cageapp.App, logDetail bool) *command { + return &command{ + di: di, + app: app, + logDetail: logDetail, + spinInterval: 100 * time.Millisecond, + } +} + +func (a *command) Run(ctx context.Context, cluster, service string) error { + t := a.di.Get(key.Time).(types.Time) + l := a.di.Get(key.Logger).(logger.Logger) + scanner := a.di.Get(key.Scanner).(Scanner) + spinner := logger.NewSpinner() + errchannel := make(chan error, 1) + go func() { + defer close(errchannel) + results, err := scanner.Scan(ctx, cluster, service) + printer := &Printer{ + Logger: l, + NoColor: a.app.NoColor, + LogDetail: a.logDetail, + } + if err != nil { + errchannel <- err + } else { + l.Printf("\r") // clear spinner line + printer.Print(results) + } + }() + for { + timer := t.NewTimer(a.spinInterval) + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errchannel: + return err + case <-timer.C: + l.Printf( + "\r%s Scanning ECR image vulnerabilities for ECS service %s/%s", + spinner.Next(), cluster, service, + ) + } + } +} diff --git a/cli/cage/audit/command_test.go b/cli/cage/audit/command_test.go new file mode 100644 index 0000000..c9a3426 --- /dev/null +++ b/cli/cage/audit/command_test.go @@ -0,0 +1,86 @@ +package audit_test + +import ( + "context" + "testing" + + "github.com/loilo-inc/canarycage/cli/cage/audit" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/mocks/mock_audit" + "github.com/loilo-inc/canarycage/mocks/mock_logger" + "github.com/loilo-inc/canarycage/test" + "github.com/loilo-inc/logos/di" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestAuditCommandRun(t *testing.T) { + makeMockLogger := func(ctrl *gomock.Controller) *mock_logger.MockLogger { + mockLogger := mock_logger.NewMockLogger(ctrl) + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + return mockLogger + } + t.Run("should return error from scanner", func(t *testing.T) { + ctrl := gomock.NewController(t) + ctx := context.Background() + mockScanner := mock_audit.NewMockScanner(ctrl) + + mockDI := di.NewDomain(func(b *di.B) { + b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Scanner, mockScanner) + b.Set(key.Time, test.NewFakeNeverTimer()) + }) + + mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(nil, test.Err) + + app := &cageapp.App{NoColor: false} + cmd := audit.NewCommand(mockDI, app, false) + + err := cmd.Run(ctx, "cluster", "service") + assert.EqualError(t, err, "error") + }) + + t.Run("should return nil on successful scan", func(t *testing.T) { + ctrl := gomock.NewController(t) + ctx := context.Background() + mockScanner := mock_audit.NewMockScanner(ctrl) + mockDI := di.NewDomain(func(b *di.B) { + b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Scanner, mockScanner) + b.Set(key.Time, test.NewFakeNeverTimer()) + }) + + results := []*audit.ScanResult{} + mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(results, nil) + + app := &cageapp.App{NoColor: false} + cmd := audit.NewCommand(mockDI, app, false) + + err := cmd.Run(ctx, "cluster", "service") + assert.NoError(t, err) + }) + + t.Run("should return context error when context is cancelled", func(t *testing.T) { + ctrl := gomock.NewController(t) + ctx, cancel := context.WithCancel(context.Background()) + mockScanner := mock_audit.NewMockScanner(ctrl) + mockDI := di.NewDomain(func(b *di.B) { + b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Scanner, mockScanner) + b.Set(key.Time, test.NewFakeNeverTimer()) + }) + mockScanner.EXPECT().Scan(ctx, "cluster", "service").DoAndReturn(func(context.Context, string, string) ([]audit.ScanResult, error) { + cancel() + return nil, nil + }) + + app := &cageapp.App{NoColor: false} + cmd := audit.NewCommand(mockDI, app, false) + + err := cmd.Run(ctx, "cluster", "service") + assert.Equal(t, context.Canceled, err) + }) +} diff --git a/cli/cage/audit/deps.go b/cli/cage/audit/deps.go new file mode 100644 index 0000000..1b6e85f --- /dev/null +++ b/cli/cage/audit/deps.go @@ -0,0 +1,32 @@ +package audit + +import ( + "context" + "os" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/logger" + "github.com/loilo-inc/canarycage/timeout" + "github.com/loilo-inc/logos/di" +) + +func ProvideAuditDI(region string) (*di.D, error) { + conf, err := config.LoadDefaultConfig( + context.Background(), + config.WithRegion(region), + ) + if err != nil { + return nil, err + } + d := di.NewDomain(func(b *di.B) { + ecsCli := ecs.NewFromConfig(conf) + ecrCli := ecr.NewFromConfig(conf) + b.Set(key.Scanner, NewScanner(ecsCli, ecrCli)) + b.Set(key.Logger, logger.DefaultLogger(os.Stdout)) + b.Set(key.Time, &timeout.Time{}) + }) + return d, nil +} diff --git a/cli/cage/audit/deps_test.go b/cli/cage/audit/deps_test.go new file mode 100644 index 0000000..d24c049 --- /dev/null +++ b/cli/cage/audit/deps_test.go @@ -0,0 +1,41 @@ +package audit + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProvideAuditDI(t *testing.T) { + t.Run("successfully creates scan DI with valid region", func(t *testing.T) { + region := "us-east-1" + + d, err := ProvideAuditDI(region) + assert.NoError(t, err) + assert.NotNil(t, d) + }) + + t.Run("returns error with invalid region", func(t *testing.T) { + region := "" + + d, err := ProvideAuditDI(region) + if err != nil { + assert.Nil(t, d, "expected DI domain to be nil when error occurs") + return + } + assert.NotNil(t, d, "expected DI domain to be non-nil when no error") + }) + + t.Run("creates DI domain with different regions", func(t *testing.T) { + regions := []string{"us-west-2", "eu-west-1", "ap-northeast-1"} + + for _, region := range regions { + d, err := ProvideAuditDI(region) + if err != nil { + t.Logf("region %s returned error: %v", region, err) + continue + } + assert.NotNil(t, d, "expected DI domain to be non-nil for region %s", region) + } + }) +} diff --git a/cli/cage/scan/ecr.go b/cli/cage/audit/ecr.go similarity index 99% rename from cli/cage/scan/ecr.go rename to cli/cage/audit/ecr.go index 18d20e4..00e16ed 100644 --- a/cli/cage/scan/ecr.go +++ b/cli/cage/audit/ecr.go @@ -1,4 +1,4 @@ -package scan +package audit import ( "context" diff --git a/cli/cage/scan/ecr_test.go b/cli/cage/audit/ecr_test.go similarity index 99% rename from cli/cage/scan/ecr_test.go rename to cli/cage/audit/ecr_test.go index 0252dca..4fe12d9 100644 --- a/cli/cage/scan/ecr_test.go +++ b/cli/cage/audit/ecr_test.go @@ -1,4 +1,4 @@ -package scan +package audit import ( "context" diff --git a/cli/cage/scan/ecs.go b/cli/cage/audit/ecs.go similarity index 99% rename from cli/cage/scan/ecs.go rename to cli/cage/audit/ecs.go index ccb928c..e60cd99 100644 --- a/cli/cage/scan/ecs.go +++ b/cli/cage/audit/ecs.go @@ -1,4 +1,4 @@ -package scan +package audit import ( "context" diff --git a/cli/cage/scan/ecs_test.go b/cli/cage/audit/ecs_test.go similarity index 99% rename from cli/cage/scan/ecs_test.go rename to cli/cage/audit/ecs_test.go index 0e1d3a6..7ff1c92 100644 --- a/cli/cage/scan/ecs_test.go +++ b/cli/cage/audit/ecs_test.go @@ -1,4 +1,4 @@ -package scan +package audit import ( "context" diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go new file mode 100644 index 0000000..c73bf6f --- /dev/null +++ b/cli/cage/audit/printer.go @@ -0,0 +1,103 @@ +package audit + +import ( + "fmt" + "strings" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/loilo-inc/canarycage/cli/color" + "github.com/loilo-inc/canarycage/logger" +) + +type Printer struct { + Logger logger.Logger + LogDetail bool + NoColor bool +} + +func (p *Printer) Print(result []*ScanResult) { + containerMax, imageMax := MaxHeaderWidth(result) + // |container|status|critical|high|medium|low|info|image| + headerFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8s|%%-5s|%%-6s|%%-4s|%%-4s|%%-%ds|\n", containerMax, imageMax) + p.Logger.Printf(headerFmt, "CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE") + bodyFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8d|%%-5d|%%-6d|%%-4d|%%-4d|%%-%ds|\n", containerMax, imageMax) + agg := NewAggregater() + for _, r := range result { + agg.Add(r) + } + for _, summaries := range agg.summaries { + for _, summary := range summaries { + p.Logger.Printf( + bodyFmt, + summary.ContainerName, + summary.Status, + summary.CriticalCount, + summary.HighCount, + summary.MediumCount, + summary.LowCount, + summary.InfoCount, + summary.ImageURI, + ) + } + } + p.logImageScanFindings("CRITICAL", agg.CriticalCves()) + p.logImageScanFindings("HIGH", agg.HighCves()) + p.logImageScanFindings("MEDIUM", agg.MediumCves()) + total := agg.TotalCVECount() + chalk := color.Color{NoColor: p.NoColor} + if total == 0 { + p.Logger.Printf("%s\n", chalk.Greenf("No CVEs found")) + return + } + summary := agg.SummarizeTotal() + highest := &severityPrinter{ + severity: summary.HighestSeverity, + } + var list []string + for _, v := range summary.SeverityCounts() { + if v.Count == 0 { + continue + } + sp := &severityPrinter{severity: v.Severity} + list = append(list, fmt.Sprintf("%d %s", v.Count, sp.BSprintf("%s", v.Severity))) + } + + p.Logger.Printf( + "Total: %s (%s)\n", + highest.BSprintf("%d", summary.TotalCount), + strings.Join(list, ", "), + ) +} + +func (p *Printer) logImageScanFindings(serverity ecrtypes.FindingSeverity, findings []ecrtypes.ImageScanFinding) { + if len(findings) == 0 { + return + } + sp := &severityPrinter{severity: serverity} + p.Logger.Printf("=== %s ===\n", sp.BSprintf("%s", serverity)) + for _, cve := range findings { + p.Logger.Printf("- %s (%s)\n", *cve.Name, *cve.Uri) + if p.LogDetail && cve.Description != nil { + p.Logger.Printf("%s\n", *cve.Description) + } + } +} + +func formatImageLabel(info *ImageInfo) string { + return fmt.Sprintf("%s/%s:%s", info.Registry, info.Repository, info.Tag) +} + +func MaxHeaderWidth(imageInfos []*ScanResult) (int, int) { + containerMax := len("CONTAINER") + imageMax := len("IMAGE") + for _, info := range imageInfos { + if l := len(info.ImageInfo.ContainerName); l > containerMax { + containerMax = l + } + imageLabel := formatImageLabel(info.ImageInfo) + if l := len(imageLabel); l > imageMax { + imageMax = l + } + } + return containerMax, imageMax +} diff --git a/cli/cage/audit/printer_test.go b/cli/cage/audit/printer_test.go new file mode 100644 index 0000000..673b511 --- /dev/null +++ b/cli/cage/audit/printer_test.go @@ -0,0 +1,185 @@ +package audit + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" +) + +type mockLogger struct { + logs []string +} + +func (m *mockLogger) Printf(format string, args ...any) { + m.logs = append(m.logs, fmt.Sprintf(format, args...)) +} + +var imageInfo = &ImageInfo{ + ContainerName: "test-container", + Registry: "test-registry", + Repository: "test-repo", + Tag: "latest", +} + +func makeScanResult( + list ...ecrtypes.FindingSeverity) []*ScanResult { + return []*ScanResult{ + { + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + Registry: "test-registry", + Repository: "test-repo", + Tag: "latest", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: makeFindings(list), + }, + }, + } +} + +func makeFindings(severities []ecrtypes.FindingSeverity) []ecrtypes.ImageScanFinding { + findings := make([]ecrtypes.ImageScanFinding, len(severities)) + for i, sev := range severities { + findings[i] = ecrtypes.ImageScanFinding{ + Severity: sev, + Name: aws.String(fmt.Sprintf("CVE-2023-%04d", i+1)), + Uri: aws.String("http://example.com"), + Description: aws.String("Test vulnerability description"), + } + } + return findings +} + +func TestPrinter_Print(t *testing.T) { + t.Run("prints no CVEs message when no findings", func(t *testing.T) { + logger := &mockLogger{} + printer := &Printer{ + Logger: logger, + LogDetail: false, + NoColor: true, + } + + result := makeScanResult() + printer.Print(result) + + // Check that "No CVEs found" message is present + found := false + for _, log := range logger.logs { + if log == "No CVEs found\n" { + found = true + break + } + } + if !found { + t.Error("Expected 'No CVEs found' message") + } + }) + + t.Run("prints table header", func(t *testing.T) { + logger := &mockLogger{} + printer := &Printer{ + Logger: logger, + LogDetail: false, + NoColor: true, + } + + result := makeScanResult(ecrtypes.FindingSeverityCritical) + printer.Print(result) + + // Check that header contains expected columns + if len(logger.logs) == 0 { + t.Fatal("Expected logs to be generated") + } + header := logger.logs[0] + expectedCols := []string{"CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE"} + for _, col := range expectedCols { + if !containsString(header, col) { + t.Errorf("Header missing column: %s", col) + } + } + }) + + t.Run("prints findings by severity", func(t *testing.T) { + logger := &mockLogger{} + printer := &Printer{ + Logger: logger, + LogDetail: false, + NoColor: true, + } + + result := makeScanResult( + ecrtypes.FindingSeverityCritical, + ecrtypes.FindingSeverityHigh, + ecrtypes.FindingSeverityMedium, + ) + printer.Print(result) + + // Should have CRITICAL, HIGH, MEDIUM sections + criticalFound := false + highFound := false + mediumFound := false + for _, log := range logger.logs { + if containsString(log, "CRITICAL") && containsString(log, "===") { + criticalFound = true + } + if containsString(log, "HIGH") && containsString(log, "===") { + highFound = true + } + if containsString(log, "MEDIUM") && containsString(log, "===") { + mediumFound = true + } + } + if !criticalFound { + t.Error("Expected CRITICAL section") + } + if !highFound { + t.Error("Expected HIGH section") + } + if !mediumFound { + t.Error("Expected MEDIUM section") + } + }) + + t.Run("prints total summary with counts", func(t *testing.T) { + logger := &mockLogger{} + printer := &Printer{ + Logger: logger, + LogDetail: false, + NoColor: true, + } + + result := makeScanResult( + ecrtypes.FindingSeverityCritical, + ecrtypes.FindingSeverityHigh, + ) + printer.Print(result) + + // Check for total line + totalFound := false + for _, log := range logger.logs { + if containsString(log, "Total:") { + totalFound = true + break + } + } + if !totalFound { + t.Error("Expected Total summary line") + } + }) +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && stringContains(s, substr)) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cli/cage/scan/scanner.go b/cli/cage/audit/scanner.go similarity index 76% rename from cli/cage/scan/scanner.go rename to cli/cage/audit/scanner.go index 0fff1ce..89b6189 100644 --- a/cli/cage/scan/scanner.go +++ b/cli/cage/audit/scanner.go @@ -1,10 +1,9 @@ -package scan +package audit import ( "context" "fmt" - "github.com/apex/log" "github.com/loilo-inc/canarycage/awsiface" ) @@ -28,7 +27,6 @@ func (s *scanner) Scan( ) (results []*ScanResult, err error) { ecsTool := newEcsTool(s.ecs) ecrTool := newEcrTool(s.ecr) - log.Infof("Scanning ECR image vulnerabilities for ECS service %s/%s", cluster, service) var imageInfos []*ImageInfo if imageInfos, err = ecsTool.GetServiceImageInfos(ctx, cluster, service); err != nil { return nil, err @@ -37,20 +35,21 @@ func (s *scanner) Scan( for i, info := range imageInfos { if info.IsECRImage() { findingsList[i] = scanImage(ctx, ecrTool, info) - findingsList[i].ImageInfo = imageInfos[i] } else { - findingsList[i] = &ScanResult{ImageInfo: info, Err: fmt.Errorf("non-ECR image")} + findingsList[i] = &ScanResult{ImageInfo: info, Err: ErrNonEcrImage} } } return findingsList, nil } +var ErrNonEcrImage = fmt.Errorf("non-ECR image") + func scanImage(ctx context.Context, ecrTool EcrTool, info *ImageInfo) *ScanResult { if imageID, err := ecrTool.GetActualImageIdentifier(ctx, info); err != nil { - return &ScanResult{Err: err} + return &ScanResult{ImageInfo: info, Err: err} } else if findings, err := ecrTool.GetImageScanFindings(ctx, info, imageID); err != nil { - return &ScanResult{Err: err} + return &ScanResult{ImageInfo: info, Err: err} } else { - return &ScanResult{ImageScanFindings: findings} + return &ScanResult{ImageInfo: info, ImageScanFindings: findings} } } diff --git a/cli/cage/scan/scanner_test.go b/cli/cage/audit/scanner_test.go similarity index 99% rename from cli/cage/scan/scanner_test.go rename to cli/cage/audit/scanner_test.go index 7c24333..8968bc5 100644 --- a/cli/cage/scan/scanner_test.go +++ b/cli/cage/audit/scanner_test.go @@ -1,4 +1,4 @@ -package scan +package audit import ( "context" diff --git a/cli/cage/audit/types.go b/cli/cage/audit/types.go new file mode 100644 index 0000000..eaa8960 --- /dev/null +++ b/cli/cage/audit/types.go @@ -0,0 +1,95 @@ +package audit + +import ( + "regexp" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" +) + +type ImageInfo struct { + ContainerName string + Registry string + Repository string + Tag string + PlatFormOS ecstypes.OSFamily + PlatformArch ecstypes.CPUArchitecture +} + +func (i *ImageInfo) IsECRImage() bool { + return i.Registry == "public.ecr.aws" || i.registryHasECRSuffix() +} + +var ecrURLPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-za-zA-Z0-9-]+\.amazonaws\.com$`) + +func (i *ImageInfo) registryHasECRSuffix() bool { + return ecrURLPattern.MatchString(i.Registry) +} + +type ScanResult struct { + ImageInfo *ImageInfo + ImageScanFindings *ecrtypes.ImageScanFindings + Err error +} + +func (s *ScanResult) ContainerName() string { + if s.ImageInfo != nil { + return s.ImageInfo.ContainerName + } + return "" +} + +func (s *ScanResult) ImageURI() string { + if s.ImageInfo != nil { + return formatImageLabel(s.ImageInfo) + } + return "" +} + +type ScanResultSummary struct { + ContainerName string + Status string + CriticalCount int32 + HighCount int32 + MediumCount int32 + LowCount int32 + InfoCount int32 + ImageURI string +} + +func summaryScanResult(result *ScanResult) *ScanResultSummary { + var status string + var critical, high, medium, low, info int32 + findings := result.ImageScanFindings + for _, f := range findings.Findings { + switch f.Severity { + case "CRITICAL": + critical++ + case "HIGH": + high++ + case "MEDIUM": + medium++ + case "LOW": + low++ + case "INFORMATIONAL": + info++ + } + } + if len(result.ImageScanFindings.Findings) == 0 { + status = "NONE" + } else if critical > 0 || high > 0 { + status = "VULNERABLE" + } else if medium > 0 { + status = "WARNING" + } + return &ScanResultSummary{ + ContainerName: result.ContainerName(), + Status: status, + CriticalCount: critical, + HighCount: high, + MediumCount: medium, + LowCount: low, + InfoCount: info, + ImageURI: result.ImageURI(), + } +} diff --git a/cli/cage/audit/types_test.go b/cli/cage/audit/types_test.go new file mode 100644 index 0000000..06544f9 --- /dev/null +++ b/cli/cage/audit/types_test.go @@ -0,0 +1,250 @@ +package audit + +import ( + "testing" + + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" +) + +func TestImageInfo_IsECRImage(t *testing.T) { + tests := []struct { + name string + registry string + want bool + }{ + { + name: "public ECR registry", + registry: "public.ecr.aws", + want: true, + }, + { + name: "private ECR registry with standard suffix", + registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + want: true, + }, + { + name: "private ECR registry with different region", + registry: "123456789012.dkr.ecr.eu-west-1.amazonaws.com", + want: true, + }, + { + name: "Docker Hub registry", + registry: "docker.io", + want: false, + }, + { + name: "empty registry", + registry: "", + want: false, + }, + { + name: "non-ECR AWS registry", + registry: "amazonaws.com", + want: false, + }, + { + name: "registry with partial ECR suffix", + registry: "example.com", + want: false, + }, + { + name: "registry with ECR substring but not suffix", + registry: ".dkr.ecr.amazonaws.com.example.com", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := &ImageInfo{ + Registry: tt.registry, + } + if got := i.IsECRImage(); got != tt.want { + t.Errorf("ImageInfo.IsECRImage() = %v, want %v", got, tt.want) + } + }) + } +} +func Test_summaryScanResult(t *testing.T) { + tests := []struct { + name string + result *ScanResult + want *ScanResultSummary + }{ + { + name: "no findings - NONE status", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + Registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + Repository: "test-repo", + Tag: "latest", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{}, + }, + }, + want: &ScanResultSummary{ + ContainerName: "test-container", + Status: "NONE", + CriticalCount: 0, + HighCount: 0, + MediumCount: 0, + LowCount: 0, + InfoCount: 0, + ImageURI: formatImageLabel(&ImageInfo{ + ContainerName: "test-container", + Registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", + Repository: "test-repo", + Tag: "latest", + }), + }, + }, + { + name: "critical findings - VULNERABLE status", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: ecrtypes.FindingSeverityCritical}, + {Severity: ecrtypes.FindingSeverityHigh}, + }, + }, + }, + want: &ScanResultSummary{ + ContainerName: "test-container", + Status: "VULNERABLE", + CriticalCount: 1, + HighCount: 1, + MediumCount: 0, + LowCount: 0, + InfoCount: 0, + }, + }, + { + name: "high findings - VULNERABLE status", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: ecrtypes.FindingSeverityHigh}, + {Severity: ecrtypes.FindingSeverityHigh}, + }, + }, + }, + want: &ScanResultSummary{ + ContainerName: "test-container", + Status: "VULNERABLE", + CriticalCount: 0, + HighCount: 2, + MediumCount: 0, + LowCount: 0, + InfoCount: 0, + }, + }, + { + name: "medium findings - WARNING status", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: ecrtypes.FindingSeverityMedium}, + {Severity: ecrtypes.FindingSeverityLow}, + }, + }, + }, + want: &ScanResultSummary{ + ContainerName: "test-container", + Status: "WARNING", + CriticalCount: 0, + HighCount: 0, + MediumCount: 1, + LowCount: 1, + InfoCount: 0, + }, + }, + { + name: "low and informational findings - empty status", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: ecrtypes.FindingSeverityLow}, + {Severity: ecrtypes.FindingSeverityInformational}, + }, + }, + }, + want: &ScanResultSummary{ + ContainerName: "test-container", + Status: "", + CriticalCount: 0, + HighCount: 0, + MediumCount: 0, + LowCount: 1, + InfoCount: 1, + }, + }, + { + name: "mixed severity findings", + result: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "mixed-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + {Severity: ecrtypes.FindingSeverityCritical}, + {Severity: ecrtypes.FindingSeverityCritical}, + {Severity: ecrtypes.FindingSeverityHigh}, + {Severity: ecrtypes.FindingSeverityMedium}, + {Severity: ecrtypes.FindingSeverityLow}, + {Severity: ecrtypes.FindingSeverityInformational}, + }, + }, + }, + want: &ScanResultSummary{ + ContainerName: "mixed-container", + Status: "VULNERABLE", + CriticalCount: 2, + HighCount: 1, + MediumCount: 1, + LowCount: 1, + InfoCount: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := summaryScanResult(tt.result) + if got.ContainerName != tt.want.ContainerName { + t.Errorf("ContainerName = %v, want %v", got.ContainerName, tt.want.ContainerName) + } + if got.Status != tt.want.Status { + t.Errorf("Status = %v, want %v", got.Status, tt.want.Status) + } + if got.CriticalCount != tt.want.CriticalCount { + t.Errorf("CriticalCount = %v, want %v", got.CriticalCount, tt.want.CriticalCount) + } + if got.HighCount != tt.want.HighCount { + t.Errorf("HighCount = %v, want %v", got.HighCount, tt.want.HighCount) + } + if got.MediumCount != tt.want.MediumCount { + t.Errorf("MediumCount = %v, want %v", got.MediumCount, tt.want.MediumCount) + } + if got.LowCount != tt.want.LowCount { + t.Errorf("LowCount = %v, want %v", got.LowCount, tt.want.LowCount) + } + if got.InfoCount != tt.want.InfoCount { + t.Errorf("InfoCount = %v, want %v", got.InfoCount, tt.want.InfoCount) + } + }) + } +} diff --git a/cli/cage/cageapp/flags.go b/cli/cage/cageapp/flags.go index 2a07409..4de6702 100644 --- a/cli/cage/cageapp/flags.go +++ b/cli/cage/cageapp/flags.go @@ -9,8 +9,9 @@ import ( ) type App struct { - CI bool - Stdin io.Reader + CI bool + NoColor bool + Stdin io.Reader } func NewApp() *App { diff --git a/cli/cage/cageapp/provider_test.go b/cli/cage/cageapp/provider_test.go deleted file mode 100644 index ea4d263..0000000 --- a/cli/cage/cageapp/provider_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package cageapp_test - -import ( - "testing" - - "github.com/loilo-inc/canarycage/cli/cage/cageapp" - "github.com/loilo-inc/canarycage/env" - "github.com/stretchr/testify/assert" -) - -func TestProvideCageCli(t *testing.T) { - t.Run("successfully creates cage cli with valid region", func(t *testing.T) { - envars := &env.Envars{ - Region: "us-east-1", - } - - cage, err := cageapp.ProvideCageCli(envars) - assert.NoError(t, err) - assert.NotNil(t, cage) - }) - - t.Run("returns error with invalid region", func(t *testing.T) { - envars := &env.Envars{ - Region: "", - } - - cage, err := cageapp.ProvideCageCli(envars) - if err != nil { - assert.Nil(t, cage, "expected cage to be nil when error occurs") - return - } - assert.NotNil(t, cage, "expected cage to be non-nil when no error") - }) - - t.Run("handles nil envars", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - return - } - }() - - cage, err := cageapp.ProvideCageCli(nil) - if err == nil { - assert.NotNil(t, cage, "expected cage to be non-nil when no error") - } - }) -} - -func TestProvideScanDI(t *testing.T) { - t.Run("successfully creates scan DI with valid region", func(t *testing.T) { - region := "us-east-1" - - d, err := cageapp.ProvideScanDI(region) - assert.NoError(t, err) - assert.NotNil(t, d) - }) - - t.Run("returns error with invalid region", func(t *testing.T) { - region := "" - - d, err := cageapp.ProvideScanDI(region) - if err != nil { - assert.Nil(t, d, "expected DI domain to be nil when error occurs") - return - } - assert.NotNil(t, d, "expected DI domain to be non-nil when no error") - }) - - t.Run("creates DI domain with different regions", func(t *testing.T) { - regions := []string{"us-west-2", "eu-west-1", "ap-northeast-1"} - - for _, region := range regions { - d, err := cageapp.ProvideScanDI(region) - if err != nil { - t.Logf("region %s returned error: %v", region, err) - continue - } - assert.NotNil(t, d, "expected DI domain to be non-nil for region %s", region) - } - }) -} diff --git a/cli/cage/commands/scan.go b/cli/cage/commands/audit.go similarity index 63% rename from cli/cage/commands/scan.go rename to cli/cage/commands/audit.go index 39613fa..a25cbaa 100644 --- a/cli/cage/commands/scan.go +++ b/cli/cage/commands/audit.go @@ -1,32 +1,36 @@ package commands import ( - "context" "errors" + "github.com/loilo-inc/canarycage/cli/cage/audit" "github.com/loilo-inc/canarycage/cli/cage/cageapp" - "github.com/loilo-inc/canarycage/cli/cage/scan" "github.com/loilo-inc/canarycage/env" - "github.com/loilo-inc/canarycage/key" - "github.com/loilo-inc/canarycage/logger" "github.com/loilo-inc/logos/di" "github.com/urfave/cli/v2" ) type diProvider = func(region string) (*di.D, error) -func Scan(diProvider diProvider) *cli.Command { +func Audit(app *cageapp.App, diProvider diProvider) *cli.Command { var region string var cluster string var service string + var logDetail bool return &cli.Command{ - Name: "scan", - Usage: "Scan ECR image vulnerabilities for the given ECS service", - ArgsUsage: "", + Name: "audit", + Usage: "Audit container images used in an ECS service", + ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ cageapp.RegionFlag(®ion), cageapp.ClusterFlag(&cluster), cageapp.ServiceFlag(&service), + &cli.BoolFlag{ + Name: "detail", + Usage: "By default, only the name and URI of the finding are logged.", + Value: false, + Destination: &logDetail, + }, }, Action: func(ctx *cli.Context) error { dir, _, err := RequireArgs(ctx, 0, 1) @@ -49,19 +53,12 @@ func Scan(diProvider diProvider) *cli.Command { } else if cluster == "" || service == "" { return errors.New("either directory argument or both --cluster and --service flags must be provided") } - d, err := diProvider(region) + di, err := diProvider(region) if err != nil { return err } - scanner := d.Get(key.Scanner).(scan.Scanner) - result, err := scanner.Scan(context.Background(), cluster, service) - if err != nil { - return err - } - logger := d.Get(key.Logger).(logger.Logger) - printer := scan.NewPrinter(logger) - printer.Print(result) - return nil + cmd := audit.NewCommand(di, app, logDetail) + return cmd.Run(ctx.Context, cluster, service) }, } } diff --git a/cli/cage/commands/scan_test.go b/cli/cage/commands/audit_test.go similarity index 62% rename from cli/cage/commands/scan_test.go rename to cli/cage/commands/audit_test.go index 70a3f41..aa48bba 100644 --- a/cli/cage/commands/scan_test.go +++ b/cli/cage/commands/audit_test.go @@ -7,105 +7,109 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/loilo-inc/canarycage/cli/cage/scan" + "github.com/loilo-inc/canarycage/cli/cage/audit" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/key" + "github.com/loilo-inc/canarycage/mocks/mock_audit" "github.com/loilo-inc/canarycage/mocks/mock_logger" - "github.com/loilo-inc/canarycage/mocks/mock_scan" + "github.com/loilo-inc/canarycage/test" "github.com/loilo-inc/logos/di" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v2" "go.uber.org/mock/gomock" ) -func TestScan(t *testing.T) { +func TestAudit(t *testing.T) { + makeMockLogger := func(ctrl *gomock.Controller) *mock_logger.MockLogger { + mockLogger := mock_logger.NewMockLogger(ctrl) + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + return mockLogger + } t.Run("returns error when region is missing", func(t *testing.T) { - app := setupScanApp(t, nil) - err := app.Run([]string{"cage", "scan", "--region", ""}) + app := setupAuditApp(t, nil) + err := app.Run([]string{"cage", "audit", "--region", ""}) assert.Error(t, err) assert.Contains(t, err.Error(), "--region flag is required") }) t.Run("return errors when too many arguments", func(t *testing.T) { - app := setupScanApp(t, nil) - err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "arg1", "arg2"}) + app := setupAuditApp(t, nil) + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "arg1", "arg2"}) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid number of arguments. expected at most 1") }) t.Run("returns error when both directory and flags are missing", func(t *testing.T) { - app := setupScanApp(t, nil) + app := setupAuditApp(t, nil) - err := app.Run([]string{"cage", "scan", "--region", "us-east-1"}) + err := app.Run([]string{"cage", "audit", "--region", "us-east-1"}) assert.Error(t, err) assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") }) t.Run("returns error when only cluster flag is provided", func(t *testing.T) { - app := setupScanApp(t, nil) + app := setupAuditApp(t, nil) - err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "--cluster", "test-cluster"}) + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "--cluster", "test-cluster"}) assert.Error(t, err) assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") }) t.Run("returns error when only service flag is provided", func(t *testing.T) { - app := setupScanApp(t, nil) + app := setupAuditApp(t, nil) - err := app.Run([]string{"cage", "scan", "--region", "us-east-1", "--service", "test-service"}) + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "--service", "test-service"}) assert.Error(t, err) assert.Contains(t, err.Error(), "either directory argument or both --cluster and --service flags must be provided") }) t.Run("returns error when diProvider fails", func(t *testing.T) { expectedErr := errors.New("di provider error") - app := setupScanApp(t, func(region string) (*di.D, error) { + app := setupAuditApp(t, func(region string) (*di.D, error) { return nil, expectedErr }) err := app.Run([]string{ - "cage", "scan", "--region", "us-east-1", "--cluster", "test-cluster", "--service", "test-service", + "cage", "audit", "--region", "us-east-1", "--cluster", "test-cluster", "--service", "test-service", }) assert.Error(t, err) assert.Equal(t, expectedErr, err) }) - setupBase := func(t *testing.T) (*cli.App, *mock_scan.MockScanner, *mock_logger.MockLogger) { + setupBase := func(t *testing.T) (*cli.App, *mock_audit.MockScanner) { t.Helper() ctrl := gomock.NewController(t) - mockScanner := mock_scan.NewMockScanner(ctrl) - mockLogger := mock_logger.NewMockLogger(ctrl) + mockScanner := mock_audit.NewMockScanner(ctrl) + mockLogger := makeMockLogger(ctrl) d := di.NewDomain(func(b *di.B) { b.Set(key.Scanner, mockScanner) b.Set(key.Logger, mockLogger) + b.Set(key.Time, test.NewFakeNeverTimer()) }) - app := setupScanApp(t, func(region string) (*di.D, error) { + app := setupAuditApp(t, func(region string) (*di.D, error) { assert.Equal(t, "us-east-1", region) return d, nil }) - return app, mockScanner, mockLogger + return app, mockScanner } t.Run("Succcess", func(t *testing.T) { setup := func(t *testing.T) *cli.App { t.Helper() - app, mockScanner, mockLogger := setupBase(t) + app, mockScanner := setupBase(t) mockScanner.EXPECT(). Scan(gomock.Any(), "cluster", "service"). // from fixtures/service.json Return(makeScanResult(), nil) - - mockLogger.EXPECT().Printf( - gomock.Any(), gomock.Any(), gomock.Any(), - gomock.Any(), gomock.Any(), gomock.Any(), - gomock.Any(), gomock.Any(), gomock.Any(), - ).Times(2) return app } t.Run("executes scan with directory argument", func(t *testing.T) { app := setup(t) - err := app.Run([]string{"cage", "scan", + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "../../../fixtures"}) assert.NoError(t, err) }) t.Run("executes scan with flags", func(t *testing.T) { app := setup(t) - err := app.Run([]string{"cage", "scan", + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "--cluster", "cluster", "--service", "service"}) @@ -114,12 +118,12 @@ func TestScan(t *testing.T) { }) t.Run("Error", func(t *testing.T) { t.Run("error on scanner.Scan()", func(t *testing.T) { - app, mockScanner, _ := setupBase(t) + app, mockScanner := setupBase(t) mockScanner.EXPECT(). Scan(gomock.Any(), "cluster", "service"). Return(nil, errors.New("scan error")) - err := app.Run([]string{"cage", "scan", + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "--cluster", "cluster", "--service", "service"}) @@ -127,8 +131,8 @@ func TestScan(t *testing.T) { assert.Contains(t, err.Error(), "scan error") }) t.Run("error on loading service definition", func(t *testing.T) { - app := setupScanApp(t, nil) - err := app.Run([]string{"cage", "scan", + app := setupAuditApp(t, nil) + err := app.Run([]string{"cage", "audit", "--region", "us-east-1", "../../../fixtures/invalid-service"}) assert.Error(t, err) assert.Contains(t, err.Error(), "no 'service.json' found") @@ -136,20 +140,21 @@ func TestScan(t *testing.T) { }) } -func setupScanApp(t *testing.T, diProvider func(region string) (*di.D, error)) *cli.App { +func setupAuditApp(t *testing.T, diProvider func(region string) (*di.D, error)) *cli.App { t.Helper() + conf := &cageapp.App{} app := cli.NewApp() app.Name = "cage" app.Commands = []*cli.Command{ - Scan(diProvider), + Audit(conf, diProvider), } return app } -func makeScanResult() []*scan.ScanResult { - return []*scan.ScanResult{ +func makeScanResult() []*audit.ScanResult { + return []*audit.ScanResult{ { - ImageInfo: &scan.ImageInfo{ + ImageInfo: &audit.ImageInfo{ Repository: "test-repo", Tag: "latest", Registry: "dockerhub.io", @@ -161,6 +166,7 @@ func makeScanResult() []*scan.ScanResult { { Name: aws.String("CVE-2023-1234"), Description: aws.String("Test vulnerability description"), + Uri: aws.String("https://example.com/CVE-2023-1234"), Severity: ecrtypes.FindingSeverityHigh, }, }, diff --git a/cli/cage/cageapp/provider.go b/cli/cage/commands/provider.go similarity index 58% rename from cli/cage/cageapp/provider.go rename to cli/cage/commands/provider.go index d987a6b..97670e2 100644 --- a/cli/cage/cageapp/provider.go +++ b/cli/cage/commands/provider.go @@ -1,29 +1,27 @@ -package cageapp +package commands import ( "context" - "os" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" cage "github.com/loilo-inc/canarycage" - "github.com/loilo-inc/canarycage/cli/cage/scan" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/key" - "github.com/loilo-inc/canarycage/logger" "github.com/loilo-inc/canarycage/task" "github.com/loilo-inc/canarycage/timeout" "github.com/loilo-inc/canarycage/types" "github.com/loilo-inc/logos/di" - "golang.org/x/xerrors" ) func ProvideCageCli(envars *env.Envars) (types.Cage, error) { - conf, err := loadAwsConfig(envars.Region) + conf, err := config.LoadDefaultConfig( + context.Background(), + config.WithRegion(envars.Region), + ) if err != nil { return nil, err } @@ -39,27 +37,3 @@ func ProvideCageCli(envars *env.Envars) (types.Cage, error) { cagecli := cage.NewCage(d) return cagecli, nil } - -func ProvideScanDI(region string) (*di.D, error) { - conf, err := loadAwsConfig(region) - if err != nil { - return nil, err - } - d := di.NewDomain(func(b *di.B) { - ecsCli := ecs.NewFromConfig(conf) - ecrCli := ecr.NewFromConfig(conf) - b.Set(key.Scanner, scan.NewScanner(ecsCli, ecrCli)) - b.Set(key.Logger, logger.DefaultLogger(os.Stdout)) - }) - return d, nil -} - -func loadAwsConfig(region string) (aws.Config, error) { - conf, err := config.LoadDefaultConfig( - context.Background(), - config.WithRegion(region)) - if err != nil { - return aws.Config{}, xerrors.Errorf("failed to load aws config: %w", err) - } - return conf, nil -} diff --git a/cli/cage/commands/provider_test.go b/cli/cage/commands/provider_test.go new file mode 100644 index 0000000..aa3f2cb --- /dev/null +++ b/cli/cage/commands/provider_test.go @@ -0,0 +1,46 @@ +package commands + +import ( + "testing" + + "github.com/loilo-inc/canarycage/env" + "github.com/stretchr/testify/assert" +) + +func TestProvideCageCli(t *testing.T) { + t.Run("successfully creates cage cli with valid region", func(t *testing.T) { + envars := &env.Envars{ + Region: "us-east-1", + } + + cage, err := ProvideCageCli(envars) + assert.NoError(t, err) + assert.NotNil(t, cage) + }) + + t.Run("returns error with invalid region", func(t *testing.T) { + envars := &env.Envars{ + Region: "", + } + + cage, err := ProvideCageCli(envars) + if err != nil { + assert.Nil(t, cage, "expected cage to be nil when error occurs") + return + } + assert.NotNil(t, cage, "expected cage to be non-nil when no error") + }) + + t.Run("handles nil envars", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + return + } + }() + + cage, err := ProvideCageCli(nil) + if err == nil { + assert.NotNil(t, cage, "expected cage to be non-nil when no error") + } + }) +} diff --git a/cli/cage/commands/a_test.go b/cli/cage/commands/tools_test.go similarity index 100% rename from cli/cage/commands/a_test.go rename to cli/cage/commands/tools_test.go diff --git a/cli/cage/main.go b/cli/cage/main.go index e69828a..885e47d 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -5,6 +5,7 @@ import ( "log" "os" + "github.com/loilo-inc/canarycage/cli/cage/audit" "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/commands" "github.com/loilo-inc/canarycage/cli/cage/upgrade" @@ -26,13 +27,13 @@ func main() { app.Version = fmt.Sprintf("%s (commit: %s, date: %s)", version, commit, date) app.Usage = "A deployment tool for AWS ECS" app.Description = "A deployment tool for AWS ECS" - cmds := commands.NewCageCommands(cageapp.ProvideCageCli) + cmds := commands.NewCageCommands(commands.ProvideCageCli) app.Commands = []*cli.Command{ cmds.Up(appConf), cmds.RollOut(appConf), cmds.Run(appConf), commands.Upgrade(upgrade.NewUpgrader(version)), - commands.Scan(cageapp.ProvideScanDI), + commands.Audit(appConf, audit.ProvideAuditDI), } app.Flags = []cli.Flag{ &cli.BoolFlag{ @@ -41,6 +42,12 @@ func main() { EnvVars: []string{"CI"}, Destination: &appConf.CI, }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable colored output", + EnvVars: []string{"NO_COLOR"}, + Destination: &appConf.NoColor, + }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) diff --git a/cli/cage/scan/printer.go b/cli/cage/scan/printer.go deleted file mode 100644 index cb878bc..0000000 --- a/cli/cage/scan/printer.go +++ /dev/null @@ -1,91 +0,0 @@ -package scan - -import ( - "fmt" - - "github.com/loilo-inc/canarycage/logger" -) - -type printer struct { - logger logger.Logger -} - -type Printer interface { - Print(result []*ScanResult) -} - -func NewPrinter(logger logger.Logger) Printer { - return &printer{logger: logger} -} - -func (p *printer) Print(result []*ScanResult) { - containerMax, imageMax := MaxHeaderWidth(result) - // |container|status|critical|high|medium|low|info|image| - headerFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8s|%%-5s|%%-6s|%%-4s|%%-4s|%%-%ds|\n", containerMax, imageMax) - p.logger.Printf(headerFmt, "CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE") - bodyFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8d|%%-5d|%%-6d|%%-4d|%%-4d|%%-%ds|\n", containerMax, imageMax) - for _, r := range result { - if r.Err != nil { - p.logger.Printf(bodyFmt, - r.ImageInfo.ContainerName, - "ERROR", 0, 0, 0, 0, 0, - formatImageLabel(r.ImageInfo), - ) - continue - } - findings := r.ImageScanFindings - var critical, high, medium, low, info int32 - for _, f := range findings.Findings { - switch f.Severity { - case "CRITICAL": - critical++ - case "HIGH": - high++ - case "MEDIUM": - medium++ - case "LOW": - low++ - case "INFORMATIONAL": - info++ - } - } - status := "OK" - if len(findings.Findings) == 0 { - status = "NONE" - } else if critical > 0 || high > 0 { - status = "VULNERABLE" - } else if medium > 0 { - status = "WARNING" - } - p.logger.Printf( - bodyFmt, - r.ImageInfo.ContainerName, - status, - critical, - high, - medium, - low, - info, - formatImageLabel(r.ImageInfo), - ) - } -} - -func formatImageLabel(info *ImageInfo) string { - return fmt.Sprintf("%s/%s:%s", info.Registry, info.Repository, info.Tag) -} - -func MaxHeaderWidth(imageInfos []*ScanResult) (int, int) { - containerMax := len("CONTAINER") - imageMax := len("IMAGE") - for _, info := range imageInfos { - if l := len(info.ImageInfo.ContainerName); l > containerMax { - containerMax = l - } - imageLabel := formatImageLabel(info.ImageInfo) - if l := len(imageLabel); l > imageMax { - imageMax = l - } - } - return containerMax, imageMax -} diff --git a/cli/cage/scan/printer_test.go b/cli/cage/scan/printer_test.go deleted file mode 100644 index 56066da..0000000 --- a/cli/cage/scan/printer_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package scan_test - -import ( - "fmt" - "testing" - - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/loilo-inc/canarycage/cli/cage/scan" - "github.com/stretchr/testify/assert" -) - -type mockLogger struct { - logs []string -} - -func (m *mockLogger) Printf(format string, args ...any) { - m.logs = append(m.logs, fmt.Sprintf(format, args...)) -} - -var imageInfo = &scan.ImageInfo{ - ContainerName: "test-container", - Registry: "test-registry", - Repository: "test-repo", - Tag: "latest", -} - -func TestPrinter_Print(t *testing.T) { - tests := []struct { - name string - results []*scan.ScanResult - expectedLines int - expectedStatus []string - expectedCounts [][]int32 - }{ - { - name: "single result with no findings", - results: makeScanResult(), - expectedLines: 2, // header + 1 body - expectedStatus: []string{"NONE"}, - expectedCounts: [][]int32{{0, 0, 0, 0, 0}}, - }, - { - name: "single result with vulnerabilities", - results: makeScanResult( - "CRITICAL", - "HIGH", - "MEDIUM", - "LOW", - "INFORMATIONAL", - ), - expectedLines: 2, - expectedStatus: []string{"VULNERABLE"}, - expectedCounts: [][]int32{{1, 1, 1, 1, 1}}, - }, - { - name: "single result with only medium severity", - results: makeScanResult("MEDIUM", "MEDIUM"), - expectedLines: 2, - expectedStatus: []string{"WARNING"}, - expectedCounts: [][]int32{{0, 0, 2, 0, 0}}, - }, - { - name: "result with error", - results: []*scan.ScanResult{ - { - ImageInfo: imageInfo, - Err: fmt.Errorf("scan failed"), - }, - }, - expectedLines: 2, - expectedStatus: []string{"ERROR"}, - expectedCounts: [][]int32{{0, 0, 0, 0, 0}}, - }, - { - name: "multiple results mixed", - results: []*scan.ScanResult{ - makeScanResult("CRITICAL", "HIGH")[0], - { - ImageInfo: imageInfo, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{}, - }, - }, - { - ImageInfo: imageInfo, - Err: fmt.Errorf("error"), - }, - }, - expectedLines: 4, // header + 3 bodies - expectedStatus: []string{"VULNERABLE", "NONE", "ERROR"}, - }, - { - name: "result with only low severity", - results: makeScanResult("LOW", "INFORMATIONAL"), - expectedLines: 2, - expectedStatus: []string{"OK"}, - expectedCounts: [][]int32{{0, 0, 0, 1, 1}}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - logger := &mockLogger{logs: []string{}} - printer := scan.NewPrinter(logger) - - printer.Print(tt.results) - - assert.Equal(t, tt.expectedLines, len(logger.logs), "unexpected number of log lines") - - // Check header is present - if len(logger.logs) > 0 { - header := logger.logs[0] - assert.Contains(t, header, "CONTAINER", "header should contain CONTAINER") - assert.Contains(t, header, "STATUS", "header should contain STATUS") - } - - // Check statuses - for i, expectedStatus := range tt.expectedStatus { - bodyLine := logger.logs[i+1] - assert.Contains(t, bodyLine, expectedStatus, "line should contain expected status") - } - - // Check container names - for i, result := range tt.results { - bodyLine := logger.logs[i+1] - assert.Contains(t, bodyLine, result.ImageInfo.ContainerName, "line should contain container name") - } - }) - } -} - -func TestPrinter_Print_EmptyResults(t *testing.T) { - logger := &mockLogger{logs: []string{}} - printer := scan.NewPrinter(logger) - - printer.Print([]*scan.ScanResult{}) - - assert.Equal(t, 1, len(logger.logs), "expected header only") -} - -func makeScanResult( - list ...ecrtypes.FindingSeverity) []*scan.ScanResult { - return []*scan.ScanResult{ - { - ImageInfo: &scan.ImageInfo{ - ContainerName: "test-container", - Registry: "test-registry", - Repository: "test-repo", - Tag: "latest", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: makeFindings(list), - }, - }, - } -} - -func makeFindings(severities []ecrtypes.FindingSeverity) []ecrtypes.ImageScanFinding { - findings := make([]ecrtypes.ImageScanFinding, len(severities)) - for i, sev := range severities { - findings[i] = ecrtypes.ImageScanFinding{Severity: sev} - } - return findings -} diff --git a/cli/cage/scan/types.go b/cli/cage/scan/types.go deleted file mode 100644 index c8863f0..0000000 --- a/cli/cage/scan/types.go +++ /dev/null @@ -1,32 +0,0 @@ -package scan - -import ( - "regexp" - - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/aws/aws-sdk-go-v2/service/ecs/types" -) - -type ImageInfo struct { - Registry string - ContainerName string - PlatformArch types.CPUArchitecture - Repository string - Tag string -} - -func (i *ImageInfo) IsECRImage() bool { - return i.Registry == "public.ecr.aws" || i.registryHasECRSuffix() -} - -var ecrURLPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$`) - -func (i *ImageInfo) registryHasECRSuffix() bool { - return ecrURLPattern.MatchString(i.Registry) -} - -type ScanResult struct { - ImageInfo *ImageInfo - ImageScanFindings *ecrtypes.ImageScanFindings - Err error -} diff --git a/cli/cage/scan/types_test.go b/cli/cage/scan/types_test.go deleted file mode 100644 index 3e6ef9a..0000000 --- a/cli/cage/scan/types_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package scan_test - -import ( - "testing" - - "github.com/loilo-inc/canarycage/cli/cage/scan" -) - -func TestImageInfo_IsECRImage(t *testing.T) { - tests := []struct { - name string - registry string - want bool - }{ - { - name: "public ECR registry", - registry: "public.ecr.aws", - want: true, - }, - { - name: "private ECR registry with standard suffix", - registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", - want: true, - }, - { - name: "private ECR registry with different region", - registry: "123456789012.dkr.ecr.eu-west-1.amazonaws.com", - want: true, - }, - { - name: "Docker Hub registry", - registry: "docker.io", - want: false, - }, - { - name: "empty registry", - registry: "", - want: false, - }, - { - name: "non-ECR AWS registry", - registry: "amazonaws.com", - want: false, - }, - { - name: "registry with partial ECR suffix", - registry: "example.com", - want: false, - }, - { - name: "registry with ECR substring but not suffix", - registry: ".dkr.ecr.amazonaws.com.example.com", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - i := &scan.ImageInfo{ - Registry: tt.registry, - } - if got := i.IsECRImage(); got != tt.want { - t.Errorf("ImageInfo.IsECRImage() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/cli/color/color.go b/cli/color/color.go new file mode 100644 index 0000000..c924e37 --- /dev/null +++ b/cli/color/color.go @@ -0,0 +1,59 @@ +package color + +import "fmt" + +type Color struct { + NoColor bool +} + +func (c *Color) Redf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[31m" + fmt.Sprintf(s, args...) + "\033[0m" +} + +func (c *Color) Greenf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[32m" + fmt.Sprintf(s, args...) + "\033[0m" +} + +func (c *Color) Yellowf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[33m" + fmt.Sprintf(s, args...) + "\033[0m" +} +func (c *Color) Bluef(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[34m" + fmt.Sprintf(s, args...) + "\033[0m" +} +func (c *Color) Magentaf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[35m" + fmt.Sprintf(s, args...) + "\033[0m" +} +func (c *Color) Cyanf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[36m" + fmt.Sprintf(s, args...) + "\033[0m" +} +func (c *Color) Whitef(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[37m" + fmt.Sprintf(s, args...) + "\033[0m" +} + +func (c *Color) Boldf(s string, args ...interface{}) string { + if c.NoColor { + return s + } + return "\033[1m" + fmt.Sprintf(s, args...) + "\033[0m" +} diff --git a/logger/spinner.go b/logger/spinner.go new file mode 100644 index 0000000..fae384c --- /dev/null +++ b/logger/spinner.go @@ -0,0 +1,19 @@ +package logger + +type spinner struct { + frames []string + index int +} + +func NewSpinner() *spinner { + return &spinner{ + frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + index: 0, + } +} + +func (s *spinner) Next() string { + frame := s.frames[s.index] + s.index = (s.index + 1) % len(s.frames) + return frame +} diff --git a/logger/spinner_test.go b/logger/spinner_test.go new file mode 100644 index 0000000..86f2c5e --- /dev/null +++ b/logger/spinner_test.go @@ -0,0 +1,35 @@ +package logger + +import "testing" + +func TestNewSpinner(t *testing.T) { + s := NewSpinner() + if s == nil { + t.Fatal("NewSpinner() returned nil") + } + if len(s.frames) != 10 { + t.Errorf("expected 10 frames, got %d", len(s.frames)) + } + if s.index != 0 { + t.Errorf("expected initial index 0, got %d", s.index) + } +} + +func TestSpinnerNext(t *testing.T) { + s := NewSpinner() + expectedFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + + // Test that Next() returns frames in order + for i := 0; i < len(expectedFrames); i++ { + frame := s.Next() + if frame != expectedFrames[i] { + t.Errorf("expected frame %q at index %d, got %q", expectedFrames[i], i, frame) + } + } + + // Test that it wraps around + frame := s.Next() + if frame != expectedFrames[0] { + t.Errorf("expected frame to wrap around to %q, got %q", expectedFrames[0], frame) + } +} diff --git a/mocks/mock_scan/scanner.go b/mocks/mock_audit/scanner.go similarity index 81% rename from mocks/mock_scan/scanner.go rename to mocks/mock_audit/scanner.go index 15f599f..957baa7 100644 --- a/mocks/mock_scan/scanner.go +++ b/mocks/mock_audit/scanner.go @@ -1,19 +1,19 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: ./cli/cage/scan/scanner.go +// Source: ./cli/cage/audit/scanner.go // // Generated by this command: // -// mockgen -source=./cli/cage/scan/scanner.go +// mockgen -source=./cli/cage/audit/scanner.go // -// Package mock_scan is a generated GoMock package. -package mock_scan +// Package mock_audit is a generated GoMock package. +package mock_audit import ( context "context" reflect "reflect" - scan "github.com/loilo-inc/canarycage/cli/cage/scan" + audit "github.com/loilo-inc/canarycage/cli/cage/audit" gomock "go.uber.org/mock/gomock" ) @@ -42,10 +42,10 @@ func (m *MockScanner) EXPECT() *MockScannerMockRecorder { } // Scan mocks base method. -func (m *MockScanner) Scan(ctx context.Context, cluster, service string) ([]*scan.ScanResult, error) { +func (m *MockScanner) Scan(ctx context.Context, cluster, service string) ([]*audit.ScanResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Scan", ctx, cluster, service) - ret0, _ := ret[0].([]*scan.ScanResult) + ret0, _ := ret[0].([]*audit.ScanResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/test/fake_timer.go b/test/fake_timer.go index 111c5d8..a758da4 100644 --- a/test/fake_timer.go +++ b/test/fake_timer.go @@ -11,12 +11,12 @@ func newTimer(_ time.Duration) *time.Timer { go func() { ch <- time.Now() }() - return &time.Timer{ - C: ch, - } + return &time.Timer{C: ch} } -type timeImpl struct{} +type timeImpl struct { + never bool +} func (t *timeImpl) Now() time.Time { return time.Now() @@ -24,6 +24,18 @@ func (t *timeImpl) Now() time.Time { func (t *timeImpl) NewTimer(d time.Duration) *time.Timer { return newTimer(d) } + func NewFakeTime() types.Time { return &timeImpl{} } + +type neverTimer struct{} + +func (t *neverTimer) NewTimer(d time.Duration) *time.Timer { + ch := make(chan time.Time) + return &time.Timer{C: ch} +} + +func NewFakeNeverTimer() types.Time { + return &neverTimer{} +} From 50c9d819d8e3add8495f901788b762c051b0e6e8 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Tue, 20 Jan 2026 20:30:20 +0900 Subject: [PATCH 15/23] test --- cli/cage/audit/aggregator.go | 4 +- cli/cage/audit/aggregator_test.go | 144 ++++++++++++++++++++++++++++++ cli/cage/audit/printer.go | 5 +- cli/cage/audit/types.go | 2 +- {cli/color => logger}/color.go | 2 +- logger/color_test.go | 131 +++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 7 deletions(-) rename {cli/color => logger}/color.go (98%) create mode 100644 logger/color_test.go diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index 92cd9d6..30888bf 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -4,7 +4,7 @@ import ( "fmt" ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/loilo-inc/canarycage/cli/color" + "github.com/loilo-inc/canarycage/logger" ) type aggregater struct { @@ -132,7 +132,7 @@ func (a *aggregater) filterCvesBySeverity(severity ecrtypes.FindingSeverity) []e type severityPrinter struct { noColor bool severity ecrtypes.FindingSeverity - color color.Color + color logger.Color } func (s *severityPrinter) Sprintf(format string, a ...any) string { diff --git a/cli/cage/audit/aggregator_test.go b/cli/cage/audit/aggregator_test.go index c4c65ea..6ce777c 100644 --- a/cli/cage/audit/aggregator_test.go +++ b/cli/cage/audit/aggregator_test.go @@ -71,3 +71,147 @@ func TestSeverityPrinter_BSprintf(t *testing.T) { want := "\x1b[1m\x1b[35mtest critical\x1b[0m\x1b[0m" assert.Equal(t, want, got) } + +func TestNewAggregater(t *testing.T) { + agg := NewAggregater() + assert.NotNil(t, agg) + assert.NotNil(t, agg.cves) + assert.NotNil(t, agg.cveToSeverity) + assert.NotNil(t, agg.summaries) + assert.Equal(t, 0, len(agg.cves)) + assert.Equal(t, 0, len(agg.cveToSeverity)) + assert.Equal(t, 0, len(agg.summaries)) +} + +func TestAggregater_Add(t *testing.T) { + tests := []struct { + name string + scanResult *ScanResult + wantStatus string + wantCVECount int + }{ + { + name: "add result with error", + scanResult: &ScanResult{ + Err: assert.AnError, + ImageInfo: &ImageInfo{}, + }, + wantStatus: "ERROR", + wantCVECount: 0, + }, + { + name: "add result with nil findings", + scanResult: &ScanResult{ + ImageScanFindings: nil, + ImageInfo: &ImageInfo{}, + }, + wantStatus: "N/A", + wantCVECount: 0, + }, + { + name: "add result with findings", + scanResult: &ScanResult{ + ImageInfo: &ImageInfo{ + ContainerName: "test-container", + }, + ImageScanFindings: &ecrtypes.ImageScanFindings{ + Findings: []ecrtypes.ImageScanFinding{ + { + Name: stringPtr("CVE-2021-1234"), + Severity: ecrtypes.FindingSeverityCritical, + }, + { + Name: stringPtr("CVE-2021-5678"), + Severity: ecrtypes.FindingSeverityHigh, + }, + }, + }, + }, + wantStatus: "VULNERABLE", + wantCVECount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agg := NewAggregater() + agg.Add(tt.scanResult) + assert.Equal(t, 1, len(agg.summaries)) + assert.Equal(t, tt.wantStatus, agg.summaries[tt.scanResult.ImageInfo.ContainerName][0].Status) + assert.Equal(t, tt.wantCVECount, len(agg.cves)) + }) + + } +} + +func TestAggregater_SummarizeTotal(t *testing.T) { + agg := NewAggregater() + agg.cves = map[string]ecrtypes.ImageScanFinding{ + "CVE-2021-1": {Name: stringPtr("CVE-2021-1"), Severity: ecrtypes.FindingSeverityCritical}, + "CVE-2021-2": {Name: stringPtr("CVE-2021-2"), Severity: ecrtypes.FindingSeverityHigh}, + "CVE-2021-3": {Name: stringPtr("CVE-2021-3"), Severity: ecrtypes.FindingSeverityMedium}, + "CVE-2021-4": {Name: stringPtr("CVE-2021-4"), Severity: ecrtypes.FindingSeverityLow}, + "CVE-2021-5": {Name: stringPtr("CVE-2021-5"), Severity: ecrtypes.FindingSeverityInformational}, + } + agg.cveToSeverity = map[string]string{ + "CVE-2021-1": string(ecrtypes.FindingSeverityCritical), + "CVE-2021-2": string(ecrtypes.FindingSeverityHigh), + "CVE-2021-3": string(ecrtypes.FindingSeverityMedium), + "CVE-2021-4": string(ecrtypes.FindingSeverityLow), + "CVE-2021-5": string(ecrtypes.FindingSeverityInformational), + } + + result := agg.SummarizeTotal() + assert.Equal(t, int32(1), result.CriticalCount) + assert.Equal(t, int32(1), result.HighCount) + assert.Equal(t, int32(1), result.MediumCount) + assert.Equal(t, int32(1), result.LowCount) + assert.Equal(t, int32(1), result.InfoCount) + assert.Equal(t, int32(5), result.TotalCount) + assert.Equal(t, ecrtypes.FindingSeverityCritical, result.HighestSeverity) +} + +func TestAggregater_FilterCvesBySeverity(t *testing.T) { + agg := NewAggregater() + agg.cves = map[string]ecrtypes.ImageScanFinding{ + "CVE-2021-1": {Name: stringPtr("CVE-2021-1"), Severity: ecrtypes.FindingSeverityCritical}, + "CVE-2021-2": {Name: stringPtr("CVE-2021-2"), Severity: ecrtypes.FindingSeverityHigh}, + "CVE-2021-3": {Name: stringPtr("CVE-2021-3"), Severity: ecrtypes.FindingSeverityCritical}, + } + agg.cveToSeverity = map[string]string{ + "CVE-2021-1": string(ecrtypes.FindingSeverityCritical), + "CVE-2021-2": string(ecrtypes.FindingSeverityHigh), + "CVE-2021-3": string(ecrtypes.FindingSeverityCritical), + } + + critical := agg.CriticalCves() + assert.Equal(t, 2, len(critical)) + + high := agg.HighCves() + assert.Equal(t, 1, len(high)) + + medium := agg.MediumCves() + assert.Equal(t, 0, len(medium)) +} + +func TestAggregateResult_SeverityCounts(t *testing.T) { + result := &AggregateResult{ + CriticalCount: 1, + HighCount: 2, + MediumCount: 3, + LowCount: 4, + InfoCount: 5, + } + + counts := result.SeverityCounts() + assert.Equal(t, 5, len(counts)) + assert.Equal(t, 5, counts[0].Count) + assert.Equal(t, 4, counts[1].Count) + assert.Equal(t, 3, counts[2].Count) + assert.Equal(t, 2, counts[3].Count) + assert.Equal(t, 1, counts[4].Count) +} + +func stringPtr(s string) *string { + return &s +} diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index c73bf6f..dd0af81 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -5,7 +5,6 @@ import ( "strings" ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/loilo-inc/canarycage/cli/color" "github.com/loilo-inc/canarycage/logger" ) @@ -44,9 +43,9 @@ func (p *Printer) Print(result []*ScanResult) { p.logImageScanFindings("HIGH", agg.HighCves()) p.logImageScanFindings("MEDIUM", agg.MediumCves()) total := agg.TotalCVECount() - chalk := color.Color{NoColor: p.NoColor} + color := logger.Color{NoColor: p.NoColor} if total == 0 { - p.Logger.Printf("%s\n", chalk.Greenf("No CVEs found")) + p.Logger.Printf("%s\n", color.Greenf("No CVEs found")) return } summary := agg.SummarizeTotal() diff --git a/cli/cage/audit/types.go b/cli/cage/audit/types.go index eaa8960..9477aa7 100644 --- a/cli/cage/audit/types.go +++ b/cli/cage/audit/types.go @@ -58,7 +58,7 @@ type ScanResultSummary struct { } func summaryScanResult(result *ScanResult) *ScanResultSummary { - var status string + var status = "OK" var critical, high, medium, low, info int32 findings := result.ImageScanFindings for _, f := range findings.Findings { diff --git a/cli/color/color.go b/logger/color.go similarity index 98% rename from cli/color/color.go rename to logger/color.go index c924e37..bc0682f 100644 --- a/cli/color/color.go +++ b/logger/color.go @@ -1,4 +1,4 @@ -package color +package logger import "fmt" diff --git a/logger/color_test.go b/logger/color_test.go new file mode 100644 index 0000000..bcf67ba --- /dev/null +++ b/logger/color_test.go @@ -0,0 +1,131 @@ +package logger + +import "testing" + +func TestColor_Redf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Redf("test %s", "message") + expected := "\033[31mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Redf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Greenf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Greenf("test %s", "message") + expected := "\033[32mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Greenf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Yellowf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Yellowf("test %s", "message") + expected := "\033[33mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Yellowf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Bluef(t *testing.T) { + c := &Color{NoColor: false} + result := c.Bluef("test %s", "message") + expected := "\033[34mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Bluef("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Magentaf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Magentaf("test %s", "message") + expected := "\033[35mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Magentaf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Cyanf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Cyanf("test %s", "message") + expected := "\033[36mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Cyanf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Whitef(t *testing.T) { + c := &Color{NoColor: false} + result := c.Whitef("test %s", "message") + expected := "\033[37mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Whitef("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestColor_Boldf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Boldf("test %s", "message") + expected := "\033[1mtest message\033[0m" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + + c.NoColor = true + result = c.Boldf("test %s", "message") + expected = "test %s" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} From 43f643f26643d6006e831fabfdc4be9d120acfa5 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 11:49:44 +0900 Subject: [PATCH 16/23] Update types_test.go --- cli/cage/audit/types_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cage/audit/types_test.go b/cli/cage/audit/types_test.go index 06544f9..3ce77a3 100644 --- a/cli/cage/audit/types_test.go +++ b/cli/cage/audit/types_test.go @@ -184,7 +184,7 @@ func Test_summaryScanResult(t *testing.T) { }, want: &ScanResultSummary{ ContainerName: "test-container", - Status: "", + Status: "OK", CriticalCount: 0, HighCount: 0, MediumCount: 0, From 74b2585235068f515154081a27d88b75008998af Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 12:32:50 +0900 Subject: [PATCH 17/23] a --- cli/cage/audit/aggregator.go | 22 +++-- cli/cage/audit/command.go | 8 +- cli/cage/audit/command_test.go | 6 +- cli/cage/audit/printer.go | 62 +++++++++----- cli/cage/audit/printer_test.go | 31 +------ logger/color.go | 66 +++++++-------- logger/color_test.go | 142 ++++++++++++++------------------- 7 files changed, 154 insertions(+), 183 deletions(-) diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index 30888bf..2bd53a4 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -8,17 +8,19 @@ import ( ) type aggregater struct { - cves map[string]ecrtypes.ImageScanFinding - cveToSeverity map[string]string + cves map[string]ecrtypes.ImageScanFinding + cveToSeverity map[string]string + cveToContainers map[string][]string // container name to summaries summaries map[string][]*ScanResultSummary } func NewAggregater() *aggregater { return &aggregater{ - cves: make(map[string]ecrtypes.ImageScanFinding), - cveToSeverity: make(map[string]string), - summaries: make(map[string][]*ScanResultSummary)} + cves: make(map[string]ecrtypes.ImageScanFinding), + cveToSeverity: make(map[string]string), + cveToContainers: make(map[string][]string), + summaries: make(map[string][]*ScanResultSummary)} } func (a *aggregater) Add(r *ScanResult) { @@ -42,6 +44,7 @@ func (a *aggregater) Add(r *ScanResult) { if _, exists := a.cves[*f.Name]; !exists { a.cves[*f.Name] = f a.cveToSeverity[*f.Name] = string(f.Severity) + a.cveToContainers[*f.Name] = append(a.cveToContainers[*f.Name], container) } } } @@ -129,8 +132,15 @@ func (a *aggregater) filterCvesBySeverity(severity ecrtypes.FindingSeverity) []e return cves } +func (a *aggregater) GetVulnContainers(cveName string) []string { + containersSet, exists := a.cveToContainers[cveName] + if !exists { + return []string{} + } + return containersSet +} + type severityPrinter struct { - noColor bool severity ecrtypes.FindingSeverity color logger.Color } diff --git a/cli/cage/audit/command.go b/cli/cage/audit/command.go index 7a4fe97..31d90b3 100644 --- a/cli/cage/audit/command.go +++ b/cli/cage/audit/command.go @@ -36,15 +36,11 @@ func (a *command) Run(ctx context.Context, cluster, service string) error { go func() { defer close(errchannel) results, err := scanner.Scan(ctx, cluster, service) - printer := &Printer{ - Logger: l, - NoColor: a.app.NoColor, - LogDetail: a.logDetail, - } + printer := NewPrinter(l, a.app.NoColor, a.logDetail) + l.Printf("\r") // clear spinner line if err != nil { errchannel <- err } else { - l.Printf("\r") // clear spinner line printer.Print(results) } }() diff --git a/cli/cage/audit/command_test.go b/cli/cage/audit/command_test.go index c9a3426..108c79b 100644 --- a/cli/cage/audit/command_test.go +++ b/cli/cage/audit/command_test.go @@ -36,7 +36,7 @@ func TestAuditCommandRun(t *testing.T) { mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(nil, test.Err) - app := &cageapp.App{NoColor: false} + app := &cageapp.App{} cmd := audit.NewCommand(mockDI, app, false) err := cmd.Run(ctx, "cluster", "service") @@ -56,7 +56,7 @@ func TestAuditCommandRun(t *testing.T) { results := []*audit.ScanResult{} mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(results, nil) - app := &cageapp.App{NoColor: false} + app := &cageapp.App{} cmd := audit.NewCommand(mockDI, app, false) err := cmd.Run(ctx, "cluster", "service") @@ -77,7 +77,7 @@ func TestAuditCommandRun(t *testing.T) { return nil, nil }) - app := &cageapp.App{NoColor: false} + app := &cageapp.App{} cmd := audit.NewCommand(mockDI, app, false) err := cmd.Run(ctx, "cluster", "service") diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index dd0af81..a49e0bc 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -8,17 +8,25 @@ import ( "github.com/loilo-inc/canarycage/logger" ) -type Printer struct { - Logger logger.Logger - LogDetail bool - NoColor bool +type printer struct { + logger logger.Logger + color logger.Color + logDetail bool } -func (p *Printer) Print(result []*ScanResult) { +func NewPrinter(l logger.Logger, noColor, logDetail bool) *printer { + return &printer{ + logger: l, + color: logger.Color{NoColor: noColor}, + logDetail: logDetail, + } +} + +func (p *printer) Print(result []*ScanResult) { containerMax, imageMax := MaxHeaderWidth(result) // |container|status|critical|high|medium|low|info|image| headerFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8s|%%-5s|%%-6s|%%-4s|%%-4s|%%-%ds|\n", containerMax, imageMax) - p.Logger.Printf(headerFmt, "CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE") + p.logger.Printf(headerFmt, "CONTAINER", "STATUS", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "IMAGE") bodyFmt := fmt.Sprintf("|%%-%ds|%%-10s|%%-8d|%%-5d|%%-6d|%%-4d|%%-4d|%%-%ds|\n", containerMax, imageMax) agg := NewAggregater() for _, r := range result { @@ -26,7 +34,7 @@ func (p *Printer) Print(result []*ScanResult) { } for _, summaries := range agg.summaries { for _, summary := range summaries { - p.Logger.Printf( + p.logger.Printf( bodyFmt, summary.ContainerName, summary.Status, @@ -39,45 +47,57 @@ func (p *Printer) Print(result []*ScanResult) { ) } } - p.logImageScanFindings("CRITICAL", agg.CriticalCves()) - p.logImageScanFindings("HIGH", agg.HighCves()) - p.logImageScanFindings("MEDIUM", agg.MediumCves()) + p.logImageScanFindings("CRITICAL", agg.CriticalCves(), agg) + p.logImageScanFindings("HIGH", agg.HighCves(), agg) + p.logImageScanFindings("MEDIUM", agg.MediumCves(), agg) total := agg.TotalCVECount() - color := logger.Color{NoColor: p.NoColor} + color := p.color if total == 0 { - p.Logger.Printf("%s\n", color.Greenf("No CVEs found")) + p.logger.Printf("%s\n", color.Greenf("No CVEs found")) return } summary := agg.SummarizeTotal() highest := &severityPrinter{ severity: summary.HighestSeverity, + color: p.color, } var list []string for _, v := range summary.SeverityCounts() { if v.Count == 0 { continue } - sp := &severityPrinter{severity: v.Severity} + sp := &severityPrinter{severity: v.Severity, color: p.color} list = append(list, fmt.Sprintf("%d %s", v.Count, sp.BSprintf("%s", v.Severity))) } - p.Logger.Printf( - "Total: %s (%s)\n", + p.logger.Printf( + "\nTotal: %s (%s)\n", highest.BSprintf("%d", summary.TotalCount), strings.Join(list, ", "), ) } -func (p *Printer) logImageScanFindings(serverity ecrtypes.FindingSeverity, findings []ecrtypes.ImageScanFinding) { +func (p *printer) logImageScanFindings( + serverity ecrtypes.FindingSeverity, + findings []ecrtypes.ImageScanFinding, + aggregater *aggregater, +) { if len(findings) == 0 { return } - sp := &severityPrinter{severity: serverity} - p.Logger.Printf("=== %s ===\n", sp.BSprintf("%s", serverity)) + sp := &severityPrinter{severity: serverity, color: p.color} + color := p.color + p.logger.Printf("\n=== %s ===\n", sp.BSprintf("%s", serverity)) for _, cve := range findings { - p.Logger.Printf("- %s (%s)\n", *cve.Name, *cve.Uri) - if p.LogDetail && cve.Description != nil { - p.Logger.Printf("%s\n", *cve.Description) + containers := aggregater.GetVulnContainers(*cve.Name) + var containerList []string + for _, c := range containers { + containerList = append(containerList, color.Boldf("%s", c)) + } + p.logger.Printf("- %s \n", strings.Join(containerList, ", ")) + p.logger.Printf(" %s (%s)\n", *cve.Name, *cve.Uri) + if p.logDetail { + p.logger.Printf("\n%s\n", *cve.Description) } } } diff --git a/cli/cage/audit/printer_test.go b/cli/cage/audit/printer_test.go index 673b511..4fd803c 100644 --- a/cli/cage/audit/printer_test.go +++ b/cli/cage/audit/printer_test.go @@ -16,13 +16,6 @@ func (m *mockLogger) Printf(format string, args ...any) { m.logs = append(m.logs, fmt.Sprintf(format, args...)) } -var imageInfo = &ImageInfo{ - ContainerName: "test-container", - Registry: "test-registry", - Repository: "test-repo", - Tag: "latest", -} - func makeScanResult( list ...ecrtypes.FindingSeverity) []*ScanResult { return []*ScanResult{ @@ -56,11 +49,7 @@ func makeFindings(severities []ecrtypes.FindingSeverity) []ecrtypes.ImageScanFin func TestPrinter_Print(t *testing.T) { t.Run("prints no CVEs message when no findings", func(t *testing.T) { logger := &mockLogger{} - printer := &Printer{ - Logger: logger, - LogDetail: false, - NoColor: true, - } + printer := NewPrinter(logger, true, false) result := makeScanResult() printer.Print(result) @@ -80,11 +69,7 @@ func TestPrinter_Print(t *testing.T) { t.Run("prints table header", func(t *testing.T) { logger := &mockLogger{} - printer := &Printer{ - Logger: logger, - LogDetail: false, - NoColor: true, - } + printer := NewPrinter(logger, true, false) result := makeScanResult(ecrtypes.FindingSeverityCritical) printer.Print(result) @@ -104,11 +89,7 @@ func TestPrinter_Print(t *testing.T) { t.Run("prints findings by severity", func(t *testing.T) { logger := &mockLogger{} - printer := &Printer{ - Logger: logger, - LogDetail: false, - NoColor: true, - } + printer := NewPrinter(logger, true, false) result := makeScanResult( ecrtypes.FindingSeverityCritical, @@ -145,11 +126,7 @@ func TestPrinter_Print(t *testing.T) { t.Run("prints total summary with counts", func(t *testing.T) { logger := &mockLogger{} - printer := &Printer{ - Logger: logger, - LogDetail: false, - NoColor: true, - } + printer := NewPrinter(logger, true, false) result := makeScanResult( ecrtypes.FindingSeverityCritical, diff --git a/logger/color.go b/logger/color.go index bc0682f..118703d 100644 --- a/logger/color.go +++ b/logger/color.go @@ -6,54 +6,44 @@ type Color struct { NoColor bool } -func (c *Color) Redf(s string, args ...interface{}) string { +func (c *Color) sprintf(prefix, s, suffix string, args ...any) string { if c.NoColor { - return s + return fmt.Sprintf(s, args...) } - return "\033[31m" + fmt.Sprintf(s, args...) + "\033[0m" + return prefix + fmt.Sprintf(s, args...) + suffix } -func (c *Color) Greenf(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[32m" + fmt.Sprintf(s, args...) + "\033[0m" +func (c *Color) Red(s string) string { + return c.Redf("%s", s) +} +func (c *Color) Redf(s string, args ...any) string { + return c.sprintf("\033[31m", s, "\033[0m", args...) } -func (c *Color) Yellowf(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[33m" + fmt.Sprintf(s, args...) + "\033[0m" +func (c *Color) Green(s string) string { + return c.Greenf("%s", s) } -func (c *Color) Bluef(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[34m" + fmt.Sprintf(s, args...) + "\033[0m" +func (c *Color) Greenf(s string, args ...any) string { + return c.sprintf("\033[32m", s, "\033[0m", args...) } -func (c *Color) Magentaf(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[35m" + fmt.Sprintf(s, args...) + "\033[0m" + +func (c *Color) Yellow(s string) string { + return c.Yellowf("%s", s) } -func (c *Color) Cyanf(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[36m" + fmt.Sprintf(s, args...) + "\033[0m" +func (c *Color) Yellowf(s string, args ...any) string { + return c.sprintf("\033[33m", s, "\033[0m", args...) } -func (c *Color) Whitef(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[37m" + fmt.Sprintf(s, args...) + "\033[0m" + +func (c *Color) Magenta(s string) string { + return c.Magentaf("%s", s) +} +func (c *Color) Magentaf(s string, args ...any) string { + return c.sprintf("\033[35m", s, "\033[0m", args...) } -func (c *Color) Boldf(s string, args ...interface{}) string { - if c.NoColor { - return s - } - return "\033[1m" + fmt.Sprintf(s, args...) + "\033[0m" +func (c *Color) Bold(s string) string { + return c.Boldf("%s", s) +} +func (c *Color) Boldf(s string, args ...any) string { + return c.sprintf("\033[1m", s, "\033[0m", args...) } diff --git a/logger/color_test.go b/logger/color_test.go index bcf67ba..c15924a 100644 --- a/logger/color_test.go +++ b/logger/color_test.go @@ -2,130 +2,108 @@ package logger import "testing" -func TestColor_Redf(t *testing.T) { +func TestColor_Red(t *testing.T) { c := &Color{NoColor: false} - result := c.Redf("test %s", "message") - expected := "\033[31mtest message\033[0m" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Redf("test %s", "message") - expected = "test %s" + result := c.Red("error") + expected := "\033[31merror\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Red() = %q, want %q", result, expected) } } -func TestColor_Greenf(t *testing.T) { +func TestColor_Redf(t *testing.T) { c := &Color{NoColor: false} - result := c.Greenf("test %s", "message") - expected := "\033[32mtest message\033[0m" + result := c.Redf("error: %s", "message") + expected := "\033[31merror: message\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Greenf("test %s", "message") - expected = "test %s" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Redf() = %q, want %q", result, expected) } } -func TestColor_Yellowf(t *testing.T) { +func TestColor_Green(t *testing.T) { c := &Color{NoColor: false} - result := c.Yellowf("test %s", "message") - expected := "\033[33mtest message\033[0m" + result := c.Green("success") + expected := "\033[32msuccess\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Yellowf("test %s", "message") - expected = "test %s" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Green() = %q, want %q", result, expected) } } -func TestColor_Bluef(t *testing.T) { +func TestColor_Greenf(t *testing.T) { c := &Color{NoColor: false} - result := c.Bluef("test %s", "message") - expected := "\033[34mtest message\033[0m" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Bluef("test %s", "message") - expected = "test %s" + result := c.Greenf("success: %d", 100) + expected := "\033[32msuccess: 100\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Greenf() = %q, want %q", result, expected) } } -func TestColor_Magentaf(t *testing.T) { +func TestColor_Yellow(t *testing.T) { c := &Color{NoColor: false} - result := c.Magentaf("test %s", "message") - expected := "\033[35mtest message\033[0m" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Magentaf("test %s", "message") - expected = "test %s" + result := c.Yellow("warning") + expected := "\033[33mwarning\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Yellow() = %q, want %q", result, expected) } } -func TestColor_Cyanf(t *testing.T) { +func TestColor_Yellowf(t *testing.T) { c := &Color{NoColor: false} - result := c.Cyanf("test %s", "message") - expected := "\033[36mtest message\033[0m" + result := c.Yellowf("warning: %s", "test") + expected := "\033[33mwarning: test\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } - - c.NoColor = true - result = c.Cyanf("test %s", "message") - expected = "test %s" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Yellowf() = %q, want %q", result, expected) } } -func TestColor_Whitef(t *testing.T) { +func TestColor_Magenta(t *testing.T) { c := &Color{NoColor: false} - result := c.Whitef("test %s", "message") - expected := "\033[37mtest message\033[0m" + result := c.Magenta("info") + expected := "\033[35minfo\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Magenta() = %q, want %q", result, expected) } +} - c.NoColor = true - result = c.Whitef("test %s", "message") - expected = "test %s" +func TestColor_Magentaf(t *testing.T) { + c := &Color{NoColor: false} + result := c.Magentaf("info: %v", true) + expected := "\033[35minfo: true\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Magentaf() = %q, want %q", result, expected) } } func TestColor_Boldf(t *testing.T) { c := &Color{NoColor: false} - result := c.Boldf("test %s", "message") - expected := "\033[1mtest message\033[0m" + result := c.Boldf("bold: %s", "text") + expected := "\033[1mbold: text\033[0m" if result != expected { - t.Errorf("expected %q, got %q", expected, result) + t.Errorf("Boldf() = %q, want %q", result, expected) } +} - c.NoColor = true - result = c.Boldf("test %s", "message") - expected = "test %s" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) +func TestColor_NoColor(t *testing.T) { + c := &Color{NoColor: true} + tests := []struct { + name string + fn func() string + expected string + }{ + {"Red", func() string { return c.Red("text") }, "text"}, + {"Redf", func() string { return c.Redf("text: %s", "arg") }, "text: arg"}, + {"Green", func() string { return c.Green("text") }, "text"}, + {"Yellow", func() string { return c.Yellow("text") }, "text"}, + {"Magenta", func() string { return c.Magenta("text") }, "text"}, + {"Boldf", func() string { return c.Boldf("text: %d", 42) }, "text: 42"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.fn() + if result != tt.expected { + t.Errorf("%s with NoColor = %q, want %q", tt.name, result, tt.expected) + } + }) } } From 7a31f5a7f5e43a3ef093741a0c8340b5997e6ece Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 20:17:08 +0900 Subject: [PATCH 18/23] a --- Makefile | 2 + awsiface/conf.go | 17 ++ awsiface/conf_test.go | 59 +++++ cli/cage/audit/aggregator.go | 2 +- cli/cage/audit/command.go | 16 +- cli/cage/audit/command_test.go | 27 ++- cli/cage/audit/deps.go | 16 +- cli/cage/audit/deps_test.go | 57 ++--- cli/cage/audit/printer.go | 2 +- cli/cage/cageapp/types.go | 19 ++ cli/cage/commands/audit.go | 32 +-- cli/cage/commands/audit_test.go | 85 ++----- cli/cage/commands/command.go | 11 +- cli/cage/commands/command_test.go | 7 +- cli/cage/commands/provider.go | 10 +- cli/cage/commands/provider_test.go | 7 +- cli/cage/commands/tools_test.go | 3 +- cli/cage/main.go | 2 +- coverage-cli.txt | 350 +++++++++++++++++++++++++++++ logger/color_test.go | 9 + mocks/mock_types/iface.go | 38 ++++ types/iface.go | 4 + 22 files changed, 618 insertions(+), 157 deletions(-) create mode 100644 awsiface/conf.go create mode 100644 awsiface/conf_test.go create mode 100644 cli/cage/cageapp/types.go create mode 100644 coverage-cli.txt diff --git a/Makefile b/Makefile index 22d5976..b68388a 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ MOCKGEN := go run go.uber.org/mock/mockgen@v0.6.0 .PHONY: test test: go test ./... -coverprofile=coverage.txt -covermode=count +test/cli: + go test ./cli/... -coverprofile=coverage-cli.txt -covermode=count test-container: docker build -t canarycage/test-container test-container push-test-container: test-container diff --git a/awsiface/conf.go b/awsiface/conf.go new file mode 100644 index 0000000..ef8c931 --- /dev/null +++ b/awsiface/conf.go @@ -0,0 +1,17 @@ +package awsiface + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" +) + +// coverage cheat: always use MustLoadConfig to avoid error handling repetition +func MustLoadConfig(ctx context.Context, opts ...func(*config.LoadOptions) error) aws.Config { + cfg, err := config.LoadDefaultConfig(ctx, opts...) + if err != nil { + panic(err) + } + return cfg +} diff --git a/awsiface/conf_test.go b/awsiface/conf_test.go new file mode 100644 index 0000000..fd75907 --- /dev/null +++ b/awsiface/conf_test.go @@ -0,0 +1,59 @@ +package awsiface + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" +) + +func TestMustLoadConfig_Success(t *testing.T) { + ctx := context.Background() + + // This should not panic in normal circumstances + defer func() { + if r := recover(); r != nil { + t.Errorf("MustLoadConfig panicked unexpectedly: %v", r) + } + }() + + cfg := MustLoadConfig(ctx) + + if cfg.Region == "" && cfg.Credentials == nil { + t.Log("Config loaded (region or credentials may be empty in test environment)") + } +} + +func TestMustLoadConfig_WithOptions(t *testing.T) { + ctx := context.Background() + + defer func() { + if r := recover(); r != nil { + t.Errorf("MustLoadConfig with options panicked unexpectedly: %v", r) + } + }() + + cfg := MustLoadConfig(ctx, config.WithRegion("us-west-2")) + + if cfg.Region != "us-west-2" { + t.Errorf("Expected region us-west-2, got %s", cfg.Region) + } +} + +func TestMustLoadConfig_Panic(t *testing.T) { + ctx := context.Background() + + defer func() { + if r := recover(); r == nil { + t.Error("Expected MustLoadConfig to panic with invalid option, but it didn't") + } + }() + + // Pass an option that returns an error to trigger panic + invalidOpt := func(*config.LoadOptions) error { + return errors.New("forced error") + } + + MustLoadConfig(ctx, invalidOpt) +} diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index 2bd53a4..b027914 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -159,5 +159,5 @@ func (s *severityPrinter) Sprintf(format string, a ...any) string { } func (s *severityPrinter) BSprintf(format string, a ...any) string { - return s.color.Boldf("%s", s.Sprintf(format, a...)) + return s.color.Bold(s.Sprintf(format, a...)) } diff --git a/cli/cage/audit/command.go b/cli/cage/audit/command.go index 31d90b3..01be13c 100644 --- a/cli/cage/audit/command.go +++ b/cli/cage/audit/command.go @@ -13,21 +13,19 @@ import ( type command struct { di *di.D - app *cageapp.App - logDetail bool + input *cageapp.AuditCmdInput spinInterval time.Duration } -func NewCommand(di *di.D, app *cageapp.App, logDetail bool) *command { +func NewCommand(di *di.D, input *cageapp.AuditCmdInput) *command { return &command{ di: di, - app: app, - logDetail: logDetail, + input: input, spinInterval: 100 * time.Millisecond, } } -func (a *command) Run(ctx context.Context, cluster, service string) error { +func (a *command) Run(ctx context.Context) error { t := a.di.Get(key.Time).(types.Time) l := a.di.Get(key.Logger).(logger.Logger) scanner := a.di.Get(key.Scanner).(Scanner) @@ -35,8 +33,8 @@ func (a *command) Run(ctx context.Context, cluster, service string) error { errchannel := make(chan error, 1) go func() { defer close(errchannel) - results, err := scanner.Scan(ctx, cluster, service) - printer := NewPrinter(l, a.app.NoColor, a.logDetail) + results, err := scanner.Scan(ctx, a.input.Cluster, a.input.Service) + printer := NewPrinter(l, a.input.App.NoColor, a.input.LogDetail) l.Printf("\r") // clear spinner line if err != nil { errchannel <- err @@ -54,7 +52,7 @@ func (a *command) Run(ctx context.Context, cluster, service string) error { case <-timer.C: l.Printf( "\r%s Scanning ECR image vulnerabilities for ECS service %s/%s", - spinner.Next(), cluster, service, + spinner.Next(), a.input.Cluster, a.input.Service, ) } } diff --git a/cli/cage/audit/command_test.go b/cli/cage/audit/command_test.go index 108c79b..8798819 100644 --- a/cli/cage/audit/command_test.go +++ b/cli/cage/audit/command_test.go @@ -36,10 +36,13 @@ func TestAuditCommandRun(t *testing.T) { mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(nil, test.Err) - app := &cageapp.App{} - cmd := audit.NewCommand(mockDI, app, false) + cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ + Cluster: "cluster", + Service: "service", + App: &cageapp.App{}, + }) - err := cmd.Run(ctx, "cluster", "service") + err := cmd.Run(ctx) assert.EqualError(t, err, "error") }) @@ -56,10 +59,13 @@ func TestAuditCommandRun(t *testing.T) { results := []*audit.ScanResult{} mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(results, nil) - app := &cageapp.App{} - cmd := audit.NewCommand(mockDI, app, false) + cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ + Cluster: "cluster", + Service: "service", + App: &cageapp.App{}, + }) - err := cmd.Run(ctx, "cluster", "service") + err := cmd.Run(ctx) assert.NoError(t, err) }) @@ -77,10 +83,13 @@ func TestAuditCommandRun(t *testing.T) { return nil, nil }) - app := &cageapp.App{} - cmd := audit.NewCommand(mockDI, app, false) + cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ + Cluster: "cluster", + Service: "service", + App: &cageapp.App{}, + }) - err := cmd.Run(ctx, "cluster", "service") + err := cmd.Run(ctx) assert.Equal(t, context.Canceled, err) }) } diff --git a/cli/cage/audit/deps.go b/cli/cage/audit/deps.go index 1b6e85f..aa1a623 100644 --- a/cli/cage/audit/deps.go +++ b/cli/cage/audit/deps.go @@ -7,20 +7,20 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/loilo-inc/canarycage/awsiface" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/key" "github.com/loilo-inc/canarycage/logger" "github.com/loilo-inc/canarycage/timeout" + "github.com/loilo-inc/canarycage/types" "github.com/loilo-inc/logos/di" ) -func ProvideAuditDI(region string) (*di.D, error) { - conf, err := config.LoadDefaultConfig( - context.Background(), - config.WithRegion(region), +func ProvideAuditCmd(ctx context.Context, input *cageapp.AuditCmdInput) (types.Audit, error) { + conf := awsiface.MustLoadConfig( + ctx, + config.WithRegion(input.Region), ) - if err != nil { - return nil, err - } d := di.NewDomain(func(b *di.B) { ecsCli := ecs.NewFromConfig(conf) ecrCli := ecr.NewFromConfig(conf) @@ -28,5 +28,5 @@ func ProvideAuditDI(region string) (*di.D, error) { b.Set(key.Logger, logger.DefaultLogger(os.Stdout)) b.Set(key.Time, &timeout.Time{}) }) - return d, nil + return NewCommand(d, input), nil } diff --git a/cli/cage/audit/deps_test.go b/cli/cage/audit/deps_test.go index d24c049..cbad420 100644 --- a/cli/cage/audit/deps_test.go +++ b/cli/cage/audit/deps_test.go @@ -1,41 +1,46 @@ package audit import ( + "context" "testing" - "github.com/stretchr/testify/assert" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" ) -func TestProvideAuditDI(t *testing.T) { - t.Run("successfully creates scan DI with valid region", func(t *testing.T) { - region := "us-east-1" +func TestProvideAuditCmd(t *testing.T) { + ctx := context.Background() + input := &cageapp.AuditCmdInput{ + Region: "us-west-2", + } - d, err := ProvideAuditDI(region) - assert.NoError(t, err) - assert.NotNil(t, d) - }) + audit, err := ProvideAuditCmd(ctx, input) + if err != nil { + t.Fatalf("ProvideAuditCmd() error = %v, want nil", err) + } - t.Run("returns error with invalid region", func(t *testing.T) { - region := "" + if audit == nil { + t.Fatal("ProvideAuditCmd() returned nil audit") + } +} - d, err := ProvideAuditDI(region) - if err != nil { - assert.Nil(t, d, "expected DI domain to be nil when error occurs") - return - } - assert.NotNil(t, d, "expected DI domain to be non-nil when no error") - }) +func TestProvideAuditCmd_WithDifferentRegions(t *testing.T) { + regions := []string{"us-east-1", "eu-west-1", "ap-northeast-1"} - t.Run("creates DI domain with different regions", func(t *testing.T) { - regions := []string{"us-west-2", "eu-west-1", "ap-northeast-1"} + for _, region := range regions { + t.Run(region, func(t *testing.T) { + ctx := context.Background() + input := &cageapp.AuditCmdInput{ + Region: region, + } - for _, region := range regions { - d, err := ProvideAuditDI(region) + audit, err := ProvideAuditCmd(ctx, input) if err != nil { - t.Logf("region %s returned error: %v", region, err) - continue + t.Fatalf("ProvideAuditCmd() error = %v, want nil", err) + } + + if audit == nil { + t.Fatalf("ProvideAuditCmd() returned nil audit for region %s", region) } - assert.NotNil(t, d, "expected DI domain to be non-nil for region %s", region) - } - }) + }) + } } diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index a49e0bc..1659ffb 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -92,7 +92,7 @@ func (p *printer) logImageScanFindings( containers := aggregater.GetVulnContainers(*cve.Name) var containerList []string for _, c := range containers { - containerList = append(containerList, color.Boldf("%s", c)) + containerList = append(containerList, color.Bold(c)) } p.logger.Printf("- %s \n", strings.Join(containerList, ", ")) p.logger.Printf(" %s (%s)\n", *cve.Name, *cve.Uri) diff --git a/cli/cage/cageapp/types.go b/cli/cage/cageapp/types.go new file mode 100644 index 0000000..04d05d6 --- /dev/null +++ b/cli/cage/cageapp/types.go @@ -0,0 +1,19 @@ +package cageapp + +import ( + "context" + + "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/types" +) + +type CageCmdProvider = func(ctx context.Context, e *env.Envars) (types.Cage, error) + +type AuditCmdInput struct { + App *App + Region string + Cluster string + Service string + LogDetail bool +} +type AuditCmdProvider = func(ctx context.Context, input *AuditCmdInput) (types.Audit, error) diff --git a/cli/cage/commands/audit.go b/cli/cage/commands/audit.go index a25cbaa..e572236 100644 --- a/cli/cage/commands/audit.go +++ b/cli/cage/commands/audit.go @@ -3,33 +3,26 @@ package commands import ( "errors" - "github.com/loilo-inc/canarycage/cli/cage/audit" "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/env" - "github.com/loilo-inc/logos/di" "github.com/urfave/cli/v2" ) -type diProvider = func(region string) (*di.D, error) - -func Audit(app *cageapp.App, diProvider diProvider) *cli.Command { - var region string - var cluster string - var service string - var logDetail bool +func Audit(provider cageapp.AuditCmdProvider) *cli.Command { + var input cageapp.AuditCmdInput return &cli.Command{ Name: "audit", Usage: "Audit container images used in an ECS service", ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ - cageapp.RegionFlag(®ion), - cageapp.ClusterFlag(&cluster), - cageapp.ServiceFlag(&service), + cageapp.RegionFlag(&input.Region), + cageapp.ClusterFlag(&input.Cluster), + cageapp.ServiceFlag(&input.Service), &cli.BoolFlag{ Name: "detail", Usage: "By default, only the name and URI of the finding are logged.", Value: false, - Destination: &logDetail, + Destination: &input.LogDetail, }, }, Action: func(ctx *cli.Context) error { @@ -37,7 +30,7 @@ func Audit(app *cageapp.App, diProvider diProvider) *cli.Command { if err != nil { return err } - if region == "" { + if input.Region == "" { return errors.New("--region flag is required") } if dir != "" { @@ -48,17 +41,16 @@ func Audit(app *cageapp.App, diProvider diProvider) *cli.Command { if srv.ServiceName == nil || srv.Cluster == nil { return errors.New("service.json must contain ServiceName and Cluster") } - service = *srv.ServiceName - cluster = *srv.Cluster - } else if cluster == "" || service == "" { + input.Service = *srv.ServiceName + input.Cluster = *srv.Cluster + } else if input.Cluster == "" || input.Service == "" { return errors.New("either directory argument or both --cluster and --service flags must be provided") } - di, err := diProvider(region) + cmd, err := provider(ctx.Context, &input) if err != nil { return err } - cmd := audit.NewCommand(di, app, logDetail) - return cmd.Run(ctx.Context, cluster, service) + return cmd.Run(ctx.Context) }, } } diff --git a/cli/cage/commands/audit_test.go b/cli/cage/commands/audit_test.go index aa48bba..52c527e 100644 --- a/cli/cage/commands/audit_test.go +++ b/cli/cage/commands/audit_test.go @@ -1,32 +1,19 @@ package commands import ( + "context" "errors" "testing" - "github.com/aws/aws-sdk-go-v2/aws" - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - - "github.com/loilo-inc/canarycage/cli/cage/audit" "github.com/loilo-inc/canarycage/cli/cage/cageapp" - "github.com/loilo-inc/canarycage/key" - "github.com/loilo-inc/canarycage/mocks/mock_audit" - "github.com/loilo-inc/canarycage/mocks/mock_logger" - "github.com/loilo-inc/canarycage/test" - "github.com/loilo-inc/logos/di" + "github.com/loilo-inc/canarycage/mocks/mock_types" + "github.com/loilo-inc/canarycage/types" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v2" "go.uber.org/mock/gomock" ) func TestAudit(t *testing.T) { - makeMockLogger := func(ctrl *gomock.Controller) *mock_logger.MockLogger { - mockLogger := mock_logger.NewMockLogger(ctrl) - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - return mockLogger - } t.Run("returns error when region is missing", func(t *testing.T) { app := setupAuditApp(t, nil) err := app.Run([]string{"cage", "audit", "--region", ""}) @@ -65,7 +52,8 @@ func TestAudit(t *testing.T) { t.Run("returns error when diProvider fails", func(t *testing.T) { expectedErr := errors.New("di provider error") - app := setupAuditApp(t, func(region string) (*di.D, error) { + app := setupAuditApp(t, func(ctx context.Context, input *cageapp.AuditCmdInput) (types.Audit, error) { + assert.Equal(t, "us-east-1", input.Region) return nil, expectedErr }) @@ -75,30 +63,24 @@ func TestAudit(t *testing.T) { assert.Error(t, err) assert.Equal(t, expectedErr, err) }) - setupBase := func(t *testing.T) (*cli.App, *mock_audit.MockScanner) { + setupBase := func(t *testing.T) (*cli.App, *mock_types.MockAudit) { t.Helper() ctrl := gomock.NewController(t) - mockScanner := mock_audit.NewMockScanner(ctrl) - mockLogger := makeMockLogger(ctrl) - d := di.NewDomain(func(b *di.B) { - b.Set(key.Scanner, mockScanner) - b.Set(key.Logger, mockLogger) - b.Set(key.Time, test.NewFakeNeverTimer()) - }) + mockAudit := mock_types.NewMockAudit(ctrl) - app := setupAuditApp(t, func(region string) (*di.D, error) { - assert.Equal(t, "us-east-1", region) - return d, nil + app := setupAuditApp(t, func(ctx context.Context, input *cageapp.AuditCmdInput) (types.Audit, error) { + assert.Equal(t, "us-east-1", input.Region) + return mockAudit, nil }) - return app, mockScanner + return app, mockAudit } t.Run("Succcess", func(t *testing.T) { setup := func(t *testing.T) *cli.App { t.Helper() - app, mockScanner := setupBase(t) - mockScanner.EXPECT(). - Scan(gomock.Any(), "cluster", "service"). // from fixtures/service.json - Return(makeScanResult(), nil) + app, mockAudit := setupBase(t) + mockAudit.EXPECT(). + Run(gomock.Any()). + Return(nil) return app } t.Run("executes scan with directory argument", func(t *testing.T) { @@ -118,10 +100,10 @@ func TestAudit(t *testing.T) { }) t.Run("Error", func(t *testing.T) { t.Run("error on scanner.Scan()", func(t *testing.T) { - app, mockScanner := setupBase(t) - mockScanner.EXPECT(). - Scan(gomock.Any(), "cluster", "service"). - Return(nil, errors.New("scan error")) + app, mockAudit := setupBase(t) + mockAudit.EXPECT(). + Run(gomock.Any()). + Return(errors.New("scan error")) err := app.Run([]string{"cage", "audit", "--region", "us-east-1", @@ -140,37 +122,12 @@ func TestAudit(t *testing.T) { }) } -func setupAuditApp(t *testing.T, diProvider func(region string) (*di.D, error)) *cli.App { +func setupAuditApp(t *testing.T, provider cageapp.AuditCmdProvider) *cli.App { t.Helper() - conf := &cageapp.App{} app := cli.NewApp() app.Name = "cage" app.Commands = []*cli.Command{ - Audit(conf, diProvider), + Audit(provider), } return app } - -func makeScanResult() []*audit.ScanResult { - return []*audit.ScanResult{ - { - ImageInfo: &audit.ImageInfo{ - Repository: "test-repo", - Tag: "latest", - Registry: "dockerhub.io", - ContainerName: "web-app", - PlatformArch: "amd64", - }, - ImageScanFindings: &ecrtypes.ImageScanFindings{ - Findings: []ecrtypes.ImageScanFinding{ - { - Name: aws.String("CVE-2023-1234"), - Description: aws.String("Test vulnerability description"), - Uri: aws.String("https://example.com/CVE-2023-1234"), - Severity: ecrtypes.FindingSeverityHigh, - }, - }, - }, - }, - } -} diff --git a/cli/cage/commands/command.go b/cli/cage/commands/command.go index 48f409a..41e578c 100644 --- a/cli/cage/commands/command.go +++ b/cli/cage/commands/command.go @@ -1,7 +1,10 @@ package commands import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" @@ -9,18 +12,16 @@ import ( ) type CageCommands struct { - cageCliProvider cageCliProvider + cageCliProvider cageapp.CageCmdProvider } func NewCageCommands( - cageCliProvider cageCliProvider, + cageCliProvider cageapp.CageCmdProvider, ) *CageCommands { cmds := &CageCommands{cageCliProvider: cageCliProvider} return cmds } -type cageCliProvider = func(e *env.Envars) (types.Cage, error) - func RequireArgs( ctx *cli.Context, minArgs int, @@ -63,7 +64,7 @@ func (c *CageCommands) setupCage( if err := env.EnsureEnvars(envars); err != nil { return nil, err } - cagecli, err := c.cageCliProvider(envars) + cagecli, err := c.cageCliProvider(context.TODO(), envars) if err != nil { return nil, err } diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 261ea69..8cf213e 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -1,6 +1,7 @@ package commands import ( + "context" "testing" "github.com/loilo-inc/canarycage/env" @@ -15,7 +16,7 @@ func TestSetupCage(t *testing.T) { t.Run("basic", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2"} cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { return cageCli, nil }) v, err := cmd.setupCage(envars, "../../../fixtures") @@ -31,7 +32,7 @@ func TestSetupCage(t *testing.T) { t.Run("should skip load task definition if --taskDefinitionArn provided", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2", TaskDefinitionArn: "arn"} cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { return cageCli, nil }) v, err := cmd.setupCage(envars, "../../../fixtures") @@ -46,7 +47,7 @@ func TestSetupCage(t *testing.T) { }) t.Run("should error if error returned from NewCage", func(t *testing.T) { envars := &env.Envars{Region: "us-west-2"} - cmd := NewCageCommands(func(envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { return nil, test.Err }) _, err := cmd.setupCage(envars, "../../../fixtures") diff --git a/cli/cage/commands/provider.go b/cli/cage/commands/provider.go index 97670e2..7ccfde8 100644 --- a/cli/cage/commands/provider.go +++ b/cli/cage/commands/provider.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" cage "github.com/loilo-inc/canarycage" + "github.com/loilo-inc/canarycage/awsiface" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/key" "github.com/loilo-inc/canarycage/task" @@ -17,14 +18,11 @@ import ( "github.com/loilo-inc/logos/di" ) -func ProvideCageCli(envars *env.Envars) (types.Cage, error) { - conf, err := config.LoadDefaultConfig( - context.Background(), +func ProvideCageCli(ctx context.Context, envars *env.Envars) (types.Cage, error) { + conf := awsiface.MustLoadConfig( + ctx, config.WithRegion(envars.Region), ) - if err != nil { - return nil, err - } d := di.NewDomain(func(b *di.B) { b.Set(key.Env, envars) b.Set(key.EcsCli, ecs.NewFromConfig(conf)) diff --git a/cli/cage/commands/provider_test.go b/cli/cage/commands/provider_test.go index aa3f2cb..3251798 100644 --- a/cli/cage/commands/provider_test.go +++ b/cli/cage/commands/provider_test.go @@ -1,6 +1,7 @@ package commands import ( + "context" "testing" "github.com/loilo-inc/canarycage/env" @@ -13,7 +14,7 @@ func TestProvideCageCli(t *testing.T) { Region: "us-east-1", } - cage, err := ProvideCageCli(envars) + cage, err := ProvideCageCli(context.TODO(), envars) assert.NoError(t, err) assert.NotNil(t, cage) }) @@ -23,7 +24,7 @@ func TestProvideCageCli(t *testing.T) { Region: "", } - cage, err := ProvideCageCli(envars) + cage, err := ProvideCageCli(context.TODO(), envars) if err != nil { assert.Nil(t, cage, "expected cage to be nil when error occurs") return @@ -38,7 +39,7 @@ func TestProvideCageCli(t *testing.T) { } }() - cage, err := ProvideCageCli(nil) + cage, err := ProvideCageCli(context.TODO(), nil) if err == nil { assert.NotNil(t, cage, "expected cage to be non-nil when no error") } diff --git a/cli/cage/commands/tools_test.go b/cli/cage/commands/tools_test.go index e41b938..a99a601 100644 --- a/cli/cage/commands/tools_test.go +++ b/cli/cage/commands/tools_test.go @@ -1,6 +1,7 @@ package commands_test import ( + "context" "io" "testing" @@ -21,7 +22,7 @@ func setup(t *testing.T, input io.Reader) (*cli.App, *mock_types.MockCage) { cagecli := mock_types.NewMockCage(ctrl) cageapp := &cageapp.App{Stdin: input} app := cli.NewApp() - cmds := commands.NewCageCommands(func(envars *env.Envars) (types.Cage, error) { + cmds := commands.NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { return cagecli, nil }) app.Commands = []*cli.Command{ diff --git a/cli/cage/main.go b/cli/cage/main.go index 885e47d..408964d 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -33,7 +33,7 @@ func main() { cmds.RollOut(appConf), cmds.Run(appConf), commands.Upgrade(upgrade.NewUpgrader(version)), - commands.Audit(appConf, audit.ProvideAuditDI), + commands.Audit(audit.ProvideAuditCmd), } app.Flags = []cli.Flag{ &cli.BoolFlag{ diff --git a/coverage-cli.txt b/coverage-cli.txt new file mode 100644 index 0000000..07dfa9d --- /dev/null +++ b/coverage-cli.txt @@ -0,0 +1,350 @@ +mode: count +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:17.45,19.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:24.9,26.56 2 15 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:26.56,28.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:28.8,28.40 1 15 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:28.40,30.3 1 4 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:31.2,31.12 1 11 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:36.9,38.2 1 4 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:42.9,44.2 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:49.9,50.59 1 5 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:50.59,52.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:53.2,53.61 1 4 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:53.61,55.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:56.2,56.13 1 3 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:56.13,57.62 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:57.62,59.4 1 0 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:61.2,64.13 4 3 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:64.13,66.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:67.2,67.48 1 3 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:67.48,69.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:70.2,70.12 1 2 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:34.50,36.2 1 5 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:38.44,41.16 3 5 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:41.16,43.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:44.2,47.23 4 5 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:47.23,48.65 1 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:48.65,51.4 2 1 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:54.2,60.45 7 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:60.45,61.43 1 8 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:61.43,63.4 1 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:64.3,64.41 1 8 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:64.41,66.4 1 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:68.2,68.48 1 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:68.48,70.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:71.2,73.16 3 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:73.16,75.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:76.2,78.16 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:78.16,80.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:81.2,83.22 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:83.22,85.17 2 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:85.17,87.4 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:88.3,88.20 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:90.2,90.59 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:90.59,92.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:93.2,94.12 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:97.83,101.16 4 10 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:101.16,103.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:104.2,105.35 2 9 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:105.35,107.17 2 11 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:107.17,108.12 1 1 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:110.3,110.37 1 10 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:110.37,112.9 2 2 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:113.9,113.38 1 8 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:113.38,115.9 2 5 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:118.2,118.26 1 9 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:118.26,120.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:121.2,121.27 1 7 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:127.19,129.16 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:129.16,131.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:132.2,135.16 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:135.16,137.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:138.2,141.74 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:141.74,143.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:145.2,146.41 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:146.41,148.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:150.2,151.16 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:151.16,153.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:154.2,156.16 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:156.16,158.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:159.2,161.16 3 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:161.16,163.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:164.2,165.53 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:165.53,167.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:168.2,168.29 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:171.62,173.16 2 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:173.16,175.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:176.2,178.16 3 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:178.16,180.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:181.2,184.29 4 4 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:184.29,185.17 1 10 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:185.17,186.12 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:188.3,189.22 2 7 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:189.22,191.4 1 1 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:192.3,192.28 1 6 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:194.2,195.9 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:195.9,197.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:198.2,199.16 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:199.16,201.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:202.2,202.17 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:208.9,210.56 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:210.56,212.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:213.2,214.59 2 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:214.59,216.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:217.2,217.59 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:217.59,219.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:220.2,220.47 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:220.47,222.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:223.2,223.51 1 3 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:223.51,225.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:226.2,226.12 1 3 +github.com/loilo-inc/canarycage/cli/cage/main.go:22.13,52.41 11 0 +github.com/loilo-inc/canarycage/cli/cage/main.go:52.41,54.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:18.34,24.2 1 12 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:26.41,28.18 2 7 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:28.18,34.3 2 1 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:34.8,34.39 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:34.39,40.3 2 1 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:41.2,43.49 3 5 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:43.49,44.44 1 8 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:44.44,48.4 3 8 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:62.56,65.26 3 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:65.26,67.19 2 11 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:68.49,69.26 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:70.45,71.22 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:72.47,73.24 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:74.44,75.21 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:76.54,77.22 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:80.2,80.30 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:80.30,82.3 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:82.8,82.33 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:82.33,84.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:84.8,84.35 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:84.35,86.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:86.8,88.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:89.2,91.15 3 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:99.60,107.2 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:109.42,111.2 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:113.65,115.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:117.61,119.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:121.63,123.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:125.106,127.40 2 21 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:127.40,128.30 1 27 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:128.30,130.4 1 9 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:132.2,132.13 1 21 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:135.65,137.13 2 6 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:137.13,139.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:140.2,140.22 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:148.67,149.20 1 21 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:150.40,151.40 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:152.36,153.36 1 5 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:154.38,155.39 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:156.10,157.35 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:161.68,163.2 1 16 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:20.66,26.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:28.50,34.12 6 3 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:34.12,39.17 5 3 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:39.17,41.4 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:41.9,43.4 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:45.2,45.6 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:45.6,47.10 2 3 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:48.21,49.20 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:50.28,51.14 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/command.go:52.18,56.5 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:19.94,24.34 2 4 +github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:24.34,30.3 5 4 +github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:31.2,31.34 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:26.55,28.2 1 13 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:30.117,35.16 2 9 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:35.16,37.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:38.2,38.64 1 8 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:38.64,40.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:42.2,43.88 2 6 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:43.88,45.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:47.2,47.55 1 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:47.55,48.48 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:48.48,49.33 1 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:49.33,50.13 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:52.4,52.79 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:52.79,54.5 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:56.3,56.119 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:59.2,59.71 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:62.150,67.16 2 4 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:67.16,69.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:70.2,70.34 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:70.34,72.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:73.2,73.35 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:98.62,99.14 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:100.15,101.39 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:102.15,103.39 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:104.10,105.12 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:21.55,23.2 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:25.115,30.16 2 11 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:30.16,32.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:33.2,33.69 1 9 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:33.69,35.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:36.2,41.16 3 7 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:41.16,43.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:44.2,45.15 2 6 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:45.15,47.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:49.2,50.75 2 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:50.75,52.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:54.2,54.39 1 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:54.39,56.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:58.2,59.45 2 4 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:59.45,60.40 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:60.40,62.4 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:63.3,70.5 2 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:73.2,73.20 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:82.51,84.21 2 8 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:84.21,87.3 2 5 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:89.2,92.78 4 3 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:95.50,98.34 3 11 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:98.34,101.21 3 9 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:101.21,103.4 1 8 +github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:105.2,105.24 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:17.68,23.2 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:25.47,32.27 6 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:32.27,34.3 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:35.2,35.42 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:35.42,36.37 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:36.37,48.4 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:50.2,55.16 6 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:55.16,58.3 2 3 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:59.2,65.45 4 3 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:65.45,66.19 1 15 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:66.19,67.12 1 9 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:69.3,70.84 2 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:73.2,77.3 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:84.3,85.24 1 18 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:85.24,87.3 1 12 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:88.2,91.31 4 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:91.31,94.32 3 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:94.32,96.4 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:97.3,99.18 3 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:99.18,101.4 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:105.47,107.2 1 16 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:109.58,112.34 3 6 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:112.34,113.63 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:113.63,115.4 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:116.3,117.41 2 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:117.41,119.4 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:121.2,121.31 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:19.73,21.2 1 6 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:27.38,31.87 4 2 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:31.87,33.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:34.2,35.34 2 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:35.34,36.24 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:36.24,38.4 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:38.9,40.4 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:42.2,42.26 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:47.83,48.77 1 4 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:48.77,50.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:50.8,50.90 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:50.90,52.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:52.8,54.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:19.39,21.2 1 10 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:25.49,27.2 1 9 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:35.45,36.24 1 18 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:36.24,38.3 1 18 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:39.2,39.11 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:42.40,43.24 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:43.24,45.3 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:46.2,46.11 1 0 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:60.63,64.38 4 11 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:64.38,65.21 1 22 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:66.19,67.14 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:68.15,69.10 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:70.17,71.12 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:72.14,73.9 1 3 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:74.24,75.10 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:78.2,78.49 1 11 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:78.49,80.3 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:80.8,80.37 1 9 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:80.37,82.3 1 7 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:82.8,82.23 1 2 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:82.23,84.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/audit/types.go:85.2,94.3 1 11 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:17.20,19.2 1 1 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:21.47,29.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:30.48,37.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:38.48,45.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:46.58,53.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:55.57,63.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:65.50,74.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:76.54,85.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:87.50,96.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:98.52,107.2 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:11.60,28.40 2 10 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:28.40,30.18 2 10 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:30.18,32.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:33.4,33.26 1 9 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:33.26,35.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:36.4,36.17 1 8 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:36.17,38.19 2 2 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:38.19,40.6 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:41.5,41.53 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:41.53,43.6 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:44.5,45.33 2 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:46.10,46.57 1 6 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:46.57,48.5 1 3 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:49.4,50.18 2 4 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:50.18,52.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:53.4,53.31 1 3 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:20.17,23.2 2 19 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:29.42,30.26 1 26 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:30.26,32.3 1 3 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:32.8,32.33 1 23 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:32.33,34.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:35.2,37.8 3 22 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:43.23,46.60 3 16 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:46.60,48.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:48.8,50.3 1 16 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:51.2,51.36 1 16 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:51.36,52.57 1 15 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:52.57,54.4 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:54.9,56.4 1 15 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:58.2,64.49 2 16 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:64.49,66.3 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:67.2,68.16 2 16 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:68.16,70.3 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/command.go:71.2,71.21 1 15 +github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:21.82,26.34 2 3 +github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:26.34,34.3 7 2 +github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:35.2,36.21 2 2 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:14.63,46.40 3 16 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:46.40,48.18 2 6 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:48.18,50.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:51.4,52.18 2 5 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:52.18,54.5 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:55.4,55.15 1 5 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:55.15,57.59 2 4 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:57.59,59.6 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:61.4,62.18 2 4 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:62.18,63.31 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:63.31,65.6 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:65.11,67.6 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:68.5,68.15 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:70.4,71.14 2 3 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:15.59,29.40 2 16 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:29.40,31.18 2 5 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:31.18,33.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:34.4,35.18 2 4 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:35.18,37.5 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:38.4,38.15 1 4 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:38.15,40.56 2 3 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:40.56,42.6 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:44.4,54.19 3 3 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:54.19,56.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/run.go:57.4,58.14 2 2 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:12.58,28.40 2 16 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:28.40,30.18 2 5 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:30.18,32.5 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:33.4,34.18 2 4 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:34.18,36.5 1 0 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:37.4,37.15 1 4 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:37.15,39.59 2 3 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:39.59,41.6 1 1 +github.com/loilo-inc/canarycage/cli/cage/commands/up.go:43.4,44.14 2 3 +github.com/loilo-inc/canarycage/cli/cage/commands/upgrade.go:8.54,20.40 2 2 +github.com/loilo-inc/canarycage/cli/cage/commands/upgrade.go:20.40,24.4 1 2 diff --git a/logger/color_test.go b/logger/color_test.go index c15924a..7d9553f 100644 --- a/logger/color_test.go +++ b/logger/color_test.go @@ -74,6 +74,15 @@ func TestColor_Magentaf(t *testing.T) { } } +func TestColor_Bold(t *testing.T) { + c := &Color{NoColor: false} + result := c.Bold("bold") + expected := "\033[1mbold\033[0m" + if result != expected { + t.Errorf("Bold() = %q, want %q", result, expected) + } +} + func TestColor_Boldf(t *testing.T) { c := &Color{NoColor: false} result := c.Boldf("bold: %s", "text") diff --git a/mocks/mock_types/iface.go b/mocks/mock_types/iface.go index ce06076..de84332 100644 --- a/mocks/mock_types/iface.go +++ b/mocks/mock_types/iface.go @@ -87,6 +87,44 @@ func (mr *MockCageMockRecorder) Up(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockCage)(nil).Up), ctx) } +// MockAudit is a mock of Audit interface. +type MockAudit struct { + ctrl *gomock.Controller + recorder *MockAuditMockRecorder + isgomock struct{} +} + +// MockAuditMockRecorder is the mock recorder for MockAudit. +type MockAuditMockRecorder struct { + mock *MockAudit +} + +// NewMockAudit creates a new mock instance. +func NewMockAudit(ctrl *gomock.Controller) *MockAudit { + mock := &MockAudit{ctrl: ctrl} + mock.recorder = &MockAuditMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAudit) EXPECT() *MockAuditMockRecorder { + return m.recorder +} + +// Run mocks base method. +func (m *MockAudit) Run(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Run", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Run indicates an expected call of Run. +func (mr *MockAuditMockRecorder) Run(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockAudit)(nil).Run), ctx) +} + // MockTime is a mock of Time interface. type MockTime struct { ctrl *gomock.Controller diff --git a/types/iface.go b/types/iface.go index e4ca1f0..37c6707 100644 --- a/types/iface.go +++ b/types/iface.go @@ -13,6 +13,10 @@ type Cage interface { RollOut(ctx context.Context, input *RollOutInput) (*RollOutResult, error) } +type Audit interface { + Run(ctx context.Context) error +} + type Time interface { NewTimer(time.Duration) *time.Timer } From 7f960aef5a6564fbc60ae10e3d59f26657beddaa Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 20:53:43 +0900 Subject: [PATCH 19/23] a --- Makefile | 2 +- cli/cage/audit/command.go | 2 +- cli/cage/audit/deps_test.go | 11 +- cli/cage/audit/types_test.go | 2 + cli/cage/cageapp/flags.go | 8 - cli/cage/cageapp/flags_test.go | 17 -- cli/cage/cageapp/types.go | 32 ++- cli/cage/cageapp/types_test.go | 43 ++++ cli/cage/commands/audit.go | 4 +- cli/cage/commands/command.go | 10 +- cli/cage/commands/command_test.go | 40 ++-- cli/cage/commands/provider.go | 8 +- cli/cage/commands/provider_test.go | 16 +- cli/cage/commands/rollout.go | 35 ++- cli/cage/commands/run.go | 20 +- cli/cage/commands/tools_test.go | 15 +- cli/cage/commands/up.go | 24 +- cli/cage/main.go | 11 +- coverage-cli.txt | 350 ----------------------------- 19 files changed, 172 insertions(+), 478 deletions(-) delete mode 100644 cli/cage/cageapp/flags_test.go create mode 100644 cli/cage/cageapp/types_test.go delete mode 100644 coverage-cli.txt diff --git a/Makefile b/Makefile index b68388a..1b7c54b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ MOCKGEN := go run go.uber.org/mock/mockgen@v0.6.0 test: go test ./... -coverprofile=coverage.txt -covermode=count test/cli: - go test ./cli/... -coverprofile=coverage-cli.txt -covermode=count + go test ./cli/... -coverprofile=coverage.txt -covermode=count test-container: docker build -t canarycage/test-container test-container push-test-container: test-container diff --git a/cli/cage/audit/command.go b/cli/cage/audit/command.go index 01be13c..94f813c 100644 --- a/cli/cage/audit/command.go +++ b/cli/cage/audit/command.go @@ -34,7 +34,7 @@ func (a *command) Run(ctx context.Context) error { go func() { defer close(errchannel) results, err := scanner.Scan(ctx, a.input.Cluster, a.input.Service) - printer := NewPrinter(l, a.input.App.NoColor, a.input.LogDetail) + printer := NewPrinter(l, a.input.NoColor, a.input.LogDetail) l.Printf("\r") // clear spinner line if err != nil { errchannel <- err diff --git a/cli/cage/audit/deps_test.go b/cli/cage/audit/deps_test.go index cbad420..ecc5c1a 100644 --- a/cli/cage/audit/deps_test.go +++ b/cli/cage/audit/deps_test.go @@ -9,10 +9,8 @@ import ( func TestProvideAuditCmd(t *testing.T) { ctx := context.Background() - input := &cageapp.AuditCmdInput{ - Region: "us-west-2", - } - + input := cageapp.NewAuditCmdInput() + input.Region = "us-east-1" audit, err := ProvideAuditCmd(ctx, input) if err != nil { t.Fatalf("ProvideAuditCmd() error = %v, want nil", err) @@ -29,9 +27,8 @@ func TestProvideAuditCmd_WithDifferentRegions(t *testing.T) { for _, region := range regions { t.Run(region, func(t *testing.T) { ctx := context.Background() - input := &cageapp.AuditCmdInput{ - Region: region, - } + input := cageapp.NewAuditCmdInput() + input.Region = region audit, err := ProvideAuditCmd(ctx, input) if err != nil { diff --git a/cli/cage/audit/types_test.go b/cli/cage/audit/types_test.go index 3ce77a3..f8af97a 100644 --- a/cli/cage/audit/types_test.go +++ b/cli/cage/audit/types_test.go @@ -248,3 +248,5 @@ func Test_summaryScanResult(t *testing.T) { }) } } + + diff --git a/cli/cage/cageapp/flags.go b/cli/cage/cageapp/flags.go index 4de6702..8e3f6e3 100644 --- a/cli/cage/cageapp/flags.go +++ b/cli/cage/cageapp/flags.go @@ -1,9 +1,6 @@ package cageapp import ( - "io" - "os" - "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" ) @@ -11,11 +8,6 @@ import ( type App struct { CI bool NoColor bool - Stdin io.Reader -} - -func NewApp() *App { - return &App{Stdin: os.Stdin} } func RegionFlag(dest *string) *cli.StringFlag { diff --git a/cli/cage/cageapp/flags_test.go b/cli/cage/cageapp/flags_test.go deleted file mode 100644 index 1f02f1c..0000000 --- a/cli/cage/cageapp/flags_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package cageapp - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewApp(t *testing.T) { - app := NewApp() - assert := assert.New(t) - - assert.NotNil(app, "NewApp() returned nil") - assert.Equal(os.Stdin, app.Stdin, "expected Stdin to be os.Stdin") - assert.False(app.CI, "expected CI to be false") -} diff --git a/cli/cage/cageapp/types.go b/cli/cage/cageapp/types.go index 04d05d6..c865550 100644 --- a/cli/cage/cageapp/types.go +++ b/cli/cage/cageapp/types.go @@ -2,18 +2,46 @@ package cageapp import ( "context" + "io" "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" ) -type CageCmdProvider = func(ctx context.Context, e *env.Envars) (types.Cage, error) +type CageCmdInput struct { + *env.Envars + *App + Stdin io.Reader +} + +func NewCageCmdInput(stdin io.Reader, opts ...func(*CageCmdInput)) *CageCmdInput { + input := &CageCmdInput{ + Envars: &env.Envars{}, + App: &App{}, + Stdin: stdin, + } + for _, opt := range opts { + opt(input) + } + return input +} + +type CageCmdProvider = func(ctx context.Context, input *CageCmdInput) (types.Cage, error) type AuditCmdInput struct { - App *App + *App Region string Cluster string Service string LogDetail bool } + type AuditCmdProvider = func(ctx context.Context, input *AuditCmdInput) (types.Audit, error) + +func NewAuditCmdInput(opts ...func(*AuditCmdInput)) *AuditCmdInput { + input := &AuditCmdInput{App: &App{}} + for _, opt := range opts { + opt(input) + } + return input +} diff --git a/cli/cage/cageapp/types_test.go b/cli/cage/cageapp/types_test.go new file mode 100644 index 0000000..31e8a7d --- /dev/null +++ b/cli/cage/cageapp/types_test.go @@ -0,0 +1,43 @@ +package cageapp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCageCmdInput(t *testing.T) { + t.Run("basic", func(t *testing.T) { + input := NewCageCmdInput(strings.NewReader("test")) + assert := assert.New(t) + assert.NotNil(input.App) + assert.NotNil(input.Envars) + assert.NotNil(input.Stdin) + }) + t.Run("with options", func(t *testing.T) { + input := NewCageCmdInput(nil, func(c *CageCmdInput) { + c.Envars.Region = "us-west-2" + }) + assert := assert.New(t) + assert.NotNil(input.App) + assert.NotNil(input.Envars) + assert.Equal("us-west-2", input.Envars.Region) + }) +} + +func TestNewAuditCmdInput(t *testing.T) { + t.Run("basic", func(t *testing.T) { + input := NewAuditCmdInput() + assert := assert.New(t) + assert.NotNil(input.App) + }) + t.Run("with options", func(t *testing.T) { + input := NewAuditCmdInput(func(a *AuditCmdInput) { + a.Region = "us-west-2" + }) + assert := assert.New(t) + assert.NotNil(input.App) + assert.Equal("us-west-2", input.Region) + }) +} diff --git a/cli/cage/commands/audit.go b/cli/cage/commands/audit.go index e572236..4f42bef 100644 --- a/cli/cage/commands/audit.go +++ b/cli/cage/commands/audit.go @@ -9,7 +9,7 @@ import ( ) func Audit(provider cageapp.AuditCmdProvider) *cli.Command { - var input cageapp.AuditCmdInput + input := cageapp.NewAuditCmdInput() return &cli.Command{ Name: "audit", Usage: "Audit container images used in an ECS service", @@ -46,7 +46,7 @@ func Audit(provider cageapp.AuditCmdProvider) *cli.Command { } else if input.Cluster == "" || input.Service == "" { return errors.New("either directory argument or both --cluster and --service flags must be provided") } - cmd, err := provider(ctx.Context, &input) + cmd, err := provider(ctx.Context, input) if err != nil { return err } diff --git a/cli/cage/commands/command.go b/cli/cage/commands/command.go index 41e578c..bdf26a5 100644 --- a/cli/cage/commands/command.go +++ b/cli/cage/commands/command.go @@ -38,7 +38,7 @@ func RequireArgs( } func (c *CageCommands) setupCage( - envars *env.Envars, + input *cageapp.CageCmdInput, dir string, ) (types.Cage, error) { var service *ecs.CreateServiceInput @@ -48,23 +48,23 @@ func (c *CageCommands) setupCage( } else { service = srv } - if envars.TaskDefinitionArn == "" { + if input.TaskDefinitionArn == "" { if td, err := env.LoadTaskDefinition(dir); err != nil { return nil, err } else { taskDefinition = td } } - env.MergeEnvars(envars, &env.Envars{ + env.MergeEnvars(input.Envars, &env.Envars{ Cluster: *service.Cluster, Service: *service.ServiceName, TaskDefinitionInput: taskDefinition, ServiceDefinitionInput: service, }) - if err := env.EnsureEnvars(envars); err != nil { + if err := env.EnsureEnvars(input.Envars); err != nil { return nil, err } - cagecli, err := c.cageCliProvider(context.TODO(), envars) + cagecli, err := c.cageCliProvider(context.TODO(), input) if err != nil { return nil, err } diff --git a/cli/cage/commands/command_test.go b/cli/cage/commands/command_test.go index 8cf213e..d67180c 100644 --- a/cli/cage/commands/command_test.go +++ b/cli/cage/commands/command_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/mocks/mock_types" "github.com/loilo-inc/canarycage/test" "github.com/loilo-inc/canarycage/types" @@ -14,43 +14,47 @@ import ( func TestSetupCage(t *testing.T) { t.Run("basic", func(t *testing.T) { - envars := &env.Envars{Region: "us-west-2"} + input := cageapp.NewCageCmdInput(nil) + input.Region = "us-west-2" cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(ctx context.Context, envars *cageapp.CageCmdInput) (types.Cage, error) { return cageCli, nil }) - v, err := cmd.setupCage(envars, "../../../fixtures") + v, err := cmd.setupCage(input, "../../../fixtures") if err != nil { t.Fatal(err) } assert.Equal(t, v, cageCli) - assert.Equal(t, envars.Service, "service") - assert.Equal(t, envars.Cluster, "cluster") - assert.NotNil(t, envars.ServiceDefinitionInput) - assert.NotNil(t, envars.TaskDefinitionInput) + assert.Equal(t, input.Service, "service") + assert.Equal(t, input.Cluster, "cluster") + assert.NotNil(t, input.ServiceDefinitionInput) + assert.NotNil(t, input.TaskDefinitionInput) }) t.Run("should skip load task definition if --taskDefinitionArn provided", func(t *testing.T) { - envars := &env.Envars{Region: "us-west-2", TaskDefinitionArn: "arn"} + input := cageapp.NewCageCmdInput(nil) + input.Region = "us-west-2" + input.TaskDefinitionArn = "arn" cageCli := mock_types.NewMockCage(gomock.NewController(t)) - cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { + cmd := NewCageCommands(func(ctx context.Context, input *cageapp.CageCmdInput) (types.Cage, error) { return cageCli, nil }) - v, err := cmd.setupCage(envars, "../../../fixtures") + v, err := cmd.setupCage(input, "../../../fixtures") if err != nil { t.Fatal(err) } assert.Equal(t, v, cageCli) - assert.Equal(t, envars.Service, "service") - assert.Equal(t, envars.Cluster, "cluster") - assert.NotNil(t, envars.ServiceDefinitionInput) - assert.Nil(t, envars.TaskDefinitionInput) + assert.Equal(t, input.Service, "service") + assert.Equal(t, input.Cluster, "cluster") + assert.NotNil(t, input.ServiceDefinitionInput) + assert.Nil(t, input.TaskDefinitionInput) }) t.Run("should error if error returned from NewCage", func(t *testing.T) { - envars := &env.Envars{Region: "us-west-2"} - cmd := NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { + input := cageapp.NewCageCmdInput(nil) + input.Region = "us-west-2" + cmd := NewCageCommands(func(ctx context.Context, input *cageapp.CageCmdInput) (types.Cage, error) { return nil, test.Err }) - _, err := cmd.setupCage(envars, "../../../fixtures") + _, err := cmd.setupCage(input, "../../../fixtures") assert.EqualError(t, err, "error") }) } diff --git a/cli/cage/commands/provider.go b/cli/cage/commands/provider.go index 7ccfde8..9b2fdab 100644 --- a/cli/cage/commands/provider.go +++ b/cli/cage/commands/provider.go @@ -10,7 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" cage "github.com/loilo-inc/canarycage" "github.com/loilo-inc/canarycage/awsiface" - "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/key" "github.com/loilo-inc/canarycage/task" "github.com/loilo-inc/canarycage/timeout" @@ -18,13 +18,13 @@ import ( "github.com/loilo-inc/logos/di" ) -func ProvideCageCli(ctx context.Context, envars *env.Envars) (types.Cage, error) { +func ProvideCageCli(ctx context.Context, input *cageapp.CageCmdInput) (types.Cage, error) { conf := awsiface.MustLoadConfig( ctx, - config.WithRegion(envars.Region), + config.WithRegion(input.Region), ) d := di.NewDomain(func(b *di.B) { - b.Set(key.Env, envars) + b.Set(key.Env, input.Envars) b.Set(key.EcsCli, ecs.NewFromConfig(conf)) b.Set(key.EcrCli, ecr.NewFromConfig(conf)) b.Set(key.Ec2Cli, ec2.NewFromConfig(conf)) diff --git a/cli/cage/commands/provider_test.go b/cli/cage/commands/provider_test.go index 3251798..0867a4a 100644 --- a/cli/cage/commands/provider_test.go +++ b/cli/cage/commands/provider_test.go @@ -4,27 +4,25 @@ import ( "context" "testing" - "github.com/loilo-inc/canarycage/env" + "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/stretchr/testify/assert" ) func TestProvideCageCli(t *testing.T) { t.Run("successfully creates cage cli with valid region", func(t *testing.T) { - envars := &env.Envars{ - Region: "us-east-1", - } + input := cageapp.NewCageCmdInput(nil) + input.Envars.Region = "us-west-2" - cage, err := ProvideCageCli(context.TODO(), envars) + cage, err := ProvideCageCli(context.TODO(), input) assert.NoError(t, err) assert.NotNil(t, cage) }) t.Run("returns error with invalid region", func(t *testing.T) { - envars := &env.Envars{ - Region: "", - } + input := cageapp.NewCageCmdInput(nil) + input.Envars.Region = "" - cage, err := ProvideCageCli(context.TODO(), envars) + cage, err := ProvideCageCli(context.TODO(), input) if err != nil { assert.Nil(t, cage, "expected cage to be nil when error occurs") return diff --git a/cli/cage/commands/rollout.go b/cli/cage/commands/rollout.go index ce67e84..d4c4c9b 100644 --- a/cli/cage/commands/rollout.go +++ b/cli/cage/commands/rollout.go @@ -11,8 +11,7 @@ import ( "github.com/urfave/cli/v2" ) -func (c *CageCommands) RollOut(app *cageapp.App) *cli.Command { - envars := &env.Envars{} +func (c *CageCommands) RollOut(input *cageapp.CageCmdInput) *cli.Command { var updateServiceConf bool return &cli.Command{ Name: "rollout", @@ -21,16 +20,16 @@ func (c *CageCommands) RollOut(app *cageapp.App) *cli.Command { Args: true, ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ - cageapp.RegionFlag(&envars.Region), - cageapp.ClusterFlag(&envars.Cluster), - cageapp.ServiceFlag(&envars.Service), - cageapp.TaskDefinitionArnFlag(&envars.TaskDefinitionArn), - cageapp.CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), + cageapp.RegionFlag(&input.Region), + cageapp.ClusterFlag(&input.Cluster), + cageapp.ServiceFlag(&input.Service), + cageapp.TaskDefinitionArnFlag(&input.TaskDefinitionArn), + cageapp.CanaryTaskIdleDurationFlag(&input.CanaryTaskIdleDuration), &cli.StringFlag{ Name: "canaryInstanceArn", EnvVars: []string{env.CanaryInstanceArnKey}, Usage: "EC2 instance ARN for placing canary task. required only when LaunchType is EC2", - Destination: &envars.CanaryInstanceArn, + Destination: &input.CanaryInstanceArn, }, &cli.BoolFlag{ Name: "updateService", @@ -38,32 +37,32 @@ func (c *CageCommands) RollOut(app *cageapp.App) *cli.Command { Usage: "Update service configurations except for task definiton. Default is false.", Destination: &updateServiceConf, }, - cageapp.TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), - cageapp.TaskHealthCheckWaitFlag(&envars.CanaryTaskHealthCheckWait), - cageapp.TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), - cageapp.ServiceStableWaitFlag(&envars.ServiceStableWait), + cageapp.TaskRunningWaitFlag(&input.CanaryTaskRunningWait), + cageapp.TaskHealthCheckWaitFlag(&input.CanaryTaskHealthCheckWait), + cageapp.TaskStoppedWaitFlag(&input.CanaryTaskStoppedWait), + cageapp.ServiceStableWaitFlag(&input.ServiceStableWait), }, Action: func(ctx *cli.Context) error { dir, _, err := RequireArgs(ctx, 1, 1) if err != nil { return err } - cagecli, err := c.setupCage(envars, dir) + cagecli, err := c.setupCage(input, dir) if err != nil { return err } - if !app.CI { - prompter := prompt.NewPrompter(app.Stdin) - if err := prompter.ConfirmService(envars); err != nil { + if !input.CI { + prompter := prompt.NewPrompter(input.Stdin) + if err := prompter.ConfirmService(input.Envars); err != nil { return err } } result, err := cagecli.RollOut(context.Background(), &types.RollOutInput{UpdateService: updateServiceConf}) if err != nil { if !result.ServiceUpdated { - log.Errorf("🤕 failed to roll out new tasks but service '%s' is not changed", envars.Service) + log.Errorf("🤕 failed to roll out new tasks but service '%s' is not changed", input.Service) } else { - log.Errorf("😭 failed to roll out new tasks and service '%s' might be changed. CHECK ECS CONSOLE NOW!", envars.Service) + log.Errorf("😭 failed to roll out new tasks and service '%s' might be changed. CHECK ECS CONSOLE NOW!", input.Service) } return err } diff --git a/cli/cage/commands/run.go b/cli/cage/commands/run.go index 2726819..7d4377e 100644 --- a/cli/cage/commands/run.go +++ b/cli/cage/commands/run.go @@ -7,13 +7,11 @@ import ( ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/prompt" - "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" ) -func (c *CageCommands) Run(app *cageapp.App) *cli.Command { - envars := &env.Envars{} +func (c *CageCommands) Run(input *cageapp.CageCmdInput) *cli.Command { return &cli.Command{ Name: "run", Usage: "run task with specified task definition", @@ -21,23 +19,23 @@ func (c *CageCommands) Run(app *cageapp.App) *cli.Command { Args: true, ArgsUsage: " ...", Flags: []cli.Flag{ - cageapp.RegionFlag(&envars.Region), - cageapp.ClusterFlag(&envars.Cluster), - cageapp.TaskRunningWaitFlag(&envars.CanaryTaskRunningWait), - cageapp.TaskStoppedWaitFlag(&envars.CanaryTaskStoppedWait), + cageapp.RegionFlag(&input.Region), + cageapp.ClusterFlag(&input.Cluster), + cageapp.TaskRunningWaitFlag(&input.CanaryTaskRunningWait), + cageapp.TaskStoppedWaitFlag(&input.CanaryTaskStoppedWait), }, Action: func(ctx *cli.Context) error { dir, rest, err := RequireArgs(ctx, 3, 100) if err != nil { return err } - cagecli, err := c.setupCage(envars, dir) + cagecli, err := c.setupCage(input, dir) if err != nil { return err } - if !app.CI { - prompter := prompt.NewPrompter(app.Stdin) - if err := prompter.ConfirmTask(envars); err != nil { + if !input.CI { + prompter := prompt.NewPrompter(input.Stdin) + if err := prompter.ConfirmTask(input.Envars); err != nil { return err } } diff --git a/cli/cage/commands/tools_test.go b/cli/cage/commands/tools_test.go index a99a601..eaacd3a 100644 --- a/cli/cage/commands/tools_test.go +++ b/cli/cage/commands/tools_test.go @@ -7,7 +7,6 @@ import ( "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/commands" - "github.com/loilo-inc/canarycage/env" "github.com/loilo-inc/canarycage/mocks/mock_types" "github.com/loilo-inc/canarycage/types" "github.com/urfave/cli/v2" @@ -17,23 +16,23 @@ import ( var stdinService = "ap-notheast-1\ncluster\nservice\nyes\n" var stdinTask = "ap-notheast-1\ncluster\nyes\n" -func setup(t *testing.T, input io.Reader) (*cli.App, *mock_types.MockCage) { +func setup(t *testing.T, stdin io.Reader) (*cli.App, *mock_types.MockCage) { ctrl := gomock.NewController(t) cagecli := mock_types.NewMockCage(ctrl) - cageapp := &cageapp.App{Stdin: input} + input := cageapp.NewCageCmdInput(stdin) app := cli.NewApp() - cmds := commands.NewCageCommands(func(ctx context.Context, envars *env.Envars) (types.Cage, error) { + cmds := commands.NewCageCommands(func(ctx context.Context, input *cageapp.CageCmdInput) (types.Cage, error) { return cagecli, nil }) app.Commands = []*cli.Command{ - cmds.Up(cageapp), - cmds.RollOut(cageapp), - cmds.Run(cageapp), + cmds.Up(input), + cmds.RollOut(input), + cmds.Run(input), } app.Flags = []cli.Flag{ &cli.BoolFlag{ Name: "ci", - Destination: &cageapp.CI, + Destination: &input.CI, Value: false, }, } diff --git a/cli/cage/commands/up.go b/cli/cage/commands/up.go index 0769fe7..b1a7379 100644 --- a/cli/cage/commands/up.go +++ b/cli/cage/commands/up.go @@ -5,12 +5,10 @@ import ( "github.com/loilo-inc/canarycage/cli/cage/cageapp" "github.com/loilo-inc/canarycage/cli/cage/prompt" - "github.com/loilo-inc/canarycage/env" "github.com/urfave/cli/v2" ) -func (c *CageCommands) Up(app *cageapp.App) *cli.Command { - envars := &env.Envars{} +func (c *CageCommands) Up(input *cageapp.CageCmdInput) *cli.Command { return &cli.Command{ Name: "up", Usage: "create new ECS service with specified task definition", @@ -18,25 +16,25 @@ func (c *CageCommands) Up(app *cageapp.App) *cli.Command { Args: true, ArgsUsage: "[directory path of service.json and task-definition.json]", Flags: []cli.Flag{ - cageapp.RegionFlag(&envars.Region), - cageapp.ClusterFlag(&envars.Cluster), - cageapp.ServiceFlag(&envars.Service), - cageapp.TaskDefinitionArnFlag(&envars.TaskDefinitionArn), - cageapp.CanaryTaskIdleDurationFlag(&envars.CanaryTaskIdleDuration), - cageapp.ServiceStableWaitFlag(&envars.ServiceStableWait), + cageapp.RegionFlag(&input.Region), + cageapp.ClusterFlag(&input.Cluster), + cageapp.ServiceFlag(&input.Service), + cageapp.TaskDefinitionArnFlag(&input.TaskDefinitionArn), + cageapp.CanaryTaskIdleDurationFlag(&input.CanaryTaskIdleDuration), + cageapp.ServiceStableWaitFlag(&input.ServiceStableWait), }, Action: func(ctx *cli.Context) error { dir, _, err := RequireArgs(ctx, 1, 1) if err != nil { return err } - cagecli, err := c.setupCage(envars, dir) + cagecli, err := c.setupCage(input, dir) if err != nil { return err } - if !app.CI { - prompter := prompt.NewPrompter(app.Stdin) - if err := prompter.ConfirmService(envars); err != nil { + if !input.CI { + prompter := prompt.NewPrompter(input.Stdin) + if err := prompter.ConfirmService(input.Envars); err != nil { return err } } diff --git a/cli/cage/main.go b/cli/cage/main.go index 408964d..58983ff 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -20,7 +20,10 @@ var ( ) func main() { - appConf := cageapp.NewApp() + appConf := &cageapp.App{} + configCmdInput := func(input *cageapp.CageCmdInput) { + input.App = appConf + } app := cli.NewApp() app.Name = "canarycage" app.HelpName = "cage" @@ -29,9 +32,9 @@ func main() { app.Description = "A deployment tool for AWS ECS" cmds := commands.NewCageCommands(commands.ProvideCageCli) app.Commands = []*cli.Command{ - cmds.Up(appConf), - cmds.RollOut(appConf), - cmds.Run(appConf), + cmds.Up(cageapp.NewCageCmdInput(os.Stdin, configCmdInput)), + cmds.RollOut(cageapp.NewCageCmdInput(os.Stdin, configCmdInput)), + cmds.Run(cageapp.NewCageCmdInput(os.Stdin, configCmdInput)), commands.Upgrade(upgrade.NewUpgrader(version)), commands.Audit(audit.ProvideAuditCmd), } diff --git a/coverage-cli.txt b/coverage-cli.txt deleted file mode 100644 index 07dfa9d..0000000 --- a/coverage-cli.txt +++ /dev/null @@ -1,350 +0,0 @@ -mode: count -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:17.45,19.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:24.9,26.56 2 15 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:26.56,28.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:28.8,28.40 1 15 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:28.40,30.3 1 4 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:31.2,31.12 1 11 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:36.9,38.2 1 4 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:42.9,44.2 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:49.9,50.59 1 5 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:50.59,52.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:53.2,53.61 1 4 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:53.61,55.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:56.2,56.13 1 3 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:56.13,57.62 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:57.62,59.4 1 0 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:61.2,64.13 4 3 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:64.13,66.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:67.2,67.48 1 3 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:67.48,69.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/prompt/prompt.go:70.2,70.12 1 2 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:34.50,36.2 1 5 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:38.44,41.16 3 5 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:41.16,43.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:44.2,47.23 4 5 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:47.23,48.65 1 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:48.65,51.4 2 1 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:54.2,60.45 7 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:60.45,61.43 1 8 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:61.43,63.4 1 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:64.3,64.41 1 8 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:64.41,66.4 1 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:68.2,68.48 1 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:68.48,70.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:71.2,73.16 3 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:73.16,75.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:76.2,78.16 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:78.16,80.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:81.2,83.22 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:83.22,85.17 2 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:85.17,87.4 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:88.3,88.20 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:90.2,90.59 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:90.59,92.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:93.2,94.12 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:97.83,101.16 4 10 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:101.16,103.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:104.2,105.35 2 9 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:105.35,107.17 2 11 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:107.17,108.12 1 1 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:110.3,110.37 1 10 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:110.37,112.9 2 2 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:113.9,113.38 1 8 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:113.38,115.9 2 5 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:118.2,118.26 1 9 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:118.26,120.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:121.2,121.27 1 7 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:127.19,129.16 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:129.16,131.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:132.2,135.16 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:135.16,137.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:138.2,141.74 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:141.74,143.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:145.2,146.41 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:146.41,148.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:150.2,151.16 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:151.16,153.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:154.2,156.16 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:156.16,158.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:159.2,161.16 3 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:161.16,163.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:164.2,165.53 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:165.53,167.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:168.2,168.29 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:171.62,173.16 2 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:173.16,175.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:176.2,178.16 3 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:178.16,180.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:181.2,184.29 4 4 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:184.29,185.17 1 10 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:185.17,186.12 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:188.3,189.22 2 7 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:189.22,191.4 1 1 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:192.3,192.28 1 6 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:194.2,195.9 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:195.9,197.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:198.2,199.16 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:199.16,201.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:202.2,202.17 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:208.9,210.56 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:210.56,212.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:213.2,214.59 2 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:214.59,216.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:217.2,217.59 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:217.59,219.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:220.2,220.47 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:220.47,222.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:223.2,223.51 1 3 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:223.51,225.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/upgrade/upgrade.go:226.2,226.12 1 3 -github.com/loilo-inc/canarycage/cli/cage/main.go:22.13,52.41 11 0 -github.com/loilo-inc/canarycage/cli/cage/main.go:52.41,54.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:18.34,24.2 1 12 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:26.41,28.18 2 7 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:28.18,34.3 2 1 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:34.8,34.39 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:34.39,40.3 2 1 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:41.2,43.49 3 5 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:43.49,44.44 1 8 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:44.44,48.4 3 8 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:62.56,65.26 3 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:65.26,67.19 2 11 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:68.49,69.26 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:70.45,71.22 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:72.47,73.24 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:74.44,75.21 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:76.54,77.22 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:80.2,80.30 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:80.30,82.3 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:82.8,82.33 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:82.33,84.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:84.8,84.35 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:84.35,86.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:86.8,88.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:89.2,91.15 3 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:99.60,107.2 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:109.42,111.2 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:113.65,115.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:117.61,119.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:121.63,123.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:125.106,127.40 2 21 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:127.40,128.30 1 27 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:128.30,130.4 1 9 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:132.2,132.13 1 21 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:135.65,137.13 2 6 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:137.13,139.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:140.2,140.22 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:148.67,149.20 1 21 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:150.40,151.40 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:152.36,153.36 1 5 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:154.38,155.39 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:156.10,157.35 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/aggregator.go:161.68,163.2 1 16 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:20.66,26.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:28.50,34.12 6 3 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:34.12,39.17 5 3 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:39.17,41.4 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:41.9,43.4 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:45.2,45.6 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:45.6,47.10 2 3 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:48.21,49.20 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:50.28,51.14 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/command.go:52.18,56.5 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:19.94,24.34 2 4 -github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:24.34,30.3 5 4 -github.com/loilo-inc/canarycage/cli/cage/audit/deps.go:31.2,31.34 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:26.55,28.2 1 13 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:30.117,35.16 2 9 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:35.16,37.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:38.2,38.64 1 8 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:38.64,40.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:42.2,43.88 2 6 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:43.88,45.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:47.2,47.55 1 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:47.55,48.48 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:48.48,49.33 1 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:49.33,50.13 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:52.4,52.79 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:52.79,54.5 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:56.3,56.119 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:59.2,59.71 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:62.150,67.16 2 4 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:67.16,69.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:70.2,70.34 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:70.34,72.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:73.2,73.35 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:98.62,99.14 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:100.15,101.39 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:102.15,103.39 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecr.go:104.10,105.12 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:21.55,23.2 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:25.115,30.16 2 11 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:30.16,32.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:33.2,33.69 1 9 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:33.69,35.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:36.2,41.16 3 7 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:41.16,43.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:44.2,45.15 2 6 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:45.15,47.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:49.2,50.75 2 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:50.75,52.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:54.2,54.39 1 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:54.39,56.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:58.2,59.45 2 4 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:59.45,60.40 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:60.40,62.4 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:63.3,70.5 2 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:73.2,73.20 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:82.51,84.21 2 8 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:84.21,87.3 2 5 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:89.2,92.78 4 3 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:95.50,98.34 3 11 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:98.34,101.21 3 9 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:101.21,103.4 1 8 -github.com/loilo-inc/canarycage/cli/cage/audit/ecs.go:105.2,105.24 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:17.68,23.2 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:25.47,32.27 6 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:32.27,34.3 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:35.2,35.42 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:35.42,36.37 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:36.37,48.4 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:50.2,55.16 6 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:55.16,58.3 2 3 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:59.2,65.45 4 3 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:65.45,66.19 1 15 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:66.19,67.12 1 9 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:69.3,70.84 2 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:73.2,77.3 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:84.3,85.24 1 18 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:85.24,87.3 1 12 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:88.2,91.31 4 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:91.31,94.32 3 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:94.32,96.4 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:97.3,99.18 3 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:99.18,101.4 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:105.47,107.2 1 16 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:109.58,112.34 3 6 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:112.34,113.63 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:113.63,115.4 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:116.3,117.41 2 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:117.41,119.4 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/printer.go:121.2,121.31 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:19.73,21.2 1 6 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:27.38,31.87 4 2 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:31.87,33.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:34.2,35.34 2 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:35.34,36.24 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:36.24,38.4 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:38.9,40.4 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:42.2,42.26 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:47.83,48.77 1 4 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:48.77,50.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:50.8,50.90 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:50.90,52.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/scanner.go:52.8,54.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:19.39,21.2 1 10 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:25.49,27.2 1 9 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:35.45,36.24 1 18 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:36.24,38.3 1 18 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:39.2,39.11 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:42.40,43.24 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:43.24,45.3 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:46.2,46.11 1 0 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:60.63,64.38 4 11 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:64.38,65.21 1 22 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:66.19,67.14 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:68.15,69.10 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:70.17,71.12 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:72.14,73.9 1 3 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:74.24,75.10 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:78.2,78.49 1 11 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:78.49,80.3 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:80.8,80.37 1 9 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:80.37,82.3 1 7 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:82.8,82.23 1 2 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:82.23,84.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/audit/types.go:85.2,94.3 1 11 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:17.20,19.2 1 1 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:21.47,29.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:30.48,37.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:38.48,45.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:46.58,53.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:55.57,63.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:65.50,74.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:76.54,85.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:87.50,96.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/cageapp/flags.go:98.52,107.2 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:11.60,28.40 2 10 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:28.40,30.18 2 10 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:30.18,32.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:33.4,33.26 1 9 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:33.26,35.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:36.4,36.17 1 8 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:36.17,38.19 2 2 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:38.19,40.6 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:41.5,41.53 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:41.53,43.6 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:44.5,45.33 2 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:46.10,46.57 1 6 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:46.57,48.5 1 3 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:49.4,50.18 2 4 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:50.18,52.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/audit.go:53.4,53.31 1 3 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:20.17,23.2 2 19 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:29.42,30.26 1 26 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:30.26,32.3 1 3 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:32.8,32.33 1 23 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:32.33,34.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:35.2,37.8 3 22 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:43.23,46.60 3 16 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:46.60,48.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:48.8,50.3 1 16 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:51.2,51.36 1 16 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:51.36,52.57 1 15 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:52.57,54.4 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:54.9,56.4 1 15 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:58.2,64.49 2 16 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:64.49,66.3 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:67.2,68.16 2 16 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:68.16,70.3 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/command.go:71.2,71.21 1 15 -github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:21.82,26.34 2 3 -github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:26.34,34.3 7 2 -github.com/loilo-inc/canarycage/cli/cage/commands/provider.go:35.2,36.21 2 2 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:14.63,46.40 3 16 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:46.40,48.18 2 6 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:48.18,50.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:51.4,52.18 2 5 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:52.18,54.5 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:55.4,55.15 1 5 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:55.15,57.59 2 4 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:57.59,59.6 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:61.4,62.18 2 4 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:62.18,63.31 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:63.31,65.6 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:65.11,67.6 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:68.5,68.15 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/rollout.go:70.4,71.14 2 3 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:15.59,29.40 2 16 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:29.40,31.18 2 5 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:31.18,33.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:34.4,35.18 2 4 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:35.18,37.5 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:38.4,38.15 1 4 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:38.15,40.56 2 3 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:40.56,42.6 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:44.4,54.19 3 3 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:54.19,56.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/run.go:57.4,58.14 2 2 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:12.58,28.40 2 16 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:28.40,30.18 2 5 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:30.18,32.5 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:33.4,34.18 2 4 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:34.18,36.5 1 0 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:37.4,37.15 1 4 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:37.15,39.59 2 3 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:39.59,41.6 1 1 -github.com/loilo-inc/canarycage/cli/cage/commands/up.go:43.4,44.14 2 3 -github.com/loilo-inc/canarycage/cli/cage/commands/upgrade.go:8.54,20.40 2 2 -github.com/loilo-inc/canarycage/cli/cage/commands/upgrade.go:20.40,24.4 1 2 From cbba2b76115b490993453e898833851c5263ffe9 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 21:06:57 +0900 Subject: [PATCH 20/23] PR --- Makefile | 4 ++-- cli/cage/audit/aggregator.go | 12 ++++++------ cli/cage/audit/printer.go | 6 +++--- cli/cage/audit/types.go | 2 +- cli/cage/commands/audit.go | 3 ++- cli/cage/commands/audit_test.go | 3 ++- cli/cage/main.go | 2 +- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 1b7c54b..e9920b5 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ mocks: go.sum \ mocks/mock_awsiface/iface.go \ mocks/mock_types/iface.go \ mocks/mock_upgrade/upgrade.go \ - mocks/mock_scan/scanner.go \ + mocks/mock_audit/scanner.go \ mocks/mock_task/task.go \ mocks/mock_taskset/taskset.go \ mocks/mock_task/factory.go \ @@ -28,7 +28,7 @@ mocks/mock_types/iface.go: types/iface.go $(MOCKGEN) -source=./types/iface.go > mocks/mock_types/iface.go mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go -mocks/mock_scan/scanner.go: cli/cage/audit/scanner.go +mocks/mock_audit/scanner.go: cli/cage/audit/scanner.go $(MOCKGEN) -source=./cli/cage/audit/scanner.go > mocks/mock_audit/scanner.go mocks/mock_task/task.go: task/task.go $(MOCKGEN) -source=./task/task.go > mocks/mock_task/task.go diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index b027914..5bb673b 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -61,7 +61,7 @@ type AggregateResult struct { func (a *aggregater) SummarizeTotal() *AggregateResult { result := &AggregateResult{} - highestServity := ecrtypes.FindingSeverityInformational + highest := ecrtypes.FindingSeverityInformational for cve := range a.cves { severity := a.cveToSeverity[cve] switch severity { @@ -78,15 +78,15 @@ func (a *aggregater) SummarizeTotal() *AggregateResult { } } if result.CriticalCount > 0 { - highestServity = ecrtypes.FindingSeverityCritical + highest = ecrtypes.FindingSeverityCritical } else if result.HighCount > 0 { - highestServity = ecrtypes.FindingSeverityHigh + highest = ecrtypes.FindingSeverityHigh } else if result.MediumCount > 0 { - highestServity = ecrtypes.FindingSeverityMedium + highest = ecrtypes.FindingSeverityMedium } else { - highestServity = ecrtypes.FindingSeverityLow + highest = ecrtypes.FindingSeverityLow } - result.HighestSeverity = highestServity + result.HighestSeverity = highest result.TotalCount = int32(len(a.cves)) return result } diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index 1659ffb..caddb8a 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -78,16 +78,16 @@ func (p *printer) Print(result []*ScanResult) { } func (p *printer) logImageScanFindings( - serverity ecrtypes.FindingSeverity, + severity ecrtypes.FindingSeverity, findings []ecrtypes.ImageScanFinding, aggregater *aggregater, ) { if len(findings) == 0 { return } - sp := &severityPrinter{severity: serverity, color: p.color} + sp := &severityPrinter{severity: severity, color: p.color} color := p.color - p.logger.Printf("\n=== %s ===\n", sp.BSprintf("%s", serverity)) + p.logger.Printf("\n=== %s ===\n", sp.BSprintf("%s", severity)) for _, cve := range findings { containers := aggregater.GetVulnContainers(*cve.Name) var containerList []string diff --git a/cli/cage/audit/types.go b/cli/cage/audit/types.go index 9477aa7..ac5e7cb 100644 --- a/cli/cage/audit/types.go +++ b/cli/cage/audit/types.go @@ -20,7 +20,7 @@ func (i *ImageInfo) IsECRImage() bool { return i.Registry == "public.ecr.aws" || i.registryHasECRSuffix() } -var ecrURLPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-za-zA-Z0-9-]+\.amazonaws\.com$`) +var ecrURLPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.[a-zA-Z0-9-]+\.amazonaws\.com$`) func (i *ImageInfo) registryHasECRSuffix() bool { return ecrURLPattern.MatchString(i.Registry) diff --git a/cli/cage/commands/audit.go b/cli/cage/commands/audit.go index 4f42bef..997affe 100644 --- a/cli/cage/commands/audit.go +++ b/cli/cage/commands/audit.go @@ -8,8 +8,9 @@ import ( "github.com/urfave/cli/v2" ) -func Audit(provider cageapp.AuditCmdProvider) *cli.Command { +func Audit(app *cageapp.App, provider cageapp.AuditCmdProvider) *cli.Command { input := cageapp.NewAuditCmdInput() + input.App = app return &cli.Command{ Name: "audit", Usage: "Audit container images used in an ECS service", diff --git a/cli/cage/commands/audit_test.go b/cli/cage/commands/audit_test.go index 52c527e..b5646c8 100644 --- a/cli/cage/commands/audit_test.go +++ b/cli/cage/commands/audit_test.go @@ -124,10 +124,11 @@ func TestAudit(t *testing.T) { func setupAuditApp(t *testing.T, provider cageapp.AuditCmdProvider) *cli.App { t.Helper() + conf := &cageapp.App{} app := cli.NewApp() app.Name = "cage" app.Commands = []*cli.Command{ - Audit(provider), + Audit(conf, provider), } return app } diff --git a/cli/cage/main.go b/cli/cage/main.go index 58983ff..bba8a47 100644 --- a/cli/cage/main.go +++ b/cli/cage/main.go @@ -36,7 +36,7 @@ func main() { cmds.RollOut(cageapp.NewCageCmdInput(os.Stdin, configCmdInput)), cmds.Run(cageapp.NewCageCmdInput(os.Stdin, configCmdInput)), commands.Upgrade(upgrade.NewUpgrader(version)), - commands.Audit(audit.ProvideAuditCmd), + commands.Audit(appConf, audit.ProvideAuditCmd), } app.Flags = []cli.Flag{ &cli.BoolFlag{ From b11b96d760fe5bfb4035eba87ca9baefed57fbf2 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Wed, 21 Jan 2026 21:28:04 +0900 Subject: [PATCH 21/23] a --- cli/cage/audit/aggregator.go | 6 +- cli/cage/audit/aggregator_test.go | 79 +++++++++----- cli/cage/audit/ecs.go | 8 +- cli/cage/audit/printer.go | 6 +- cli/cage/audit/printer_test.go | 165 +++++++++++++++++++++++++++++- cli/cage/audit/scanner.go | 8 +- cli/cage/audit/scanner_test.go | 6 +- cli/cage/audit/types.go | 20 +--- cli/cage/audit/types_test.go | 23 ++--- 9 files changed, 244 insertions(+), 77 deletions(-) diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index 5bb673b..83df742 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -24,7 +24,7 @@ func NewAggregater() *aggregater { } func (a *aggregater) Add(r *ScanResult) { - container := r.ContainerName() + container := r.ContainerName if r.Err != nil { a.summaries[container] = append(a.summaries[container], &ScanResultSummary{ ContainerName: container, @@ -83,8 +83,10 @@ func (a *aggregater) SummarizeTotal() *AggregateResult { highest = ecrtypes.FindingSeverityHigh } else if result.MediumCount > 0 { highest = ecrtypes.FindingSeverityMedium - } else { + } else if result.LowCount > 0 { highest = ecrtypes.FindingSeverityLow + } else { + highest = ecrtypes.FindingSeverityInformational } result.HighestSeverity = highest result.TotalCount = int32(len(a.cves)) diff --git a/cli/cage/audit/aggregator_test.go b/cli/cage/audit/aggregator_test.go index 6ce777c..2d3f7f3 100644 --- a/cli/cage/audit/aggregator_test.go +++ b/cli/cage/audit/aggregator_test.go @@ -93,8 +93,7 @@ func TestAggregater_Add(t *testing.T) { { name: "add result with error", scanResult: &ScanResult{ - Err: assert.AnError, - ImageInfo: &ImageInfo{}, + Err: assert.AnError, }, wantStatus: "ERROR", wantCVECount: 0, @@ -103,7 +102,6 @@ func TestAggregater_Add(t *testing.T) { name: "add result with nil findings", scanResult: &ScanResult{ ImageScanFindings: nil, - ImageInfo: &ImageInfo{}, }, wantStatus: "N/A", wantCVECount: 0, @@ -111,7 +109,7 @@ func TestAggregater_Add(t *testing.T) { { name: "add result with findings", scanResult: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -145,30 +143,55 @@ func TestAggregater_Add(t *testing.T) { } func TestAggregater_SummarizeTotal(t *testing.T) { - agg := NewAggregater() - agg.cves = map[string]ecrtypes.ImageScanFinding{ - "CVE-2021-1": {Name: stringPtr("CVE-2021-1"), Severity: ecrtypes.FindingSeverityCritical}, - "CVE-2021-2": {Name: stringPtr("CVE-2021-2"), Severity: ecrtypes.FindingSeverityHigh}, - "CVE-2021-3": {Name: stringPtr("CVE-2021-3"), Severity: ecrtypes.FindingSeverityMedium}, - "CVE-2021-4": {Name: stringPtr("CVE-2021-4"), Severity: ecrtypes.FindingSeverityLow}, - "CVE-2021-5": {Name: stringPtr("CVE-2021-5"), Severity: ecrtypes.FindingSeverityInformational}, - } - agg.cveToSeverity = map[string]string{ - "CVE-2021-1": string(ecrtypes.FindingSeverityCritical), - "CVE-2021-2": string(ecrtypes.FindingSeverityHigh), - "CVE-2021-3": string(ecrtypes.FindingSeverityMedium), - "CVE-2021-4": string(ecrtypes.FindingSeverityLow), - "CVE-2021-5": string(ecrtypes.FindingSeverityInformational), - } - - result := agg.SummarizeTotal() - assert.Equal(t, int32(1), result.CriticalCount) - assert.Equal(t, int32(1), result.HighCount) - assert.Equal(t, int32(1), result.MediumCount) - assert.Equal(t, int32(1), result.LowCount) - assert.Equal(t, int32(1), result.InfoCount) - assert.Equal(t, int32(5), result.TotalCount) - assert.Equal(t, ecrtypes.FindingSeverityCritical, result.HighestSeverity) + t.Run("should summarize total counts and highest severity", func(t *testing.T) { + agg := NewAggregater() + agg.cves = map[string]ecrtypes.ImageScanFinding{ + "CVE-2021-1": {Name: stringPtr("CVE-2021-1"), Severity: ecrtypes.FindingSeverityCritical}, + "CVE-2021-2": {Name: stringPtr("CVE-2021-2"), Severity: ecrtypes.FindingSeverityHigh}, + "CVE-2021-3": {Name: stringPtr("CVE-2021-3"), Severity: ecrtypes.FindingSeverityMedium}, + "CVE-2021-4": {Name: stringPtr("CVE-2021-4"), Severity: ecrtypes.FindingSeverityLow}, + "CVE-2021-5": {Name: stringPtr("CVE-2021-5"), Severity: ecrtypes.FindingSeverityInformational}, + } + agg.cveToSeverity = map[string]string{ + "CVE-2021-1": string(ecrtypes.FindingSeverityCritical), + "CVE-2021-2": string(ecrtypes.FindingSeverityHigh), + "CVE-2021-3": string(ecrtypes.FindingSeverityMedium), + "CVE-2021-4": string(ecrtypes.FindingSeverityLow), + "CVE-2021-5": string(ecrtypes.FindingSeverityInformational), + } + + result := agg.SummarizeTotal() + assert.Equal(t, int32(1), result.CriticalCount) + assert.Equal(t, int32(1), result.HighCount) + assert.Equal(t, int32(1), result.MediumCount) + assert.Equal(t, int32(1), result.LowCount) + assert.Equal(t, int32(1), result.InfoCount) + assert.Equal(t, int32(5), result.TotalCount) + assert.Equal(t, ecrtypes.FindingSeverityCritical, result.HighestSeverity) + }) + t.Run("highest", func(t *testing.T) { + tests := []struct { + severity ecrtypes.FindingSeverity + }{ + {severity: ecrtypes.FindingSeverityHigh}, + {severity: ecrtypes.FindingSeverityMedium}, + {severity: ecrtypes.FindingSeverityLow}, + {severity: ecrtypes.FindingSeverityInformational}, + } + for _, tt := range tests { + t.Run(string(tt.severity), func(t *testing.T) { + agg := NewAggregater() + agg.cves = map[string]ecrtypes.ImageScanFinding{ + "CVE-2021-1": {Name: stringPtr("CVE-2021-1"), Severity: tt.severity}, + } + agg.cveToSeverity = map[string]string{ + "CVE-2021-1": string(tt.severity), + } + result := agg.SummarizeTotal() + assert.Equal(t, tt.severity, result.HighestSeverity) + }) + } + }) } func TestAggregater_FilterCvesBySeverity(t *testing.T) { diff --git a/cli/cage/audit/ecs.go b/cli/cage/audit/ecs.go index e60cd99..5817c39 100644 --- a/cli/cage/audit/ecs.go +++ b/cli/cage/audit/ecs.go @@ -15,14 +15,14 @@ type ecsTool struct { } type EcsTool interface { - GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]*ImageInfo, error) + GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]ImageInfo, error) } func newEcsTool(ecsClient awsiface.EcsClient) EcsTool { return &ecsTool{Ecs: ecsClient} } -func (t *ecsTool) GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]*ImageInfo, error) { +func (t *ecsTool) GetServiceImageInfos(ctx context.Context, cluster string, service string) ([]ImageInfo, error) { res, err := t.Ecs.DescribeServices(ctx, &ecs.DescribeServicesInput{ Cluster: &cluster, Services: []string{service}, @@ -55,13 +55,13 @@ func (t *ecsTool) GetServiceImageInfos(ctx context.Context, cluster string, serv return nil, fmt.Errorf("no container definitions found for task definition: %s", taskDefinition) } - images := make([]*ImageInfo, 0, len(td.ContainerDefinitions)) + images := make([]ImageInfo, 0, len(td.ContainerDefinitions)) for _, cd := range td.ContainerDefinitions { if cd.Name == nil || cd.Image == nil { return nil, fmt.Errorf("container definition is missing name or image: %s", taskDefinition) } parsed := ParseImageInfo(*cd.Image) - images = append(images, &ImageInfo{ + images = append(images, ImageInfo{ ContainerName: *cd.Name, PlatformArch: arch, Registry: parsed.Registry, diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index caddb8a..49357ba 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -102,8 +102,8 @@ func (p *printer) logImageScanFindings( } } -func formatImageLabel(info *ImageInfo) string { - return fmt.Sprintf("%s/%s:%s", info.Registry, info.Repository, info.Tag) +func (i *ImageInfo) formatImageLabel() string { + return fmt.Sprintf("%s/%s:%s", i.Registry, i.Repository, i.Tag) } func MaxHeaderWidth(imageInfos []*ScanResult) (int, int) { @@ -113,7 +113,7 @@ func MaxHeaderWidth(imageInfos []*ScanResult) (int, int) { if l := len(info.ImageInfo.ContainerName); l > containerMax { containerMax = l } - imageLabel := formatImageLabel(info.ImageInfo) + imageLabel := info.formatImageLabel() if l := len(imageLabel); l > imageMax { imageMax = l } diff --git a/cli/cage/audit/printer_test.go b/cli/cage/audit/printer_test.go index 4fd803c..42db43f 100644 --- a/cli/cage/audit/printer_test.go +++ b/cli/cage/audit/printer_test.go @@ -20,7 +20,7 @@ func makeScanResult( list ...ecrtypes.FindingSeverity) []*ScanResult { return []*ScanResult{ { - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", Registry: "test-registry", Repository: "test-repo", @@ -148,6 +148,169 @@ func TestPrinter_Print(t *testing.T) { }) } +func TestPrinter_logImageScanFindings(t *testing.T) { + t.Run("does nothing when findings are empty", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) + agg := NewAggregater() + + printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, []ecrtypes.ImageScanFinding{}, agg) + + if len(logger.logs) != 0 { + t.Errorf("Expected no logs, got %d", len(logger.logs)) + } + }) + + t.Run("prints severity header", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) + agg := NewAggregater() + result := makeScanResult(ecrtypes.FindingSeverityCritical) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) + + headerFound := false + for _, log := range logger.logs { + if containsString(log, "=== CRITICAL ===") { + headerFound = true + break + } + } + if !headerFound { + t.Error("Expected severity header to be printed") + } + }) + + t.Run("prints CVE name and URI", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) + agg := NewAggregater() + result := makeScanResult(ecrtypes.FindingSeverityHigh) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityHigh, findings, agg) + + cveFound := false + uriFound := false + for _, log := range logger.logs { + if containsString(log, "CVE-2023-0001") { + cveFound = true + } + if containsString(log, "http://example.com") { + uriFound = true + } + } + if !cveFound { + t.Error("Expected CVE name to be printed") + } + if !uriFound { + t.Error("Expected CVE URI to be printed") + } + }) + + t.Run("prints container names", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) + agg := NewAggregater() + result := makeScanResult(ecrtypes.FindingSeverityMedium) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityMedium, findings, agg) + + containerFound := false + for _, log := range logger.logs { + if containsString(log, "test-container") { + containerFound = true + break + } + } + if !containerFound { + t.Error("Expected container name to be printed") + } + }) + + t.Run("prints description when logDetail is true", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, true) // logDetail = true + agg := NewAggregater() + result := makeScanResult(ecrtypes.FindingSeverityCritical) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) + + descriptionFound := false + for _, log := range logger.logs { + if containsString(log, "Test vulnerability description") { + descriptionFound = true + break + } + } + if !descriptionFound { + t.Error("Expected description to be printed when logDetail is true") + } + }) + + t.Run("does not print description when logDetail is false", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) // logDetail = false + agg := NewAggregater() + result := makeScanResult(ecrtypes.FindingSeverityCritical) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) + + descriptionFound := false + for _, log := range logger.logs { + if containsString(log, "Test vulnerability description") { + descriptionFound = true + break + } + } + if descriptionFound { + t.Error("Expected description NOT to be printed when logDetail is false") + } + }) + + t.Run("handles multiple findings", func(t *testing.T) { + logger := &mockLogger{} + printer := NewPrinter(logger, true, false) + agg := NewAggregater() + result := makeScanResult( + ecrtypes.FindingSeverityHigh, + ecrtypes.FindingSeverityHigh, + ecrtypes.FindingSeverityHigh, + ) + agg.Add(result[0]) + + findings := result[0].ImageScanFindings.Findings + printer.logImageScanFindings(ecrtypes.FindingSeverityHigh, findings, agg) + + cve1Found := false + cve2Found := false + cve3Found := false + for _, log := range logger.logs { + if containsString(log, "CVE-2023-0001") { + cve1Found = true + } + if containsString(log, "CVE-2023-0002") { + cve2Found = true + } + if containsString(log, "CVE-2023-0003") { + cve3Found = true + } + } + if !cve1Found || !cve2Found || !cve3Found { + t.Error("Expected all three CVEs to be printed") + } + }) +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && stringContains(s, substr)) } diff --git a/cli/cage/audit/scanner.go b/cli/cage/audit/scanner.go index 89b6189..6d8b029 100644 --- a/cli/cage/audit/scanner.go +++ b/cli/cage/audit/scanner.go @@ -27,7 +27,7 @@ func (s *scanner) Scan( ) (results []*ScanResult, err error) { ecsTool := newEcsTool(s.ecs) ecrTool := newEcrTool(s.ecr) - var imageInfos []*ImageInfo + var imageInfos []ImageInfo if imageInfos, err = ecsTool.GetServiceImageInfos(ctx, cluster, service); err != nil { return nil, err } @@ -44,10 +44,10 @@ func (s *scanner) Scan( var ErrNonEcrImage = fmt.Errorf("non-ECR image") -func scanImage(ctx context.Context, ecrTool EcrTool, info *ImageInfo) *ScanResult { - if imageID, err := ecrTool.GetActualImageIdentifier(ctx, info); err != nil { +func scanImage(ctx context.Context, ecrTool EcrTool, info ImageInfo) *ScanResult { + if imageID, err := ecrTool.GetActualImageIdentifier(ctx, &info); err != nil { return &ScanResult{ImageInfo: info, Err: err} - } else if findings, err := ecrTool.GetImageScanFindings(ctx, info, imageID); err != nil { + } else if findings, err := ecrTool.GetImageScanFindings(ctx, &info, imageID); err != nil { return &ScanResult{ImageInfo: info, Err: err} } else { return &ScanResult{ImageInfo: info, ImageScanFindings: findings} diff --git a/cli/cage/audit/scanner_test.go b/cli/cage/audit/scanner_test.go index 8968bc5..7ceca8e 100644 --- a/cli/cage/audit/scanner_test.go +++ b/cli/cage/audit/scanner_test.go @@ -126,7 +126,7 @@ func TestScanImage(t *testing.T) { errID: errors.New("id error"), } - result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + result := scanImage(ctx, tool, ImageInfo{Repository: "repo"}) assert.EqualError(t, result.Err, "id error") }) @@ -137,7 +137,7 @@ func TestScanImage(t *testing.T) { errScan: errors.New("scan error"), } - result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + result := scanImage(ctx, tool, ImageInfo{Repository: "repo"}) assert.EqualError(t, result.Err, "scan error") }) @@ -149,7 +149,7 @@ func TestScanImage(t *testing.T) { findings: findings, } - result := scanImage(ctx, tool, &ImageInfo{Repository: "repo"}) + result := scanImage(ctx, tool, ImageInfo{Repository: "repo"}) assert.NoError(t, result.Err) assert.Equal(t, findings, result.ImageScanFindings) diff --git a/cli/cage/audit/types.go b/cli/cage/audit/types.go index ac5e7cb..741fc17 100644 --- a/cli/cage/audit/types.go +++ b/cli/cage/audit/types.go @@ -27,25 +27,11 @@ func (i *ImageInfo) registryHasECRSuffix() bool { } type ScanResult struct { - ImageInfo *ImageInfo + ImageInfo ImageScanFindings *ecrtypes.ImageScanFindings Err error } -func (s *ScanResult) ContainerName() string { - if s.ImageInfo != nil { - return s.ImageInfo.ContainerName - } - return "" -} - -func (s *ScanResult) ImageURI() string { - if s.ImageInfo != nil { - return formatImageLabel(s.ImageInfo) - } - return "" -} - type ScanResultSummary struct { ContainerName string Status string @@ -83,13 +69,13 @@ func summaryScanResult(result *ScanResult) *ScanResultSummary { status = "WARNING" } return &ScanResultSummary{ - ContainerName: result.ContainerName(), + ContainerName: result.ContainerName, Status: status, CriticalCount: critical, HighCount: high, MediumCount: medium, LowCount: low, InfoCount: info, - ImageURI: result.ImageURI(), + ImageURI: result.formatImageLabel(), } } diff --git a/cli/cage/audit/types_test.go b/cli/cage/audit/types_test.go index f8af97a..3abd2a0 100644 --- a/cli/cage/audit/types_test.go +++ b/cli/cage/audit/types_test.go @@ -56,7 +56,7 @@ func TestImageInfo_IsECRImage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - i := &ImageInfo{ + i := ImageInfo{ Registry: tt.registry, } if got := i.IsECRImage(); got != tt.want { @@ -74,7 +74,7 @@ func Test_summaryScanResult(t *testing.T) { { name: "no findings - NONE status", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", Registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", Repository: "test-repo", @@ -92,18 +92,13 @@ func Test_summaryScanResult(t *testing.T) { MediumCount: 0, LowCount: 0, InfoCount: 0, - ImageURI: formatImageLabel(&ImageInfo{ - ContainerName: "test-container", - Registry: "123456789012.dkr.ecr.us-east-1.amazonaws.com", - Repository: "test-repo", - Tag: "latest", - }), + ImageURI: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo:latest", }, }, { name: "critical findings - VULNERABLE status", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -126,7 +121,7 @@ func Test_summaryScanResult(t *testing.T) { { name: "high findings - VULNERABLE status", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -149,7 +144,7 @@ func Test_summaryScanResult(t *testing.T) { { name: "medium findings - WARNING status", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -172,7 +167,7 @@ func Test_summaryScanResult(t *testing.T) { { name: "low and informational findings - empty status", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "test-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -195,7 +190,7 @@ func Test_summaryScanResult(t *testing.T) { { name: "mixed severity findings", result: &ScanResult{ - ImageInfo: &ImageInfo{ + ImageInfo: ImageInfo{ ContainerName: "mixed-container", }, ImageScanFindings: &ecrtypes.ImageScanFindings{ @@ -248,5 +243,3 @@ func Test_summaryScanResult(t *testing.T) { }) } } - - From 43824a356ecd4737f9b6f4b6cd0f133d98b0301f Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Thu, 22 Jan 2026 13:08:46 +0900 Subject: [PATCH 22/23] a --- Makefile | 3 ++ cli/cage/audit/aggregator.go | 5 +-- cli/cage/audit/aggregator_test.go | 25 ++++++++++++ cli/cage/audit/command.go | 40 +++++++++--------- cli/cage/audit/command_test.go | 67 +++++++++++++++++-------------- cli/cage/audit/deps.go | 5 ++- cli/cage/audit/printer.go | 4 ++ key/keys.go | 1 + mocks/mock_audit/printer.go | 53 ++++++++++++++++++++++++ 9 files changed, 148 insertions(+), 55 deletions(-) create mode 100644 mocks/mock_audit/printer.go diff --git a/Makefile b/Makefile index e9920b5..24cab7e 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ mocks: go.sum \ mocks/mock_types/iface.go \ mocks/mock_upgrade/upgrade.go \ mocks/mock_audit/scanner.go \ + mocks/mock_audit/printer.go \ mocks/mock_task/task.go \ mocks/mock_taskset/taskset.go \ mocks/mock_task/factory.go \ @@ -30,6 +31,8 @@ mocks/mock_upgrade/upgrade.go: cli/cage/upgrade/upgrade.go $(MOCKGEN) -source=./cli/cage/upgrade/upgrade.go > mocks/mock_upgrade/upgrade.go mocks/mock_audit/scanner.go: cli/cage/audit/scanner.go $(MOCKGEN) -source=./cli/cage/audit/scanner.go > mocks/mock_audit/scanner.go +mocks/mock_audit/printer.go: cli/cage/audit/printer.go + $(MOCKGEN) -source=./cli/cage/audit/printer.go > mocks/mock_audit/printer.go mocks/mock_task/task.go: task/task.go $(MOCKGEN) -source=./task/task.go > mocks/mock_task/task.go mocks/mock_taskset/taskset.go: taskset/taskset.go diff --git a/cli/cage/audit/aggregator.go b/cli/cage/audit/aggregator.go index 83df742..f4b4f78 100644 --- a/cli/cage/audit/aggregator.go +++ b/cli/cage/audit/aggregator.go @@ -135,10 +135,7 @@ func (a *aggregater) filterCvesBySeverity(severity ecrtypes.FindingSeverity) []e } func (a *aggregater) GetVulnContainers(cveName string) []string { - containersSet, exists := a.cveToContainers[cveName] - if !exists { - return []string{} - } + containersSet := a.cveToContainers[cveName] return containersSet } diff --git a/cli/cage/audit/aggregator_test.go b/cli/cage/audit/aggregator_test.go index 2d3f7f3..ee80e70 100644 --- a/cli/cage/audit/aggregator_test.go +++ b/cli/cage/audit/aggregator_test.go @@ -235,6 +235,31 @@ func TestAggregateResult_SeverityCounts(t *testing.T) { assert.Equal(t, 1, counts[4].Count) } +func TestAggregater_GetVulnContainers(t *testing.T) { + t.Run("returns containers affected by CVE", func(t *testing.T) { + agg := NewAggregater() + agg.cveToContainers = map[string][]string{ + "CVE-2021-1234": {"container1", "container2"}, + "CVE-2021-5678": {"container3"}, + } + + containers := agg.GetVulnContainers("CVE-2021-1234") + assert.Equal(t, 2, len(containers)) + assert.Contains(t, containers, "container1") + assert.Contains(t, containers, "container2") + }) + + t.Run("returns nil for non-existent CVE", func(t *testing.T) { + agg := NewAggregater() + agg.cveToContainers = map[string][]string{ + "CVE-2021-1234": {"container1"}, + } + + containers := agg.GetVulnContainers("CVE-9999-9999") + assert.Nil(t, containers) + }) +} + func stringPtr(s string) *string { return &s } diff --git a/cli/cage/audit/command.go b/cli/cage/audit/command.go index 94f813c..88eb445 100644 --- a/cli/cage/audit/command.go +++ b/cli/cage/audit/command.go @@ -26,34 +26,36 @@ func NewCommand(di *di.D, input *cageapp.AuditCmdInput) *command { } func (a *command) Run(ctx context.Context) error { - t := a.di.Get(key.Time).(types.Time) + results, err := a.doScan(ctx) + if err != nil { + return err + } + p := a.di.Get(key.Printer).(Printer) + p.Print(results) + return nil +} + +func (a *command) doScan(ctx context.Context) (results []*ScanResult, err error) { l := a.di.Get(key.Logger).(logger.Logger) - scanner := a.di.Get(key.Scanner).(Scanner) + t := a.di.Get(key.Time).(types.Time) + defer l.Printf("\r") + waiter := make(chan struct{}, 1) spinner := logger.NewSpinner() - errchannel := make(chan error, 1) go func() { - defer close(errchannel) - results, err := scanner.Scan(ctx, a.input.Cluster, a.input.Service) - printer := NewPrinter(l, a.input.NoColor, a.input.LogDetail) - l.Printf("\r") // clear spinner line - if err != nil { - errchannel <- err - } else { - printer.Print(results) - } + defer close(waiter) + scanner := a.di.Get(key.Scanner).(Scanner) + results, err = scanner.Scan(ctx, a.input.Cluster, a.input.Service) + waiter <- struct{}{} }() for { timer := t.NewTimer(a.spinInterval) select { case <-ctx.Done(): - return ctx.Err() - case err := <-errchannel: - return err + return nil, ctx.Err() + case <-waiter: + return case <-timer.C: - l.Printf( - "\r%s Scanning ECR image vulnerabilities for ECS service %s/%s", - spinner.Next(), a.input.Cluster, a.input.Service, - ) + l.Printf("\r%s", spinner.Next()) } } } diff --git a/cli/cage/audit/command_test.go b/cli/cage/audit/command_test.go index 8798819..36d6bf4 100644 --- a/cli/cage/audit/command_test.go +++ b/cli/cage/audit/command_test.go @@ -16,48 +16,51 @@ import ( ) func TestAuditCommandRun(t *testing.T) { - makeMockLogger := func(ctrl *gomock.Controller) *mock_logger.MockLogger { + setup := func(t *testing.T) (*mock_audit.MockScanner, *mock_logger.MockLogger, *mock_audit.MockPrinter) { + t.Helper() + ctrl := gomock.NewController(t) + mockScanner := mock_audit.NewMockScanner(ctrl) + mockPrinter := mock_audit.NewMockPrinter(ctrl) mockLogger := mock_logger.NewMockLogger(ctrl) - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Printf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - return mockLogger + return mockScanner, mockLogger, mockPrinter } t.Run("should return error from scanner", func(t *testing.T) { - ctrl := gomock.NewController(t) ctx := context.Background() - mockScanner := mock_audit.NewMockScanner(ctrl) - + mockScanner, mockLogger, _ := setup(t) mockDI := di.NewDomain(func(b *di.B) { - b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Logger, mockLogger) b.Set(key.Scanner, mockScanner) b.Set(key.Time, test.NewFakeNeverTimer()) }) - - mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(nil, test.Err) - - cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ - Cluster: "cluster", - Service: "service", - App: &cageapp.App{}, - }) - + gomock.InOrder( + mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(nil, test.Err), + mockLogger.EXPECT().Printf("\r"), + ) + input := cageapp.NewAuditCmdInput() + input.Cluster = "cluster" + input.Service = "service" + cmd := audit.NewCommand(mockDI, input) err := cmd.Run(ctx) - assert.EqualError(t, err, "error") + assert.Equal(t, test.Err, err) }) t.Run("should return nil on successful scan", func(t *testing.T) { - ctrl := gomock.NewController(t) ctx := context.Background() - mockScanner := mock_audit.NewMockScanner(ctrl) + + mockScanner, mockLogger, mockPrinter := setup(t) mockDI := di.NewDomain(func(b *di.B) { - b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Logger, mockLogger) b.Set(key.Scanner, mockScanner) + b.Set(key.Printer, mockPrinter) b.Set(key.Time, test.NewFakeNeverTimer()) }) - results := []*audit.ScanResult{} - mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(results, nil) + var results []*audit.ScanResult + gomock.InOrder( + mockScanner.EXPECT().Scan(ctx, "cluster", "service").Return(results, nil), + mockLogger.EXPECT().Printf("\r"), + mockPrinter.EXPECT().Print(results), + ) cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ Cluster: "cluster", @@ -70,18 +73,20 @@ func TestAuditCommandRun(t *testing.T) { }) t.Run("should return context error when context is cancelled", func(t *testing.T) { - ctrl := gomock.NewController(t) ctx, cancel := context.WithCancel(context.Background()) - mockScanner := mock_audit.NewMockScanner(ctrl) + mockScanner, mockLogger, _ := setup(t) mockDI := di.NewDomain(func(b *di.B) { - b.Set(key.Logger, makeMockLogger(ctrl)) + b.Set(key.Logger, mockLogger) b.Set(key.Scanner, mockScanner) b.Set(key.Time, test.NewFakeNeverTimer()) }) - mockScanner.EXPECT().Scan(ctx, "cluster", "service").DoAndReturn(func(context.Context, string, string) ([]audit.ScanResult, error) { - cancel() - return nil, nil - }) + gomock.InOrder( + mockScanner.EXPECT().Scan(ctx, "cluster", "service").DoAndReturn(func(context.Context, string, string) ([]audit.ScanResult, error) { + cancel() + return nil, nil + }), + mockLogger.EXPECT().Printf("\r"), + ) cmd := audit.NewCommand(mockDI, &cageapp.AuditCmdInput{ Cluster: "cluster", diff --git a/cli/cage/audit/deps.go b/cli/cage/audit/deps.go index aa1a623..c1f65b5 100644 --- a/cli/cage/audit/deps.go +++ b/cli/cage/audit/deps.go @@ -24,8 +24,11 @@ func ProvideAuditCmd(ctx context.Context, input *cageapp.AuditCmdInput) (types.A d := di.NewDomain(func(b *di.B) { ecsCli := ecs.NewFromConfig(conf) ecrCli := ecr.NewFromConfig(conf) + l := logger.DefaultLogger(os.Stdout) + p := NewPrinter(l, input.NoColor, input.LogDetail) b.Set(key.Scanner, NewScanner(ecsCli, ecrCli)) - b.Set(key.Logger, logger.DefaultLogger(os.Stdout)) + b.Set(key.Logger, l) + b.Set(key.Printer, p) b.Set(key.Time, &timeout.Time{}) }) return NewCommand(d, input), nil diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index 49357ba..1fe51db 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -14,6 +14,10 @@ type printer struct { logDetail bool } +type Printer interface { + Print(result []*ScanResult) +} + func NewPrinter(l logger.Logger, noColor, logDetail bool) *printer { return &printer{ logger: l, diff --git a/key/keys.go b/key/keys.go index c65a919..2fef017 100644 --- a/key/keys.go +++ b/key/keys.go @@ -9,6 +9,7 @@ const ( AlbCli DepsKey = "alb" Logger DepsKey = "logger" Scanner DepsKey = "scanner" + Printer DepsKey = "printer" Env DepsKey = "env" Time DepsKey = "time" TaskFactory DepsKey = "task-factory" diff --git a/mocks/mock_audit/printer.go b/mocks/mock_audit/printer.go new file mode 100644 index 0000000..787517c --- /dev/null +++ b/mocks/mock_audit/printer.go @@ -0,0 +1,53 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./cli/cage/audit/printer.go +// +// Generated by this command: +// +// mockgen -source=./cli/cage/audit/printer.go +// + +// Package mock_audit is a generated GoMock package. +package mock_audit + +import ( + reflect "reflect" + + audit "github.com/loilo-inc/canarycage/cli/cage/audit" + gomock "go.uber.org/mock/gomock" +) + +// MockPrinter is a mock of Printer interface. +type MockPrinter struct { + ctrl *gomock.Controller + recorder *MockPrinterMockRecorder + isgomock struct{} +} + +// MockPrinterMockRecorder is the mock recorder for MockPrinter. +type MockPrinterMockRecorder struct { + mock *MockPrinter +} + +// NewMockPrinter creates a new mock instance. +func NewMockPrinter(ctrl *gomock.Controller) *MockPrinter { + mock := &MockPrinter{ctrl: ctrl} + mock.recorder = &MockPrinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrinter) EXPECT() *MockPrinterMockRecorder { + return m.recorder +} + +// Print mocks base method. +func (m *MockPrinter) Print(result []*audit.ScanResult) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Print", result) +} + +// Print indicates an expected call of Print. +func (mr *MockPrinterMockRecorder) Print(result any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Print", reflect.TypeOf((*MockPrinter)(nil).Print), result) +} From 5d243076599f59cd65b47b0b3830a881b60faae5 Mon Sep 17 00:00:00 2001 From: Yusuke Sakurai Date: Thu, 22 Jan 2026 17:32:37 +0900 Subject: [PATCH 23/23] add package name --- cli/cage/audit/printer.go | 15 +++- cli/cage/audit/printer_test.go | 158 +++++++++++++++++++-------------- 2 files changed, 106 insertions(+), 67 deletions(-) diff --git a/cli/cage/audit/printer.go b/cli/cage/audit/printer.go index 1fe51db..83340b7 100644 --- a/cli/cage/audit/printer.go +++ b/cli/cage/audit/printer.go @@ -98,8 +98,19 @@ func (p *printer) logImageScanFindings( for _, c := range containers { containerList = append(containerList, color.Bold(c)) } - p.logger.Printf("- %s \n", strings.Join(containerList, ", ")) - p.logger.Printf(" %s (%s)\n", *cve.Name, *cve.Uri) + var packageName string = "unknown" + var packageVersion string = "unknown" + for _, attr := range cve.Attributes { + switch *attr.Key { + case "package_name": + packageName = *attr.Value + case "package_version": + packageVersion = *attr.Value + } + } + p.logger.Printf("- %s %s \n", *cve.Name, strings.Join(containerList, ", ")) + p.logger.Printf(" %s::%s (%s)\n", + packageName, packageVersion, *cve.Uri) if p.logDetail { p.logger.Printf("\n%s\n", *cve.Description) } diff --git a/cli/cage/audit/printer_test.go b/cli/cage/audit/printer_test.go index 42db43f..e38a326 100644 --- a/cli/cage/audit/printer_test.go +++ b/cli/cage/audit/printer_test.go @@ -149,7 +149,7 @@ func TestPrinter_Print(t *testing.T) { } func TestPrinter_logImageScanFindings(t *testing.T) { - t.Run("does nothing when findings are empty", func(t *testing.T) { + t.Run("returns early when no findings", func(t *testing.T) { logger := &mockLogger{} printer := NewPrinter(logger, true, false) agg := NewAggregater() @@ -165,21 +165,27 @@ func TestPrinter_logImageScanFindings(t *testing.T) { logger := &mockLogger{} printer := NewPrinter(logger, true, false) agg := NewAggregater() - result := makeScanResult(ecrtypes.FindingSeverityCritical) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com"), + Description: aws.String("Test description"), + Attributes: []ecrtypes.Attribute{}, + }, + } + printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) headerFound := false for _, log := range logger.logs { - if containsString(log, "=== CRITICAL ===") { + if containsString(log, "CRITICAL") && containsString(log, "===") { headerFound = true break } } if !headerFound { - t.Error("Expected severity header to be printed") + t.Error("Expected severity header with CRITICAL") } }) @@ -187,10 +193,16 @@ func TestPrinter_logImageScanFindings(t *testing.T) { logger := &mockLogger{} printer := NewPrinter(logger, true, false) agg := NewAggregater() - result := makeScanResult(ecrtypes.FindingSeverityHigh) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com/cve"), + Description: aws.String("Test description"), + Attributes: []ecrtypes.Attribute{}, + }, + } + printer.logImageScanFindings(ecrtypes.FindingSeverityHigh, findings, agg) cveFound := false @@ -199,114 +211,130 @@ func TestPrinter_logImageScanFindings(t *testing.T) { if containsString(log, "CVE-2023-0001") { cveFound = true } - if containsString(log, "http://example.com") { + if containsString(log, "http://example.com/cve") { uriFound = true } } if !cveFound { - t.Error("Expected CVE name to be printed") + t.Error("Expected CVE name in output") } if !uriFound { - t.Error("Expected CVE URI to be printed") + t.Error("Expected CVE URI in output") } }) - t.Run("prints container names", func(t *testing.T) { + t.Run("extracts package name and version from attributes", func(t *testing.T) { logger := &mockLogger{} printer := NewPrinter(logger, true, false) agg := NewAggregater() - result := makeScanResult(ecrtypes.FindingSeverityMedium) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com"), + Description: aws.String("Test description"), + Attributes: []ecrtypes.Attribute{ + {Key: aws.String("package_name"), Value: aws.String("test-package")}, + {Key: aws.String("package_version"), Value: aws.String("1.2.3")}, + }, + }, + } + printer.logImageScanFindings(ecrtypes.FindingSeverityMedium, findings, agg) - containerFound := false + packageFound := false for _, log := range logger.logs { - if containsString(log, "test-container") { - containerFound = true + if containsString(log, "test-package::1.2.3") { + packageFound = true break } } - if !containerFound { - t.Error("Expected container name to be printed") + if !packageFound { + t.Error("Expected package name and version in output") } }) - t.Run("prints description when logDetail is true", func(t *testing.T) { + t.Run("uses unknown for missing package info", func(t *testing.T) { logger := &mockLogger{} - printer := NewPrinter(logger, true, true) // logDetail = true + printer := NewPrinter(logger, true, false) agg := NewAggregater() - result := makeScanResult(ecrtypes.FindingSeverityCritical) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings - printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com"), + Description: aws.String("Test description"), + Attributes: []ecrtypes.Attribute{}, + }, + } + + printer.logImageScanFindings(ecrtypes.FindingSeverityLow, findings, agg) - descriptionFound := false + unknownFound := false for _, log := range logger.logs { - if containsString(log, "Test vulnerability description") { - descriptionFound = true + if containsString(log, "unknown::unknown") { + unknownFound = true break } } - if !descriptionFound { - t.Error("Expected description to be printed when logDetail is true") + if !unknownFound { + t.Error("Expected unknown::unknown for missing package info") } }) - t.Run("does not print description when logDetail is false", func(t *testing.T) { + t.Run("prints description when logDetail is true", func(t *testing.T) { logger := &mockLogger{} - printer := NewPrinter(logger, true, false) // logDetail = false + printer := NewPrinter(logger, true, true) // logDetail = true agg := NewAggregater() - result := makeScanResult(ecrtypes.FindingSeverityCritical) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings - printer.logImageScanFindings(ecrtypes.FindingSeverityCritical, findings, agg) + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com"), + Description: aws.String("Detailed vulnerability description"), + Attributes: []ecrtypes.Attribute{}, + }, + } - descriptionFound := false + printer.logImageScanFindings(ecrtypes.FindingSeverityHigh, findings, agg) + + descFound := false for _, log := range logger.logs { - if containsString(log, "Test vulnerability description") { - descriptionFound = true + if containsString(log, "Detailed vulnerability description") { + descFound = true break } } - if descriptionFound { - t.Error("Expected description NOT to be printed when logDetail is false") + if !descFound { + t.Error("Expected description in output when logDetail is true") } }) - t.Run("handles multiple findings", func(t *testing.T) { + t.Run("does not print description when logDetail is false", func(t *testing.T) { logger := &mockLogger{} - printer := NewPrinter(logger, true, false) + printer := NewPrinter(logger, true, false) // logDetail = false agg := NewAggregater() - result := makeScanResult( - ecrtypes.FindingSeverityHigh, - ecrtypes.FindingSeverityHigh, - ecrtypes.FindingSeverityHigh, - ) - agg.Add(result[0]) - findings := result[0].ImageScanFindings.Findings + findings := []ecrtypes.ImageScanFinding{ + { + Name: aws.String("CVE-2023-0001"), + Uri: aws.String("http://example.com"), + Description: aws.String("Detailed vulnerability description"), + Attributes: []ecrtypes.Attribute{}, + }, + } + printer.logImageScanFindings(ecrtypes.FindingSeverityHigh, findings, agg) - cve1Found := false - cve2Found := false - cve3Found := false + descFound := false for _, log := range logger.logs { - if containsString(log, "CVE-2023-0001") { - cve1Found = true - } - if containsString(log, "CVE-2023-0002") { - cve2Found = true - } - if containsString(log, "CVE-2023-0003") { - cve3Found = true + if containsString(log, "Detailed vulnerability description") { + descFound = true + break } } - if !cve1Found || !cve2Found || !cve3Found { - t.Error("Expected all three CVEs to be printed") + if descFound { + t.Error("Expected no description in output when logDetail is false") } }) }