A ctlplne studio product

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/16 names 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 /24 wins 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: true to forbid both directions, or ports: [...] to scope the prohibition to specific destination ports (empty = all ports).
  • frameworks maps 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 unavailable with 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.

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 (permission lifecycle.export; add ?redact=true for a PII-masked bundle, see governance.md).
  • Data-subject export and erasure — POST /v1/lifecycle/subjects/export and POST /v1/lifecycle/subjects/erase handle 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.

Rendered live from github.com/ctlplne/probectl — found a mistake? edit this page.