Skip to content

feat(anomaly-detector): hourly LLM-based journal anomaly detection - #70

Draft
gsanchietti wants to merge 23 commits into
mainfrom
anomaly_detector
Draft

feat(anomaly-detector): hourly LLM-based journal anomaly detection#70
gsanchietti wants to merge 23 commits into
mainfrom
anomaly_detector

Conversation

@gsanchietti

@gsanchietti gsanchietti commented Jul 29, 2026

Copy link
Copy Markdown
Member

Adds an opt-in hourly anomaly detector to ns8-loki, alongside syslog-forwarder and cloud-log-manager-forwarder. Disabled by default; requires an explicit API key.

Implements the approved design in docs/superpowers/specs/2026-07-29-loki-anomaly-detector-design.md.

What it does

A systemd timer fires a Type=oneshot service hourly. One run:

  1. Takes the previous full hour as its window (wall clock, so no cursor file and no drift).
  2. Queries Loki for a per-(module_id, priority) rate digest, a 7-day baseline, up to max_lines prefiltered lines (PRIORITY < 5 or category="security"), and its own last 10 findings.
  3. Scrubs likely secrets from everything before it leaves the process.
  4. POSTs an OpenAI-compatible chat completion demanding a JSON-schema-constrained answer.
  5. Prints validated findings as JSON lines on stdout — which under systemd is the journal, so the collector ships them back into Loki. That round trip is the detector's memory: no state file, nothing extra to back up.

Skipping the LLM call on an idle window keeps quiet nodes cheap. A summary line is still written every window, so nominal hours are graphable.

Manual test

Install it on an existing machine:

runagent -m loki1
cd ../bin
curl https://raw.githubusercontent.com/NethServer/ns8-loki/refs/heads/anomaly_detector/imageroot/bin/anomaly-detector > anomaly-detector

Run using an OpenAI model:

ANOMALY_LLM_BASE_URL=https://api.openai.com/v1  ANOMALY_LLM_MODEL=gpt-4o-mini ANOMALY_LLM_API_KEY=sk-proj-xxx python3 anomaly-detector --since 2h --pretty --no-webhook

Run using a free model with OpenRouter:

ANOMALY_LLM_BASE_URL=https://openrouter.ai/api/v1 ANOMALY_LLM_MODEL=nvidia/nemotron-3-ultra-550b-a55b:free ANOMALY_LLM_API_KEY=sk-or-v1-xxx python3 anomaly-detector --since 2h --pretty --no-webhook

Configuration

api-cli run module/loki1/set-anomaly-detector --data '{
  "active": true,
  "base_url": "https://api.openai.com/v1",
  "model": "gpt-4o-mini",
  "api_key": "sk-..."
}'

base_url is a plain configured value, so OpenAI, OpenRouter, vLLM, Ollama or a self-hosted gateway all work through one code path.

Security

  • The API key goes to state/secrets.env at mode 0600, never through agent.set_env — that writes state/environment, which is mirrored into the Redis hash module/<id>/environment. Verified on a live node: the Redis leak check returns nothing, and get-configuration reports only api_key_configured.
  • Log lines are scrubbed inbound and findings scrubbed again outbound. IPs, hostnames, module IDs and usernames are deliberately kept — they carry the signal.
  • Log text is attacker-influenced (public-internet sshd lines reach the prompt by design), so every line is flattened to one line before entering the prompt's fenced blocks. A crafted message cannot forge extra lines or close a fence.
  • The detector excludes its own SYSLOG_IDENTIFIER before the priority filter, so its stderr diagnostics (journald records them at PRIORITY=3) cannot feed back into the next window.
  • The README states plainly that enabling this sends log text to a third-party API. Scrubbing is defence in depth, not a guarantee.

Verification

  • 160 unit tests, offline, in a container (./test-unit.sh) — new pytest harness plus a CI job.
  • Verified against a live cluster: LogQL queries, SyslogIdentifier=%u/%N resolving to loki1/anomaly-detector (the module_id label the recall query depends on), the action's secret handling and disable path, and get-configuration output.
  • Verified against real LLM endpoints. Worth knowing: free models are unreliable here. Two returned schema-valid JSON wrapped in a markdown fence, and one omitted a required field despite strict: true. The detector unwraps a whole-response fence, then validates exactly as strictly as before; anything that does not validate is logged truncated and exits non-zero rather than emitting a half-understood finding.

The Robot suite in tests/20__anomaly_detector.robot has not run yet — it needs CI. It uses a local stub returning a canned OpenAI-shaped completion, so there is no egress and no cost. Draft until that goes green.

Deviations from the spec

Five, each deliberate and documented in the plan: the script is import-safe with a main() so its pure functions are testable; the digest and baseline use Loki's HTTP instant-query API rather than logcli, whose metric output format is not a stable contract; scrub order puts the e-mail rule before the blob rule; the nominal path still emits a summary line; and unit tests use pytest rather than the Robot-based ns8-core script the spec cited.

Review

A whole-branch review found five substantive issues, all fixed in fbc8ab2: the e-mail scrub rule was redacting systemd unit names like agent@nethvoice2.service, stripping module names from exactly the lines that identify a crash loop; recall_findings kept the oldest findings instead of the newest; recalled titles bypassed line flattening; restore left a live API key on disk for a detector that could never run; and the Robot suite could pass without ever exercising the LLM path.

