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
18 changes: 12 additions & 6 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)}
}
17 changes: 13 additions & 4 deletions docs/policy_artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
66 changes: 66 additions & 0 deletions internal/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package internal

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"

Expand Down Expand Up @@ -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,
Expand All @@ -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))
}
75 changes: 75 additions & 0 deletions internal/source_digest_test.go
Original file line number Diff line number Diff line change
@@ -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")))
}
66 changes: 47 additions & 19 deletions runner/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Loading
Loading