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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .taskfiles/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion acceptance/mcp_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
62 changes: 58 additions & 4 deletions cmd/complypack/cli/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions cmd/complypack/cli/pack_provenance_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading