Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: New Release

on:
push:
tags:
- 'v*'

jobs:
release:
runs-on: ubuntu-latest
permissions:
packages: write
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install gooci cli
run: go install github.com/compliance-framework/gooci@v0.0.7
- name: Authenticate gooci cli
run: gooci login ghcr.io --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }}
- name: gooci Upload Version
run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.ref_name }}
- name: gooci Upload Latest
if: "!contains(github.ref_name, '-')"
run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
57 changes: 57 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,63 @@ import (
"testing"
)

func copyRepos(src []RepositoryContext) []RepositoryContext {
dst := make([]RepositoryContext, len(src))
copy(dst, src)
return dst
}

func assertReposUnchanged(t *testing.T, original, after []RepositoryContext) {
t.Helper()
if len(after) != len(original) {
t.Errorf("input slice length changed: want %d, got %d", len(original), len(after))
return
}
for i := range original {
if original[i].AccountID != after[i].AccountID || original[i].RepositoryName != after[i].RepositoryName {
t.Errorf("input slice mutated at index %d: want %+v, got %+v", i, original[i], after[i])
}
}
}

func TestFilterByAccounts(t *testing.T) {
seed := []RepositoryContext{
{AccountID: "111111111111", RepositoryName: "repo-a"},
{AccountID: "222222222222", RepositoryName: "repo-b"},
{AccountID: "333333333333", RepositoryName: "repo-c"},
}

t.Run("empty accounts returns all", func(t *testing.T) {
repos := copyRepos(seed)
got := FilterByAccounts(repos, nil)
if len(got) != 3 {
t.Fatalf("want 3, got %d", len(got))
}
assertReposUnchanged(t, seed, repos)
})

t.Run("filters to matching accounts", func(t *testing.T) {
repos := copyRepos(seed)
got := FilterByAccounts(repos, []string{"111111111111", "333333333333"})
if len(got) != 2 {
t.Fatalf("want 2, got %d", len(got))
}
if got[0].AccountID != "111111111111" || got[1].AccountID != "333333333333" {
t.Errorf("unexpected accounts: %v", got)
}
assertReposUnchanged(t, seed, repos)
})

t.Run("no match returns empty", func(t *testing.T) {
repos := copyRepos(seed)
got := FilterByAccounts(repos, []string{"999999999999"})
if len(got) != 0 {
t.Fatalf("want 0, got %d", len(got))
}
assertReposUnchanged(t, seed, repos)
})
}

func TestArnPartition(t *testing.T) {
cases := []struct {
region string
Expand Down
19 changes: 19 additions & 0 deletions internal/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ func StringAddressed(str string) *string {
return &str
}

// FilterByAccounts returns only the repositories whose AccountID appears in
// accounts. If accounts is empty the full list is returned unchanged.
func FilterByAccounts(repos []RepositoryContext, accounts []string) []RepositoryContext {
if len(accounts) == 0 {
return repos
}
allowed := make(map[string]struct{}, len(accounts))
for _, a := range accounts {
allowed[a] = struct{}{}
}
out := make([]RepositoryContext, 0, len(repos))
for _, r := range repos {
if _, ok := allowed[r.AccountID]; ok {
out = append(out, r)
}
}
return out
}

// arnPartition returns the AWS partition for the given region.
// cn-* regions use aws-cn; us-gov-* regions use aws-us-gov; everything else uses aws.
func arnPartition(region string) string {
Expand Down
77 changes: 53 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,26 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api
}
}

// Scope policy paths to each resource type using behavior mapping.
// Default: a bundle named "*ecr-repository-policies*" covers repository/registry checks;
// one named "*ecr-image-policies*" covers image checks. A single bundle containing all
// policies (e.g. plugin-aws-ecr-policies) is mapped to all three behaviors so it
// continues to work unchanged; operators may override by supplying two separate bundles
// and configuring PolicyBehavior in the agent config.
defaultBehaviorMapping := map[string][]string{
"ecr-repository-policies": {"repository", "registry"},
"ecr-image-policies": {"image"},
// Single all-in-one bundle: cover every behavior so it works out of the box.
"plugin-aws-ecr-policies": {"repository", "registry", "image"},
}
policyEval := request.
WithDefaultPolicyBehavior(defaultBehaviorMapping).
WithUndefinedMappedTo([]string{"repository", "registry"})

repositoryPaths := policyEval.PolicyPathsForBehavior("repository")
registryPaths := policyEval.PolicyPathsForBehavior("registry")
imagePaths := policyEval.PolicyPathsForBehavior("image")

dataFetcher := internal.NewDataFetcher(l.logger, l.config)
policyEvaluator := internal.NewPolicyEvaluator(ctx, l.logger, activities)

Expand All @@ -111,43 +131,52 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api
if err != nil {
return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("region %s: fetching repositories: %w", region, err)
}

// Filter to configured accounts if any are specified.
repos = internal.FilterByAccounts(repos, l.config.Accounts)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, repo := range repos {
evidences, err := policyEvaluator.EvalRepository(ctx, repo, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels)
evidences, err := policyEvaluator.EvalRepository(ctx, repo, repositoryPaths, l.policyData, l.config.PolicyLabels)
allEvidences = append(allEvidences, evidences...)
if err != nil {
evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating repository %s: %w", repo.RepositoryName, err))
}
}

// CONFIG — registry scanning check (one per region)
registry, err := dataFetcher.FetchRegistryConfig(ctx, region)
if err != nil {
l.logger.Warn("failed to fetch registry scanning config", "region", region, "error", err)
} else {
evidences, err := policyEvaluator.EvalRegistry(ctx, registry, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels)
allEvidences = append(allEvidences, evidences...)
// CONFIG — registry scanning check (one per region, skipped for image-only runs)
if len(registryPaths) > 0 {
registry, err := dataFetcher.FetchRegistryConfig(ctx, region)
if err != nil {
evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating registry %s/%s: %w", registry.AccountID, region, err))
evalErrors = errors.Join(evalErrors, fmt.Errorf("region %s: fetching registry scanning config: %w", region, err))
} else {
evidences, err := policyEvaluator.EvalRegistry(ctx, registry, registryPaths, l.policyData, l.config.PolicyLabels)
allEvidences = append(allEvidences, evidences...)
if err != nil {
evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating registry %s/%s: %w", registry.AccountID, region, err))
}
}
}

// DYNAMIC — image scan checks
if len(repos) > 0 {
repoNames := make([]string, len(repos))
for i, r := range repos {
repoNames[i] = r.RepositoryName
// DYNAMIC — image scan checks (skipped for config-only runs)
if len(imagePaths) > 0 && len(repos) > 0 {
// Group repos by account so each account's images are fetched with the
// correct accountID — using repos[0].AccountID for all repos is wrong
// when multiple accounts are configured.
reposByAccount := make(map[string][]string)
for _, r := range repos {
reposByAccount[r.AccountID] = append(reposByAccount[r.AccountID], r.RepositoryName)
}
accountID := repos[0].AccountID

images, err := dataFetcher.FetchImages(ctx, region, repoNames, accountID, lookbackDays)
if err != nil {
return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("region %s: fetching images: %w", region, err)
}
for _, image := range images {
evidences, err := policyEvaluator.EvalImage(ctx, image, request.GetPolicyPaths(), l.policyData, l.config.PolicyLabels)
allEvidences = append(allEvidences, evidences...)
for accID, names := range reposByAccount {
images, err := dataFetcher.FetchImages(ctx, region, names, accID, lookbackDays)
if err != nil {
evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating image %s@%s: %w", image.RepositoryName, image.ImageDigest, err))
return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, fmt.Errorf("region %s account %s: fetching images: %w", region, accID, err)
}
for _, image := range images {
evidences, err := policyEvaluator.EvalImage(ctx, image, imagePaths, l.policyData, l.config.PolicyLabels)
allEvidences = append(allEvidences, evidences...)
if err != nil {
evalErrors = errors.Join(evalErrors, fmt.Errorf("evaluating image %s@%s: %w", image.RepositoryName, image.ImageDigest, err))
}
}
}
}
Expand Down
Loading