Skip to content

Security Audit (lo audit)

lo audit is a static, read-only security-posture audit. It reads your cluster.lok8s.yaml plus the addon/kustomize inputs (exactly like lo lint) and reports security findings with a severity, a per-cluster score, and a non-zero exit when anything fails. It never touches a live cluster and needs no kubeconfig, so it runs offline and in CI.

bash
lo audit                 # audit the active domain (lo use <domain>)
lo audit my-cluster.example.com   # audit a specific cluster
lo audit --json          # machine-readable output (stable schema, see below)
lo audit --sarif         # SARIF 2.1.0 for GitHub code scanning (see below)

What it checks

Each check is fail-soft: an input it cannot read yields unknown (never an error), so the audit always completes. One deliberate fail-closed exception: an EncryptionConfiguration that is present but unparseable counts as not-encrypting → fail, not unknown. You must not trust a config that provably exists but cannot be proven to encrypt.

idseverityWhat it looks at
encryption-at-resthighSecret encryption at rest (etcd). For KubeOne the authoritative signal is the driver's features.encryptionProviders.enable; a spec.features.encryptionProviders.enable override wins; a rendered EncryptionConfiguration in lo build artifacts counts as proof only when a real provider (aescbc/secretbox/kms) is the write provider for the FIRST resource group covering secrets (apiserver precedence: the first matching group wins; a later wildcard group can't rescue a secrets → identity group). An identity-only or empty config encrypts nothing and counts as disabled. Disabled on a prod-intent cluster → fail. A local kind (kind: Lo) cluster is always not-applicable (pass), even if an encryption config is present.
cilium-policy-enforcementhighCilium policyAuditMode / policyEnforcementMode in the effective cilium values (base < driver < provider < inline). Audit mode = insecure (see below). Values that fail to parse/merge (e.g. a broken inline override) → unknown, never a silent pass.
exposed-endpointsmedium / lowNodePort and LoadBalancer Services, HTTPRoutes, and whether a default-Deny SecurityPolicy (IP-allowlist) carve-out fronts them.
k8s-version-supporthigh / mediumIs spec.kubernetes.version a still-supported minor? EOL on a prod-intent cluster is a fail; on a dev cluster it's a warn.
privileged-workloadsmediumprivileged, hostNetwork, or hostPath in the cluster's own targets (the vetted framework addons are out of scope to avoid noise).
plaintext-endpointshigh / mediumA non-HTTPS spec.oidc.issuer (fail: the apiserver would trust tokens over cleartext) and http:// endpoints referenced in targets (warn).

"Prod-intent" is every cluster kind except Lo (the local kind driver). An empty or unknown kind scores as prod-intent too, fail-closed: the audit treats a cluster that cannot be proven to be a dev cluster like production.

Cilium: audit mode vs enforce (the headline)

Cilium can run NetworkPolicy in two modes:

  • Enforce (policyAuditMode: false): a NetworkPolicy that selects a pod actually drops traffic it doesn't allow. This is what "network policy" means.
  • Audit (policyAuditMode: true): Cilium logs every policy verdict but drops nothing. Policies that look like they lock the cluster down enforce nothing. You develop the allow-set with hubble observe --verdict AUDIT, confirm no critical traffic gets a deny verdict, and only then flip to enforce.

lok8s ships policyAuditMode: true as the KubeOne default (.lok8s/addons/cilium/values.kubeone.yaml) precisely because flipping straight to enforce without a complete allow-set has deadlocked the pilot (a default-deny that cut etcd peer traffic → quorum loss). Audit mode is the safe way to build the allow-set, but it is not a secure end state, so lo audit flags it as a high finding. Running lo audit on the stock pilot config reports:

  FAIL [high    ] cilium-policy-enforcement — Cilium network-policy enforcement
         Cilium policyAuditMode: true — NetworkPolicies (incl. host-firewall CCNPs)
         are LOGGED, not enforced; nothing is actually blocked.
         remediation: Validate the allow-set (hubble observe --verdict AUDIT covers
         etcd 2379/2380, apiserver 6443, kubelet 10250, vxlan 8472), then set
         policyAuditMode: false in addons/cilium/values.kubeone.yaml to ENFORCE.

That is by design: it is a standing reminder that the pilot has host-firewall policies defined but not yet enforcing. To clear it, complete the allow-set and set policyAuditMode: false (per-cluster via a spec.bootstrap cilium override, or in the driver values file once validated everywhere).

Scoring

The report starts at 100 and subtracts a severity-weighted penalty per finding:

criticalhighmediumlow
fail4025155
warn151052

The score clamps to [0, 100] and maps to a grade (A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F < 60). A pass costs nothing, and a low-severity unknown is free too. But a high/critical check that cannot be evaluated (unknown, e.g. a deploy-only or unreadable cluster) caps the score at 70 (grade C at best): "couldn't check" must never read as a perfect score for score-keyed tooling. The command exits non-zero iff any finding has status fail, so it gates CI.

