SeccompProfile
manifests. The Broker holds no profile lifecycle state: whether a profile
is deployed, in audit or enforcing is decided entirely by the SeccompProfile
CR you apply (see the CRD reference and the
distribution guide). With
broker.auth.enabled, the read endpoints (including the POST .../export)
need the read scope. The write endpoints (PUT/DELETE /seccomp/crs/…,
POST /seccomp/node-status, POST /seccomp/denials) are for the Controller
and need ingest.
Two kinds of signal live here and they are not the same thing. The observed
syscall set and its drift are kguardian’s own inference — what the eBPF
observer saw, compared against what a CR allows. A denial is the kernel’s
own verdict: seccomp was consulted, and it acted. See
GET /seccomp/denials.
GET /seccomp/profiles
Every workload with an observed syscall set: the set, its capture completeness, and — if aSeccompProfile CR references the workload — that
CR’s deployment state and drift against the observed set.
The Broker caches the rendered body for SECCOMP_PROFILES_CACHE_TTL_SECS
(chart value broker.seccomp.profilesCacheTtlSeconds, default 10) and serves
every caller the same bytes within that window; requests that arrive during a
rebuild wait for it rather than each querying the database. The UI polls this
route every 15 s per open tab; the Controller polled it every 30 s per node
until #1632 moved it to the per-workload route. A CR’s deployment
state or drift can therefore lag this list by up to the TTL — the per-workload
route below is never cached. Set the TTL to 0 to rebuild on every request.
/metrics reports broker_seccomp_profiles_cache_hits_total and
broker_seccomp_profiles_cache_misses_total.
Response
-
hash— fingerprint of the observed set rendered withSCMP_ACT_LOG; changes when the union grows. -
capture.level— the lowest capture tier across every pod that contributed syscalls to the union, dead pods included:full,high,medium,low,custom, orunknown(a contributor from a controller that predates tiers, or no contributors).capture.complete(mirrored ascaptureComplete) istrueonly when there is at least one contributor and every one isfull;customis never complete.capture.podslists contributors worst-first, capped at 20, withmorefor the remainder andincompletethe number of contributors belowfull. -
suggestedName—<kind lowercased>-<name>, themetadata.namethe export uses unless overridden. -
crCount— CRs whoseworkloadRefmatches this workload;0⇒crisnull. When several match,cris the most recently updated one. -
cr— the mirroredSeccompProfile:defaultAction(SCMP_ACT_LOG⇒ audit, anything else ⇒ enforcing),hashof the file it renders to (the CR’sstatus.hash), the count of syscalls itsSCMP_ACT_ALLOWrules name,architectures, and thelocalhostProfilepath.distributionis broker-computed from node-status reports:ready= nodes reporting the path with a hash equal tocr.hash,present= nodes reporting the path at all,total= live nodes, where a node is live while its last node-status report is younger thanSECCOMP_NODE_STATUS_STALE_SECS(chart valuebroker.seccomp.nodeStatusStaleSeconds, default 900 s). A node posts once per distribution pass, so the window must be at least three timesseccomp.distributeIntervalSeconds(default 30, so 900 s is 30 passes) and the chart refuses to render otherwise. A node whose report has gone stale counts towards none of the three.statusDistributionis the CR’s ownstatus.distributionas the controllers last mirrored it (nullif not yet written).drift.missing= observed but not allowed by the CR,drift.extra= allowed by the CR but never observed,inSync=missingis empty. -
denials— what the kernel’s seccomp filter actually acted on for this workload, rolled up over the Broker’s retention window:totaldenial events across every syscall and action, thesyscallsandactionsseen (both sorted;syscallsis capped at 50, since this list is mirrored into a CR’s.statusand therefore into etcd), andlastSeen, the newest observation.lastSeenisnullwhentotalis0. On a cluster with more than 10 000 distinct(workload, syscall, action)pairs the name lists are shortened evenly across workloads — every workload keeps a share, none is emptied — and the Broker logs that it hit the ceiling.totalandlastSeenare read on their own uncapped query and are never affected.nulland{"total": 0}are different answers, and the Controller treats them differently.nullmeans the Broker cannot say;{"total": 0}means it can, and the answer is “clean”. The Controller maps the first to aDenialsObservedcondition ofUnknownand the second toFalse. This mirrors the rule thecrblock follows — emit no block rather than an empty one that reads as “all clear”. What separates them is whether anything is watching, which the Broker knows from the per-node capture heartbeat (seePOST /seccomp/denials). A block needs at least one node to have reported inside its own staleness window with its probe attached. Until then every workload isnull, because “this workload was never denied” and “nothing on this cluster is capturing” are the same observation. A second condition withholds the block for one namespace at a time: a stored denial the Broker could not attribute to a workload. Attribution resolves the owning workload frompod_details, and it refuses rather than guesses — the pod was not yet known when the denial arrived, or two namespaces have a pod of the same name, which collapses to one row because that table is keyed on the pod name alone. A refusal is not neutral: the per-workload rollup only sees attributed rows, so without this the denial’s own workload would read{"total": 0}. Every workload in that namespace readsnulluntil the row is resolved or pruned; other namespaces are unaffected. The Broker logs the namespaces, andGET /seccomp/denials?namespace=<ns>shows the rows themselves, which is the endpoint that can show a denial without having to claim a workload for it. A Broker pass re-runs attribution hourly, so a denial that merely beat its pod’s registration resolves on its own. A stored denial is not liveness. A row proves capture worked once; it says nothing about whether anything is watching now, so disabling capture, scaling the DaemonSet to zero or restoring a database from a cluster that captured all read asnullrather than as clean, however many denials are on file. A node reporting in withcapturing: falsedoes not count. ACONFIG_AUDITSYSCALL=nkernel makes the Controller skip the probe and carry on, so that node is demonstrably alive and watching nothing; reading it as capture would turn graceful degradation into a false all-clear — and adenialsblock of zero is what promotes a profile from audit to enforcing. -
recommendedSnippet—kguardian/<namespace>/<cr.name>.jsonwhen a CR exists, else<suggestedName>.
GET /seccomp/profiles///
One workload’s summary (all fields above) plus the observed set rendered as a seccomp document withSCMP_ACT_LOG — a preview, not what is on any node.
404 if the workload has no observed set yet.
This is the read the Controller’s distributor makes: one request per distinct
workloadRef per resync, to compute the referencing CR’s CaptureComplete,
Drift and DenialsObserved conditions. A 404 becomes
Unknown/NoObservations; any other failure leaves the conditions as they
were. The Controller never reads the cluster-wide list above, and a cluster
with no SeccompProfile objects costs the Broker nothing per resync beyond the
node-status report.
GET /seccomp/profiles////export
TheSeccompProfile manifest for the observed set, ready to commit.
Content-Type: application/yaml.
Query
Bad values are a
400; 404 if the workload has no observed set; 409 if
the capture is partial and defaultAction enforces (see below).
Every response — including the 409 — carries the capture verdict in
headers, so a caller streaming the body to a file can branch without
parsing YAML:
Response — 200
architectures is omitted when the observed set has none.
Every manifest states the tier it was built from in metadata.annotations
(kguardian.dev/capture-level and kguardian.dev/capture-complete). Unlike
the comment header — which kubectl apply discards and most GitOps
renderers strip — annotations are part of the object: they survive apply,
show up in kubectl get -o yaml, and an admission policy can require
capture-complete: "true" before a profile is allowed to enforce. They are
also the only capture signal in the format=json document, which has no
comments at all.
When capture.complete is false the object also carries
kguardian.dev/capture-warning, and the YAML header adds:
Response — 409 (partial capture, enforcing action)
Only the full tier observes every syscall, so a profile built from a lower
tier omits calls the workload really makes. Rendering that with a denying
defaultAction is refused:
SCMP_ACT_LOG (the default) is never refused: it records rather than
blocks, so growing a profile in audit mode from a partial capture is safe
and is the supported workflow.
POST /seccomp/profiles////export
The same document, with staged edits applied server-side — what the UI’s pre-export editor uses.acknowledgePartial is accepted here too, as a
JSON bool or a string. The exported set is (observed ∪ add) \ remove.
Names must match ^[a-z][a-z0-9_]{0,63}$, ≤ 512 per list, and may not
appear in both lists (400). The observed union in the broker is never
modified by this call. The same 409 partial-capture gate applies.
PUT /seccomp/crs//
Controller-only. Mirrors aSeccompProfile CR into the broker so the UI can
show CR state and drift without talking to the API server. Every controller
sends it when the payload would change, after a failed or doubtful send, and
otherwise once every ten quiet reconciles (about five minutes at the default
resync), so a row the Broker lost still comes back; the write is idempotent.
spec is the CR spec verbatim; hash is the CR’s status.hash (may be ""
before the first render — then no node can be ready for it); status is
optional and mirrored as cr.statusDistribution. Only rules with
action: SCMP_ACT_ALLOW count towards the CR’s syscall set for drift.
Returns 200 { "namespace", "name", "hash", "syscallCount" }.
DELETE /seccomp/crs/{namespace}/{name} removes the mirror row when the CR
is deleted: 200 { "deleted": true }, or 404 if unknown (every node
sends it; the controller treats 404 as success).
GET /seccomp/denials
Kernel seccomp verdicts, newestlast_seen first, tie-broken on the
BIGSERIAL primary key so two pages of the same query never reshuffle their
boundary rows.
A row here means the kernel consulted a seccomp filter and acted — under
SCMP_ACT_LOG it recorded what it would have blocked, under
SCMP_ACT_ERRNO or a killing action it blocked for real. That is a different
claim from cr.drift on the summary above, which is kguardian comparing its
own observations against a CR and would report the same thing whether or not
the profile was ever loaded into a kernel.
Rows are aggregated, not an event log. The Controller drains a BPF map
every 10 s and ships counts, so one row covers an arbitrary number of kernel
events: count accumulates across every drain for that (pod, syscall, action) and firstSeen/lastSeen bracket the whole accumulation rather
than one report.
Query Parameters
All filters are optional and combine with AND.namespace, kind and
workload are index-backed by (pod_namespace, workload_kind, workload_name); since is index-backed by last_seen.
Example
Response
Returns an empty array
[] when no rows match — never null. Status is
always 200 on a successful query.
POST /seccomp/denials
Controller-only. One node’s drain of its BPF denial map, on the interval set byseccomp.denials.intervalSeconds (default 10).
Sent on every drain, including one that drained nothing. An empty batch
is not a wasted request — it is the node’s capture heartbeat, and it is the
only thing that lets the Broker tell “nothing was denied” from “nothing was
watching”. Without it a cluster where no workload has ever tripped a filter
sits at DenialsObserved: Unknown forever, which blocks exactly the
audit-to-enforcing promotion the feature exists to support.
node is required and non-empty; an empty denials array is valid.
syscallNr, actionRaw and arch are optional. Returns 200
{ "accepted": <n> }, where n counts stored rows, not wire entries —
entries are folded onto (podUid, syscall, action) before the write, so a
batch carrying the same key twice reports one.
capturing says whether the denial probe actually attached on this node, as
distinct from whether the Controller is reachable. Send false when the
probe was skipped — a kernel without CONFIG_AUDITSYSCALL=y has no audit_seccomp
symbol to hook, and the Controller degrades gracefully rather than
crash-looping. It is recorded per node with the report time, and a node goes
stale after 5 minutes at the default cadence rather than being assumed either
way.
intervalSeconds is the node’s own drain cadence — the gap between the
reports above — and the Broker sizes that staleness window from it per node
rather than from a fixed number. Without it, raising
seccomp.denials.intervalSeconds past a third of the window would make a
perfectly healthy node read as stale on every report, and a stale node is
Unknown, which blocks promotion. A report that omits the field is
unaffected: absent or zero falls back to the 5-minute default.
The window is bounded at 30 minutes, and a declared cadence above 600
seconds is clamped to it (the chart refuses one at install time). The window
is not only a timeout — it is how long kguardian keeps reporting a workload
clean on the strength of one old report — so an unbounded cadence meant a
departing node, or a single POST, bought the whole cluster hours of
{"total": 0} with nothing watching. A node draining slower than 600 s is
heard from less often than the Broker will vouch for it, so its workloads read
null between reports; the Broker warns when it clamps.
A pod with its own network namespace is attributed by that namespace. A
hostNetwork: true pod has none — it shares the node’s with every other
hostNetwork pod and with kubelet, the container shims and any
SystemCallFilter= unit — so the Controller attributes it by the cgroup the
calling task was in, which is per container whatever the pod’s network
namespace is. Two hostNetwork pods on one node are told apart, and the node’s
own processes are credited to neither.
A verdict whose cgroup matches no container on the node is refused rather
than credited to whichever hostNetwork pod registered that namespace last: a
denial pinned on the wrong workload flips that workload’s DenialsObserved
to True and blocks a promotion that should have gone ahead. That is the
expected outcome for the node’s own processes. For a container it means its
cgroup could not be resolved, and that workload will report no denials of its
own — the Controller warns once per drain with the cgroup id, and the pod
watcher warns separately naming the container. Resolving a cgroup needs the
host cgroup v2 root mounted into the Controller, which the chart does
whenever seccomp.denials.enabled or compute.enabled is set.
Denials in hand outrank the flag in both directions: a batch whose rows
survive validation proves the probe fired, so a node claiming false while
shipping real denials is believed on the evidence rather than on its own
self-assessment. Absent the flag and absent any row that survived
validation, the report proves nothing and is recorded as not capturing — a
batch of nothing but rejected entries is not evidence that anything is
watching.
Each count is a delta since that node’s previous drain, not a running
total: the Controller clears its map after a successful POST. The Broker
accumulates (count = count + excluded, firstSeen = LEAST(…),
lastSeen = GREATEST(…)), so resending a level rather than a delta would
inflate the stored count and the kguardian_seccomp_denials_total counter
with it.
The whole batch is one transaction, so a drain either lands completely or not
at all — a partially applied batch would be double-counted by the retry.
Entries that cannot identify themselves are dropped with a per-field warning
in the Broker log rather than failing the batch: a missing podUid,
podName, podNamespace, syscall or action, an oversized field, or a
count of zero or less. A zero-count entry is not a denial, and accepting
one would add a workload to the rollup — and flip a CR’s DenialsObserved
condition — for something the kernel never did.
firstSeen and lastSeen ahead of the Broker’s clock are clamped to the
ingest time rather than rejected, and the node is named in a warning. A clock
a few seconds fast is ordinary drift and the denial it carries still happened,
so the row is kept; but a lastSeen in the future is one the retention prune
(lastSeen < now - window) can never match and one that holds the top of
GET /seccomp/denials, which orders on it, until the clock catches up. The
count is never adjusted — that one is a measurement, and a measurement
nobody took is worse than a missing row.
This route carries its own 4 MiB JSON body limit (the Broker default is 256
KiB), which is roughly 20 000 denials per request. Chunk larger drains.
An older Broker returns 404 for this route. The Controller treats that as a
warning and keeps running.
Metrics
/metrics exposes three denial series. Note where each number comes from —
the distinction is what keeps the alerts on them honest.
The two counters are not derived from
seccomp_denials. Retention
deletes from that table, so a counter read off it would fall at every prune —
and Prometheus reads a counter decrease as a process restart, compensating in
a way that injects a phantom spike into rate() and increase(). Both
shipped alerts sit on exactly those functions, so a table-derived counter
would page at every retention boundary with a spike indistinguishable from
the real signal. Accumulating at ingest means only a Broker restart resets
them, which is a genuine reset and the one case Prometheus handles by design.
kguardian_seccomp_denial_workloads is table-derived, and that is correct:
“distinct workloads with a denial inside the retention window” is a question
about current state, so pruning should move it down.
The workload’s namespace is labelled workload_namespace, not namespace.
prometheus-operator relabels __meta_kubernetes_namespace onto a namespace
target label for every ServiceMonitor-generated job, and honor_labels
defaults to false, so a namespace label exposed by the Broker would lose
that conflict: Prometheus renames it to exported_namespace and sets
namespace to whichever namespace kguardian is installed in. Grouping on
that would fold payments/worker and media/worker into one series, so the
shipped alerts and any query of your own should use workload_namespace.
The API keeps its own spelling — GET /seccomp/denials still takes
?namespace=, because nothing relabels an HTTP query string.
kguardian_seccomp_denials_total is deliberately not labelled by
syscall. There are ~300 syscalls on x86_64 and a workload running against the
wrong profile can trip dozens, so a syscall label would multiply series count
by two orders of magnitude for something GET /seccomp/denials already
answers. Unattributed denials keep their namespace and action and carry empty
workload_kind / workload labels rather than being dropped. The series
count is capped at 10,000; beyond that new label sets are refused (existing
ones are never evicted, which would restart them from zero) and the Broker
logs how many increments were dropped.
Retention
The Broker prunesseccomp_denials rows whose lastSeen is older than
SECCOMP_DENIALS_RETENTION_DAYS (default 30) every
SECCOMP_DENIALS_RETENTION_INTERVAL_SECS (default 3600), in batches of
SECCOMP_DENIALS_RETENTION_BATCH_SIZE (default 5000, clamped to
[100, 100000]) — the same batched-delete discipline as
audit verdicts. Set
SECCOMP_DENIALS_RETENTION_DAYS=0 to disable pruning; the table then grows
unbounded.
Heartbeat rows in seccomp_denial_nodes are pruned in the same pass, for
nodes that stopped reporting a full retention window ago — the node is gone,
not merely quiet. That window is deliberately much longer than the 5-minute
staleness check above: staleness decides whether to trust a node’s report,
this decides whether the node still exists.
Pruning is on lastSeen, not firstSeen. A row accumulates across drains,
so a pod that has been tripping the same syscall for two months has a
firstSeen well outside a 30-day window while still being an active denial —
pruning on firstSeen would delete exactly the longest-running problems.
This window is not only a disk setting. GET /seccomp/profiles’ denials
block and the kguardian_seccomp_denial_workloads gauge both report over
whatever is left in the table and apply no date filter of their own, so
widening it widens what a CR’s DenialsObserved condition considers current.
That is also why it has its own env vars rather than sharing the audit ones.
GET /seccomp/profile-file////
Debug render of the observed set as a bare seccomp document, addressed by the summary’shash. A stale hash returns 404. Nothing reads this on the
distribution path any more — nodes render from the CR.
POST /seccomp/node-status
The controller reports, after every reconcile pass, the profile files present on its node with their hashes. Replaces the node’s row wholesale — it is a snapshot, not a delta.node_name is required; an empty files is valid. hash is the FNV-1a-64
of the bytes on disk after the pass. Returns 200 with an empty body. A node
counts towards cr.distribution.ready when it reports the CR’s path with a
hash equal to the mirrored cr.hash, and towards present when it reports
the path at all — and towards total for as long as its report stays inside
SECCOMP_NODE_STATUS_STALE_SECS (default 900 s). The pre-CRD body shape —
"paths": ["…json"], no hashes — is still accepted and counts as
present-but-unverified.
Removed in this release
POST …/publish, …/unpublish, …/enforce, …/audit,
PUT/DELETE …/override, the ?state= filter, the hash-less
profile-file route, the SECCOMP_OVERRIDES_ENABLED flag, and the state /
stableProfile / localhostProfile / defaultAction / distribution /
publishedAt / publishedBy / publishForced / override summary fields. Deployment and enforcement
decisions live in the SeccompProfile CR; there is no broker-side write path
for them.