Skip to content

fix: record Gemara source provenance in pack artifacts - #225

Open
trevor-vaughan wants to merge 2 commits into
mainfrom
opsx/221-bug-source-provenance
Open

fix: record Gemara source provenance in pack artifacts#225
trevor-vaughan wants to merge 2 commits into
mainfrom
opsx/221-bug-source-provenance

Conversation

@trevor-vaughan

Copy link
Copy Markdown
Member

Summary

Resolves declared gemara.sources during complypack pack and writes per-policy provenance (imported catalogs and guidance references) into the OCI config blob. Packs with no declared sources are unaffected.

Key changes:

  • Config model redesignConfig.Source is now []Provenance, each carrying a PolicyID and []GemaraRef (ReferenceID, URI, Version). This is a breaking change to the config blob JSON schema.
  • Provenance pipeline — new BuildProvenance function in internal/pipeline maps resolved policies to deterministically ordered provenance records (sorted by PolicyID, then ReferenceID) for byte-identical config blobs.
  • URI sanitization (CWE-200) — credentials, query strings, and fragments are stripped from URIs before recording into the published blob. Local file:// paths are omitted entirely.
  • Credential redaction in errors (CWE-209)RedactCredentials in internal/registry strips userinfo from source references in error messages. loadFileArtifacts redacts both the wrapper message and the wrapped os.PathError.
  • Batch error reportingLoadAndResolve collects all load/merge failures into one joined error naming every offending source, instead of failing on the first.
  • 5-minute context timeout (CWE-400) — bounds aggregate resolution of all declared Gemara sources so a slow or hostile source cannot hang the command.
  • --cache-dir flag — for headless or restricted environments where HOME is unset.
  • README — documents gemara.sources config, pack flags, and source provenance blob format.

Related Issues

Review Hints

  • Start with pkg/complypack/config.go to see the new Provenance / GemaraRef types and validation, then internal/pipeline/provenance.go for BuildProvenance and sanitizeURI.

  • internal/registry/client.go has RedactCredentials and splitScheme — the credential stripping heuristic uses last-@-before-first-/ to handle passwords containing @ while preserving digest @sha256: references. Worth checking that the edge cases in TestRedactCredentials cover your mental model.

  • cmd/complypack/cli/pack_provenance_test.go has the end-to-end credential-leak test: it pushes a pack with a credentialed mapping-reference URL and asserts the published config blob contains neither the password nor the query string.

  • The batch-error rework in internal/pipeline/pipeline.go changed the error format from "failed to load artifacts from ..." to "source ...: ...", which required updating assertions in internal/mcp/server_test.go and acceptance/mcp_server_test.go.

  • Review all commits together — they build on each other as a single logical change.

@trevor-vaughan trevor-vaughan added enhancement New feature or request security llm_assisted Issue triaged or authored with LLM assistance labels Aug 4, 2026
- refactor(config): redesign Config.Source from *Provenance to []Provenance
  - Each entry carries a PolicyID and []GemaraRef (ReferenceID, URI, Version)
  - Validation rejects empty PolicyID, empty GemaraContent, and empty
    ReferenceID with indexed error messages
- feat(pipeline): add BuildProvenance to map resolved policies to provenance
  - Output sorted by PolicyID then ReferenceID for byte-identical blobs
- fix(security): sanitize URIs before recording into published blobs (CWE-200)
  - Strips credentials, query strings, and fragments; omits file:// paths
- fix(security): redact credentials in error messages (CWE-209)
  - RedactCredentials in registry strips userinfo using last-@-before-slash
  - loadFileArtifacts redacts both the wrapper and the wrapped os.PathError
- refactor(pipeline): collect all load/merge failures into one joined error
  - Names every offending source instead of failing on the first
- feat(pack): add --cache-dir flag for headless/restricted environments
- feat(pack): bound source resolution with 5-minute context timeout (CWE-400)
- docs(readme): document gemara.sources config, pack flags, source provenance
- test: BuildProvenance cardinality, determinism, URI sanitization, import-less
  policies, orphan refs, nil/empty input, RedactCredentials edge cases,
  end-to-end credential leak through Pack, batch errors, context cancellation

BREAKING CHANGE: Config.Source changed from *Provenance to []Provenance and Provenance.GemaraContent changed from string to []GemaraRef — consumers of the OCI config blob JSON must update deserialization

Fixes: #221

Assisted-By: Claude Opus 4.8
Signed-off-by: Trevor Vaughan <tvaughan@redhat.com>
@trevor-vaughan
trevor-vaughan force-pushed the opsx/221-bug-source-provenance branch from 6fa364f to fc377de Compare August 4, 2026 20:44
@jpower432 jpower432 self-assigned this Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

✅ CRAP Load Analysis: PASS (no baseline)

No baseline file found at .gaze/baseline.json. Showing current scores without regression detection.

How to Enable Regression Detection

Generate and commit a baseline file to track CRAP score changes over time:

# 1. Install gaze
go install github.com/unbound-force/gaze/cmd/gaze@latest

# 2. Run tests and generate baseline
go test -coverprofile=coverage.out ./...
mkdir -p .gaze
gaze crap --format=json --coverprofile=coverage.out ./... > .gaze/baseline.json

# 3. Commit the baseline
git add .gaze/baseline.json
git commit -m "chore: add CRAP baseline for regression detection"

For more information:

Summary

Metric Value
Functions analysed 121
Avg complexity 4.5
Avg line coverage 77.3%
Avg CRAP score 14.5
CRAPload (>= 15) 14
Avg contract coverage 72.7%
Avg GazeCRAP score 29.3
GazeCRAPload (>= 15) 2

