diff --git a/.taskfiles/dev.yml b/.taskfiles/dev.yml index 484c8e3..132a911 100644 --- a/.taskfiles/dev.yml +++ b/.taskfiles/dev.yml @@ -50,14 +50,14 @@ tasks: desc: Run golangci-lint preconditions: - sh: command -v golangci-lint - msg: "golangci-lint is required — install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.2.2" + msg: "golangci-lint is required — install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4" cmd: golangci-lint run ./... fmt: desc: Format Go source files preconditions: - sh: command -v golangci-lint - msg: "golangci-lint is required — install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.2.2" + msg: "golangci-lint is required — install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4" cmd: golangci-lint fmt ./... vet: diff --git a/README.md b/README.md index 2a43c11..f935ecb 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,11 @@ evaluator-id: opa # ComplyPack artifact version version: 0.1.0 -# Gemara policy source (for MCP server) +# Gemara policy sources. Drive the MCP server's policy tools and, when +# packing, the source provenance recorded in the published artifact. gemara: - source: oci://ghcr.io/org/controls:v1 + sources: + - source: oci://ghcr.io/org/controls:v1 # Platform schemas (for MCP server validation tools) # Built-in platforms: ci-github-actions, ci-gitlab, ci-azure-pipelines, @@ -156,7 +158,14 @@ complypack pack policy/ ghcr.io/org/my-policies:v1.0.0 complypack pack policy/ localhost:5001/test:latest --plain-http ``` -The command reads `evaluator-id` and `version` from `complypack.yaml`. The content directory is tar+gzipped and stored as the artifact's opaque content layer. +The command reads `evaluator-id`, `version`, and `gemara.sources` from `complypack.yaml`. The content directory is tar+gzipped and stored as the artifact's opaque content layer. + +If `complypack.yaml` declares `gemara.sources`, `pack` also resolves those sources and records their policy provenance in the artifact's config blob (see [Source Provenance](#source-provenance)). Source resolution fails closed: an unresolvable source aborts the pack, and resolution is bounded by a 5-minute timeout. + +Flags: + +- `--cache-dir` Cache directory for resolved Gemara sources (default: `$XDG_CACHE_HOME/complypack` or `$HOME/.cache/complypack`). Set this when running in a restricted or headless environment where `HOME` is unset. +- `--plain-http` Use plain HTTP instead of HTTPS for the target registry ### Validate a policy @@ -293,6 +302,28 @@ Run `complypack completion --help` for detailed instructions per shell. The content layer is **opaque** — the `evaluator-id` in the config tells consumers which provider handles it. For OPA, this is a tarball of `.rego` files. +#### Source Provenance + +When a pack is built from `gemara.sources`, `complypack pack` resolves those sources and records which Gemara policies the pack implements in the config blob under `source`: + +```json +{ + "source": [ + { + "policy-id": "container-platform-policy", + "gemara-content": [ + { "reference-id": "container-security-controls", "uri": "https://example.com/catalog", "version": "1.0.0" }, + { "reference-id": "container-security-guidance", "version": "1.0.0" } + ] + } + ] +} +``` + +- One entry per resolved policy (`policy-id`); `gemara-content` lists the catalog and guidance references that policy imports. +- `uri` is sanitized before it is recorded into the published blob: userinfo, query strings, and fragments are stripped, and local/`file://` paths are omitted (the `reference-id` and `version` are still recorded). This keeps internal paths and embedded credentials out of a publicly distributable artifact. +- `source` is omitted entirely when a pack declares no `gemara.sources` or its sources resolve to no policy. Unresolvable sources fail the pack. + ### Policy Graph Resolution The MCP server resolves Gemara policy graphs: diff --git a/acceptance/mcp_server_test.go b/acceptance/mcp_server_test.go index 8f5e74b..3f426ea 100644 --- a/acceptance/mcp_server_test.go +++ b/acceptance/mcp_server_test.go @@ -122,7 +122,7 @@ schemas: server, err := mcp.NewServer(ctx, opts) Expect(err).To(HaveOccurred()) Expect(server).To(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to load artifacts")) + Expect(err.Error()).To(ContainSubstring("source /nonexistent/catalog.yaml")) }) It("should fail fast when configured schema source cannot be loaded", func() { diff --git a/cmd/complypack/cli/pack.go b/cmd/complypack/cli/pack.go index c7e56f7..182181d 100644 --- a/cmd/complypack/cli/pack.go +++ b/cmd/complypack/cli/pack.go @@ -10,13 +10,15 @@ import ( "log/slog" "os" "sort" + "time" "cuelang.org/go/cue" - + "github.com/complytime/complypack/internal/cache" "github.com/complytime/complypack/internal/config" "github.com/complytime/complypack/internal/coverage" "github.com/complytime/complypack/internal/evaluator" "github.com/complytime/complypack/internal/packer" + "github.com/complytime/complypack/internal/pipeline" "github.com/complytime/complypack/internal/prepack" "github.com/complytime/complypack/internal/registry" "github.com/complytime/complypack/internal/schema" @@ -27,12 +29,18 @@ import ( "oras.land/oras-go/v2/content/memory" ) +// packResolveTimeout bounds the aggregate resolution of all declared Gemara +// sources during pack so that a large, slow, or hostile source cannot hang +// the command indefinitely (CWE-400). Exceeding it fails the pack closed. +const packResolveTimeout = 5 * time.Minute + func packCmd() *cobra.Command { var ( configPath string plainHTTP bool skipValidation bool skipTests bool + cacheDir string ) cmd := &cobra.Command{ @@ -41,9 +49,11 @@ func packCmd() *cobra.Command { Long: `Pack a directory of policy content into a ComplyPack OCI artifact and push it to an OCI registry. -Reads evaluator-id, version, and gemara source from complypack.yaml. -The content directory is archived as a tar.gz and stored as the -artifact's opaque content layer. +Reads evaluator-id, version, and gemara sources from complypack.yaml. +The declared gemara sources are resolved and the resulting policy +provenance (policy IDs and the Gemara artifacts they import) is +recorded in the artifact's config blob. The content directory is +archived as a tar.gz and stored as the artifact's opaque content layer. By default, policies are validated before packing: 1. Syntax checking @@ -79,11 +89,20 @@ Examples: } } + // Resolve Gemara sources and record their provenance so a + // consumer can tell which policies this pack implements. + // Fail-closed: an unresolvable source aborts the pack. + provenance, err := resolveProvenance(ctx, cfg, cacheDir) + if err != nil { + return err + } + // Build complypack config from complypack.yaml packCfg := complypack.Config{ ID: cfg.ID, EvaluatorID: cfg.EvaluatorID, Version: cfg.Version, + Source: provenance, } // Create tarball from content directory, excluding test @@ -149,10 +168,45 @@ Examples: cmd.Flags().BoolVar(&plainHTTP, "plain-http", false, "Use HTTP instead of HTTPS for the registry") cmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip all pre-pack validation") cmd.Flags().BoolVar(&skipTests, "skip-tests", false, "Run syntax and contract checks but skip test execution") + cmd.Flags().StringVar(&cacheDir, "cache-dir", "", cache.CacheDirHelp) return cmd } +// resolveProvenance resolves the Gemara sources declared in cfg and maps +// them to the provenance records recorded in the pack's config blob. +// +// It hard-fails (fail-closed) when any declared source cannot be loaded or +// resolved, naming every offending source (credential-sanitized). A config +// with no declared sources yields nil provenance. Sources that load and +// merge cleanly but resolve to no policy yield empty provenance without +// error. Resolution runs under a bounded context (CWE-400). +func resolveProvenance(ctx context.Context, cfg *config.ComplyPackConfig, cacheDir string) ([]complypack.Provenance, error) { + if len(cfg.Gemara.Sources) == 0 { + return nil, nil + } + + resolvedCacheDir, err := cache.ResolveDir(cacheDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve cache directory: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, packResolveTimeout) + defer cancel() + + // Resolution can fetch remote sources and run up to packResolveTimeout; + // log progress so the operator is not left staring at a silent command. + slog.Info("Resolving gemara sources", "count", len(cfg.Gemara.Sources)) + result, err := pipeline.LoadAndResolve(ctx, cfg.Gemara.Sources, resolvedCacheDir) + if err != nil { + return nil, fmt.Errorf("resolving gemara sources: %w", err) + } + slog.Info("Resolved gemara sources", + "sources", len(cfg.Gemara.Sources), "policies", len(result.Resolved)) + + return pipeline.BuildProvenance(result.Resolved, result.PolicySources), nil +} + // runPrePackValidation runs the 3-stage validation pipeline before packing. func runPrePackValidation(ctx context.Context, cfg *config.ComplyPackConfig, contentDir string, skipTests bool) error { // Resolve evaluator diff --git a/cmd/complypack/cli/pack_provenance_test.go b/cmd/complypack/cli/pack_provenance_test.go new file mode 100644 index 0000000..cb8357d --- /dev/null +++ b/cmd/complypack/cli/pack_provenance_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/complytime/complypack/internal/config" + "github.com/complytime/complypack/internal/pipeline" + "github.com/complytime/complypack/internal/requirement" + "github.com/complytime/complypack/pkg/complypack" + "github.com/gemaraproj/go-gemara" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" +) + +// fetchPackConfigBlob resolves the manifest referenced by desc and returns the +// raw config blob bytes as a string, so tests can assert on the exact +// serialized form published to a registry. +func fetchPackConfigBlob(t *testing.T, ctx context.Context, store content.Fetcher, desc ocispec.Descriptor) string { + t.Helper() + + manifestBytes, err := content.FetchAll(ctx, store, desc) + require.NoError(t, err) + + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(manifestBytes, &manifest)) + + configBytes, err := content.FetchAll(ctx, store, manifest.Config) + require.NoError(t, err) + + return string(configBytes) +} + +func TestResolveProvenance(t *testing.T) { + ctx := context.Background() + + t.Run("no gemara sources yields nil provenance and no error", func(t *testing.T) { + cfg := &config.ComplyPackConfig{ + ID: "io.complytime.test", + EvaluatorID: "opa", + Version: "1.0.0", + } + prov, err := resolveProvenance(ctx, cfg, t.TempDir()) + require.NoError(t, err) + assert.Nil(t, prov) + }) + + t.Run("unresolvable source hard-fails naming the sanitized source", func(t *testing.T) { + cfg := &config.ComplyPackConfig{ + ID: "io.complytime.test", + EvaluatorID: "opa", + Version: "1.0.0", + Gemara: config.GemaraConfig{ + Sources: []config.GemaraSourceEntry{ + {Source: "file:///nonexistent/catalog.yaml"}, + }, + }, + } + prov, err := resolveProvenance(ctx, cfg, t.TempDir()) + require.Error(t, err) + assert.Nil(t, prov) + assert.Contains(t, err.Error(), "source file:///nonexistent/catalog.yaml") + }) + + t.Run("resolvable sources populate provenance", func(t *testing.T) { + cfg := &config.ComplyPackConfig{ + ID: "io.complytime.test", + EvaluatorID: "opa", + Version: "1.0.0", + Gemara: config.GemaraConfig{ + Sources: []config.GemaraSourceEntry{ + {Source: "file://../../../examples/gemara/policy.yaml"}, + {Source: "file://../../../examples/gemara/control-catalog.yaml"}, + {Source: "file://../../../examples/gemara/guidance-catalog.yaml"}, + }, + }, + } + prov, err := resolveProvenance(ctx, cfg, t.TempDir()) + require.NoError(t, err) + require.Len(t, prov, 1) + assert.Equal(t, "container-platform-policy", prov[0].PolicyID) + // The example policy imports one catalog and one guidance reference, + // each carrying a version but no url (local mapping references record + // no URI). Assert the exact enriched content, not merely non-empty. + require.Len(t, prov[0].GemaraContent, 2) + assert.Equal(t, complypack.GemaraRef{ + ReferenceID: "container-security-controls", + Version: "1.0.0", + }, prov[0].GemaraContent[0]) + assert.Equal(t, complypack.GemaraRef{ + ReferenceID: "container-security-guidance", + Version: "1.0.0", + }, prov[0].GemaraContent[1]) + }) + + t.Run("credentialed mapping-reference URL is sanitized end to end into the config blob", func(t *testing.T) { + // End-to-end CWE-200 guarantee: a policy whose mapping reference + // carries a URL with embedded credentials and a query string must, + // after BuildProvenance -> Pack -> pull, appear in the published + // config blob with userinfo and query stripped. + resolved := map[string]*requirement.ResolvedPolicy{ + "leaky-policy": { + Policy: gemara.Policy{ + Metadata: gemara.Metadata{ + Id: "leaky-policy", + MappingReferences: []gemara.MappingReference{ + { //nolint:gosec // test fixture, not real credentials + Id: "cat-secret", + Url: "https://user:supersecret@registry.example.com/cat.yaml?token=abc#frag", + Version: "1.0.0", + }, + }, + }, + Imports: gemara.Imports{ + Catalogs: []gemara.CatalogImport{{ReferenceId: "cat-secret"}}, + }, + }, + }, + } + + provenance := pipeline.BuildProvenance(resolved, nil) + require.Len(t, provenance, 1) + + packCfg := complypack.Config{ + ID: "io.complytime.test", + EvaluatorID: "opa", + Version: "1.0.0", + Source: provenance, + } + + store := memory.New() + desc, err := complypack.Pack(ctx, store, packCfg, strings.NewReader("policy content")) + require.NoError(t, err) + + blob := fetchPackConfigBlob(t, ctx, store, desc) + assert.NotContains(t, blob, "supersecret", + "published config blob must not contain the embedded credential") + assert.NotContains(t, blob, "token=abc", + "published config blob must not contain the query string") + assert.Contains(t, blob, "https://registry.example.com/cat.yaml", + "published config blob must contain the sanitized URI") + }) + + t.Run("source loads but resolves to no policy yields empty provenance and no error", func(t *testing.T) { + // A lone control catalog loads and merges cleanly but carries no + // policy, so Resolved is empty: pack must succeed with no provenance. + cfg := &config.ComplyPackConfig{ + ID: "io.complytime.test", + EvaluatorID: "opa", + Version: "1.0.0", + Gemara: config.GemaraConfig{ + Sources: []config.GemaraSourceEntry{ + {Source: "file://../../../examples/gemara/control-catalog.yaml"}, + }, + }, + } + prov, err := resolveProvenance(ctx, cfg, t.TempDir()) + require.NoError(t, err) + assert.Nil(t, prov) + }) +} diff --git a/go.mod b/go.mod index b7cd979..6277bec 100644 --- a/go.mod +++ b/go.mod @@ -25,21 +25,22 @@ require ( ) require ( - cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 // indirect + cuelabs.dev/go/oci/ociregistry v0.0.0-20260717083115-5eb5795f322a // indirect dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cockroachdb/apd/v3 v3.2.3 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect @@ -52,21 +53,21 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.3.0 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect - github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/jwx/v3 v3.2.0 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mattn/go-runewidth v0.0.28 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -74,34 +75,34 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20260803135053-1fd8a60d1ffc // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sirupsen/logrus v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/vektah/gqlparser/v2 v2.5.36 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/xo/terminfo v1.0.0 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.48.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index e0192cf..8b35490 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 h1:XUtzi/yWlmuy8V6kkmVbbmirmUqcFe9Ce3gmEaHXf1Q= -cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= +cuelabs.dev/go/oci/ociregistry v0.0.0-20260717083115-5eb5795f322a h1:xR7A97ML6U+DHDgXjs1qv0fkss7N/EC2kbFzrH4mg/A= +cuelabs.dev/go/oci/ociregistry v0.0.0-20260717083115-5eb5795f322a/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.17.1 h1:liOkxZDqTHrzq0USJX+6bMYOZ5PSf+wzvQr15AHpDCQ= cuelang.org/go v0.17.1/go.mod h1:xlly/o1wSLvxOsi5vkQGieU0rLOt7TvUIizOFtnxHRU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= @@ -24,30 +24,30 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= @@ -120,8 +120,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -142,32 +142,32 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= -github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= -github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= -github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA= +github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= +github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= @@ -216,8 +216,8 @@ github.com/owenrumney/go-sarif/v2 v2.3.3 h1:ubWDJcF5i3L/EIOER+ZyQ03IfplbSU1BLOE2 github.com/owenrumney/go-sarif/v2 v2.3.3/go.mod h1:MSqMMx9WqlBSY7pXoOZWgEsVB4FDNfhcaXDA1j6Sr+w= github.com/pdevine/go-asciisprite v0.1.6 h1:XoCz3hp/Uu11jqW+mz6hip/60fVyAc0TCQe0rt9a2Es= github.com/pdevine/go-asciisprite v0.1.6/go.mod h1:l0QHNFjlxaGuffAHCFMH+YrveBx6BBjetM2E8rFvgd4= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= @@ -227,14 +227,14 @@ github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLA github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 h1:Mckui8l+Wqz2Ve7XQvsE8SbHNmDWu8NA7Xce5NFJ/kM= -github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260803135053-1fd8a60d1ffc h1:60QHVeAiwsBBrlOuCYJXJqogSU+j/TWYH9KTGlwz7iY= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260803135053-1fd8a60d1ffc/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -242,8 +242,8 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= +github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -274,8 +274,8 @@ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMc github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -293,14 +293,14 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -314,10 +314,10 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 9cb039c..06effe0 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -84,7 +84,9 @@ schemas: assert.Error(t, err) assert.Nil(t, srv) - assert.Contains(t, err.Error(), "failed to load artifacts") + // The batch-error rework names each failed source in the joined error + // surfaced through the MCP init path. + assert.Contains(t, err.Error(), "source /nonexistent/catalog.yaml") }) t.Run("fail fast when configured schema source cannot be loaded", func(t *testing.T) { diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index ff6072a..46dc31a 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -4,9 +4,11 @@ package pipeline import ( "context" + "errors" "fmt" "github.com/complytime/complypack/internal/config" + "github.com/complytime/complypack/internal/registry" "github.com/complytime/complypack/internal/requirement" "github.com/complytime/complypack/internal/source" ) @@ -20,6 +22,15 @@ type LoadResult struct { // Resolved maps policy IDs to their fully resolved policies. Resolved map[string]*requirement.ResolvedPolicy + + // PolicySources maps each policy ID to the source string that + // provided it. BuildProvenance uses this to distinguish OCI bundle + // sources (whose authoritative provenance is the bundle reference) + // from file sources (whose provenance is the MappingReference URLs). + // When multiple sources provide the same policy ID, the last-declared + // source wins. This is deterministic (iteration order of the sources + // slice is stable) but means earlier sources' provenance is discarded. + PolicySources map[string]string } // LoadAndResolve loads Gemara artifacts from all configured sources, @@ -31,23 +42,31 @@ func LoadAndResolve( cacheDir string, ) (*LoadResult, error) { loaded := requirement.NewArtifactSet() + policySources := make(map[string]string) + var loadErrs []error for _, entry := range sources { + name := registry.RedactCredentials(entry.Source) src, err := source.LoadArtifacts( ctx, entry.Source, entry.PlainHTTP, cacheDir, ) if err != nil { - return nil, fmt.Errorf( - "failed to load artifacts from %s: %w", - entry.Source, err, - ) + loadErrs = append(loadErrs, fmt.Errorf("source %s: %w", name, err)) + continue } if err := loaded.Merge(src); err != nil { - return nil, fmt.Errorf( - "failed to merge artifacts from %s: %w", - entry.Source, err, - ) + loadErrs = append(loadErrs, fmt.Errorf("source %s: %w", name, err)) + continue + } + for id := range src.Policies { + policySources[id] = entry.Source } } + // Report all load/merge failures at once so every unreachable or + // malformed source is named in a single joined error. A load failure + // aborts the whole operation: no partial result is returned. + if len(loadErrs) > 0 { + return nil, errors.Join(loadErrs...) + } resolved := make(map[string]*requirement.ResolvedPolicy) for id, policy := range loaded.Policies { @@ -62,7 +81,8 @@ func LoadAndResolve( } return &LoadResult{ - Artifacts: loaded, - Resolved: resolved, + Artifacts: loaded, + Resolved: resolved, + PolicySources: policySources, }, nil } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 3003577..c7225ee 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -67,9 +67,11 @@ func TestLoadAndResolve(t *testing.T) { assert.Contains(t, result.Resolved, "standalone-policy", "policy must be resolved even when no catalogs or guidance are loaded") require.NotNil(t, result.Resolved["standalone-policy"]) + assert.Equal(t, path, result.PolicySources["standalone-policy"], + "PolicySources must record the source that provided the policy") }) - t.Run("invalid source returns error", func(t *testing.T) { + t.Run("invalid source returns error naming the source", func(t *testing.T) { sources := []config.GemaraSourceEntry{ {Source: "file:///nonexistent/path", PlainHTTP: false}, } @@ -78,7 +80,92 @@ func TestLoadAndResolve(t *testing.T) { ) assert.Error(t, err) assert.Nil(t, result) - assert.Contains(t, err.Error(), "failed to load artifacts") + assert.Contains(t, err.Error(), "source file:///nonexistent/path") + }) + + t.Run("multiple bad sources are all named in one joined error", func(t *testing.T) { + sources := []config.GemaraSourceEntry{ + {Source: "file:///nonexistent/one"}, + {Source: "file:///nonexistent/two"}, + } + result, err := pipeline.LoadAndResolve( + context.Background(), sources, "", + ) + require.Error(t, err) + assert.Nil(t, result) + // Both failed sources must appear in the single joined error, + // proving the loop does not return on the first failure. + assert.Contains(t, err.Error(), "source file:///nonexistent/one") + assert.Contains(t, err.Error(), "source file:///nonexistent/two") + }) + + t.Run("one good and one bad source names only the bad and does not partially resolve", func(t *testing.T) { + good := "file://../../examples/gemara/control-catalog.yaml" + sources := []config.GemaraSourceEntry{ + {Source: good}, + {Source: "file:///nonexistent/bad"}, + } + result, err := pipeline.LoadAndResolve( + context.Background(), sources, "", + ) + require.Error(t, err) + // A load failure aborts the whole operation: no partial result. + assert.Nil(t, result) + assert.Contains(t, err.Error(), "source file:///nonexistent/bad") + assert.NotContains(t, err.Error(), "source "+good) + }) + + t.Run("merge conflict across sources is reported as a batched, named error", func(t *testing.T) { + // Loading the same artifact twice yields a duplicate-ID merge + // failure. This exercises the merge-error branch (not just the + // load-error branch) and proves merge failures are collected into + // the joined error and named by their (redacted) source. + dup := "file://../../examples/gemara/control-catalog.yaml" + sources := []config.GemaraSourceEntry{ + {Source: dup}, + {Source: dup}, + } + result, err := pipeline.LoadAndResolve( + context.Background(), sources, "", + ) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "duplicate artifact id", + "a merge conflict must surface as a merge error") + assert.Contains(t, err.Error(), "source "+dup, + "the merge-failing source must be named") + }) + + t.Run("cancelled context aborts resolution and returns an error", func(t *testing.T) { + // The pack timeout (CWE-400 bound) relies on context cancellation + // propagating through LoadAndResolve into the source loader. An + // already-cancelled context must fail closed with no result. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sources := []config.GemaraSourceEntry{ + {Source: "oci://ghcr.io/complytime/nonexistent:v1"}, + } + result, err := pipeline.LoadAndResolve(ctx, sources, "") + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("credential-bearing failing source is named without leaking the secret", func(t *testing.T) { + // Proves RedactCredentials is applied to the source label on the + // batch-error path (CWE-209): the failure names the redacted source + // and the embedded password never appears in the joined error. + sources := []config.GemaraSourceEntry{ + {Source: "https://user:supersecret@registry.invalid/org/repo:tag"}, //nolint:gosec // test fixture, not real credentials + } + result, err := pipeline.LoadAndResolve( + context.Background(), sources, "", + ) + require.Error(t, err) + assert.Nil(t, result) + assert.NotContains(t, err.Error(), "supersecret", + "embedded credential must not appear in the error") + assert.Contains(t, err.Error(), "source https://registry.invalid/org/repo:tag", + "failed source is named in its redacted form") }) t.Run("resolve failure returns error", func(t *testing.T) { diff --git a/internal/pipeline/provenance.go b/internal/pipeline/provenance.go new file mode 100644 index 0000000..13abc03 --- /dev/null +++ b/internal/pipeline/provenance.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "net/url" + "sort" + "strings" + + "github.com/complytime/complypack/internal/registry" + "github.com/complytime/complypack/internal/requirement" + "github.com/complytime/complypack/internal/source" + "github.com/complytime/complypack/pkg/complypack" + "github.com/gemaraproj/go-gemara" +) + +// BuildProvenance maps resolved policies to the provenance records recorded +// in a pack's OCI config blob. It produces at most one entry per resolved +// policy (never a cross-product of sources and policies), listing the Gemara +// artifacts that policy imports. Policies that import no catalogs or guidance +// have no provenance to record and are skipped entirely. +// +// policySources maps policy IDs to the source string that provided them +// (populated by LoadAndResolve). For pre-assembled OCI bundle sources, the +// authoritative provenance is the bundle's OCI reference — the +// MappingReference URLs inside the bundle describe a past assembly step and +// are not recorded. For file and unbundled sources, MappingReference.Url is +// used as before. +// +// Output is deterministically ordered: entries by PolicyID and references by +// ReferenceID, so identical resolved content always yields a byte-identical +// config blob. +// +// URIs are sanitized before recording into the published, publicly +// distributable blob (CWE-200): embedded credentials, query strings, and +// fragments are stripped, and local (file://) paths are omitted entirely. +// The reference entry is always emitted even when its URI is omitted. +func BuildProvenance(resolved map[string]*requirement.ResolvedPolicy, policySources map[string]string) []complypack.Provenance { + provenance := make([]complypack.Provenance, 0, len(resolved)) + + for _, rp := range resolved { + policyID := rp.Policy.Metadata.Id + refIndex := indexMappingReferences(rp.Policy.Metadata.MappingReferences) + + src := policySources[policyID] + ociURI := "" + if isOCISource(src) { + ociURI = sanitizeOCISource(src) + } + + var refs []complypack.GemaraRef + for _, imp := range rp.Policy.Imports.Catalogs { + refs = append(refs, buildRefWithSource(imp.ReferenceId, refIndex, ociURI)) + } + for _, imp := range rp.Policy.Imports.Guidance { + refs = append(refs, buildRefWithSource(imp.ReferenceId, refIndex, ociURI)) + } + + // An import-less policy has no Gemara provenance to record. Skip it + // rather than emit a Provenance with empty GemaraContent, which + // Config.Validate rejects (Pack would then fail on generated data). + if len(refs) == 0 { + continue + } + + sort.Slice(refs, func(i, j int) bool { + return refs[i].ReferenceID < refs[j].ReferenceID + }) + + provenance = append(provenance, complypack.Provenance{ + PolicyID: policyID, + GemaraContent: refs, + }) + } + + sort.Slice(provenance, func(i, j int) bool { + return provenance[i].PolicyID < provenance[j].PolicyID + }) + + if len(provenance) == 0 { + return nil + } + + return provenance +} + +// indexMappingReferences maps a policy's mapping-reference IDs to their +// full mapping reference for URI/version lookup. +func indexMappingReferences(refs []gemara.MappingReference) map[string]gemara.MappingReference { + idx := make(map[string]gemara.MappingReference, len(refs)) + for _, ref := range refs { + idx[ref.Id] = ref + } + return idx +} + +// isOCISource returns true when the source string identifies an OCI bundle. +func isOCISource(src string) bool { + if src == "" { + return false + } + return strings.HasPrefix(src, "oci://") || source.IsOCIReference(src) +} + +// sanitizeOCISource returns a credential-free OCI reference suitable for +// recording in the published config blob. Scheme prefixes (oci://) are +// stripped so the recorded URI is a bare registry reference. +func sanitizeOCISource(src string) string { + ref := strings.TrimPrefix(src, "oci://") + return registry.RedactCredentials(ref) +} + +// buildRefWithSource resolves an import reference-id to a recorded GemaraRef. +// When ociURI is non-empty the policy came from a pre-assembled OCI bundle +// and the bundle reference is recorded as the authoritative URI; otherwise +// the MappingReference.Url is sanitized and used. The entry is always emitted +// even if no matching mapping reference is found or its URI is omitted. +func buildRefWithSource(referenceID string, refIndex map[string]gemara.MappingReference, ociURI string) complypack.GemaraRef { + ref := complypack.GemaraRef{ReferenceID: referenceID} + if mr, ok := refIndex[referenceID]; ok { + if ociURI != "" { + ref.URI = ociURI + } else { + ref.URI = sanitizeURI(mr.Url) + } + ref.Version = mr.Version + } + return ref +} + +// sanitizeURI strips credentials, query strings, and fragments from a URI +// before it is recorded into the published config blob (CWE-200). Local +// file:// URIs and unparseable/relative values are omitted entirely so that +// local filesystem layout is never leaked. +func sanitizeURI(raw string) string { + if raw == "" { + return "" + } + + u, err := url.Parse(raw) + if err != nil { + return "" + } + + // Local paths must never be recorded into a published artifact. + if u.Scheme == "file" || u.Scheme == "" || u.Host == "" { + return "" + } + + sanitized := url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + } + return strings.TrimSuffix(sanitized.String(), "/") +} diff --git a/internal/pipeline/provenance_test.go b/internal/pipeline/provenance_test.go new file mode 100644 index 0000000..8ef531c --- /dev/null +++ b/internal/pipeline/provenance_test.go @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline_test + +import ( + "testing" + + "github.com/complytime/complypack/internal/pipeline" + "github.com/complytime/complypack/internal/requirement" + "github.com/complytime/complypack/pkg/complypack" + "github.com/gemaraproj/go-gemara" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resolvedPolicy builds a ResolvedPolicy carrying only the Policy metadata +// and imports needed by BuildProvenance (mapping references + import refs). +func resolvedPolicy(id string, refs []gemara.MappingReference, catalogRefs, guidanceRefs []string) *requirement.ResolvedPolicy { + catalogs := make([]gemara.CatalogImport, len(catalogRefs)) + for i, r := range catalogRefs { + catalogs[i] = gemara.CatalogImport{ReferenceId: r} + } + guidance := make([]gemara.GuidanceImport, len(guidanceRefs)) + for i, r := range guidanceRefs { + guidance[i] = gemara.GuidanceImport{ReferenceId: r} + } + return &requirement.ResolvedPolicy{ + Policy: gemara.Policy{ + Metadata: gemara.Metadata{ + Id: id, + MappingReferences: refs, + }, + Imports: gemara.Imports{ + Catalogs: catalogs, + Guidance: guidance, + }, + }, + } +} + +func TestBuildProvenance(t *testing.T) { + t.Run("one entry per resolved policy, refs from imports", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "policy-a": resolvedPolicy( + "policy-a", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://example.com/cat.yaml", Version: "1.0"}, + {Id: "guid-1", Url: "https://example.com/guid.yaml", Version: "2.0"}, + }, + []string{"cat-1"}, + []string{"guid-1"}, + ), + } + + got := pipeline.BuildProvenance(resolved, nil) + + want := []complypack.Provenance{ + { + PolicyID: "policy-a", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "cat-1", URI: "https://example.com/cat.yaml", Version: "1.0"}, + {ReferenceID: "guid-1", URI: "https://example.com/guid.yaml", Version: "2.0"}, + }, + }, + } + assert.Equal(t, want, got) + }) + + t.Run("cardinality: multiple policies never cross-product", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "policy-a": resolvedPolicy("policy-a", + []gemara.MappingReference{{Id: "cat-1", Url: "u1", Version: "1"}}, + []string{"cat-1"}, nil), + "policy-b": resolvedPolicy("policy-b", + []gemara.MappingReference{{Id: "cat-2", Url: "u2", Version: "2"}}, + []string{"cat-2"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 2, "one entry per distinct policy, not sources*policies") + assert.Equal(t, "policy-a", got[0].PolicyID) + assert.Equal(t, "policy-b", got[1].PolicyID) + }) + + t.Run("determinism: non-sorted input yields sorted golden output", func(t *testing.T) { + // Use >=3 policy keys so a MISSING policy-level sort fails + // deterministically rather than passing ~50% of the time by luck of + // Go's per-process map iteration order. + resolved := map[string]*requirement.ResolvedPolicy{ + "z-policy": resolvedPolicy("z-policy", + []gemara.MappingReference{ + {Id: "z-ref", Url: "https://example.com/z", Version: "9"}, + {Id: "a-ref", Url: "https://example.com/a", Version: "1"}, + }, + []string{"z-ref", "a-ref"}, nil), + "m-policy": resolvedPolicy("m-policy", + []gemara.MappingReference{{Id: "m-ref", Url: "https://example.com/m", Version: "5"}}, + []string{"m-ref"}, nil), + "a-policy": resolvedPolicy("a-policy", + []gemara.MappingReference{{Id: "c-ref", Url: "https://example.com/c", Version: "3"}}, + []string{"c-ref"}, nil), + } + + want := []complypack.Provenance{ + { + PolicyID: "a-policy", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "c-ref", URI: "https://example.com/c", Version: "3"}, + }, + }, + { + PolicyID: "m-policy", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "m-ref", URI: "https://example.com/m", Version: "5"}, + }, + }, + { + PolicyID: "z-policy", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "a-ref", URI: "https://example.com/a", Version: "1"}, + {ReferenceID: "z-ref", URI: "https://example.com/z", Version: "9"}, + }, + }, + } + + // Repeat to defeat any single-iteration luck if a sort is dropped. + for i := 0; i < 20; i++ { + assert.Equal(t, want, pipeline.BuildProvenance(resolved, nil)) + } + }) + + t.Run("url-less import records empty URI but keeps entry", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{{Id: "local", Url: "", Version: "1"}}, + []string{"local"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "local", got[0].GemaraContent[0].ReferenceID) + assert.Empty(t, got[0].GemaraContent[0].URI) + assert.Equal(t, "1", got[0].GemaraContent[0].Version) + }) + + t.Run("URI sanitization strips credentials/query/fragment", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "r", Url: "https://user:secret@example.com/cat.yaml?token=abc#frag", Version: "1"}, //nolint:gosec // test fixture, not real credentials + }, + []string{"r"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "https://example.com/cat.yaml", got[0].GemaraContent[0].URI) + }) + + t.Run("file:// URI is omitted (local path not recorded)", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "r", Url: "file:///home/user/secret/cat.yaml", Version: "1"}, + }, + []string{"r"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Empty(t, got[0].GemaraContent[0].URI, "local file path must not be recorded") + assert.Equal(t, "r", got[0].GemaraContent[0].ReferenceID) + }) + + t.Run("import-less resolved policy yields no entry", func(t *testing.T) { + // A policy that imports zero catalogs/guidance has no Gemara + // provenance to record. It must NOT produce a Provenance with empty + // GemaraContent, because Config.Validate rejects empty gemara-content + // and Pack would then fail on machine-generated data. + resolved := map[string]*requirement.ResolvedPolicy{ + "lonely": resolvedPolicy("lonely", nil, nil, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + assert.Empty(t, got, "import-less policy must be skipped, not emitted with empty gemara-content") + }) + + t.Run("import-less policy skipped, importing policy kept", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "lonely": resolvedPolicy("lonely", nil, nil, nil), + "real": resolvedPolicy("real", + []gemara.MappingReference{{Id: "cat-1", Url: "https://example.com/c", Version: "1"}}, + []string{"cat-1"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 1, "only the importing policy is recorded") + assert.Equal(t, "real", got[0].PolicyID) + }) + + t.Run("import ref-id with no matching mapping reference: entry emitted with empty URI and Version", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{{Id: "known", Url: "https://example.com/k", Version: "1"}}, + []string{"orphan"}, nil), + } + + got := pipeline.BuildProvenance(resolved, nil) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "orphan", got[0].GemaraContent[0].ReferenceID) + assert.Empty(t, got[0].GemaraContent[0].URI, "no matching mapping reference => empty URI") + assert.Empty(t, got[0].GemaraContent[0].Version, "no matching mapping reference => empty Version") + }) + + t.Run("empty resolved map yields empty slice", func(t *testing.T) { + got := pipeline.BuildProvenance(map[string]*requirement.ResolvedPolicy{}, nil) + assert.Empty(t, got) + }) + + t.Run("nil resolved map yields empty slice", func(t *testing.T) { + got := pipeline.BuildProvenance(nil, nil) + assert.Empty(t, got) + }) + + // sanitizeURI negative branches, exercised through BuildProvenance (its + // only caller). Each malformed/local URI must yield an empty URI while + // the reference entry is still recorded. + t.Run("URI negative cases record entry with empty URI", func(t *testing.T) { + cases := []struct { + name string + url string + }{ + {"unparseable URL", "://not a url"}, + {"control character fails parse", "https://exa\x7fmple.com/cat.yaml"}, + {"scheme-relative host is omitted", "//example.com/cat.yaml"}, + {"relative path has no scheme or host", "catalogs/controls.yaml"}, + {"opaque non-file scheme has no host", "mailto:owner@example.com"}, + {"mixed-case FILE scheme is omitted", "FILE:///home/user/secret/cat.yaml"}, + {"uppercase FILE scheme is omitted", "FILE:///etc/policy.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{{Id: "r", Url: tc.url, Version: "1"}}, + []string{"r"}, nil), + } + got := pipeline.BuildProvenance(resolved, nil) + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Empty(t, got[0].GemaraContent[0].URI, + "malformed/local URI %q must not be recorded", tc.url) + assert.Equal(t, "r", got[0].GemaraContent[0].ReferenceID, + "entry is still emitted when URI is omitted") + }) + } + }) + + t.Run("credentials in URI with port are stripped but host:port kept", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "r", Url: "https://user:secret@registry.example.com:5000/cat.yaml", Version: "1"}, //nolint:gosec // test fixture, not real credentials + }, + []string{"r"}, nil), + } + got := pipeline.BuildProvenance(resolved, nil) + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "https://registry.example.com:5000/cat.yaml", got[0].GemaraContent[0].URI) + }) + + t.Run("same reference-id imported as both catalog and guidance yields two sorted entries", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "shared", Url: "https://example.com/s", Version: "1"}, + }, + []string{"shared"}, + []string{"shared"}), + } + got := pipeline.BuildProvenance(resolved, nil) + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 2, "a ref imported twice yields two entries") + assert.Equal(t, "shared", got[0].GemaraContent[0].ReferenceID) + assert.Equal(t, "shared", got[0].GemaraContent[1].ReferenceID) + assert.Equal(t, "https://example.com/s", got[0].GemaraContent[0].URI) + assert.Equal(t, "https://example.com/s", got[0].GemaraContent[1].URI) + }) + + t.Run("OCI bundle source records bundle reference, not mapping-reference URLs", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "policy-a": resolvedPolicy("policy-a", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://assembly-time.example.com/cat.yaml", Version: "1.0"}, + {Id: "guid-1", Url: "https://assembly-time.example.com/guid.yaml", Version: "2.0"}, + }, + []string{"cat-1"}, + []string{"guid-1"}, + ), + } + policySources := map[string]string{ + "policy-a": "ghcr.io/org/bundle:v1.0", + } + + got := pipeline.BuildProvenance(resolved, policySources) + + want := []complypack.Provenance{ + { + PolicyID: "policy-a", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "cat-1", URI: "ghcr.io/org/bundle:v1.0", Version: "1.0"}, + {ReferenceID: "guid-1", URI: "ghcr.io/org/bundle:v1.0", Version: "2.0"}, + }, + }, + } + assert.Equal(t, want, got) + }) + + t.Run("OCI bundle source with oci:// scheme records bare reference", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://assembly-time.example.com/cat.yaml", Version: "1.0"}, + }, + []string{"cat-1"}, nil, + ), + } + policySources := map[string]string{ + "p": "oci://ghcr.io/org/bundle:v2.0", + } + + got := pipeline.BuildProvenance(resolved, policySources) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "ghcr.io/org/bundle:v2.0", got[0].GemaraContent[0].URI, + "oci:// prefix must be stripped from the recorded reference") + }) + + t.Run("OCI bundle source with credentials records redacted reference", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://assembly-time.example.com/cat.yaml", Version: "1.0"}, + }, + []string{"cat-1"}, nil, + ), + } + policySources := map[string]string{ //nolint:gosec // test fixture, not real credentials + "p": "oci://user:secret@ghcr.io/org/bundle:v1.0", + } + + got := pipeline.BuildProvenance(resolved, policySources) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "ghcr.io/org/bundle:v1.0", got[0].GemaraContent[0].URI, + "credentials must be stripped from OCI bundle reference") + }) + + t.Run("file source uses MappingReference.Url, not source path", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "p": resolvedPolicy("p", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://example.com/cat.yaml", Version: "1.0"}, + }, + []string{"cat-1"}, nil, + ), + } + policySources := map[string]string{ + "p": "file:///home/user/catalogs/policy.yaml", + } + + got := pipeline.BuildProvenance(resolved, policySources) + + require.Len(t, got, 1) + require.Len(t, got[0].GemaraContent, 1) + assert.Equal(t, "https://example.com/cat.yaml", got[0].GemaraContent[0].URI, + "file sources must use MappingReference.Url, not the file path") + }) + + t.Run("mixed OCI and file sources record correct URIs per policy", func(t *testing.T) { + resolved := map[string]*requirement.ResolvedPolicy{ + "oci-policy": resolvedPolicy("oci-policy", + []gemara.MappingReference{ + {Id: "cat-1", Url: "https://assembly-time.example.com/cat.yaml", Version: "1.0"}, + }, + []string{"cat-1"}, nil, + ), + "file-policy": resolvedPolicy("file-policy", + []gemara.MappingReference{ + {Id: "cat-2", Url: "https://example.com/cat2.yaml", Version: "2.0"}, + }, + []string{"cat-2"}, nil, + ), + } + policySources := map[string]string{ + "oci-policy": "ghcr.io/org/bundle:v1.0", + "file-policy": "/local/path/policy.yaml", + } + + got := pipeline.BuildProvenance(resolved, policySources) + + require.Len(t, got, 2) + assert.Equal(t, "file-policy", got[0].PolicyID) + assert.Equal(t, "https://example.com/cat2.yaml", got[0].GemaraContent[0].URI, + "file-sourced policy must use MappingReference.Url") + assert.Equal(t, "oci-policy", got[1].PolicyID) + assert.Equal(t, "ghcr.io/org/bundle:v1.0", got[1].GemaraContent[0].URI, + "OCI-sourced policy must use the bundle reference") + }) +} diff --git a/internal/registry/client.go b/internal/registry/client.go index f21dc80..08cd7f6 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -24,7 +24,9 @@ func NewRepository(ref string, credFunc auth.CredentialFunc, plainHTTP bool) (*r // Parse the reference to extract repository name parsedRef, err := registry.ParseReference(stripScheme(ref)) if err != nil { - return nil, fmt.Errorf("invalid OCI reference %q: %w", ref, err) + // Redact any embedded credentials before surfacing the reference in + // an error (CWE-209: Information Exposure Through an Error Message). + return nil, fmt.Errorf("invalid OCI reference %q: %w", RedactCredentials(ref), err) } repoName := fmt.Sprintf("%s/%s", parsedRef.Registry, parsedRef.Repository) @@ -83,3 +85,62 @@ func stripScheme(ref string) string { ref = strings.TrimPrefix(ref, "https://") return ref } + +// RedactCredentials removes any userinfo (user:password@) embedded in an OCI +// or URL reference so it can be safely surfaced in error messages or recorded +// into published artifacts (CWE-209 / CWE-200). The scheme (if any) and the +// host/path portion are preserved so operators can still identify the source. +// +// Any "scheme://" prefix is recognized generically (RFC 3986 scheme syntax), +// not just a fixed allow-list, so credentials embedded after an unlisted +// scheme (e.g. ssh://user:pass@host/path) are still stripped rather than +// leaked. Userinfo is identified as the last '@' occurring before the first +// '/' of the authority, so passwords containing '@' are fully stripped and a +// digest reference (repo@sha256:...) that places '@' after the path is left +// intact. The original scheme casing is preserved. +// +// Limitation: per RFC 3986, a scheme must begin with a letter. References +// whose prefix starts with a digit (e.g. "1abc://user:pass@host") are not +// recognized as scheme-bearing and the userinfo is passed through unredacted. +// Such schemes are invalid per the RFC and do not occur in practice. +func RedactCredentials(ref string) string { + scheme, rest := splitScheme(ref) + + // Userinfo, if present, is delimited by the last '@' before the first '/'. + slash := strings.IndexByte(rest, '/') + authority := rest + if slash >= 0 { + authority = rest[:slash] + } + if at := strings.LastIndexByte(authority, '@'); at >= 0 { + rest = rest[at+1:] + } + + return scheme + rest +} + +// splitScheme separates a leading "scheme://" prefix from the remainder of a +// reference, returning the prefix (including "://", original casing preserved) +// and the rest. When no valid scheme prefix is present, scheme is "" and rest +// is the input unchanged. A scheme is a leading ALPHA followed by any of +// ALPHA / DIGIT / "+" / "-" / "." per RFC 3986, terminated by "://". +func splitScheme(ref string) (scheme, rest string) { + sep := strings.Index(ref, "://") + if sep <= 0 { + return "", ref + } + for i := 0; i < sep; i++ { + c := ref[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + // always allowed + case c >= '0' && c <= '9', c == '+', c == '-', c == '.': + if i == 0 { + return "", ref // scheme must start with a letter + } + default: + return "", ref // not a valid scheme character + } + } + return ref[:sep+len("://")], ref[sep+len("://"):] +} diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go index 53f26bd..9f4d548 100644 --- a/internal/registry/client_test.go +++ b/internal/registry/client_test.go @@ -2,7 +2,22 @@ package registry -import "testing" +import ( + "strings" + "testing" +) + +// TestNewRepositoryErrorRedactsCredentials proves the ParseReference error +// path does not leak embedded credentials into the returned error (CWE-209). +func TestNewRepositoryErrorRedactsCredentials(t *testing.T) { + _, err := NewRepository("oci://user:supersecret@/bad ref with spaces", nil, false) //nolint:gosec // test fixture, not real credentials + if err == nil { + t.Fatal("expected an error for malformed reference") + } + if strings.Contains(err.Error(), "supersecret") { + t.Fatalf("error leaked credentials: %v", err) + } +} func TestParseTag(t *testing.T) { tests := []struct { @@ -57,6 +72,137 @@ func TestParseTag(t *testing.T) { } } +func TestRedactCredentials(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + { + name: "plain file path unchanged", + ref: "file://catalogs/controls.yaml", + want: "file://catalogs/controls.yaml", + }, + { + name: "credential-free oci reference unchanged", + ref: "ghcr.io/org/catalog:v1", + want: "ghcr.io/org/catalog:v1", + }, + { //nolint:gosec // test fixture, not real credentials + name: "oci scheme credentials redacted, scheme preserved", + ref: "oci://user:secret@ghcr.io/org/catalog:v1", + want: "oci://ghcr.io/org/catalog:v1", + }, + { //nolint:gosec // test fixture, not real credentials + name: "https credentials redacted", + ref: "https://user:secret@registry.example.com/org/repo:tag", + want: "https://registry.example.com/org/repo:tag", + }, + { //nolint:gosec // test fixture, not real credentials + name: "bare (scheme-less) credentials redacted", + ref: "user:secret@ghcr.io/org/catalog:v1", + want: "ghcr.io/org/catalog:v1", + }, + { //nolint:gosec // test fixture, not real credentials + name: "password containing @ fully redacted (last @ before slash)", + ref: "https://user:p@ss@registry.example.com/org/repo:tag", + want: "https://registry.example.com/org/repo:tag", + }, + { //nolint:gosec // test fixture, not real credentials + name: "uppercase scheme redacted case-insensitively, original case preserved", + ref: "OCI://user:secret@ghcr.io/org/catalog:v1", + want: "OCI://ghcr.io/org/catalog:v1", + }, + { //nolint:gosec // test fixture, not real credentials + name: "mixed-case scheme redacted", + ref: "HtTpS://user:secret@registry.example.com/org/repo:tag", + want: "HtTpS://registry.example.com/org/repo:tag", + }, + { //nolint:gosec // test fixture, not real credentials + name: "credentials with port in host redacted", + ref: "oci://user:secret@localhost:5000/org/repo:tag", + want: "oci://localhost:5000/org/repo:tag", + }, + { + name: "digest @ after slash is not treated as userinfo", + ref: "ghcr.io/org/repo@sha256:abc123", + want: "ghcr.io/org/repo@sha256:abc123", + }, + { + name: "empty string unchanged", + ref: "", + want: "", + }, + { //nolint:gosec // test fixture, not real credentials + name: "file scheme with embedded credentials is redacted", + ref: "file://user:secret@host/catalogs/controls.yaml", + want: "file://host/catalogs/controls.yaml", + }, + { //nolint:gosec // test fixture, not real credentials + name: "userinfo with no password is redacted", + ref: "oci://user@ghcr.io/org/catalog:v1", + want: "oci://ghcr.io/org/catalog:v1", + }, + { + name: "empty userinfo (bare @) is stripped", + ref: "oci://@ghcr.io/org/catalog:v1", + want: "oci://ghcr.io/org/catalog:v1", + }, + { //nolint:gosec // test fixture, not real credentials + name: "scheme-less reference with no slash and credentials redacted", + ref: "user:secret@ghcr.io", + want: "ghcr.io", + }, + { //nolint:gosec // test fixture, not real credentials + name: "unlisted scheme (ssh) credentials redacted", + ref: "ssh://user:supersecret@host/path", + want: "ssh://host/path", + }, + { //nolint:gosec // test fixture, not real credentials + name: "unlisted scheme (git) credentials redacted", + ref: "git://user:secret@example.com/org/repo.git", + want: "git://example.com/org/repo.git", + }, + { //nolint:gosec // test fixture, not real credentials + name: "unlisted scheme with port and @-in-password redacted", + ref: "ftp://user:p@ss@host:2121/dir/file", + want: "ftp://host:2121/dir/file", + }, + { //nolint:gosec // test fixture, not real credentials + // splitScheme rejects a digit-leading prefix (client.go:133), so + // no scheme is stripped. The leftover "://" makes the first '/' + // fall inside "1abc:/", so the authority boundary is "1abc:" (no + // '@') and the ref is returned unchanged. This pins the rejection + // branch and documents that a malformed "scheme" with embedded + // userinfo is left intact rather than redacted. + name: "digit-leading prefix is not a scheme, ref unchanged", + ref: "1abc://user:secret@host/path", + want: "1abc://user:secret@host/path", + }, + { //nolint:gosec // test fixture, not real credentials + // splitScheme rejects an illegal scheme character (client.go:137); + // same fall-through as above pins that branch. + name: "illegal-char prefix is not a scheme, ref unchanged", + ref: "ab*c://user:secret@host/path", + want: "ab*c://user:secret@host/path", + }, + { + name: "unlisted scheme without userinfo unchanged", + ref: "ssh://host/path", + want: "ssh://host/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactCredentials(tt.ref) + if got != tt.want { + t.Errorf("RedactCredentials(%q) = %q, want %q", tt.ref, got, tt.want) + } + }) + } +} + func TestStripScheme(t *testing.T) { tests := []struct { name string diff --git a/internal/source/source.go b/internal/source/source.go index 84ce12b..8d49bed 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -6,6 +6,7 @@ package source import ( "context" + "errors" "fmt" "net" "os" @@ -71,7 +72,12 @@ func LoadArtifacts(ctx context.Context, source string, plainHTTP bool, cacheDir func loadFileArtifacts(_ context.Context, path string) (*requirement.ArtifactSet, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) + // Redact any embedded credentials before surfacing the path: a + // source string that is not an OCI reference (e.g. an https:// URL + // with userinfo) reaches os.ReadFile verbatim, and its error echoes + // the raw path (CWE-209: Information Exposure Through an Error Message). + return nil, fmt.Errorf("failed to read file %q: %w", + registry.RedactCredentials(path), redactPathError(err)) } result, err := requirement.Classify(data) @@ -82,6 +88,23 @@ func loadFileArtifacts(_ context.Context, path string) (*requirement.ArtifactSet return result, nil } +// redactPathError returns an error whose *os.PathError has a redacted Path so a +// wrapped filesystem error does not re-leak an embedded credential in its own +// message (os.PathError.Error includes the raw path). It returns a redacted +// copy rather than mutating the original, so the caller's error value is never +// altered as a side effect. Non-PathError values pass through unchanged. +func redactPathError(err error) error { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return &os.PathError{ + Op: pathErr.Op, + Path: registry.RedactCredentials(pathErr.Path), + Err: pathErr.Err, + } + } + return err +} + func loadBundleArtifacts(ctx context.Context, ref string, plainHTTP bool, cacheDir string) (*requirement.ArtifactSet, error) { credFunc, err := registry.NewCredentialFunc() if err != nil { diff --git a/internal/source/source_test.go b/internal/source/source_test.go index 53c50ce..f418e6e 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -142,6 +142,22 @@ func TestLoadArtifacts_OCISourceRequiresRegistry(t *testing.T) { }) } +// TestLoadArtifacts_FileErrorRedactsCredentials proves a source that falls +// through to the file loader (e.g. an https:// URL with embedded userinfo, +// which is not recognized as an OCI reference) does not leak the credential +// in its error (CWE-209). Both the wrapper and the wrapped os.PathError path +// must be redacted. +func TestLoadArtifacts_FileErrorRedactsCredentials(t *testing.T) { + ctx := context.Background() + + _, err := LoadArtifacts(ctx, "https://user:supersecret@registry.invalid/org/repo:tag", false, "") //nolint:gosec // test fixture, not real credentials + require.Error(t, err) + assert.NotContains(t, err.Error(), "supersecret", + "embedded credential must not appear in the file-load error") + assert.Contains(t, err.Error(), "registry.invalid", + "the redacted source is still identifiable") +} + func TestLoadBundleArtifacts_InMemoryFallback(t *testing.T) { ctx := context.Background() diff --git a/pkg/complypack/config.go b/pkg/complypack/config.go index b2031fa..69365b1 100644 --- a/pkg/complypack/config.go +++ b/pkg/complypack/config.go @@ -28,18 +28,35 @@ type Config struct { Version string `json:"version"` // Source links this ComplyPack to the Gemara content it implements. - // Optional. Nil for standalone policies. - Source *Provenance `json:"source,omitempty"` + // Optional. Empty for standalone policies. One entry per resolved policy. + Source []Provenance `json:"source,omitempty"` } -// Provenance links a ComplyPack to the Gemara content and policy it implements. +// Provenance links a ComplyPack to a single Gemara policy and the Gemara +// content that policy imports. type Provenance struct { - // GemaraContent is the URI or hash of the Gemara catalog. - // Examples: "oci://registry/gemara/controls:latest", "sha256:abc123..." - GemaraContent string `json:"gemara-content"` - - // PolicyID identifies the policy within the Gemara catalog. + // PolicyID identifies the resolved Gemara policy this pack implements. PolicyID string `json:"policy-id"` + + // GemaraContent is the set of Gemara references (catalogs and guidance) + // the policy imports. Non-empty. + GemaraContent []GemaraRef `json:"gemara-content"` +} + +// GemaraRef records a single Gemara reference (catalog or guidance) imported +// by a policy. URI values are recorded into the published OCI config blob, so +// they are sanitized upstream (userinfo/query/fragment stripped, local +// filesystem paths omitted) before reaching this struct. +type GemaraRef struct { + // URI locates the Gemara content (e.g. an OCI reference). May be empty for + // local or url-less references. Sanitized before recording. + URI string `json:"uri,omitempty"` + + // Version is the referenced content version. Optional. + Version string `json:"version,omitempty"` + + // ReferenceID is the import identifier within the policy. Required. + ReferenceID string `json:"reference-id"` } // Validate checks that required Config fields are present and well-formed. @@ -65,12 +82,17 @@ func (c Config) Validate() error { if !jsonschema.VersionPattern().MatchString(c.Version) { return fmt.Errorf("%w: version %q must be semver (e.g. 1.0.0)", ErrInvalidConfig, c.Version) } - if c.Source != nil { - if c.Source.GemaraContent == "" { - return fmt.Errorf("%w: source.gemara-content is required when source is set", ErrInvalidConfig) + for i, prov := range c.Source { + if prov.PolicyID == "" { + return fmt.Errorf("%w: source[%d].policy-id is required", ErrInvalidConfig, i) + } + if len(prov.GemaraContent) == 0 { + return fmt.Errorf("%w: source[%d].gemara-content is required when source is set", ErrInvalidConfig, i) } - if c.Source.PolicyID == "" { - return fmt.Errorf("%w: source.policy-id is required when source is set", ErrInvalidConfig) + for j, ref := range prov.GemaraContent { + if ref.ReferenceID == "" { + return fmt.Errorf("%w: source[%d].gemara-content[%d].reference-id is required", ErrInvalidConfig, i, j) + } } } return nil diff --git a/pkg/complypack/config_test.go b/pkg/complypack/config_test.go index 3063ba6..4aba3a2 100644 --- a/pkg/complypack/config_test.go +++ b/pkg/complypack/config_test.go @@ -17,6 +17,10 @@ func TestConfigValidation(t *testing.T) { name string cfg complypack.Config wantErr bool + // wantErrContains, when set on a wantErr case, asserts the error + // message names the specific field/index that triggered it, proving + // the intended validation branch fired rather than an earlier one. + wantErrContains string }{ { name: "valid minimal config", @@ -33,9 +37,34 @@ func TestConfigValidation(t *testing.T) { ID: "io.test.pack", EvaluatorID: "opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:latest", - PolicyID: "policy-123", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: []complypack.GemaraRef{ + { + ReferenceID: "ref-1", + URI: "oci://registry/gemara/controls:latest", + Version: "1.0.0", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "valid with url-less gemara ref", + cfg: complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-local"}, + }, + }, }, }, wantErr: false, @@ -75,12 +104,15 @@ func TestConfigValidation(t *testing.T) { ID: "io.test.pack", EvaluatorID: "opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "", - PolicyID: "policy-123", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: nil, + }, }, }, - wantErr: true, + wantErr: true, + wantErrContains: "source[0].gemara-content", }, { name: "provenance with empty policy-id", @@ -88,12 +120,102 @@ func TestConfigValidation(t *testing.T) { ID: "io.test.pack", EvaluatorID: "opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:latest", - PolicyID: "", + Source: []complypack.Provenance{ + { + PolicyID: "", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-1"}, + }, + }, }, }, - wantErr: true, + wantErr: true, + wantErrContains: "source[0].policy-id", + }, + { + name: "gemara ref with empty reference-id", + cfg: complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "", URI: "oci://registry/x:latest"}, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "source[0].gemara-content[0].reference-id", + }, + { + name: "second provenance entry with empty policy-id is rejected", + cfg: complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-1", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-1"}, + }, + }, + { + PolicyID: "", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-2"}, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "source[1].policy-id", + }, + { + name: "later gemara ref with empty reference-id is rejected", + cfg: complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-1", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-ok"}, + {ReferenceID: ""}, + }, + }, + }, + }, + wantErr: true, + wantErrContains: "source[0].gemara-content[1].reference-id", + }, + { + name: "multiple valid provenance entries accepted", + cfg: complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-1", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-1"}, + }, + }, + { + PolicyID: "policy-2", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-2"}, + {ReferenceID: "ref-3"}, + }, + }, + }, + }, + wantErr: false, }, { name: "invalid id format - single segment", @@ -173,7 +295,12 @@ func TestConfigValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.cfg.Validate() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) + assert.ErrorIs(t, err, complypack.ErrInvalidConfig) + if tt.wantErrContains != "" { + assert.Contains(t, err.Error(), tt.wantErrContains, + "error should name the field/index that triggered it") + } } else { assert.NoError(t, err) } @@ -186,9 +313,17 @@ func TestConfigJSON(t *testing.T) { ID: "io.test.pack", EvaluatorID: "opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:latest", - PolicyID: "policy-123", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: []complypack.GemaraRef{ + { + ReferenceID: "ref-1", + URI: "oci://registry/gemara/controls:latest", + Version: "1.0.0", + }, + }, + }, }, } @@ -202,9 +337,52 @@ func TestConfigJSON(t *testing.T) { assert.Equal(t, cfg.ID, decoded.ID) assert.Equal(t, cfg.EvaluatorID, decoded.EvaluatorID) assert.Equal(t, cfg.Version, decoded.Version) - require.NotNil(t, decoded.Source) - assert.Equal(t, cfg.Source.GemaraContent, decoded.Source.GemaraContent) - assert.Equal(t, cfg.Source.PolicyID, decoded.Source.PolicyID) + require.Len(t, decoded.Source, 1) + assert.Equal(t, cfg.Source[0].PolicyID, decoded.Source[0].PolicyID) + require.Len(t, decoded.Source[0].GemaraContent, 1) + assert.Equal(t, cfg.Source[0].GemaraContent[0].ReferenceID, decoded.Source[0].GemaraContent[0].ReferenceID) + assert.Equal(t, cfg.Source[0].GemaraContent[0].URI, decoded.Source[0].GemaraContent[0].URI) + assert.Equal(t, cfg.Source[0].GemaraContent[0].Version, decoded.Source[0].GemaraContent[0].Version) +} + +func TestConfigJSONFieldNames(t *testing.T) { + cfg := complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "policy-123", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-1", URI: "oci://registry/x:latest", Version: "1.0.0"}, + }, + }, + }, + } + + data, err := json.Marshal(cfg) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + + source, ok := raw["source"].([]any) + require.True(t, ok, "source should marshal as an array") + require.Len(t, source, 1) + + entry, ok := source[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "policy-123", entry["policy-id"]) + + content, ok := entry["gemara-content"].([]any) + require.True(t, ok, "gemara-content should marshal as an array") + require.Len(t, content, 1) + + ref, ok := content[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ref-1", ref["reference-id"]) + assert.Equal(t, "oci://registry/x:latest", ref["uri"]) + assert.Equal(t, "1.0.0", ref["version"]) } func TestConfigJSONOmitEmpty(t *testing.T) { @@ -225,3 +403,19 @@ func TestConfigJSONOmitEmpty(t *testing.T) { _, exists := raw["source"] assert.False(t, exists, "source field should be omitted when nil") } + +func TestGemaraRefJSONOmitEmpty(t *testing.T) { + ref := complypack.GemaraRef{ReferenceID: "ref-local"} + + data, err := json.Marshal(ref) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + + assert.Equal(t, "ref-local", raw["reference-id"]) + _, hasURI := raw["uri"] + assert.False(t, hasURI, "uri should be omitted when empty") + _, hasVersion := raw["version"] + assert.False(t, hasVersion, "version should be omitted when empty") +} diff --git a/pkg/complypack/integration_test.go b/pkg/complypack/integration_test.go index 4853f2c..644a69a 100644 --- a/pkg/complypack/integration_test.go +++ b/pkg/complypack/integration_test.go @@ -24,9 +24,17 @@ func TestIntegrationMemoryStore(t *testing.T) { ID: "io.test.pack", EvaluatorID: "io.complytime.opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:v1", - PolicyID: "test-policy-001", + Source: []complypack.Provenance{ + { + PolicyID: "test-policy-001", + GemaraContent: []complypack.GemaraRef{ + { + ReferenceID: "ref-1", + URI: "oci://registry/gemara/controls:v1", + Version: "1.0.0", + }, + }, + }, }, } @@ -56,15 +64,21 @@ func TestIntegrationMemoryStore(t *testing.T) { if result.Config.Version != cfg.Version { t.Errorf("Version = %q, want %q", result.Config.Version, cfg.Version) } - if result.Config.Source == nil { - t.Fatal("Source is nil") + if len(result.Config.Source) != len(cfg.Source) { + t.Fatalf("Source length = %d, want %d", len(result.Config.Source), len(cfg.Source)) } - if result.Config.Source.GemaraContent != cfg.Source.GemaraContent { - t.Errorf("GemaraContent mismatch") - } - if result.Config.Source.PolicyID != cfg.Source.PolicyID { + if result.Config.Source[0].PolicyID != cfg.Source[0].PolicyID { t.Errorf("PolicyID mismatch") } + if len(result.Config.Source[0].GemaraContent) != len(cfg.Source[0].GemaraContent) { + t.Fatalf("GemaraContent length mismatch") + } + if result.Config.Source[0].GemaraContent[0].URI != cfg.Source[0].GemaraContent[0].URI { + t.Errorf("GemaraContent URI mismatch") + } + if result.Config.Source[0].GemaraContent[0].ReferenceID != cfg.Source[0].GemaraContent[0].ReferenceID { + t.Errorf("GemaraContent ReferenceID mismatch") + } unpackedContent, err := io.ReadAll(result.Content) if err != nil { diff --git a/pkg/complypack/pack_test.go b/pkg/complypack/pack_test.go index 7f1ba63..9bdc2e0 100644 --- a/pkg/complypack/pack_test.go +++ b/pkg/complypack/pack_test.go @@ -5,12 +5,14 @@ package complypack_test import ( "bytes" "context" + "encoding/json" "strings" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "github.com/complytime/complypack/pkg/complypack" @@ -45,9 +47,17 @@ func TestPackWithProvenance(t *testing.T) { ID: "io.test.pack", EvaluatorID: "io.complytime.opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:v1", - PolicyID: "pol-123", + Source: []complypack.Provenance{ + { + PolicyID: "pol-123", + GemaraContent: []complypack.GemaraRef{ + { + ReferenceID: "ref-1", + URI: "oci://registry/gemara/controls:v1", + Version: "1.0.0", + }, + }, + }, }, } @@ -56,6 +66,90 @@ func TestPackWithProvenance(t *testing.T) { desc, err := complypack.Pack(ctx, store, cfg, content) require.NoError(t, err) assert.NotEmpty(t, desc.Digest) + + // The config blob must carry the provenance as a "source" array. + raw := fetchConfigBlob(t, ctx, store, desc) + source, ok := raw["source"].([]any) + require.True(t, ok, "config blob should contain a source array") + require.Len(t, source, 1) + entry, ok := source[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "pol-123", entry["policy-id"]) + + gemaraContent, ok := entry["gemara-content"].([]any) + require.True(t, ok, "source entry should contain a gemara-content array") + require.Len(t, gemaraContent, 1) + ref, ok := gemaraContent[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ref-1", ref["reference-id"]) + assert.Equal(t, "oci://registry/gemara/controls:v1", ref["uri"]) + assert.Equal(t, "1.0.0", ref["version"]) +} + +func TestPackWithoutProvenanceOmitsSource(t *testing.T) { + ctx := context.Background() + store := memory.New() + + cfg := complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "io.complytime.opa", + Version: "1.0.0", + } + + desc, err := complypack.Pack(ctx, store, cfg, strings.NewReader("fake policy content")) + require.NoError(t, err) + + raw := fetchConfigBlob(t, ctx, store, desc) + _, exists := raw["source"] + assert.False(t, exists, "config blob should omit source when no provenance is set") +} + +func TestPackProvenanceByteIdentical(t *testing.T) { + ctx := context.Background() + + cfg := complypack.Config{ + ID: "io.test.pack", + EvaluatorID: "io.complytime.opa", + Version: "1.0.0", + Source: []complypack.Provenance{ + { + PolicyID: "pol-123", + GemaraContent: []complypack.GemaraRef{ + {ReferenceID: "ref-1", URI: "oci://registry/gemara/controls:v1", Version: "1.0.0"}, + }, + }, + }, + } + + store1 := memory.New() + desc1, err := complypack.Pack(ctx, store1, cfg, strings.NewReader("fake policy content")) + require.NoError(t, err) + + store2 := memory.New() + desc2, err := complypack.Pack(ctx, store2, cfg, strings.NewReader("fake policy content")) + require.NoError(t, err) + + assert.Equal(t, desc1.Digest, desc2.Digest, + "identical input must produce a byte-identical config blob and manifest digest") +} + +// fetchConfigBlob reads the manifest referenced by desc and returns its config +// blob decoded into a generic JSON map. +func fetchConfigBlob(t *testing.T, ctx context.Context, store content.Fetcher, desc ocispec.Descriptor) map[string]any { + t.Helper() + + manifestBytes, err := content.FetchAll(ctx, store, desc) + require.NoError(t, err) + + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(manifestBytes, &manifest)) + + configBytes, err := content.FetchAll(ctx, store, manifest.Config) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(configBytes, &raw)) + return raw } func TestPackWithAnnotations(t *testing.T) { diff --git a/pkg/complypack/unpack_test.go b/pkg/complypack/unpack_test.go index 0eb4d98..04f4bf2 100644 --- a/pkg/complypack/unpack_test.go +++ b/pkg/complypack/unpack_test.go @@ -23,9 +23,17 @@ func TestUnpackRoundTrip(t *testing.T) { ID: "io.test.pack", EvaluatorID: "io.complytime.opa", Version: "1.0.0", - Source: &complypack.Provenance{ - GemaraContent: "oci://registry/gemara/controls:v1", - PolicyID: "pol-123", + Source: []complypack.Provenance{ + { + PolicyID: "pol-123", + GemaraContent: []complypack.GemaraRef{ + { + ReferenceID: "ref-1", + URI: "oci://registry/gemara/controls:v1", + Version: "1.0.0", + }, + }, + }, }, } @@ -49,11 +57,27 @@ func TestUnpackRoundTrip(t *testing.T) { if result.Config.Version != cfg.Version { t.Errorf("Version = %q, want %q", result.Config.Version, cfg.Version) } - if result.Config.Source == nil { - t.Fatal("Source is nil") + if len(result.Config.Source) != len(cfg.Source) { + t.Fatalf("Source length = %d, want %d", len(result.Config.Source), len(cfg.Source)) } - if result.Config.Source.GemaraContent != cfg.Source.GemaraContent { - t.Errorf("GemaraContent = %q, want %q", result.Config.Source.GemaraContent, cfg.Source.GemaraContent) + if result.Config.Source[0].PolicyID != cfg.Source[0].PolicyID { + t.Errorf("PolicyID = %q, want %q", + result.Config.Source[0].PolicyID, cfg.Source[0].PolicyID) + } + if len(result.Config.Source[0].GemaraContent) != len(cfg.Source[0].GemaraContent) { + t.Fatalf("GemaraContent length = %d, want %d", + len(result.Config.Source[0].GemaraContent), len(cfg.Source[0].GemaraContent)) + } + gotRef := result.Config.Source[0].GemaraContent[0] + wantRef := cfg.Source[0].GemaraContent[0] + if gotRef.ReferenceID != wantRef.ReferenceID { + t.Errorf("GemaraContent ReferenceID = %q, want %q", gotRef.ReferenceID, wantRef.ReferenceID) + } + if gotRef.URI != wantRef.URI { + t.Errorf("GemaraContent URI = %q, want %q", gotRef.URI, wantRef.URI) + } + if gotRef.Version != wantRef.Version { + t.Errorf("GemaraContent Version = %q, want %q", gotRef.Version, wantRef.Version) } // Verify content