The report prints findings most-actionable first (fail → warn → unknown → pass, and by severity within each group).

JSON output

--json emits a stable schema intended for tooling (e.g. a dashboard Security tab). For a single domain it is one object; for several it is a JSON array of these objects.

json
{
  "domain": "my-cluster.example.com",
  "score": 75,
  "grade": "C",
  "summary": { "pass": 5, "warn": 0, "fail": 1, "unknown": 0 },
  "checks": [
    {
      "id": "cilium-policy-enforcement",
      "title": "Cilium network-policy enforcement",
      "severity": "high",
      "status": "fail",
      "detail": "Cilium policyAuditMode: true — NetworkPolicies … are LOGGED, not enforced.",
      "remediation": "Validate the allow-set …, then set policyAuditMode: false to ENFORCE."
    }
  ]
}

Field contract:

  • severitycritical | high | medium | low
  • statuspass | warn | fail | unknown
  • every checks[] entry always carries id, title, severity, status, detail, remediation.

SARIF output (GitHub code scanning)

--sarif emits a SARIF 2.1.0 document on stdout. Upload it to GitHub code scanning and findings show up as alerts on the Security tab, with file annotations where the audit can point at one.

How findings map:

  • One SARIF run; the tool is lo-audit. Each check id becomes a rule.
  • status sets the alert level: failerror, warnwarning, unknownnote. lo audit uploads unknown on purpose: "could not check" is a finding, not a confirmation.
  • lo audit does not upload pass findings. A clean audit produces results: [], so zero alerts.
  • Every result carries a location, because code scanning discards a result without one. A finding keyed to one spec value (an EOL spec.kubernetes.version, a plaintext spec.oidc.issuer) points at that file and line. An aggregate finding (a scan over many manifests) points at the domain spec with no line, since that file is what the check is about.
  • Alert severity comes from security-severity on the rule, derived from the worst audit severity that rule reports, and every rule carries the security tag. GitHub ignores a result property bag for this. The result message is the finding detail plus its remediation, and severity, status and domain also ride in each result properties bag for other tools.
  • --sarif and --json are mutually exclusive. The exit code stays the same as every other mode: non-zero iff a finding fails.

CI example:

yaml
- name: Security audit
  run: lo audit --sarif > audit.sarif
  # non-zero exit when a finding fails — still upload the report
  continue-on-error: true

- name: Upload to code scanning
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: audit.sarif

Kubernetes version support list

The supported-minors list is static (the audit is cluster-free) and lives in .lok8s/libs/audit:

bash
_AUDIT_K8S_SUPPORTED_MINORS="1.34 1.35 1.36"
_AUDIT_K8S_LATEST_MINOR="1.36"

Update it when new minors release or old ones reach End-of-Life. See the Kubernetes releases page. The audit reports a version newer than _AUDIT_K8S_LATEST_MINOR as a low warn ("the support list may be stale"), so a bump reminds you to refresh the list.

Addon overview (lo addons --detail)

A companion read-only view inventories the addons a cluster actually deploys, resolved from spec.bootstrap (through the same parser the apply path uses, so map-form entries stay intact) and intersected with the .lok8s/addons/ tree. It shows each addon's category (from its lok8s.dev/category label) and a one-line "how to configure" pointer:

bash
lo addons --detail --domain my-cluster.example.com
Addons deployed by my-cluster.example.com (kind=kubeone)

NAME          CATEGORY        TYPE   VERSION   CONFIGURE
----          --------        ----   -------   ---------
cilium        networking      khelm  1.20.1    encryption + policy mode (policyAuditMode) in cilium inline values / values.<driver>.yaml
ccm           networking      khelm  1.35.0    spec.bootstrap ccm.values.env: ROBOT_ENABLED / HCLOUD_NETWORK (hcloud CCM)
cert-manager  infrastructure  khelm  v1.21.1   issue TLS via ClusterIssuer/Certificate CRs in a networking target
networking    target          target -         per-cluster glue in clusters/my-cluster.example.com/targets/networking

The view lists ./targets/* (and absolute-path) entries as targets (per-cluster glue), not framework addons. Every shipped addon carries a config-help entry (a parity test fails CI if an addon lands without one). Plain lo addons still lists what the tree ships; --detail shows what this cluster runs.

What it does not do

lo audit is the static half of the security picture: it reasons about the spec and the rendered manifests. It cannot see runtime-only facts (the actual apiserver flags, the live cilium-config, real cert expiry, exposed Services on the cluster). Those come from the connected-cluster path and surface separately; the static audit is the part that ships today, offline, for every self-hosted user.

See also

  • Security: how to configure at-rest encryption and the other control-plane settings.
  • Bootstrap Addons: the addon system lo addons --detail inventories.
  • Networking & Ingress: the Gateway / HTTPRoute / SecurityPolicy surface the exposure check reads.

Released under the MIT License.