View full analysis logs

@trevor-vaughan trevor-vaughan changed the title Record Gemara source provenance in pack artifacts fix: record Gemara source provenance in pack artifacts Aug 4, 2026
@trevor-vaughan
trevor-vaughan marked this pull request as ready for review August 4, 2026 21:03
@trevor-vaughan
trevor-vaughan requested a review from a team as a code owner August 4, 2026 21:03
@trevor-vaughan
trevor-vaughan marked this pull request as draft August 4, 2026 21:03

@jpower432 jpower432 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 LLM-assisted

issue (blocking): BuildProvenance doesn't distinguish bundled OCI sources from other source types.

In go-gemara, looking at bundle/assemble.go: when Gemara assembles a bundle, it fetches transitive dependencies from MappingReference.Url at assembly time and packs them as layers.

By the time complypack resolves a pre-assembled OCI bundle, those URLs were already consumed -- the authoritative provenance is the bundle's OCI reference + digest, not the mapping reference URLs which describe a past assembly step.

For file sources and unbundled/live resolution, recording MappingReference.Url is reasonable.

For pre-assembled OCI bundles specifically, the provenance should record the bundle's OCI reference.

thought: For reference, this intersects with an open Gemara question about whether bundles should be resolved at pack time or fetch time.

Comment thread cmd/complypack/cli/pack.go Outdated
Comment thread internal/pipeline/pipeline.go Outdated
Address PR #225 review feedback:

- BuildProvenance now records the bundle's OCI reference as the
  authoritative URI for policies from pre-assembled OCI bundles,
  instead of the assembly-time MappingReference URLs which describe
  a past fetch step.  File and unbundled sources continue to use
  MappingReference.Url as before.

- LoadResult.PolicySources tracks which source string provided each
  policy ID so BuildProvenance can distinguish source types.

- Remove SanitizeSourceID wrapper (single caller, zero added logic);
  call registry.RedactCredentials directly.  TestSanitizeSourceID
  removed — identical coverage exists in TestRedactCredentials.

- Convert log.Printf to slog.Info for the two provenance-resolution
  progress messages in pack.go, matching the structured logging
  convention used elsewhere in the file.

Assisted-by: Claude (Anthropic, Claude 3.5 Sonnet 4.5)
Signed-off-by: Trevor Vaughan <tvaughan@redhat.com>
@trevor-vaughan
trevor-vaughan marked this pull request as ready for review August 10, 2026 13:06

@yvonnedevlinrh yvonnedevlinrh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Overall this is a well-structured PR — the architecture follows the thin-transport/domain-logic split, security defense is layered at three levels (URI sanitization, credential redaction, error redaction), and the test suite is comprehensive with 20+ cases covering determinism, cardinality, and edge cases. CI and local pre-flight checks pass.

Requesting changes on one functional bug and one test gap that masks it. Two additional low-severity items noted inline.

Findings

# Severity Location Description
1 High provenance.go:39 BuildProvenance returns non-nil empty slice, causing "source":[] in config blob instead of omitting the key (contradicts README)
2 Medium pack_provenance_test.go:166 assert.Empty masks #1 — should be assert.Nil; missing test for sources-declared-but-zero-policies → JSON serialization path
3 Low pipeline.go:53-55 policySources map silently overwrites on duplicate policy ID across sources; deterministic but undocumented
4 Low client.go:89-100 RedactCredentials doc claims generic scheme handling but splitScheme rejects digit-leading prefixes, passing credentials through; limitation is tested but not documented

What looks good

  • Deterministic output: sort-by-PolicyID + sort-by-ReferenceID, verified with 20-iteration fuzz test
  • CWE-200/CWE-209 defense in depth across sanitizeURI, RedactCredentials, and redactPathError
  • CWE-400 timeout bound on source resolution (packResolveTimeout)
  • Backward-compatible LoadResult.PolicySources addition — existing callers unaffected
  • Config.Validate() loop correctly no-ops on nil/empty Source
  • Addressed prior review feedback (slog migration, SanitizeSourceID removal)

// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH
BuildProvenance uses make([]complypack.Provenance, 0, len(resolved)), returning a non-nil empty slice when no entries are produced. Go's json:",omitempty" only omits nil slices, so the config blob emits "source":[] instead of omitting the key. Contradicts README line 279. Fix: return nil when len(provenance) == 0.

}
prov, err := resolveProvenance(ctx, cfg, t.TempDir())
require.NoError(t, err)
assert.Empty(t, prov)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium
assert.Empty(t, prov) passes for both nil and []Provenance{}, masking finding #1. Should use assert.Nil(t, prov). No test covers the sources-declared-but-zero-policies path through to JSON serialization

loadErrs = append(loadErrs, fmt.Errorf("source %s: %w", name, err))
continue
}
for id := range src.Policies {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low
policySources[id] = entry.Source silently overwrites when multiple sources provide the same policy ID. Last-declared source wins. Deterministic but undocumented; consider a godoc note.

return ref
}

// RedactCredentials removes any userinfo (user:password@) embedded in an OCI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RedactCredentials doc claims generic scheme handling, but splitScheme rejects digit-leading prefixes (e.g. 1abc://user:secret@host), passing credentials through unredacted. Limitation is tested but not documented in the function's doc comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request llm_assisted Issue triaged or authored with LLM assistance security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: complypack.Config.Source provenance is never populated

4 participants