Design for an LLM-based journal anomaly detector shipped as an
extension of the loki module, alongside the existing syslog and
cloud-log-manager forwarders.

Hourly systemd timer runs a oneshot script that queries Loki for a
digest, a 7-day baseline and a capped set of prefiltered lines,
scrubs likely secrets, asks an OpenAI-compatible endpoint for a
schema-constrained verdict, and emits findings to the journal plus
an optional webhook.

The LLM API key lives in state/secrets.env, not in the environment
file, which is mirrored into Redis.

Assisted-by: Claude Code:claude-opus-5[1m]
Ten-task TDD plan derived from the approved design spec, with five
documented deviations and per-task verification against a live node.

Assisted-by: Claude Code:claude-opus-5[1m]
Aligned/structured logs pad the assignment separator (key : value),
which the keyword regexes required to immediately follow the keyword
with no whitespace. That left secret : value, token  =value, and
Bearer:value unredacted. Allow optional whitespace around the
separator without reopening the bare-whitespace false positive the
split was meant to fix; add regression coverage for the four reported
variants.
Verified google/gemma-4-26b-a4b-it:free honours response_format json_schema
with the plan's exact RESPONSE_SCHEMA; the same request without it returns
fenced markdown with an invented shape, so the field is load-bearing.

Also git-ignore the local credential scratch files so they cannot be
swept into a commit.

Assisted-by: Claude Code:claude-opus-5[1m]
Retry() defaulted raise_on_status to True, so urllib3 raised RetryError
once retries on 429/500/502/503/504 were exhausted instead of handing
back a Response -- ask_llm's status-code branches, and its truncated
diagnostic, never ran for the most common gateway failures. Set
raise_on_status=False so session.post() always returns the final
Response; post_webhook's raise_for_status() still fires on that
Response, so its contract is unaffected.

Add TestMakeSession.test_retry_config to lock in the Retry
configuration, since the existing ask_llm tests bypass urllib3's retry
machinery and could not have caught this.

Assisted-by: Claude Code:claude-sonnet-5
Endpoints that do not enforce response_format return schema-valid JSON
wrapped in a markdown fence. Strip a whole-response fence before parsing,
then validate exactly as strictly as before; prose around a fence still
fails. Also tell the model that window_assessment must agree with the
findings it reports, which two models otherwise contradicted.

Assisted-by: Claude Code:claude-opus-5[1m]
The action writes public settings with agent.set_env and keeps the API key
and webhook token in state/secrets.env at mode 0600, so neither reaches the
Redis-mirrored environment hash. Disabling clears both stores and stops the
timer. get-configuration reports detector state and key presence, never the
key itself.

Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
The stub returns a canned OpenAI-shaped completion so CI needs no network
egress and no API key. The suite proves the action stores the key at 0600
outside Redis, that the oneshot run lands in the journal under
SyslogIdentifier=<module>/anomaly-detector and reaches Loki with a
module_id label, and that disabling clears the stored key.

Assisted-by: Claude Code:claude-opus-5[1m]
…boundary

Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
Scrubbing no longer destroys the signal it exists to protect: systemd
templated unit names such as agent@nethvoice2.service were being redacted
as e-mail addresses, which stripped the module name from exactly the
PRIORITY=3 lines that identify a crash loop, and the base64 rule swallowed
long filesystem paths.

recall_findings kept the oldest findings rather than the newest: logcli
--forward returns entries chronologically, so a noisy run could fill the
200-entry budget with its own records and leave the detector with no memory
of what it had just reported.

Recalled titles now go through sanitize_line rather than scrub, so a title
containing a newline cannot break out of the RECENT_FINDINGS block. Log text
is attacker-influenced, and titles are derived from it.

Restore no longer leaves a live third-party API key on disk for a detector
that can never run: the public settings are restored alongside the secret
and the timer is re-enabled, or the orphaned key is discarded.

Tests: add the missing main() coverage (wiring, baseline scaling, nominal
early exit, flush-before-webhook ordering, dry-run key skip), scrub
regression cases, recall ordering, and a Robot case that deterministically
exercises the LLM path instead of passing on an empty window.

Assisted-by: Claude Code:claude-opus-5[1m]
Assisted-by: Claude Code:claude-opus-5[1m]
A busy cluster can exceed Loki's max_query_series limit on the
cluster-wide digest/baseline aggregation. query_metric now logs the
error and returns an empty rate map instead of crashing the run, so
the prefiltered-lines analysis still proceeds for that window.

Assisted-by: Claude Code:claude-sonnet-5
Some OpenAI-compatible models reject any non-default temperature
outright (400 unsupported_value), so a fixed temperature=0 breaks the
detector against them. Determinism was a nice-to-have, not a
requirement; omit the field instead of trying to special-case models.

Assisted-by: Claude Code:claude-sonnet-5
parse_findings now sorts by SEVERITIES order (critical..low) before
returning, so render_findings, the journal output and the webhook
payload all list the most urgent finding first regardless of the
order the model answered in.

Assisted-by: Claude Code:claude-sonnet-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant