Building a Kubernetes Security Reporter for Obsidian

Observability
Table of Contents

What if your Kubernetes cluster could automatically write security reports directly into your second brain? Today I built exactly thatβ€”a system that runs CIS benchmark scans against my K3s cluster and publishes the results as beautifully formatted Obsidian notes. No more checking dashboards or parsing JSON files; just open your vault and see your cluster’s security posture.


Part 1: The Problem

Security Visibility in a Homelab

Running a Kubernetes homelab means wearing many hats: developer, operator, and security engineer. Tools like kube-bench exist to scan your cluster against CIS benchmarks, but they typically output JSON or text that sits in a PersistentVolume gathering dust.

I wanted:

  • Daily automated security scans using K3s-specific CIS benchmarks
  • Human-readable reports I could review with my morning coffee
  • Integration with my knowledge management system (Obsidian)
  • Historical tracking to see security improvements over time

The Architecture

The solution involves three components:

  1. kube-bench scanner - CronJob running at 2:00 AM, saving JSON results to NFS storage
  2. kube-bench-reporter - Python app running at 2:30 AM, parsing results and publishing to Obsidian
  3. Obsidian Local REST API - Plugin exposing your vault via HTTP
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ kube-bench  │───▢│ NFS Storage  │───▢│  Reporter   β”‚
β”‚  (Scanner)  β”‚    β”‚ (JSON files) β”‚    β”‚  (Python)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚
                                              β–Ό
                                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                       β”‚  Obsidian   β”‚
                                       β”‚  REST API   β”‚
                                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Part 2: Building the Reporter

The Python Script

The reporter is surprisingly simpleβ€”about 200 lines of Python. Here’s the core logic:

#!/usr/bin/env python3
"""Kube-bench report publisher to Obsidian."""
import os
import json
import requests
import urllib3
from pathlib import Path
from datetime import datetime

# Disable SSL warnings for self-signed certificates
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

RESULTS_DIR = os.getenv("RESULTS_DIR", "/results")
OBSIDIAN_API_URL = os.getenv("OBSIDIAN_API_URL")
OBSIDIAN_API_KEY = os.getenv("OBSIDIAN_API_KEY")
OBSIDIAN_FOLDER = os.getenv("OBSIDIAN_FOLDER", "Cluster/Security")

The script:

  1. Finds the latest kube-bench JSON results file
  2. Parses and counts checks by status (PASS, FAIL, WARN, INFO)
  3. Generates two markdown files: a summary and detailed report
  4. Publishes both to Obsidian via the REST API

Generating Useful Reports

The summary report includes a table of results and lists failed checks:

def generate_summary(data, date_str):
    counts = count_by_status(data)
    failed = get_checks_by_status(data, "FAIL")

    md = f"""---
date: {date_str}
type: security-summary
tags:
  - security
  - kube-bench
  - cis-benchmark
---

# Kube-Bench Security Summary - {date_str}

## Results Overview

| Status | Count | Percentage |
|--------|-------|------------|
| PASS | {counts['PASS']} | {counts['PASS']/total*100:.1f}% |
| FAIL | {counts['FAIL']} | {counts['FAIL']/total*100:.1f}% |
...
"""

The detailed report includes every check with remediation steps for failuresβ€”perfect for actually fixing issues.

Publishing to Obsidian

The Obsidian Local REST API accepts PUT requests to create or update files:

def publish_to_obsidian(note_path, content):
    url = f"{OBSIDIAN_API_URL}/vault/{note_path}"
    headers = {
        "Authorization": f"Bearer {OBSIDIAN_API_KEY}",
        "Content-Type": "text/markdown"
    }
    response = requests.put(
        url,
        headers=headers,
        data=content.encode('utf-8'),
        timeout=30,
        verify=False  # Self-signed cert
    )
    response.raise_for_status()

Part 3: Kubernetes Deployment

GitOps with FluxCD

Following GitOps principles, everything is declarative YAML. The reporter CronJob:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: kube-bench-reporter
  namespace: kube-bench
spec:
  schedule: "30 2 * * *"  # 30 min after scanner
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: reporter
            image: lzetam/kube-bench-reporter:latest
            envFrom:
            - configMapRef:
                name: kube-bench-reporter-config
            env:
            - name: OBSIDIAN_API_URL
              valueFrom:
                secretKeyRef:
                  name: obsidian-api
                  key: api_url
            - name: OBSIDIAN_API_KEY
              valueFrom:
                secretKeyRef:
                  name: obsidian-api
                  key: api_key
            volumeMounts:
            - name: results-storage
              mountPath: /results
              readOnly: true

Secret Management with External Secrets

Credentials are stored in AWS Secrets Manager and synced via External Secrets Operator:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: obsidian-api-secret
spec:
  secretStoreRef:
    name: aws-secret-store
    kind: SecretStore
  target:
    name: obsidian-api
  data:
    - secretKey: api_url
      remoteRef:
        key: obsidian/api
        property: api_url
    - secretKey: api_key
      remoteRef:
        key: obsidian/api
        property: api_key

Part 4: Lessons Learned

Debugging Container Permissions

The first major hurdle was file permissions. The reporter container runs as user 1000 (non-root), but couldn’t read files on the NFS volume. The fix was ensuring the Dockerfile creates the user before copying files:

FROM python:3.12-slim
WORKDIR /app
RUN useradd -m -u 1000 appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser reporter.py .
USER appuser
ENTRYPOINT ["python", "reporter.py"]

Network Connectivity

Obsidian runs on my local Mac, not in Kubernetes. For pods to reach it:

  1. The Obsidian REST API must bind to 0.0.0.0 (not just localhost)
  2. The API URL in Kubernetes secrets must use the Mac’s network IP

Self-Signed Certificates

The Obsidian REST API uses HTTPS with a self-signed certificate. The reporter needs verify=False in requests and should suppress the resulting warnings:

import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

Part 5: The Result

Every morning, my Obsidian vault now has fresh security reports in Cluster/Security/:

  • Kube-Bench-Summary-YYYY-MM-DD.md - Quick overview with pass rates and failed checks
  • Kube-Bench-Details-YYYY-MM-DD.md - Complete report with remediation steps

The summary shows at a glance how my cluster is doing:

## Results Overview

| Status | Count | Percentage |
|--------|-------|------------|
| PASS | 45 | 52.3% |
| FAIL | 18 | 20.9% |
| WARN | 12 | 14.0% |
| INFO | 11 | 12.8% |

And I can click through to the detailed report to see exactly what needs fixing.


Key Takeaways

  1. Automate the boring stuff - Security scanning is only useful if you actually look at the results. Publishing to your daily knowledge system makes it visible.

  2. GitOps everything - Even “utility” workloads benefit from declarative configuration and version control.

  3. Bridge the gap - Tools like kube-bench produce machine-readable output. A simple Python script can transform that into human-readable notes.

  4. Non-root containers need care - File permissions are a common gotcha when running as non-root. Test with debug pods using the same user ID.

  5. Network boundaries matter - When your data consumer (Obsidian) is outside the cluster, you need to think about network accessibility and binding addresses.