Skip to content
Open
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
553 changes: 282 additions & 271 deletions .gaze/baseline.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@

### Fixed

- OCI reference parsing now supports standard `:tag` syntax (e.g.,
`registry.com/org/image:v0.4.0`) in addition to the existing `@version`
notation for both policies and complypacks. Digest references
(`@sha256:...`) are also supported. Invalid OCI references in
`complytime.yaml` are now detected at config load time with clear
error messages. (#594)
- Scan reports now resolve assessment plan IDs to requirement IDs,
ensuring output displays meaningful identifiers instead of internal
plan references. Affects EvaluationLog, OSCAL, SARIF, and Markdown
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ crapload-baseline: ensure-gaze test-unit ## generate baseline thresholds in .gaz
@mkdir -p .gaze
@REPO_ROOT=$$(pwd); \
gaze crap --format=json --coverprofile=$(GAZE_COVERPROFILE) ./... | \
jq --arg root "$$REPO_ROOT/" '(.scores[],.summary.worst_crap[]?,.summary.worst_gaze_crap[]?) |= (.file |= ltrimstr($$root))' > $(GAZE_BASELINE)
jq --arg root "$$REPO_ROOT/" '(.scores[],.summary.worst_crap[]?,.summary.worst_gaze_crap[]?,.summary.recommended_actions[]?) |= (.file |= ltrimstr($$root))' > $(GAZE_BASELINE)
@echo "Baseline written to $(GAZE_BASELINE)"
.PHONY: crapload-baseline

Expand All @@ -163,7 +163,7 @@ crapload-check: ensure-gaze test-unit ## check for CRAP regressions against base
fi
@REPO_ROOT=$$(pwd); \
gaze crap --format=json --coverprofile=$(GAZE_COVERPROFILE) ./... | \
jq --arg root "$$REPO_ROOT/" '(.scores[],.summary.worst_crap[]?,.summary.worst_gaze_crap[]?) |= (.file |= ltrimstr($$root))' > /tmp/crapload-current.json
jq --arg root "$$REPO_ROOT/" '(.scores[],.summary.worst_crap[]?,.summary.worst_gaze_crap[]?,.summary.recommended_actions[]?) |= (.file |= ltrimstr($$root))' > /tmp/crapload-current.json
@echo "Comparing against baseline..."
@jq -r '.scores[] | "\(.file):\(.function) \(.crap) \(.gaze_crap // 0)"' $(GAZE_BASELINE) | sort > /tmp/crapload-baseline.txt
@jq -r '.scores[] | "\(.file):\(.function) \(.crap) \(.gaze_crap // 0)"' /tmp/crapload-current.json | sort > /tmp/crapload-current.txt
Expand Down
5 changes: 4 additions & 1 deletion cmd/complyctl/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ func (o *generateOptions) run(ctx context.Context) error {
}

func (o *generateOptions) generatePolicy(ctx context.Context, cfg *complytime.WorkspaceConfig, entry complytime.PolicyEntry, baseDir string) error {
ref := complytime.ParsePolicyRef(entry.URL)
ref, err := complytime.ParsePolicyRef(entry.URL)
if err != nil {
return fmt.Errorf("invalid policy reference %q: %w", entry.URL, err)
}
eid := entry.EffectiveID()

version, graph, err := resolveVersionAndGraph(o.cacheDir, ref)
Expand Down
10 changes: 8 additions & 2 deletions cmd/complyctl/cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ func syncAllPolicies(ctx context.Context, cacheMgr *cache.Cache, state *cache.St
}

func syncSinglePolicy(ctx context.Context, cacheMgr *cache.Cache, state *cache.State, credFunc auth.CredentialFunc, entry complytime.PolicyEntry, index, total int) error {
ref := complytime.ParsePolicyRef(entry.URL)
ref, err := complytime.ParsePolicyRef(entry.URL)
if err != nil {
return fmt.Errorf("invalid policy reference %q: %w", entry.URL, err)
}
version := ref.Version

client := registry.NewClient(ref.Registry, credFunc)
Expand Down Expand Up @@ -202,7 +205,10 @@ func syncAllComplypacks(ctx context.Context, state *cache.State, credFunc auth.C
}

func syncSingleComplypack(ctx context.Context, state *cache.State, credFunc auth.CredentialFunc, entry complytime.PolicyEntry, index, total int, cacheDir string) error {
ref := complytime.ParsePolicyRef(entry.URL)
ref, err := complytime.ParsePolicyRef(entry.URL)
if err != nil {
return fmt.Errorf("invalid complypack reference %q: %w", entry.URL, err)
}
version := ref.Version

client := registry.NewClient(ref.Registry, credFunc)
Expand Down
5 changes: 4 additions & 1 deletion cmd/complyctl/cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ func (o *listOptions) run(_ context.Context) error {
continue
}

ref := complytime.ParsePolicyRef(p.URL)
ref, err := complytime.ParsePolicyRef(p.URL)
if err != nil {
return fmt.Errorf("invalid policy reference %q: %w", p.URL, err)
}
versions, _ := loader.GetCachedVersions(ref.Repository)

var versionStr string
Expand Down
5 changes: 4 additions & 1 deletion cmd/complyctl/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,10 @@ func loadWorkspaceConfig(baseDir string) (*complytime.WorkspaceConfig, error) {
}

func (o *scanOptions) scanPolicy(ctx context.Context, cfg *complytime.WorkspaceConfig, entry complytime.PolicyEntry, targetID, baseDir string) error {
ref := complytime.ParsePolicyRef(entry.URL)
ref, err := complytime.ParsePolicyRef(entry.URL)
if err != nil {
return fmt.Errorf("invalid policy reference %q: %w", entry.URL, err)
}
eid := entry.EffectiveID()

version, graph, err := resolveVersionAndGraph(o.cacheDir, ref)
Expand Down
5 changes: 1 addition & 4 deletions internal/cache/complypack_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ func (s *ComplypackSync) SyncComplypack(ctx context.Context, repository, version
return false, fmt.Errorf("complypack repository cannot be empty")
}

lookupRef := repository
if version != "" && version != "latest" {
lookupRef = repository + ":" + version
}
lookupRef := BuildLookupRef(repository, version)

remoteDigest, remoteVersion, err := s.source.DefinitionVersion(ctx, lookupRef)
if err != nil {
Expand Down
20 changes: 16 additions & 4 deletions internal/cache/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,25 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/complytime/complyctl/internal/registry"
)

// BuildLookupRef constructs an OCI lookup reference from a repository and
// version string. For digest versions (sha256:, sha512:) it uses "@" as
// the separator; for tag versions it uses ":". If the version is empty or
// "latest", the bare repository is returned so oras resolves the default tag.
func BuildLookupRef(repository, version string) string {
if version == "" || version == "latest" {
return repository
}
if strings.HasPrefix(version, "sha256:") || strings.HasPrefix(version, "sha512:") {
return repository + "@" + version
}
return repository + ":" + version
}

// Sync provides incremental sync using oras.Copy() for remote-to-local transfer.
type Sync struct {
cache *Cache
Expand All @@ -33,10 +48,7 @@ func (s *Sync) SyncPolicy(ctx context.Context, policyID, version string) error {
return fmt.Errorf("policy ID cannot be empty")
}

lookupRef := policyID
if version != "" && version != "latest" {
lookupRef = policyID + ":" + version
}
lookupRef := BuildLookupRef(policyID, version)

remoteDigest, remoteVersion, err := s.source.DefinitionVersion(ctx, lookupRef)
if err != nil {
Expand Down
33 changes: 33 additions & 0 deletions internal/cache/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,36 @@ func TestSync_StressConcurrentFailures(t *testing.T) {
"OCI layout marker must exist after successful syncs")
}
}

func TestBuildLookupRef(t *testing.T) {
tests := []struct {
name string
repository string
version string
want string
}{
{"tag version", "org/policy", "v1.0", "org/policy:v1.0"},
{"sha256 digest", "org/policy", "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "org/policy@sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},
{"sha512 digest", "org/policy", "sha512:def456", "org/policy@sha512:def456"},
{"empty version", "org/policy", "", "org/policy"},
{"latest version", "org/policy", "latest", "org/policy"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cache.BuildLookupRef(tt.repository, tt.version)
assert.Equal(t, tt.want, got)
})
}
}

// TestBuildLookupRef_Regression_NoDoubleTag verifies that the original
// bug (issue #594) is prevented: a :tag in the repository must not
// produce a double-tagged reference like "repo:v0.4.0:v0.4.0".
func TestBuildLookupRef_Regression_NoDoubleTag(t *testing.T) {
// When ParsePolicyRef correctly extracts the tag, Repository
// will be "complytime/complypack-ampel-bp" and Version "v0.4.0".
// BuildLookupRef should produce a single-tagged reference.
lookupRef := cache.BuildLookupRef("complytime/complypack-ampel-bp", "v0.4.0")
assert.Equal(t, "complytime/complypack-ampel-bp:v0.4.0", lookupRef)
assert.NotContains(t, lookupRef, ":v0.4.0:v0.4.0")
}
102 changes: 86 additions & 16 deletions internal/complytime/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/goccy/go-yaml"
orasreg "oras.land/oras-go/v2/registry"
)

var envVarPattern = regexp.MustCompile(`\$\{([^}]+)\}`)
Expand Down Expand Up @@ -85,7 +86,9 @@ func (p PolicyEntry) EffectiveID() string {
if p.ID != "" {
return p.ID
}
ref := ParsePolicyRef(p.URL)
// Error ignored: LoadFrom validates all URLs via validatePolicyRefs
// at config load time, so ParsePolicyRef will not fail for loaded entries.
ref, _ := ParsePolicyRef(p.URL)
segments := strings.Split(ref.Repository, "/")
return segments[len(segments)-1]
}
Expand All @@ -98,21 +101,45 @@ type TargetConfig struct {
Variables map[string]string `yaml:"variables,omitempty"`
}

// PolicyRef represents a parsed OCI policy reference.
// PolicyRef represents a parsed OCI policy reference with its components
// separated for downstream use (registry client construction, cache lookup,
// version resolution).
type PolicyRef struct {
Raw string
Registry string
// Raw is the original unparsed input string.
Raw string
// Registry is the registry host, optionally prefixed with http:// or
// https://. Empty for bare policy IDs (no slash in input).
Registry string
// Repository is the repository path within the registry (e.g.,
// "policies/nist-800-53-r5"). For bare policy IDs, this is the
// identifier itself.
Repository string
Version string
// Version is the tag, digest, or empty string. For :tag and @version
// inputs it holds the version string (e.g., "v1.0"). For digest inputs
// it holds the full algorithm:hex string (e.g., "sha256:9f86d...").
// Empty when no version or tag was specified.
Version string
}

// ParsePolicyRef parses a full OCI reference into its components.
// Handles optional scheme (http://, https://), registry host detection,
// and @version suffix.
func ParsePolicyRef(raw string) PolicyRef {
// :tag, @version, @digest, and bare policy IDs (no slash). Delegates to
// oras-go's registry.ParseReference for standard OCI references.
//
// The @version notation (e.g., "registry.com/repo@v1.0") is a complytime
// convention that predates standard OCI tag syntax. ParsePolicyRef converts
// @version to :tag before delegating to oras-go, preserving backwards
// compatibility. Actual digests (e.g., "@sha256:...") are passed through
// to oras-go directly.
func ParsePolicyRef(raw string) (PolicyRef, error) {
ref := PolicyRef{Raw: raw}
s := strings.TrimSpace(raw)

if s == "" {
return ref, fmt.Errorf("policy reference cannot be empty")
}

// Strip URL scheme prefix; oras-go does not accept schemes.
var scheme string
if strings.HasPrefix(s, "http://") {
scheme = "http://"
Expand All @@ -122,20 +149,40 @@ func ParsePolicyRef(raw string) PolicyRef {
s = strings.TrimPrefix(s, "https://")
}

// Bare policy IDs (no slash) are convention-based identifiers, not OCI
// references. Handle them directly: extract an optional @version suffix
// and treat the rest as the repository.
if !strings.Contains(s, "/") {
if idx := strings.LastIndex(s, "@"); idx > 0 && idx < len(s)-1 {
ref.Version = s[idx+1:]
s = s[:idx]
}
ref.Repository = s
return ref, nil
}

// Convert complytime's @version notation to standard :tag syntax before
// delegating to oras-go. Actual digests (sha256:, sha512:) keep the @
// separator so oras-go parses them as digests.
if idx := strings.LastIndex(s, "@"); idx > 0 && idx < len(s)-1 {
ref.Version = s[idx+1:]
s = s[:idx]
suffix := s[idx+1:]
if !strings.HasPrefix(suffix, "sha256:") && !strings.HasPrefix(suffix, "sha512:") {
// Non-digest @version — convert to :tag for oras-go.
s = s[:idx] + ":" + suffix
}
}

parts := strings.SplitN(s, "/", 2)
if len(parts) == 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
ref.Registry = scheme + parts[0]
ref.Repository = parts[1]
} else {
ref.Repository = s
// Delegate to oras-go for standard OCI references.
orasRef, err := orasreg.ParseReference(s)
if err != nil {
return ref, fmt.Errorf("invalid OCI reference %q: %w", raw, err)
}

return ref
ref.Registry = scheme + orasRef.Registry
ref.Repository = orasRef.Repository
ref.Version = orasRef.Reference

return ref, nil
}

// FindPolicy matches a policy identifier against the policies list by effective ID.
Expand Down Expand Up @@ -186,9 +233,32 @@ func LoadFrom(configPath string) (*WorkspaceConfig, error) {
return nil, err
}

if err := validatePolicyRefs(&config); err != nil {
return nil, fmt.Errorf("invalid config %s: %w", configPath, err)
}

return &config, nil
}

// validatePolicyRefs checks that all policy and complypack URLs are
// parseable OCI references. Called by LoadFrom at load time; if any
// entry fails parsing, LoadFrom returns an error and the config is not
// returned. Downstream code can therefore assume ParsePolicyRef will
// succeed for any URL in a loaded config.
func validatePolicyRefs(config *WorkspaceConfig) error {
for _, entry := range config.Policies {
if _, err := ParsePolicyRef(entry.URL); err != nil {
return fmt.Errorf("policies[].url %q: %w", entry.URL, err)
}
}
for _, entry := range config.Complypacks {
if _, err := ParsePolicyRef(entry.URL); err != nil {
return fmt.Errorf("complypacks[].url %q: %w", entry.URL, err)
}
}
return nil
}

// resolveEnvVars expands ${VAR} references in target variable values
// and collector auth fields from the process environment. Returns an
// error if a referenced variable is not set.
Expand Down
Loading
Loading