Skip to main content
These endpoints expose the per-workload syscall sets the Broker aggregates from the Controller’s observations, and turn them into 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 a SeccompProfile 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 with SCMP_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, or unknown (a contributor from a controller that predates tiers, or no contributors). capture.complete (mirrored as captureComplete) is true only when there is at least one contributor and every one is full; custom is never complete. capture.pods lists contributors worst-first, capped at 20, with more for the remainder and incomplete the number of contributors below full.
  • suggestedName — <kind lowercased>-<name>, the metadata.name the export uses unless overridden.
  • crCount — CRs whose workloadRef matches this workload; 0 ⇒ cr is null. When several match, cr is the most recently updated one.
  • cr — the mirrored SeccompProfile: defaultAction (SCMP_ACT_LOG ⇒ audit, anything else ⇒ enforcing), hash of the file it renders to (the CR’s status.hash), the count of syscalls its SCMP_ACT_ALLOW rules name, architectures, and the localhostProfile path. distribution is broker-computed from node-status reports: ready = nodes reporting the path with a hash equal to cr.hash, present = nodes reporting the path at all, total = live nodes, where a node is live while its last node-status report is younger than SECCOMP_NODE_STATUS_STALE_SECS (chart value broker.seccomp.nodeStatusStaleSeconds, default 900 s). A node posts once per distribution pass, so the window must be at least three times seccomp.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. statusDistribution is the CR’s own status.distribution as the controllers last mirrored it (null if not yet written). drift.missing = observed but not allowed by the CR, drift.extra = allowed by the CR but never observed, inSync = missing is empty.
  • denials — what the kernel’s seccomp filter actually acted on for this workload, rolled up over the Broker’s retention window: total denial events across every syscall and action, the syscalls and actions seen (both sorted; syscalls is capped at 50, since this list is mirrored into a CR’s .status and therefore into etcd), and lastSeen, the newest observation. lastSeen is null when total is 0. 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. total and lastSeen are read on their own uncapped query and are never affected. null and {"total": 0} are different answers, and the Controller treats them differently. null means the Broker cannot say; {"total": 0} means it can, and the answer is “clean”. The Controller maps the first to a DenialsObserved condition of Unknown and the second to False. This mirrors the rule the cr block 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 (see POST /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 is null, 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 from pod_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 reads null until the row is resolved or pruned; other namespaces are unaffected. The Broker logs the namespaces, and GET /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 as null rather than as clean, however many denials are on file. A node reporting in with capturing: false does not count. A CONFIG_AUDITSYSCALL=n kernel 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 a denials block of zero is what promotes a profile from audit to enforcing.
  • recommendedSnippet — kguardian/<namespace>/<cr.name>.json when a CR exists, else <suggestedName>.
Capture liveness is currently a cluster-wide fact, not a per-workload one. On a mixed fleet — mid kernel upgrade, say — a workload whose pods only ever ran on non-capturing nodes will still report denials: {"total": 0} as long as some other node is capturing. Check kguardian_seccomp_denial_workloads and your nodes’ kernel config before promoting a profile to enforcing on a fleet you know to be mixed.

GET /seccomp/profiles///

One workload’s summary (all fields above) plus the observed set rendered as a seccomp document with SCMP_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

The SeccompProfile 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

Names are sorted; 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.
Every field is optional; 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 a SeccompProfile 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, newest last_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 by seccomp.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 prunes seccomp_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’s hash. 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.