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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ change here must keep working with them.
## Domain rules that are easy to break

- **Agent-owned props.** The agent sets `_agent`, `_plugin_source`, `_plugin_digest`,
`_policy_source` and `_policy_digest`, and replaces any value a plugin sends for them.
`_policy_source`, `_policy_digest` and `agent-config-revision` (namespace
`https://compliance-framework.github.io/ns`), and replaces any value a plugin sends for them.
- **API-owned props.** The API alone writes `_policy_bundle_digest`, `_policy_input_digest`
and `_policy_data_digest`.
- **The agent never computes artifact digests.** It passes each evaluation's policy
Expand Down
12 changes: 10 additions & 2 deletions docs/policy_artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Every evidence the agent sends also records where its plugin and policy bundle c
| --- | --- |
| `_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_source` | The configured source of the policy bundle the evaluation used (only when the evidence carries a `PolicyEvaluation`, or, from plugins built on an older agent library, a `_policy_path` label naming one of the plugin's policy paths, 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
Expand All @@ -86,5 +86,13 @@ before digests were recorded have no such record: their evidence carries the sou
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
The agent looks the policy source up by the path it gave the plugin, which is the path the
plugin reports the evaluation under.

Evidence produced under a remote configuration overlay also carries `agent-config-revision`
(namespace `https://compliance-framework.github.io/ns`), the overlay revision the agent had
applied. It is absent when the agent runs its configuration file alone.

The agent owns these props: any a plugin sets itself are replaced, and a plugin's
`agent-config-revision` is dropped even when the agent sets none. They are recorded whether
or not the evaluation's artifacts could be stored.
77 changes: 64 additions & 13 deletions runner/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"slices"

"github.com/compliance-framework/agent/runner/proto"
"github.com/compliance-framework/api/sdk"
Expand All @@ -17,6 +18,8 @@ type apiHelper struct {
agentLabels map[string]string
pluginName string
artifacts *artifactUploader
// evidenceProps are appended to every evidence the plugin creates.
evidenceProps []types.Property

// pluginSource and policySources are where the plugin and its policy bundles came from,
// recorded on evidence as _plugin_source / _plugin_digest and _policy_source /
Expand All @@ -43,6 +46,24 @@ const (
PropPolicyDigest = "_policy_digest"
)

// PropNamespace is the OSCAL namespace of the props the agent adds through
// WithEvidenceProps.
const PropNamespace = "https://compliance-framework.github.io/ns"

// PropConfigRevision is the evidence prop naming the applied remote configuration revision
// (namespace PropNamespace). The agent owns it: any a plugin sets is dropped, whether or not
// the agent sets one.
const PropConfigRevision = "agent-config-revision"

// LabelPolicyPath is the evidence label in which plugins record the policy path they were
// given (policy-manager's _policy_path).
const LabelPolicyPath = "_policy_path"

// isAgentProp reports whether the agent owns a prop, so a plugin's value for it is dropped.
func isAgentProp(prop types.Property) bool {
return isSourceProp(prop.Name) || (prop.Ns == PropNamespace && prop.Name == PropConfigRevision)
}

func isSourceProp(name string) bool {
switch name {
case PropPluginSource, PropPluginDigest, PropPolicySource, PropPolicyDigest:
Expand Down Expand Up @@ -74,6 +95,15 @@ func WithPolicyPaths(paths []string) ApiHelperOption {
}
}

// WithEvidenceProps adds props to every evidence the plugin sends. They are agent-owned: they
// replace any prop the evidence carries with the same (ns, name). The agent uses it to stamp
// the applied remote configuration revision (R38).
func WithEvidenceProps(props ...types.Property) ApiHelperOption {
return func(h *apiHelper) {
h.evidenceProps = append(h.evidenceProps, props...)
}
}

func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[string]string, pluginName string, opts ...ApiHelperOption) *apiHelper {
logger = logger.Named("api-helper")
h := &apiHelper{
Expand Down Expand Up @@ -129,16 +159,14 @@ type apiEvidenceSender struct {
}

func (s *apiEvidenceSender) Send(e *proto.Evidence) {
var refs *types.PolicyArtifacts
policyPath := ""
var outcome evaluationOutcome
if evaluation := e.GetPolicyEvaluation(); evaluation != nil {
outcome := s.outcome(evaluation)
refs, policyPath = outcome.refs, outcome.policyPath
if refs == nil {
outcome = s.outcome(evaluation)
if outcome.refs == nil {
s.notReplayable++
}
}
if err := s.h.client.Evidence.Create(s.ctx, s.h.toSdk(e, refs, policyPath)); err != nil {
if err := s.h.client.Evidence.Create(s.ctx, s.h.toSdk(e, outcome)); err != nil {
s.sendErr = errors.Join(s.sendErr, err)
}
}
Expand Down Expand Up @@ -181,20 +209,30 @@ func (s *apiEvidenceSender) Close() error {
}

// toSdk converts evidence for the API, merging agent, config and finding labels, and
// referring to its stored artifacts. The evaluation's raw data is not included.
func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policyPath string) types.Evidence {
// referring to its evaluation's stored artifacts (outcome is the zero value for evidence
// without an evaluation). The evaluation's raw data is not included.
func (h *apiHelper) toSdk(e *proto.Evidence, outcome evaluationOutcome) types.Evidence {
evid := EvidenceProtoToSdk(e)
evid.PolicyArtifacts = refs
// The agent owns the source props; any a plugin set are replaced.
evid.PolicyArtifacts = outcome.refs
// The agent owns the source and revision props; any a plugin set are replaced.
props := evid.Props[:0]
for _, prop := range evid.Props {
if !isSourceProp(prop.Name) {
if !isAgentProp(prop) {
props = append(props, prop)
}
}
evid.Props = appendSource(props, h.pluginSource, PropPluginSource, PropPluginDigest)
if policyPath != "" {
evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(policyPath)], PropPolicySource, PropPolicyDigest)
if outcome.policyPath == "" {
// Plugins built on an agent library without policy evaluations still label their
// evidence with the policy path they were given.
if p := evid.Labels[LabelPolicyPath]; p != "" {
if _, known := h.policySources[filepath.Clean(p)]; known {
outcome.policyPath = p
}
}
}
if outcome.policyPath != "" {
evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(outcome.policyPath)], PropPolicySource, PropPolicyDigest)
}
labels := make(map[string]string)
for k, v := range h.agentLabels {
Expand All @@ -204,9 +242,22 @@ func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policy
labels[k] = v
}
evid.Labels = labels
evid.Props = mergeProps(evid.Props, h.evidenceProps)
return *evid
}

// mergeProps adds each extra prop, replacing any existing one with the same (ns, name): the
// extra props are the agent's and win over the plugin's.
func mergeProps(props []types.Property, extra []types.Property) []types.Property {
Comment thread
ccf-lisa[bot] marked this conversation as resolved.
for _, p := range extra {
props = slices.DeleteFunc(props, func(q types.Property) bool {
return q.Ns == p.Ns && q.Name == p.Name
})
props = append(props, p)
}
return props
}

func (h *apiHelper) UpsertRiskTemplates(ctx context.Context, packageName string, riskTemplates []*proto.RiskTemplate) error {
templates := ProtoToSdk(riskTemplates, RiskTemplateProtoToSdk)

Expand Down
23 changes: 23 additions & 0 deletions runner/result_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,26 @@ func TestWithPluginSelectorLabelAppendsWhenMissing(t *testing.T) {
t.Fatalf("expected plugin selector label to be appended, got %#v", got[1])
}
}

func TestMergePropsReplacesSameNsAndName(t *testing.T) {
existing := []types.Property{
{Ns: PropNamespace, Name: PropConfigRevision, Value: "plugin-set"},
{Name: "other", Value: "x"},
}
extra := []types.Property{
{Ns: PropNamespace, Name: PropConfigRevision, Value: "7"},
{Ns: "https://example.test/ns", Name: PropConfigRevision, Value: "7"},
}
got := mergeProps(existing, extra)
if len(got) != 3 {
t.Fatalf("expected 3 props, got %#v", got)
}
for _, p := range got {
if p.Value == "plugin-set" {
t.Fatalf("the agent's prop must replace an existing (ns, name), got %#v", got)
}
}
if got[2].Ns != "https://example.test/ns" {
t.Fatalf("a different namespace must be appended, got %#v", got[2])
}
}
57 changes: 57 additions & 0 deletions runner/source_props_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/compliance-framework/agent/runner/proto"
"github.com/compliance-framework/api/sdk/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -150,3 +151,59 @@ func TestSourceWithoutDigestRecordsOnlyTheReference(t *testing.T) {
assert.NotContains(t, props, PropPluginDigest)
assert.NotContains(t, props, PropPolicyDigest)
}

// TestPolicyPathLabelRecordsThePolicySource: evidence without a policy evaluation (plugins
// built on an older agent library) records the source of the policy path it is labelled with,
// when that path is one the plugin was given.
func TestPolicyPathLabelRecordsThePolicySource(t *testing.T) {
bundle := writeBundle(t, "a")
api := &fakeAPI{}
helper := newTestHelper(t, api, bundle)
WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper)

labelled := evidenceFor("labelled", nil)
labelled.Labels = map[string]string{LabelPolicyPath: bundle + "/"}
unknown := evidenceFor("unknown path", nil)
unknown.Labels = map[string]string{LabelPolicyPath: "/elsewhere/policies"}
require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{labelled, unknown}))

props := sentProps(api)
assert.Equal(t, testPolicySource, props["labelled"][PropPolicySource])
assert.Equal(t, testPolicyDigest, props["labelled"][PropPolicyDigest])
assert.NotContains(t, props["unknown path"], PropPolicySource, "a path the plugin was not given records nothing")
}

