feat(scripts): add check-dashboard-cache.py lint + CI (closes #147) (#148)

Anti-regression for the double-cache pattern documented in CLAUDE.md
(« Performance Patterns — Double Caching »). Catches the bug class that
caused PR #146 (secubox-waf 17 % sustained CPU under SOC polling)
before it lands.

What's added
============
- scripts/check-dashboard-cache.py — AST-based audit. Walks every
  packages/secubox-*/api/main.py, flags hot dashboard routes
  (/stats, /alerts, /summary, /dashboard, /metrics, /overview,
  /events, /history, /recent, /bans, /sessions, /connections)
  whose handler body does per-request I/O (open, read_text,
  read_bytes, subprocess.run, Popen) AND doesn't go through a cache
  (module-level _cache instance + .get() call, OR
  asyncio.create_task(refresh_*) at startup).
    * --check : exit 1 on unjustified violations (CI mode)
    * --report : human-readable table (default)
    * --json : machine-readable

- .cache-lint-allowlist.toml — TOML allowlist for justified
  exceptions. Each entry needs a daemon + route + justification
  (issue link expected). Seeded with the 4 current legacy cases:
  secubox-waf (covered by PR #146 — will drop the entries when
  merged), secubox-config-advisor /history (admin drill-in, not
  polled), secubox-netdiag /connections (real-time TCP state),
  secubox-tor /summary (small file read, follow-up cleanup).

- .github/workflows/dashboard-cache-check.yml — mirror of
  license-check.yml. Runs the lint + the self-tests on PRs that
  touch packages/**/api/main.py, the lint script, the allowlist,
  or the workflow itself.

- docs/CACHE-PATTERN.md — short remediation guide. Three accepted
  shapes (read-through, background-refresh, pure in-memory), code
  example, allowlist syntax, references to the canonical
  implementations (secubox-crowdsec for read-through, secubox-system
  for background-refresh).

Tests (14/14 pass)
==================
- StatsCache pattern detection (compliant cases)
- Non-compliant detection: open, subprocess.run, multiple I/O ops
- Pure in-memory handler = compliant
- Background-refresh task = compliant
- Non-hot route (/health) not audited
- @router.get treated like @app.get
- Allowlist suppresses by exact (daemon, route) key
- Allowlist doesn't cross-pollinate across routes
- Missing allowlist file = no error
- CLI --check exits 1 on unjustified
- CLI --check exits 0 when clean
- CLI --json output well-formed
- **Real repo audit with seeded allowlist passes --check**
  (sanity gate: catches regression if anyone removes an allowlist
  entry without fixing the daemon)

Baseline against the real packages/ tree
========================================
122 daemons audited, 118 compliant, 4 with findings (all
allowlisted). When PR #146 merges, the secubox-waf entries can be
removed from the allowlist — the next lint run will then prove the
fix is wired correctly.

Follow-up
=========
- Drop secubox-waf allowlist entries when #146 merges.
- Per-daemon follow-up issues for the remaining 3 (config-advisor,
  netdiag, tor) — not blocking, allowlist documents the rationale.
- Consider extending HOT_ROUTES to /health / /status once the small
  daemons that call `systemctl is-active` adopt a 10 s cache.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberMind 2026-05-18 08:21:20 +02:00 committed by GitHub
parent c5d00be820
commit 3435cfa069
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 986 additions and 0 deletions

View File

@ -0,0 +1,36 @@
# .cache-lint-allowlist.toml
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
#
# Exceptions for scripts/check-dashboard-cache.py — daemons whose dashboard
# endpoints do per-request I/O without cache. Each entry is a TEMPORARY
# admission: every line is a TODO that should be cleared by patching the
# daemon to follow the pattern (see CLAUDE.md § Performance Patterns and
# packages/secubox-crowdsec/api/main.py:106-138).
#
# Add an entry only with a justification AND an issue link.
# Remove the entry when the corresponding daemon is patched.
[[entries]]
daemon = "secubox-waf"
route = "/stats"
justification = "PR #146 in flight — applies StatsCache pattern. Drop this entry once merged + deployed."
[[entries]]
daemon = "secubox-waf"
route = "/alerts"
justification = "Same as /stats — covered by PR #146."
[[entries]]
daemon = "secubox-config-advisor"
route = "/history"
justification = "Reads a config-snapshot file per request. Low-frequency endpoint (admin drill-in, not polled by SOC). Patch when touching the daemon for unrelated reasons. Track in follow-up issue."
[[entries]]
daemon = "secubox-netdiag"
route = "/connections"
justification = "subprocess.run('ss' / 'netstat'). The data is inherently per-request real-time (open TCP connections); caching for >2 s would mislead. Acceptable as long as the endpoint isn't dashboard-polled — verify usage before un-allowlisting."
[[entries]]
daemon = "secubox-tor"
route = "/summary"
justification = "Module has a cache instance but /summary doesn't use it. Small read_text() on a Tor status file. Patch in a follow-up to call the existing cache."

View File

@ -0,0 +1,40 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
name: Dashboard Cache Lint
# Anti-regression for the double-cache pattern documented in CLAUDE.md
# (« Performance Patterns — Double Caching »). Fails if a daemon adds a
# hot dashboard endpoint (/stats, /alerts, /summary, /dashboard, /metrics,
# /overview, /events, /history, /recent, /bans, /sessions, /connections)
# that does per-request I/O without going through a cache, unless an
# explicit justified entry is added to .cache-lint-allowlist.toml.
#
# See docs/CACHE-PATTERN.md for how to comply.
on:
pull_request:
paths:
- 'packages/**/api/main.py'
- 'scripts/check-dashboard-cache.py'
- '.cache-lint-allowlist.toml'
- '.github/workflows/dashboard-cache-check.yml'
push:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run cache-pattern lint
run: python3 scripts/check-dashboard-cache.py --check
- name: Run lint self-tests
run: |
pip install pytest
python3 -m pytest scripts/tests/test_check_dashboard_cache.py -v

154
docs/CACHE-PATTERN.md Normal file
View File

@ -0,0 +1,154 @@
# Double-Cache Pattern — SecuBox dashboards
> Status: **enforced** by `scripts/check-dashboard-cache.py` via the
> `Dashboard Cache Lint` GitHub Actions workflow.
## TL;DR
If your FastAPI handler in `packages/secubox-*/api/main.py` matches any
of the **hot routes** below *and* reads logs / runs subprocesses / opens
files, it **must** go through a cache. The lint will block the PR
otherwise.
Hot routes :
```
/stats /alerts /summary /dashboard /metrics /overview
/events /history /recent /bans /sessions /connections
```
## Why
These endpoints are typically polled by dashboards (SOC, Hub, c3box…)
every 5 s. Without a cache, each call does the full work (log scan,
GeoIP lookup, subprocess) → CPU saturation under polling. See live-bench
on `admin.gk2.secubox.in` (2026-05-15) where `secubox-waf` sustained
17 % CPU and 1035 syscalls/sec from a single open SOC tab.
CLAUDE.md says it directly:
> **Toujours** pour les dashboards stats (WAF, CrowdSec, bandwidth, DPI…)
> **Toujours** quand l'endpoint lit des logs ou calcule des agrégats
> **Toujours** quand la donnée peut être périmée de 60s sans impact utilisateur
## How to comply
Three accepted shapes — pick one.
### 1. Read-through cache (preferred — simple, robust)
Mirror `packages/secubox-crowdsec/api/main.py:106-138` (or
`packages/secubox-waf/api/main.py` after PR #146 merges):
```python
import threading, time
from typing import Any, Dict, Optional
class StatsCache:
def __init__(self, ttl_seconds: int = 30):
self.ttl = ttl_seconds
self._cache: Dict[str, Any] = {}
self._timestamps: Dict[str, float] = {}
self._lock = threading.Lock()
def get(self, key: str) -> Optional[Any]:
with self._lock:
if key in self._cache and time.time() - self._timestamps[key] < self.ttl:
return self._cache[key]
return None
def set(self, key: str, value: Any):
with self._lock:
self._cache[key] = value
self._timestamps[key] = time.time()
def invalidate(self, key: Optional[str] = None):
with self._lock:
if key:
self._cache.pop(key, None); self._timestamps.pop(key, None)
else:
self._cache.clear(); self._timestamps.clear()
stats_cache = StatsCache(ttl_seconds=30)
@app.get("/stats")
async def get_stats():
cached = stats_cache.get("stats")
if cached is not None:
return cached
stats = compute_stats() # the expensive bit
stats_cache.set("stats", stats)
return stats
```
Invalidate explicitly on mutating endpoints :
```python
@app.post("/ban")
async def ban(...):
do_the_ban()
stats_cache.invalidate()
return {...}
```
### 2. Background-refresh task
Spawn a periodic task at startup that pre-warms the cache.
```python
async def refresh_cache():
while True:
try:
_cache.update(await compute_stats())
except Exception as e:
log.error("refresh failed: %s", e)
await asyncio.sleep(60)
@app.on_event("startup")
async def startup():
asyncio.create_task(refresh_cache())
```
This is more code but lets you keep handler bodies trivial. Used by
`secubox-system`, `secubox-publish`, others.
### 3. Pure in-memory handler
If the handler doesn't open files, run subprocesses, or read logs, the
lint won't flag it. Trivial in-memory aggregations from runtime state
are fine without a cache.
## How to add an exception
Only if you genuinely can't comply right now :
```toml
# .cache-lint-allowlist.toml
[[entries]]
daemon = "secubox-myservice"
route = "/stats"
justification = "Reason + issue link. e.g. real-time data, caching would mislead — see #999"
```
Every entry is implicitly a TODO. Drop the entry when you patch the
daemon.
## Running locally
```bash
python3 scripts/check-dashboard-cache.py --report # full table
python3 scripts/check-dashboard-cache.py --check # CI mode, rc=1 if violations
python3 scripts/check-dashboard-cache.py --json # machine-readable
```
Tests :
```bash
python3 -m pytest scripts/tests/test_check_dashboard_cache.py -v
```
## Reference implementation
- Read-through : `packages/secubox-crowdsec/api/main.py:106-138`
- Read-through (after PR #146) : `packages/secubox-waf/api/main.py`
- Background-refresh : `packages/secubox-system/api/main.py` (search for `create_task`)

445
scripts/check-dashboard-cache.py Executable file
View File

@ -0,0 +1,445 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""scripts/check-dashboard-cache.py — double-cache pattern lint.
Audits every ``packages/secubox-*/api/main.py`` to verify that endpoints
serving aggregate / log-derived data (``/stats``, ``/alerts``,
``/summary``, ``/dashboard``, ``/metrics``, ``/overview``) read from a
cache and don't perform unbounded I/O on every request.
The pattern is documented in ``CLAUDE.md`` (« Performance Patterns
Double Caching ») and the canonical implementation lives in
``packages/secubox-crowdsec/api/main.py`` (``StatsCache`` class).
A handler is **compliant** when at least one of these holds:
1. Its body calls ``<something>_cache.get(...)`` (read-through cache) AND
the module instantiates a cache (any name ending in ``_cache``).
2. The module starts an ``asyncio.create_task(refresh_*)`` at startup
(background-refresh pattern).
3. The handler body contains zero I/O ops (``open``, ``read_text``,
``read_bytes``, ``subprocess.run``, ``Popen``) pure in-memory.
4. The ``daemon::endpoint`` pair is in
``.cache-lint-allowlist.toml`` with a written justification (typically
for cheap ``systemctl is-active``-style ``/status`` endpoints).
Usage::
python3 scripts/check-dashboard-cache.py --check # CI, exit 1 on violation
python3 scripts/check-dashboard-cache.py --report # full triage table
python3 scripts/check-dashboard-cache.py --json # machine-readable
Designed to be wired into ``.github/workflows/dashboard-cache-check.yml``
exactly like ``license-check.yml`` does for SPDX headers.
"""
from __future__ import annotations
import argparse
import ast
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Optional
try:
import tomllib # 3.11+
except ImportError: # pragma: no cover
import tomli as tomllib # type: ignore[no-redef]
# ─────────────────────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────────────────────
# Endpoints that consume aggregate/log-derived data and are typically
# polled by dashboards. A handler attached to one of these via
# @app.get / @router.get must be cache-backed.
HOT_ROUTES = frozenset({
"/stats",
"/alerts",
"/summary",
"/dashboard",
"/metrics",
"/overview",
"/events",
"/history",
"/recent",
"/bans",
"/sessions",
"/connections",
})
# Callables in a handler body that indicate per-request I/O.
IO_CALLABLES = frozenset({
"open",
"read_text",
"read_bytes",
"loads", # json.loads of a freshly-read file — flagged when paired with open in chain
"run", # subprocess.run
"Popen",
"check_output",
"check_call",
"communicate",
})
# Decorator names that mount HTTP handlers we care about.
ROUTE_DECORATORS = frozenset({"get", "post"})
DEFAULT_ALLOWLIST_PATH = Path(".cache-lint-allowlist.toml")
DEFAULT_PACKAGES_ROOT = Path("packages")
# ─────────────────────────────────────────────────────────────────────
# Data model
# ─────────────────────────────────────────────────────────────────────
@dataclass
class Finding:
daemon: str
file: Path
line: int
route: str
handler: str
io_calls: list[str] = field(default_factory=list)
@property
def key(self) -> str:
"""daemon::route — the allowlist key."""
return f"{self.daemon}::{self.route}"
@dataclass
class DaemonReport:
daemon: str
file: Path
has_cache_instance: bool
has_bg_refresh: bool
findings: list[Finding] = field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────
# AST analysis
# ─────────────────────────────────────────────────────────────────────
def _decorator_route(dec: ast.expr) -> tuple[Optional[str], Optional[str]]:
"""Return (verb, path) if ``dec`` is ``@app.get("/x")`` or
``@router.post("/x")``, else (None, None)."""
if not isinstance(dec, ast.Call):
return None, None
func = dec.func
if not isinstance(func, ast.Attribute) or func.attr not in ROUTE_DECORATORS:
return None, None
if not isinstance(func.value, ast.Name) or func.value.id not in {"app", "router"}:
return None, None
if not dec.args:
return None, None
arg0 = dec.args[0]
if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str):
return func.attr, arg0.value
return None, None
def _is_hot(path: str) -> bool:
"""Match a route path against HOT_ROUTES, treating ``/foo/{x}`` as
``/foo`` so e.g. ``/stats/by_ip`` is *not* matched but ``/stats``
is."""
if path in HOT_ROUTES:
return True
# Allow ``/stats/`` or ``/stats`` exact match; sub-paths require
# explicit inclusion to keep the lint focused on the canonical
# dashboard surface.
return False
def _collect_io_in_body(body: list[ast.stmt]) -> list[str]:
"""Return list of ``func_name@line`` strings for each I/O callable."""
hits: list[str] = []
class Visitor(ast.NodeVisitor):
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
name: Optional[str] = None
if isinstance(node.func, ast.Name):
name = node.func.id
elif isinstance(node.func, ast.Attribute):
name = node.func.attr
if name in IO_CALLABLES:
# Filter false positives: ``json.loads(some_string)`` is fine,
# but ``json.loads(p.read_text())`` is already caught by the
# ``read_text`` hit, and bare ``loads`` of a constant we don't
# care about. Conservatively flag ``loads`` only when nested
# alongside ``read_text``/``open``.
if name == "loads":
# Skip — too noisy on its own; ``read_text`` will fire
# when the source is a freshly-read file.
return self.generic_visit(node)
hits.append(f"{name}@L{node.lineno}")
self.generic_visit(node)
for stmt in body:
Visitor().visit(stmt)
return hits
def _module_signals(tree: ast.Module) -> tuple[bool, bool]:
"""Return (has_cache_instance, has_bg_refresh).
- has_cache_instance: a module-level assignment whose target is a
Name ending in ``_cache`` (e.g. ``stats_cache = StatsCache(...)``).
- has_bg_refresh: any ``asyncio.create_task(refresh_*())`` or
``@app.on_event("startup")`` that spawns a coroutine whose name
contains ``refresh`` / ``cache`` / ``poll`` / ``loop``.
"""
has_cache = False
has_bg = False
for node in ast.walk(tree):
# Cache instance detection
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id.endswith("_cache"):
has_cache = True
# Background refresh detection
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr == "create_task"
and isinstance(func.value, ast.Name)
and func.value.id == "asyncio"
):
# Inspect the argument: is it a refresh-style coroutine?
if node.args and isinstance(node.args[0], ast.Call):
inner = node.args[0].func
inner_name = (
inner.id if isinstance(inner, ast.Name)
else inner.attr if isinstance(inner, ast.Attribute)
else ""
)
if any(kw in inner_name.lower()
for kw in ("refresh", "cache", "poll", "loop", "periodic")):
has_bg = True
return has_cache, has_bg
def _handler_uses_cache(body: list[ast.stmt]) -> bool:
"""True if the handler body calls ``something_cache.get(...)``."""
class Visitor(ast.NodeVisitor):
found = False
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
if isinstance(node.func, ast.Attribute) and node.func.attr == "get":
val = node.func.value
if isinstance(val, ast.Name) and val.id.endswith("_cache"):
self.found = True
self.generic_visit(node)
v = Visitor()
for stmt in body:
v.visit(stmt)
return v.found
def audit_daemon(main_py: Path, daemon_name: str) -> DaemonReport:
"""Audit one ``packages/<daemon>/api/main.py``."""
src = main_py.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(src, filename=str(main_py))
has_cache, has_bg = _module_signals(tree)
report = DaemonReport(
daemon=daemon_name,
file=main_py,
has_cache_instance=has_cache,
has_bg_refresh=has_bg,
)
for node in ast.walk(tree):
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
continue
for dec in node.decorator_list:
verb, path = _decorator_route(dec)
if not path or not _is_hot(path):
continue
io_hits = _collect_io_in_body(node.body)
if not io_hits:
# Pure handler — compliant by trivial absence of I/O
continue
if has_bg:
# Background-refresh pattern covers all hot routes globally
continue
if _handler_uses_cache(node.body):
# Explicit read-through cache check
continue
report.findings.append(Finding(
daemon=daemon_name,
file=main_py,
line=node.lineno,
route=path,
handler=node.name,
io_calls=io_hits,
))
break # one decorator is enough per function
return report
# ─────────────────────────────────────────────────────────────────────
# Allowlist
# ─────────────────────────────────────────────────────────────────────
def load_allowlist(path: Path) -> dict[str, str]:
"""Return ``{ "daemon::/route": "justification" }`` from TOML."""
if not path.is_file():
return {}
with path.open("rb") as f:
data = tomllib.load(f)
entries = data.get("entries", [])
return {f"{e['daemon']}::{e['route']}": e.get("justification", "") for e in entries}
# ─────────────────────────────────────────────────────────────────────
# Driver
# ─────────────────────────────────────────────────────────────────────
def find_main_files(packages_root: Path) -> Iterable[tuple[str, Path]]:
"""Yield (daemon_name, path) for each ``packages/*/api/main.py``."""
for pkg in sorted(packages_root.iterdir()):
if not pkg.is_dir():
continue
main = pkg / "api" / "main.py"
if main.is_file():
yield pkg.name, main
def run(packages_root: Path, allowlist_path: Path) -> tuple[list[DaemonReport], list[Finding]]:
"""Run the audit. Returns (all_reports, unjustified_findings)."""
allowlist = load_allowlist(allowlist_path)
all_reports: list[DaemonReport] = []
unjustified: list[Finding] = []
for daemon, main in find_main_files(packages_root):
rep = audit_daemon(main, daemon)
all_reports.append(rep)
for f in rep.findings:
if f.key not in allowlist:
unjustified.append(f)
return all_reports, unjustified
# ─────────────────────────────────────────────────────────────────────
# Output
# ─────────────────────────────────────────────────────────────────────
def format_report(
reports: list[DaemonReport],
unjustified: list[Finding],
allowlist_path: Path,
) -> str:
lines: list[str] = []
n_compliant = sum(1 for r in reports if not r.findings)
n_violating = len(reports) - n_compliant
lines.append(f"Audited {len(reports)} daemons "
f"({n_compliant} compliant, {n_violating} with findings).")
lines.append("")
if not reports:
return "\n".join(lines)
for r in reports:
if not r.findings:
continue
sig = []
if r.has_cache_instance: sig.append("cache-instance")
if r.has_bg_refresh: sig.append("bg-refresh")
sig_str = ",".join(sig) if sig else "no-cache-signals"
lines.append(f" {r.daemon} [{sig_str}]")
for f in r.findings:
allowlisted = " [ALLOWLISTED]" if f.key not in {u.key for u in unjustified} else ""
lines.append(f" {f.file}:{f.line} {f.route} ({f.handler}) "
f"io={','.join(f.io_calls)}{allowlisted}")
lines.append("")
if unjustified:
lines.append(f"{len(unjustified)} unjustified violation(s). "
f"Either implement the double-cache pattern (see "
f"packages/secubox-crowdsec/api/main.py:106-138 for the "
f"reference StatsCache class) or add a justified entry to "
f"{allowlist_path}.")
else:
lines.append("✓ No unjustified violations.")
return "\n".join(lines)
def format_json(reports: list[DaemonReport], unjustified: list[Finding]) -> str:
out = {
"summary": {
"daemons_audited": len(reports),
"with_findings": sum(1 for r in reports if r.findings),
"unjustified_violations": len(unjustified),
},
"daemons": [
{
"name": r.daemon,
"file": str(r.file),
"has_cache_instance": r.has_cache_instance,
"has_bg_refresh": r.has_bg_refresh,
"findings": [
{
"route": f.route,
"handler": f.handler,
"line": f.line,
"io_calls": f.io_calls,
}
for f in r.findings
],
}
for r in reports
],
"unjustified": [
{"daemon": f.daemon, "route": f.route, "file": str(f.file), "line": f.line}
for f in unjustified
],
}
return json.dumps(out, indent=2)
# ─────────────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────────────
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--packages", type=Path, default=DEFAULT_PACKAGES_ROOT,
help="path to packages/ root (default: packages)")
parser.add_argument("--allowlist", type=Path, default=DEFAULT_ALLOWLIST_PATH,
help="TOML allowlist for justified violations")
g = parser.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true",
help="exit 1 on unjustified violations (for CI)")
g.add_argument("--report", action="store_true",
help="print full triage table (default)")
g.add_argument("--json", action="store_true", dest="as_json",
help="machine-readable output")
args = parser.parse_args(argv)
if not args.packages.is_dir():
print(f"error: --packages {args.packages} not a directory", file=sys.stderr)
return 2
reports, unjustified = run(args.packages, args.allowlist)
if args.as_json:
print(format_json(reports, unjustified))
else:
print(format_report(reports, unjustified, args.allowlist))
if args.check and unjustified:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

View File

@ -0,0 +1,311 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for scripts/check-dashboard-cache.py."""
from __future__ import annotations
import importlib.util
import sys
import textwrap
from pathlib import Path
import pytest
# Load the script as a module (hyphenated filename → import via spec loader).
_SCRIPT = Path(__file__).resolve().parents[1] / "check-dashboard-cache.py"
_spec = importlib.util.spec_from_file_location("cdc", _SCRIPT)
assert _spec and _spec.loader
cdc = importlib.util.module_from_spec(_spec)
sys.modules["cdc"] = cdc
_spec.loader.exec_module(cdc)
# ──────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────
def make_fake_packages(root: Path, daemons: dict[str, str]) -> Path:
"""Materialise ``packages/<name>/api/main.py`` for each entry.
``daemons`` maps daemon-name source code for ``main.py``.
"""
pkg_root = root / "packages"
pkg_root.mkdir()
for name, src in daemons.items():
d = pkg_root / name / "api"
d.mkdir(parents=True)
(d / "main.py").write_text(textwrap.dedent(src))
return pkg_root
# ──────────────────────────────────────────────────────────────────────
# Core detection
# ──────────────────────────────────────────────────────────────────────
def test_compliant_handler_with_read_through_cache(tmp_path):
"""``stats_cache.get(...)`` in body → no finding."""
pkg = make_fake_packages(tmp_path, {
"secubox-foo": """
from fastapi import FastAPI
app = FastAPI()
stats_cache = object() # placeholder; module-level instance
@app.get("/stats")
async def get_stats():
cached = stats_cache.get("stats")
if cached is not None:
return cached
with open("/var/log/foo.log") as f:
return f.read()
""",
})
reports, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert not unjustified
assert len(reports[0].findings) == 0
def test_handler_doing_open_without_cache_is_flagged(tmp_path):
pkg = make_fake_packages(tmp_path, {
"secubox-bad": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats():
with open("/var/log/foo.log") as f:
return f.read()
""",
})
reports, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert len(unjustified) == 1
f = unjustified[0]
assert f.daemon == "secubox-bad"
assert f.route == "/stats"
assert any("open@" in c for c in f.io_calls)
def test_handler_doing_subprocess_run_is_flagged(tmp_path):
pkg = make_fake_packages(tmp_path, {
"secubox-bad": """
import subprocess
from fastapi import FastAPI
app = FastAPI()
@app.get("/metrics")
async def get_metrics():
return subprocess.run(["expensive"], capture_output=True)
""",
})
_, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert len(unjustified) == 1
assert "run@" in unjustified[0].io_calls[0]
def test_handler_with_no_io_is_compliant(tmp_path):
"""Pure in-memory handler doesn't need a cache."""
pkg = make_fake_packages(tmp_path, {
"secubox-pure": """
from fastapi import FastAPI
app = FastAPI()
STATE = {"x": 1}
@app.get("/stats")
async def get_stats():
return STATE
""",
})
_, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert not unjustified
def test_handler_with_bg_refresh_task_is_compliant(tmp_path):
"""``asyncio.create_task(refresh_cache())`` at startup covers all hot routes."""
pkg = make_fake_packages(tmp_path, {
"secubox-bg": """
import asyncio
from fastapi import FastAPI
app = FastAPI()
async def refresh_cache():
pass
@app.on_event("startup")
async def startup():
asyncio.create_task(refresh_cache())
@app.get("/stats")
async def get_stats():
with open("/var/log/foo.log") as f:
return f.read()
""",
})
_, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert not unjustified
def test_non_hot_route_is_not_audited(tmp_path):
"""``/health`` is NOT in HOT_ROUTES — even with I/O it should not flag."""
pkg = make_fake_packages(tmp_path, {
"secubox-meh": """
import subprocess
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def get_health():
return subprocess.run(["systemctl", "is-active", "foo"])
""",
})
_, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert not unjustified
def test_router_decorator_is_recognised(tmp_path):
"""``@router.get`` is treated like ``@app.get``."""
pkg = make_fake_packages(tmp_path, {
"secubox-r": """
from fastapi import APIRouter
router = APIRouter()
@router.get("/summary")
async def get_summary():
with open("/var/log/foo.log") as f:
return f.read()
""",
})
_, unjustified = cdc.run(pkg, Path("/nonexistent"))
assert len(unjustified) == 1
assert unjustified[0].route == "/summary"
# ──────────────────────────────────────────────────────────────────────
# Allowlist
# ──────────────────────────────────────────────────────────────────────
def test_allowlist_suppresses_known_violation(tmp_path):
pkg = make_fake_packages(tmp_path, {
"secubox-bad": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats():
with open("/var/log/foo.log") as f:
return f.read()
""",
})
allowlist = tmp_path / "allow.toml"
allowlist.write_text(textwrap.dedent("""
[[entries]]
daemon = "secubox-bad"
route = "/stats"
justification = "tracked in issue #999"
"""))
reports, unjustified = cdc.run(pkg, allowlist)
# Finding still recorded, but allowlist suppresses the failure
assert len(reports[0].findings) == 1
assert not unjustified
def test_allowlist_only_matches_exact_route(tmp_path):
"""Allowlisting daemon::/stats must not silence daemon::/alerts."""
pkg = make_fake_packages(tmp_path, {
"secubox-multi": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats():
with open("/x") as f: f.read()
@app.get("/alerts")
async def get_alerts():
with open("/y") as f: f.read()
""",
})
allowlist = tmp_path / "allow.toml"
allowlist.write_text(textwrap.dedent("""
[[entries]]
daemon = "secubox-multi"
route = "/stats"
justification = "x"
"""))
_, unjustified = cdc.run(pkg, allowlist)
assert len(unjustified) == 1
assert unjustified[0].route == "/alerts"
def test_missing_allowlist_file_is_ignored(tmp_path):
pkg = make_fake_packages(tmp_path, {
"secubox-pure": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats(): return {}
""",
})
reports, unjustified = cdc.run(pkg, tmp_path / "does-not-exist.toml")
assert not unjustified
assert len(reports) == 1
# ──────────────────────────────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────────────────────────────
def test_cli_check_exits_1_on_unjustified(tmp_path, capsys):
pkg = make_fake_packages(tmp_path, {
"secubox-bad": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats():
with open("/x") as f: f.read()
""",
})
rc = cdc.main(["--check", "--packages", str(pkg),
"--allowlist", str(tmp_path / "noop.toml")])
out = capsys.readouterr().out
assert rc == 1
assert "unjustified violation" in out
def test_cli_check_exits_0_when_clean(tmp_path):
pkg = make_fake_packages(tmp_path, {
"secubox-ok": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats(): return {}
""",
})
rc = cdc.main(["--check", "--packages", str(pkg),
"--allowlist", str(tmp_path / "noop.toml")])
assert rc == 0
def test_cli_json_output(tmp_path, capsys):
pkg = make_fake_packages(tmp_path, {
"secubox-bad": """
from fastapi import FastAPI
app = FastAPI()
@app.get("/stats")
async def get_stats():
with open("/x") as f: f.read()
""",
})
rc = cdc.main(["--json", "--packages", str(pkg),
"--allowlist", str(tmp_path / "noop.toml")])
out = capsys.readouterr().out
import json as _json
data = _json.loads(out)
assert rc == 0 # --json doesn't exit non-zero (only --check does)
assert data["summary"]["unjustified_violations"] == 1
assert data["unjustified"][0]["daemon"] == "secubox-bad"
# ──────────────────────────────────────────────────────────────────────
# Repo integration
# ──────────────────────────────────────────────────────────────────────
def test_real_repo_with_seeded_allowlist_passes_check():
"""Sanity: running on the real packages/ with .cache-lint-allowlist.toml
must be clean. Catches regressions if someone adds a new violation
or removes an allowlist entry without fixing the daemon."""
repo = Path(__file__).resolve().parents[2]
rc = cdc.main(["--check",
"--packages", str(repo / "packages"),
"--allowlist", str(repo / ".cache-lint-allowlist.toml")])
assert rc == 0, "real repo audit must pass with seeded allowlist"