diff --git a/cmd/agent.go b/cmd/agent.go index add8796..f817c41 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -1407,12 +1407,12 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { } policyPaths := make([]string, 0, len(pluginConfig.Policies)) - policySources := make(map[string]string, len(pluginConfig.Policies)) + policySources := make(map[string]runner.Source, len(pluginConfig.Policies)) for _, inputBundle := range pluginConfig.Policies { policyLocation := ar.policyLocations[string(inputBundle)] policyPaths = append(policyPaths, policyLocation) - policySources[policyLocation] = string(inputBundle) + policySources[policyLocation] = sourceOf(string(inputBundle), policyLocation) } // Create a new results helper for the plugin to send results back to @@ -1423,7 +1423,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { ) resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), - runner.WithSources(pluginConfig.Source, policySources), + runner.WithSources(sourceOf(pluginConfig.Source, source), policySources), ) policyBehaviorProto := policyBehaviorToProto(pluginConfig.PolicyBehavior) @@ -1503,14 +1503,14 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent ) policyPaths := make([]string, 0) - policySources := make(map[string]string, len(plugin.Policies)) + policySources := make(map[string]runner.Source, len(plugin.Policies)) for _, inputBundle := range plugin.Policies { policyLocation, err := ar.download(ctx, string(inputBundle), AgentPolicyDir, "policies", "", logger) if err != nil { return err } policyPaths = append(policyPaths, policyLocation) - policySources[policyLocation] = string(inputBundle) + policySources[policyLocation] = sourceOf(string(inputBundle), policyLocation) } platform := v1.Platform{ @@ -1559,7 +1559,7 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent ) resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), - runner.WithSources(plugin.Source, policySources), + runner.WithSources(sourceOf(plugin.Source, pluginExecutable), policySources), ) policyBehaviorProto := policyBehaviorToProto(plugin.PolicyBehavior) @@ -2003,3 +2003,9 @@ func (ar *AgentRunner) trackPluginClient(client *plugin.Client) func() { }) } } + +// sourceOf describes where a plugin or policy bundle came from, for evidence: its configured +// source and, where known, the digest of what the agent extracted at location. +func sourceOf(source, location string) runner.Source { + return runner.Source{Reference: source, Digest: internal.SourceDigest(source, location)} +} diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index d0300bf..04677f6 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -68,14 +68,23 @@ many evidence records it produces. ## Plugin and policy sources -Every evidence the agent sends also records where its plugin and policy bundle came from, -as the `source` configured for each in the agent config: an OCI reference such as -`ghcr.io/compliance-framework/plugin-apt-versions:v0.4.0`, or a local path. +Every evidence the agent sends also records where its plugin and policy bundle came from: | Evidence prop | Value | | --- | --- | -| `_plugin_source` | The plugin's configured `source` | +| `_plugin_source` | The plugin's configured `source`: an OCI reference such as `ghcr.io/compliance-framework/plugin-apt-versions:v0.4.0`, or a local path | +| `_plugin_digest` | For an OCI source, the registry digest the reference resolved to when the agent downloaded it; for a local plugin binary, its SHA-256 | | `_policy_source` | The configured source of the policy bundle the evaluation used (only when the evidence carries a `PolicyEvaluation`, so the bundle is known) | +| `_policy_digest` | For an OCI source, the registry digest the reference resolved to when the agent downloaded it. Not set for a local directory; `_policy_bundle_digest` covers its content | + +With `_plugin_source` and `_plugin_digest`, the image is pinned (`ref@digest`) even if the tag +later moves. + +The agent records the registry digest in `.ccf-source.json` next to the extracted files when +it downloads them, so later runs, which skip the download, still report it. Files extracted +before digests were recorded have no such record: their evidence carries the source but no +digest until they are downloaded again (a new version, a cleared cache, or a fresh agent +volume). The agent owns these props: any a plugin sets itself are replaced. They are recorded whether or not the evaluation's artifacts could be stored. diff --git a/internal/oci.go b/internal/oci.go index 6b5b5f4..704dd5a 100644 --- a/internal/oci.go +++ b/internal/oci.go @@ -2,9 +2,12 @@ package internal import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" @@ -162,6 +165,13 @@ func Download(ctx context.Context, source string, outputDir string, binaryPath s return localPath, nil } + // The registry digest the tag resolves to, recorded next to the extracted files so + // later runs, which skip the download, still know exactly what they run. + descriptor, headErr := remote.Head(tag, append([]remote.Option{remote.WithAuthFromKeychain(oci.ECRKeychain())}, option...)...) + if headErr != nil { + logger.Warn("Could not resolve the registry digest; evidence will not record it", "source", source, "error", headErr) + } + downloaderImpl, err := oci.NewDownloader( tag, outDir, @@ -174,8 +184,64 @@ func Download(ctx context.Context, source string, outputDir string, binaryPath s return "", err } + if descriptor != nil { + if err := writeSourceRecord(outDir, source, descriptor.Digest.String()); err != nil { + logger.Warn("Could not record the registry digest; evidence will not record it", "source", source, "error", err) + } + } + return localPath, nil } return "", errors.New("downloadable item source cannot be found locally and does not look like OCI") } + +// sourceRecordFile is written next to an OCI artifact's extracted files when the agent +// downloads it, recording the registry digest its tag resolved to. +const sourceRecordFile = ".ccf-source.json" + +type sourceRecord struct { + Reference string `json:"reference"` + Digest string `json:"digest"` +} + +func writeSourceRecord(outDir, reference, digest string) error { + record, err := json.Marshal(sourceRecord{Reference: reference, Digest: digest}) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(outDir, sourceRecordFile), record, 0o644) +} + +// SourceDigest returns the digest of what the agent runs from source, extracted at +// localPath. For an OCI source it is the registry digest recorded when the agent downloaded +// it, or "" for files extracted before digests were recorded. For a local file, such as a +// plugin binary, it is the file's SHA-256. Otherwise it is "". +func SourceDigest(source, localPath string) string { + if IsOCI(source) { + raw, err := os.ReadFile(filepath.Join(filepath.Dir(localPath), sourceRecordFile)) + if err != nil { + return "" + } + var record sourceRecord + if err := json.Unmarshal(raw, &record); err != nil { + return "" + } + return record.Digest + } + + info, err := os.Stat(localPath) + if err != nil || !info.Mode().IsRegular() { + return "" + } + file, err := os.Open(localPath) + if err != nil { + return "" + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "" + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)) +} diff --git a/internal/source_digest_test.go b/internal/source_digest_test.go new file mode 100644 index 0000000..3346be5 --- /dev/null +++ b/internal/source_digest_test.go @@ -0,0 +1,75 @@ +package internal + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pushTestImage pushes a random image to an in-memory registry and returns its reference and +// registry digest. +func pushTestImage(t *testing.T) (string, string) { + t.Helper() + server := httptest.NewServer(registry.New()) + t.Cleanup(server.Close) + u, err := url.Parse(server.URL) + require.NoError(t, err) + + image, err := random.Image(256, 1) + require.NoError(t, err) + reference := u.Host + "/compliance-framework/test-policies:v1.0.0" + tag, err := name.NewTag(reference) + require.NoError(t, err) + require.NoError(t, remote.Write(tag, image)) + + digest, err := image.Digest() + require.NoError(t, err) + return reference, digest.String() +} + +func TestDownloadRecordsTheRegistryDigest(t *testing.T) { + reference, digest := pushTestImage(t) + outputDir := t.TempDir() + + localPath, err := Download(context.Background(), reference, outputDir, "policies", hclog.NewNullLogger()) + require.NoError(t, err) + assert.Equal(t, digest, SourceDigest(reference, localPath)) + + // A later run finds the files extracted, skips the download, and still knows the digest. + require.NoError(t, os.MkdirAll(localPath, 0o755)) + again, err := Download(context.Background(), reference, outputDir, "policies", hclog.NewNullLogger()) + require.NoError(t, err) + assert.Equal(t, localPath, again) + assert.Equal(t, digest, SourceDigest(reference, again)) +} + +func TestSourceDigestForAnExtractionWithoutARecord(t *testing.T) { + // As left by agents from before digests were recorded. + localPath := filepath.Join(t.TempDir(), "ghcr.io", "org", "policies", "v1", "policies") + require.NoError(t, os.MkdirAll(localPath, 0o755)) + assert.Empty(t, SourceDigest("ghcr.io/org/policies:v1", localPath)) +} + +func TestSourceDigestForLocalSources(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "plugin") + require.NoError(t, os.WriteFile(binary, []byte("plugin binary"), 0o755)) + sum := sha256.Sum256([]byte("plugin binary")) + + assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), SourceDigest(binary, binary), "a local plugin binary is hashed") + assert.Empty(t, SourceDigest(dir, dir), "a local policy directory has no digest") + assert.Empty(t, SourceDigest(filepath.Join(dir, "missing"), filepath.Join(dir, "missing"))) +} diff --git a/runner/result.go b/runner/result.go index 94e5539..6055785 100644 --- a/runner/result.go +++ b/runner/result.go @@ -18,25 +18,45 @@ type apiHelper struct { pluginName string artifacts *artifactUploader - // pluginSource and policySources are the configured references the plugin and its - // policy bundles came from, recorded on evidence as _plugin_source and _policy_source. - pluginSource string - policySources map[string]string + // pluginSource and policySources are where the plugin and its policy bundles came from, + // recorded on evidence as _plugin_source / _plugin_digest and _policy_source / + // _policy_digest. + pluginSource Source + policySources map[string]Source } -// Evidence props recording where the plugin and policy bundle came from: the configured -// source, an OCI reference or a local path. +// Source is where a plugin or policy bundle came from. +type Source struct { + // Reference is the source configured for it: an OCI reference or a local path. + Reference string + // Digest is the registry digest the OCI reference resolved to when the agent downloaded + // it, or for a local plugin binary its SHA-256. Empty when not known. + Digest string +} + +// Evidence props recording where the plugin and policy bundle came from. The agent owns +// them; any a plugin sets are replaced. const ( PropPluginSource = "_plugin_source" + PropPluginDigest = "_plugin_digest" PropPolicySource = "_policy_source" + PropPolicyDigest = "_policy_digest" ) -// WithSources sets the plugin's configured source and the configured source of each policy -// bundle, keyed by the local path the agent gave the plugin. -func WithSources(pluginSource string, policySources map[string]string) ApiHelperOption { +func isSourceProp(name string) bool { + switch name { + case PropPluginSource, PropPluginDigest, PropPolicySource, PropPolicyDigest: + return true + } + return false +} + +// WithSources sets where the plugin came from, and where each policy bundle came from, keyed +// by the local path the agent gave the plugin. +func WithSources(plugin Source, policies map[string]Source) ApiHelperOption { return func(h *apiHelper) { - h.pluginSource = pluginSource - for path, source := range policySources { + h.pluginSource = plugin + for path, source := range policies { h.policySources[filepath.Clean(path)] = source } } @@ -63,7 +83,7 @@ func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[strin pluginName: pluginName, artifacts: newArtifactUploader(client), - policySources: map[string]string{}, + policySources: map[string]Source{}, } for _, opt := range opts { opt(h) @@ -168,16 +188,13 @@ func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policy // The agent owns the source props; any a plugin set are replaced. props := evid.Props[:0] for _, prop := range evid.Props { - if prop.Name != PropPluginSource && prop.Name != PropPolicySource { + if !isSourceProp(prop.Name) { props = append(props, prop) } } - evid.Props = props - if h.pluginSource != "" { - evid.Props = append(evid.Props, types.Property{Name: PropPluginSource, Value: h.pluginSource}) - } - if source := h.policySources[filepath.Clean(policyPath)]; policyPath != "" && source != "" { - evid.Props = append(evid.Props, types.Property{Name: PropPolicySource, Value: source}) + evid.Props = appendSource(props, h.pluginSource, PropPluginSource, PropPluginDigest) + if policyPath != "" { + evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(policyPath)], PropPolicySource, PropPolicyDigest) } labels := make(map[string]string) for k, v := range h.agentLabels { @@ -277,3 +294,14 @@ func withPluginSelectorLabel(labels []types.SubjectTemplateSelectorLabel, plugin Value: pluginName, }) } + +func appendSource(props []types.Property, source Source, referenceProp, digestProp string) []types.Property { + if source.Reference == "" { + return props + } + props = append(props, types.Property{Name: referenceProp, Value: source.Reference}) + if source.Digest != "" { + props = append(props, types.Property{Name: digestProp, Value: source.Digest}) + } + return props +} diff --git a/runner/source_props_test.go b/runner/source_props_test.go index f7b46b3..a0dc2f9 100644 --- a/runner/source_props_test.go +++ b/runner/source_props_test.go @@ -13,6 +13,13 @@ import ( const ( testPluginSource = "ghcr.io/compliance-framework/plugin-apt-versions:v0.4.0" testPolicySource = "ghcr.io/compliance-framework/plugin-apt-versions-policies:v0.4.0" + testPluginDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + testPolicyDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" +) + +var ( + testPlugin = Source{Reference: testPluginSource, Digest: testPluginDigest} + testPolicy = Source{Reference: testPolicySource, Digest: testPolicyDigest} ) // sentProps returns each sent evidence's props by title, as name -> value. @@ -34,7 +41,7 @@ func TestEvidenceRecordsPluginAndPolicySources(t *testing.T) { bundle := writeBundle(t, "a") api := &fakeAPI{} helper := newTestHelper(t, api, bundle) - WithSources(testPluginSource, map[string]string{bundle + "/": testPolicySource})(helper) + WithSources(testPlugin, map[string]Source{bundle + "/": testPolicy})(helper) require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ evidenceFor("evaluated", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), @@ -43,16 +50,20 @@ func TestEvidenceRecordsPluginAndPolicySources(t *testing.T) { props := sentProps(api) assert.Equal(t, testPluginSource, props["evaluated"][PropPluginSource]) + assert.Equal(t, testPluginDigest, props["evaluated"][PropPluginDigest]) assert.Equal(t, testPolicySource, props["evaluated"][PropPolicySource]) + assert.Equal(t, testPolicyDigest, props["evaluated"][PropPolicyDigest]) assert.Equal(t, testPluginSource, props["no evaluation"][PropPluginSource]) + assert.Equal(t, testPluginDigest, props["no evaluation"][PropPluginDigest]) assert.NotContains(t, props["no evaluation"], PropPolicySource, "without an evaluation the policy bundle is not known") + assert.NotContains(t, props["no evaluation"], PropPolicyDigest) } func TestStreamedReferencesRecordThePolicySource(t *testing.T) { bundle := writeBundle(t, "a") api := &fakeAPI{} helper := newTestHelper(t, api, bundle) - WithSources(testPluginSource, map[string]string{bundle: testPolicySource})(helper) + WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper) client := dialServer(t, newApiHelperGRPCServer(helper)) evaluation := &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)} @@ -64,13 +75,14 @@ func TestStreamedReferencesRecordThePolicySource(t *testing.T) { props := sentProps(api) assert.Equal(t, testPolicySource, props["first"][PropPolicySource]) assert.Equal(t, testPolicySource, props["second"][PropPolicySource]) + assert.Equal(t, testPolicyDigest, props["second"][PropPolicyDigest]) } func TestSourcesAreRecordedWhenArtifactsCannotBeStored(t *testing.T) { bundle := writeBundle(t, "a") api := &fakeAPI{artifactStatuses: []int{http.StatusNotFound}} helper := newTestHelper(t, api, bundle) - WithSources(testPluginSource, map[string]string{bundle: testPolicySource})(helper) + WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper) require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ evidenceFor("old api", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), @@ -97,12 +109,14 @@ func TestPluginCannotSetTheSourceProps(t *testing.T) { bundle := writeBundle(t, "a") api := &fakeAPI{} helper := newTestHelper(t, api, bundle) - WithSources(testPluginSource, map[string]string{bundle: testPolicySource})(helper) + WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper) e := evidenceFor("spoofed", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}) e.Props = []*proto.Property{ {Name: PropPluginSource, Value: "ghcr.io/elsewhere/plugin:v9"}, + {Name: PropPluginDigest, Value: "sha256:spoofed"}, {Name: PropPolicySource, Value: "ghcr.io/elsewhere/policies:v9"}, + {Name: PropPolicyDigest, Value: "sha256:spoofed"}, {Name: "_violation_id", Value: "kept"}, } require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{e})) @@ -112,10 +126,27 @@ func TestPluginCannotSetTheSourceProps(t *testing.T) { var sources []string for _, p := range list { prop := p.(map[string]any) - if prop["name"] == PropPluginSource || prop["name"] == PropPolicySource { + if isSourceProp(prop["name"].(string)) { sources = append(sources, prop["value"].(string)) } } - assert.ElementsMatch(t, []string{testPluginSource, testPolicySource}, sources, "only the agent's values are sent") + assert.ElementsMatch(t, []string{testPluginSource, testPluginDigest, testPolicySource, testPolicyDigest}, sources, "only the agent's values are sent") assert.Equal(t, "kept", sentProps(api)["spoofed"]["_violation_id"]) } + +func TestSourceWithoutDigestRecordsOnlyTheReference(t *testing.T) { + bundle := writeBundle(t, "a") + api := &fakeAPI{} + helper := newTestHelper(t, api, bundle) + // As for files extracted before digests were recorded. + WithSources(Source{Reference: testPluginSource}, map[string]Source{bundle: {Reference: testPolicySource}})(helper) + + require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ + evidenceFor("old cache", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), + })) + props := sentProps(api)["old cache"] + assert.Equal(t, testPluginSource, props[PropPluginSource]) + assert.Equal(t, testPolicySource, props[PropPolicySource]) + assert.NotContains(t, props, PropPluginDigest) + assert.NotContains(t, props, PropPolicyDigest) +}