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
6 changes: 6 additions & 0 deletions .github/workflows/build-and-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
push: true
- name: Build and push (custodian)
uses: docker/build-push-action@v5
Expand All @@ -90,6 +92,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.custodian-meta.outputs.tags }}
labels: ${{ steps.custodian-meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
push: true
- name: Build and push (ci)
uses: docker/build-push-action@v5
Expand All @@ -99,4 +103,6 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.ci-meta.outputs.tags }}
labels: ${{ steps.ci-meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
push: true
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ change here must keep working with them.
directory, input and policy data through to the API, which canonicalises and hashes them.
- **Evidence identity.** `policy-manager`'s `newEvidence` seed is every evidence stream's UUID, and plugins in the
field compute it. Never change it. The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs.
- **Plugin library version.** `internal/pluginlib` reads the agent library a plugin binary was built with from its
Go build info. The config report lists it per plugin (`plugins[].lib-version`) as diagnostics only; nothing is
gated on it.
- **Storage failure doesn't drop evidence.** If artifact storage fails, the evidence is still
sent, without digests.
- **OCI policy bundles.** The agent evaluates the extracted `policies/` subdirectory, and that
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ RUN go mod download

COPY . .

RUN go build -o concom main.go
ARG VERSION=dev
RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go

FROM gcr.io/distroless/base-debian12 AS final

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile-ci
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ RUN go mod download

COPY . .

RUN go build -o concom main.go
ARG VERSION=dev
RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go

FROM debian:bookworm-slim AS final

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile-custodian
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ RUN go mod download

COPY . .

RUN go build -o concom main.go
ARG VERSION=dev
RUN go build -ldflags "-X main.version=${VERSION}" -o concom main.go


# 0.9.49.0
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,17 @@ agent_evidence:
enabled: true
emit_on_run_completion: true
interval: 1h

remote_config: # Optional: set locally only; see docs/configuration.md#remote-configuration
mode: report # off | report | apply_safe | apply_all; defaults to report with api.auth, off without
poll_interval: 60s
trusted_sources: []
overridable_config_flags: []
allow_local_sources: false
```

See [configuration](./docs/configuration.md) for more information.
See [configuration](./docs/configuration.md) for more information, and
[remote configuration](./docs/configuration.md#remote-configuration) for the `remote_config` block.

The agent sets the `_agent` label using the following fallback chain: `api.auth.client_id` when available, then
`KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally a deterministic SHA-256 hash of the runtime plugin and agent
Expand Down
67 changes: 62 additions & 5 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"

Expand All @@ -32,7 +33,9 @@ import (

"github.com/compliance-framework/agent/internal"
"github.com/compliance-framework/agent/internal/agentstate"
"github.com/compliance-framework/agent/internal/pluginlib"
"github.com/compliance-framework/agent/runner"
"github.com/compliance-framework/api/pkg/agentconfig"
"github.com/compliance-framework/api/sdk"
sdktypes "github.com/compliance-framework/api/sdk/types"
"github.com/coreos/go-systemd/v22/daemon"
Expand Down Expand Up @@ -81,13 +84,48 @@ type agentEvidenceConfig struct {
}

// agentConfig is the RUNTIME form of the configuration, built from the declared form
// (agentconfig.Config) by toRuntime.
// (agentconfig.Config) by toRuntime. It is immutable once handed to AgentRunner.UpdateConfig,
// except for the protocol resolution AgentRunner.Run performs on its own copy and the sync
// metadata, which the reconciler may update atomically when a new revision leaves the
// effective configuration unchanged.
type agentConfig struct {
Daemon bool `mapstructure:"daemon"`
Verbosity int32 `mapstructure:"verbosity"`
ApiConfig *apiConfig `mapstructure:"api"`
Plugins map[string]*agentPlugin `mapstructure:"plugins"`
AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"`

// sync is what the heartbeat reports about the applied remote configuration (R11, R45).
// Read it with syncInfo; nil means the zero syncMeta.
sync *atomic.Pointer[syncMeta]
// remote is the normalized remote_config block.
remote agentconfig.RemoteConfig
}

// syncMeta describes the applied remote configuration.
type syncMeta struct {
AppliedRevision int64 // 0 when running the file only
Digest string // agentconfig.Digest of the effective declared config
Mode string // remote_config.mode
}

// syncInfo returns the sync metadata (safe for concurrent use with setSync).
func (ac *agentConfig) syncInfo() syncMeta {
if ac == nil || ac.sync == nil {
return syncMeta{}
}
if p := ac.sync.Load(); p != nil {
return *p
}
return syncMeta{}
}

// setSync stores the sync metadata. The first call must happen before the config is shared.
func (ac *agentConfig) setSync(m syncMeta) {
if ac.sync == nil {
ac.sync = &atomic.Pointer[syncMeta]{}
}
ac.sync.Store(&m)
}

// logVerbosity maps our verbosity "increase" onto hclog's levels: our 0/1/2 = Info/Debug/Trace,
Expand Down Expand Up @@ -318,6 +356,15 @@ func agentRunner(cmd *cobra.Command, args []string) error {

ar := NewAgentRunner(WithInstanceID(id))
rc := newReconciler(cmd, configPath, store, ar, logger)
rc.instanceID = id
pluginLibs := &pluginlib.Cache{}
rc.pluginLib = func(ctx context.Context, source string) (string, error) {
binary, err := ar.downloadPlugin(ctx, source, logger)
if err != nil {
return "", err
}
return pluginLibs.Version(binary)
}
rc.onStartupFailure = ar.ReportStartupFailure

active, err := rc.startup(context.Background())
Expand Down Expand Up @@ -1579,10 +1626,7 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U
)
heartbeatCtx, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
err := client.Heartbeat.Create(heartbeatCtx, sdktypes.Heartbeat{
UUID: staticAgentUUID,
CreatedAt: time.Now().UTC(),
})
err := client.Heartbeat.Create(heartbeatCtx, buildHeartbeat(config, staticAgentUUID, time.Now().UTC()))
if err != nil {
logger.Error("Error sending heartbeat via SDK", "error", err, "uuid", staticAgentUUID.String())
return err
Expand All @@ -1591,6 +1635,19 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U
return nil
}

// buildHeartbeat builds the heartbeat body. When remote configuration is not off it carries
// the applied revision (0 when running the file only, never null) and the effective digest,
// which lets the API create the instance row (R11, R45).
func buildHeartbeat(config *agentConfig, id uuid.UUID, now time.Time) sdktypes.Heartbeat {
hb := sdktypes.Heartbeat{UUID: id, CreatedAt: now}
if meta := config.syncInfo(); meta.Mode != "" && meta.Mode != agentconfig.ModeOff {
rev := meta.AppliedRevision
hb.ConfigRevision = &rev
hb.ConfigDigest = meta.Digest
}
return hb
}

type agentEvidenceCreateRequest struct {
sdktypes.Evidence
BackMatter *oscalTypes_1_1_3.BackMatter `json:"back-matter,omitempty"`
Expand Down
41 changes: 38 additions & 3 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import (
type baseSnapshot struct {
declared agentconfig.Config // file ⊕ CLI flags ⊕ bound env
raw []byte // exact bytes read (one read per load)
// envSourced are the JSON pointers of plugin leaves whose value came from a CCF_* env
// variable (R25). They are masked in reports and in the digest.
envSourced []string
// warnings are tolerated file-origin problems (R34): reported, never fatal.
warnings []agentconfig.FieldError
// skip holds the plugins dropped from the runtime because of a tolerated problem.
Expand All @@ -32,6 +35,15 @@ type baseSnapshot struct {
fingerprint string
}

// redactOpts is the single source of the masking options used for the reported base and
// effective documents AND for the effective digest (R55).
func (b *baseSnapshot) redactOpts() []agentconfig.RedactOption {
if b == nil || len(b.envSourced) == 0 {
return nil
}
return []agentconfig.RedactOption{agentconfig.WithMaskedPointers(b.envSourced...)}
}

// toleratedFileRules are the validation rules whose failure is non-fatal when the value comes
// from the local file (R34). Today a bad file schedule only logs "Error adding plugin
// schedule" and the plugin never runs; everything else that fails validation fails startup.
Expand Down Expand Up @@ -83,6 +95,9 @@ func bindAgentEnv(config *viper.Viper) error {
for key, envVar := range map[string]string{
"api.auth.client_id": "CCF_API_AUTH_CLIENT_ID",
"api.auth.client_secret": "CCF_API_AUTH_CLIENT_SECRET",
// remote_config is set locally only (file, host env, CLI) (R30). Binding the mode lets
// Helm set it even when the file omits the block (G2.1).
"remote_config.mode": "CCF_REMOTE_CONFIG_MODE",
} {
if err := config.BindEnv(key, envVar); err != nil {
return err
Expand Down Expand Up @@ -177,6 +192,24 @@ func checkExplicitZeroProtocol(v *viper.Viper, declared agentconfig.Config) erro
return fmt.Errorf("plugin %s has unsupported protocol_version=0; supported values are %d and %d", names[0], DefaultProtocolVersion, RunnerV2ProtocolVersion)
}

// envSourcedPointers returns the JSON pointers of plugin leaves whose value viper took from a
// CCF_* environment variable (R25). AutomaticEnv only overrides keys viper already knows (the
// file's keys), so checking the file's keys is exhaustive.
func envSourcedPointers(v *viper.Viper) []string {
var out []string
for _, key := range v.AllKeys() {
if !strings.HasPrefix(key, "plugins.") {
continue
}
envName := "CCF_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
if _, ok := os.LookupEnv(envName); ok {
out = append(out, agentconfig.Pointer(strings.Split(key, ".")...))
}
}
slices.Sort(out)
return out
}

// loadBase reads and validates the local configuration. It builds a fresh viper per call
// (R32). A returned error means the file is unusable (fatal at startup; keep last-known-good
// on reload). Tolerated file problems (R34) are returned as warnings and skipped plugins.
Expand All @@ -203,16 +236,17 @@ func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte) (*baseSnapsho
}

base := &baseSnapshot{
declared: declared,
raw: raw,
declared: declared,
raw: raw,
envSourced: envSourcedPointers(v),
}
part := partitionByOrigin(declared.Validate())
if len(part.fatal) > 0 {
return nil, agentconfig.ValidationErrors(part.fatal)
}
base.warnings = part.warnings
base.skip = part.skip
base.fingerprint = agentconfig.Digest(declared)
base.fingerprint = agentconfig.Digest(declared, base.redactOpts()...)
return base, nil
}

Expand Down Expand Up @@ -263,6 +297,7 @@ func toRuntime(c agentconfig.Config, skip map[string]string) (*agentConfig, erro
Daemon: c.Daemon,
Verbosity: c.Verbosity,
Plugins: map[string]*agentPlugin{},
remote: c.EffectiveRemoteConfig(),
}
if c.API != nil {
out.ApiConfig = &apiConfig{Url: c.API.URL}
Expand Down
20 changes: 20 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ func TestLoadBase_WeakDecodingUnchanged(t *testing.T) {
}
}

func TestEnvSourcedPointers(t *testing.T) {
t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "from-env")
base := mustLoadBase(t, "yaml", `
api:
url: http://localhost:8080
plugins:
github:
source: ./plugin-github
config:
token: from-file
org: acme
`)
if want := []string{"/plugins/github/config/token"}; !reflect.DeepEqual(base.envSourced, want) {
t.Fatalf("envSourced = %v, want %v", base.envSourced, want)
}
if got := base.declared.Plugins["github"].Config["token"]; got != "from-env" {
t.Fatalf("expected env value to win, got %q", got)
}
}

func TestLoadBase_BadFileScheduleIsTolerated(t *testing.T) {
base := mustLoadBase(t, "yaml", `
api:
Expand Down
Loading
Loading