diff --git a/AGENTS.md b/AGENTS.md index e92182f..a695985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 04677f6..8ee9014 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -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 @@ -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. diff --git a/runner/result.go b/runner/result.go index 6055785..489e8bc 100644 --- a/runner/result.go +++ b/runner/result.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + "slices" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/api/sdk" @@ -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 / @@ -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: @@ -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{ @@ -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) } } @@ -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 { @@ -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 { + 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) diff --git a/runner/result_test.go b/runner/result_test.go index 7f8735a..8be4d97 100644 --- a/runner/result_test.go +++ b/runner/result_test.go @@ -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]) + } +} diff --git a/runner/source_props_test.go b/runner/source_props_test.go index a0dc2f9..73b9e1e 100644 --- a/runner/source_props_test.go +++ b/runner/source_props_test.go @@ -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" ) @@ -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") +}