// TestPluginCannotSetTheConfigRevision: the agent's revision prop replaces a plugin's, and a
// plugin's is dropped when the agent sets none (it runs the file only).
func TestPluginCannotSetTheConfigRevision(t *testing.T) {
spoofed := func() *proto.Evidence {
e := evidenceFor("spoofed", nil)
e.Props = []*proto.Property{
{Ns: new(PropNamespace), Name: PropConfigRevision, Value: "99"},
{Ns: new("https://example.test/ns"), Name: PropConfigRevision, Value: "kept"},
}
return e
}
revisions := func(api *fakeAPI) []string {
var out []string
for _, p := range api.evidence[0]["props"].([]any) {
prop := p.(map[string]any)
if prop["name"] == PropConfigRevision {
out = append(out, prop["ns"].(string)+"="+prop["value"].(string))
}
}
return out
}

bundle := writeBundle(t, "a")
api := &fakeAPI{}
helper := newTestHelper(t, api, bundle)
WithEvidenceProps(types.Property{Ns: PropNamespace, Name: PropConfigRevision, Value: "7"})(helper)
require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{spoofed()}))
assert.ElementsMatch(t, []string{"https://example.test/ns=kept", PropNamespace + "=7"}, revisions(api), "the agent's revision wins")

api = &fakeAPI{}
helper = newTestHelper(t, api, bundle)
require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{spoofed()}))
assert.Equal(t, []string{"https://example.test/ns=kept"}, revisions(api), "without an applied revision a plugin's is dropped")
}
Loading