Compliance / segmentation validation
What this is
Segmentation is the practice of splitting a network into zones that must not talk to each other — the firewalled walls auditors ask about. A firewall config says what traffic is supposed to be blocked. This feature checks what your network is actually doing — and tells an auditor the difference, honestly.
probectl proves segmentation the only way an observability tool truthfully can: the operator declares the intended segmentation (PCI cardholder zones, zero-trust intents), and probectl validates those declarations against observed traffic — the eBPF and flow records the platform already collects (eBPF sees connections from inside each host's kernel; flow records are the per-conversation summaries network devices export). It flags violations with the actual flow evidence and exports audit-grade PCI/NIST/zero-trust evidence.
It lives in internal/compliance/ (the policy model in policy.go, the
validator in validator.go, the evidence export in evidence.go) and is wired
into the control plane in internal/control/complianceapi.go.
The honesty contract you must understand first
Observed ≠ intended. A zone pair with no observed traffic is not proven blocked — it might just be quiet. probectl therefore never emits the word "compliant"; the strongest claim it will make is "no violations observed, with the stated coverage." And probectl validates, never enforces — it does not block anything, ever (one of probectl's non-negotiables).
This is the whole philosophy. Think of security cameras: a camera that watched a door all week and saw nobody force it is evidence; a door no camera covers is nothing — not evidence the door held. An auditor who is told "compliant" when probectl merely didn't see any traffic has been misled. So the engine is built to distinguish "we watched and it was clean" from "we never saw anything here," and to say so out loud.
Declaring policy
Policies are YAML files in PROBECTL_COMPLIANCE_POLICY_DIR. They are validated
strictly (unknown YAML fields are rejected via KnownFields(true), so a
typo like birdirectional is an error, not a silently ignored rule), and a
malformed file fails startup — a boundary the operator believes is being
validated must actually be validated, not silently skipped (LoadDir in
policy.go). Each policy file is limited to 1 MiB and an oversized file also
fails startup before YAML decoding.
name: pci-segmentation
zones:
- name: cde
cidrs: ["10.10.0.0/16"]
- name: corp
cidrs: ["10.20.0.0/16"]
- name: dmz
cidrs: ["192.0.2.0/24"]
rules:
- id: corp-to-cde
description: Corporate systems must never reach the cardholder data environment.
from: corp
to: cde
bidirectional: true
frameworks:
pci-dss: "Req 1.3 — network segmentation of the CDE"
nist-800-207: "ZT tenet 3 — per-session least-privilege access"
- id: dmz-to-cde-db
from: dmz
to: cde
ports: [5432, 3306] # scope the prohibition to database ports
frameworks:
pci-dss: "Req 1.3.6 — no untrusted access to CHD storage"
The pieces:
- Zones are named address spaces (CIDR sets — a CIDR like
10.10.0.0/16names a whole block of addresses). When probectl sees a flow, it resolves each endpoint to the longest-prefix-matching zone (zoneOf), so a more specific/24wins over a broader/16— the most precise description of an address is the one that counts. - Rules are forbidden intents — "traffic from zone A to zone B should not
happen." A rule is directional by default; set
bidirectional: trueto forbid both directions, orports: [...]to scope the prohibition to specific destination ports (empty = all ports). frameworksmaps each rule onto audit language — PCI DSS (the card industry's security standard; the CDE is the cardholder data environment it ring-fences), NIST SP 800-207 zero-trust (the "never trust by network position" architecture), or any custom framework tag. That mapping rides into every result and every evidence record, so the export speaks the auditor's vocabulary.
Verdict semantics
For each rule, the validator produces one of three verdicts (Results in
validator.go):
| Verdict | Meaning |
|---|---|
violation |
forbidden traffic was observed (counted, timestamped, with bounded flow samples as evidence) |
observed_clean |
traffic between the zones was observed, and none of it matched the forbidden scope |
not_observed |
no traffic between the zones was observed — not proof of isolation |
The distinction between observed_clean and not_observed is the honesty
contract in action. observed_clean means "we were watching that path and saw
only allowed traffic." not_observed means "we have no visibility into that
path" — which is emphatically not the same as "that path is blocked." A
binary pass/fail would have to lump one of these in with the other, and either
lump misleads.
A violation raises a compliance.segmentation_violation signal (plane
compliance, severity critical) into the incident pipeline and the SIEM —
at most once per rule per re-alert window (the lastAlert latch in
ruleState), so a persistent breach doesn't spam a fresh alert on every packet.
The latch re-arms on a bounded quiet-period (reArmInterval, 24h, driven by
observed-traffic time): the first violation signals immediately, recurrences
inside the window are suppressed, and a violation observed after the window
re-arms the latch and signals again — so a breach that returns days later is not
silently swallowed. (The engine is in-RAM — see
docs/adr/volatile-stores.md — so a validator restart
also re-arms it; the time-based re-arm means a restart is no longer required
for a recurrence to re-signal.) The violation count and samples keep
accumulating either way.
Coverage: never claim beyond what was observed
Every API response and every evidence export carries a coverage block
(CoverageFor in validator.go) describing exactly what was watched — the
map of where the cameras actually pointed:
- which planes actually reported (
flow_observed,ebpf_observed), - the observation count and time range,
- how many declared zones actually had observed endpoints
(
zones_seen/zones_total), - and explicit caveats baked in as notes — e.g. "quiet zones are NOT proven isolated" and "absence of traffic is not proof of blocking."
So an auditor reading the output sees, in the document itself, the boundary between what probectl confirmed and what it simply couldn't see.
Audit-grade evidence
GET /v1/compliance/evidence (RBAC audit.read) exports a signed evidence
document (envelope probectl-compliance-evidence-signed/v1). Inside the envelope
is a probectl-compliance-evidence/v1 document: timestamped, per-rule records
with their framework mappings and violation samples, hash-chained — each
record's hash covers its own canonical content plus the previous record's
hash (recordHash in evidence.go) — ending with the final chain head. The
coverage caveats are embedded inside the document, so they cannot be quietly
dropped.
Why sign it, and not rely on the chain alone? Because the chain is
self-recomputable. Picture a notebook where every page is stamped with a seal
derived from the previous page's seal: it catches a page torn out with nothing
put back, but an editor who rewrites a page can also recompute every later seal,
and the chain reads as intact again. So the hash chain alone is not
tamper-evident against an editor who recomputes it (finding AI-04). The
tamper-evidence comes from the signature: the control plane seals the exact
exported bytes with the deployment's Ed25519 evidence-signing key
(SignEvidence) — the same key the auditor bundle and incident-evidence exports
use. Edit any field, recompute the whole chain, and VerifySignedEvidence still
rejects the document, because the detached signature no longer covers the bytes
(the regression test flips a violation count, re-chains it, and watches the
signature fail). A deployment with no evidence signing key is refused
(HTTP 409) rather than served an unsigned document that nothing vouches for.
The hashing goes through the internal crypto provider (crypto.Default.Hash),
never a raw primitive — the same FIPS-swappable abstraction the rest of probectl
uses (all crypto routes through internal/crypto).
The auditor bundle: one signed document instead of seven downloads
GET /v1/compliance/auditor-bundle (RBAC audit.read, format
probectl-auditor-bundle/v1) is the evidence above plus six more sections,
signed as a single Ed25519-signed document:
| Section | What it is |
|---|---|
isolation-posture |
the boot-time tenant-isolation assertion, re-run at export time |
segmentation-evidence |
the hash-chained document described above |
audit-chain-verification |
the tenant's audit stream re-verified end to end |
retention-receipts |
the retention policy and the status reported against it |
deletion-proofs |
the tenant's verifiable-deletion receipts, subjects as salted hashes only |
build-provenance |
the version, commit and which release artifact carries the SBOM — reported failed when the binary was built from a modified tree or carries no usable commit, because then it identifies no published artifact |
crypto-self-test |
the cryptographic module's self-test status, read live |
Three properties are the point of the format:
- Every section is gathered live. The isolation assertion is re-run and the audit chain re-verified when you ask, because an auditor's question is whether the control holds now, not whether it held when something last wrote it down.
- A section that could not be gathered is present and marked
unavailablewith its reason, and a control that failed is present with its evidence. An auditor must never have to notice an absence: a missing section reads as a passing one, which is the dishonesty this format exists to prevent. The same caveats are listed inside the signed bytes, so no tooling can drop them. - It verifies offline.
probectl verify-bundle <file>checks the signature, that every attachment matches the digest the manifest commits to, and that every section is accounted for — with no server, no network and no credentials. An auditor who must ask the producing server whether its own export is genuine has verified nothing.
The bundle is tenant-scoped: the tenant is bound by digest and its identifier is never serialized, no configured secret appears in it, and the signing key's public half travels with the document while the private half never does. A deployment with no evidence signing key is refused rather than served an unsigned bundle.
Framework mappings are an interpretation
Each section declares which requirement area it speaks to under NIS2, DORA and GAIA-X — access control and segmentation, cryptography policy, incident records, retention and disposal, supply-chain and third-party risk, data sovereignty, portability and erasure. They are stated as requirement themes, not article numbers, and the bundle says so in its own signed text: an article citation is a legal interpretation, and probectl is not in a position to assert one on your behalf. Supply your own reference — from counsel, or from your auditor's workpapers — and the bundle carries it alongside the area.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
FL[flow stream] --> CC[ComplianceConsumer]
EB[eBPF flows] --> CC
POL[policy YAML<br/>PROBECTL_COMPLIANCE_POLICY_DIR] -->|strict load| E[compliance.Engine<br/>per-tenant verdicts]
CC --> E
E -->|verdicts + coverage| API[GET /v1/compliance]
E -->|hash-chained export| EV[GET /v1/compliance/evidence]
E -->|"violation (signals)"| INC[incidents + SIEM]
API --> UI[Compliance page]
All validator state is per tenant (tenantState in validator.go), and
the consumer drops any flow without a tenant_id (tenant isolation is the
outermost boundary — see
security/tenant-isolation.md). The
GET /v1/compliance endpoint (RBAC threat.read) also returns a
compliance_running flag so a caller can tell whether the validator is
actually wired — "no verdicts" from a running validator and "no verdicts"
because nothing is watching must never look the same.
Configuration
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_COMPLIANCE_ENABLED |
true |
the validator + consumers (local-only) |
PROBECTL_COMPLIANCE_POLICY_DIR |
(none) | segmentation policy YAML directory; each file is limited to 1 MiB (empty = zero policies, honestly reported) |
Out of scope by design
- Enforcement / blocking — this is validation only; probectl never sits inline.
- Config-based intended segmentation analysis — probectl judges observed traffic. Statically analyzing firewall configs to predict what would be blocked is a different product.
- Certification claims — probectl generates evidence (verdicts, coverage, the hash-chained export). It does not and cannot make you "PCI certified" or "SOC 2 compliant"; only your assessor can. Treat the export as input to an audit, not the audit's conclusion.
Related compliance rights (separate features)
Two adjacent obligations auditors ask about are core (deliberately free) features that live elsewhere, not in this validator:
- Per-tenant data export —
GET /v1/lifecycle/export(permissionlifecycle.export; add?redact=truefor a PII-masked bundle, seegovernance.md). - Data-subject export and erasure —
POST /v1/lifecycle/subjects/exportandPOST /v1/lifecycle/subjects/erasehandle one person or identifier inside the caller's tenant. The receipt stores a tenant-scoped subject hash, never a global subject index. - Verifiable deletion / offboarding — cross-store erasure with a recomputable attestation.
Both are walked end-to-end in
runbooks/tenant-offboarding.md.