feat(go): port FIM/pkgdb/rootcheck integrity engines and add go-sidecar dashboard consumer

Adds sidecar-only, --state-dir-gated FIM, package-DB-anchor, and rootcheck
engines to the Go migration sidecar, wired into agent.Sweep/Initialize
behind the existing baseline lifecycle. Adds a read-only, schema-checked
Python dashboard consumer (go_sidecar_state_dir, /api/go-sidecar/*, Sidecar
tab) that never starts, stops, or mutates the Go sidecar's state.

Also fixes drift found while reconciling this work: enodia_sentinel/web.py
had reinvented local schema constants instead of using the canonical
enodia_sentinel/schemas.py catalog (now registers enodia.go.sidecar.v1
there and reuses ALERT_SNAPSHOT_V1/INCIDENT_V1); docs/RULES.md had drifted
from what `rules docs` actually generates for SIDs 100069-100078 (ruleops.py
was missing metadata for four SIDs and had stale drill text for four more),
now back in sync with a regression test pinning them together.

Updates CLAUDE.md, README.md, go-agent/README.md, and docs/{ROADMAP,
GO_PORT_HANDOFF,SURICATA_ASSIMILATION,THREAT_MODEL,OPERATIONS,SCHEMAS,
COMMAND_REFERENCE}.md to reflect the landed work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQivSKBqQJsayz1xcYqWzZ
This commit is contained in:
Luna 2026-07-24 09:59:09 -07:00
parent 1d1dee7f6e
commit d835386381
No known key found for this signature in database
43 changed files with 3419 additions and 72 deletions

View file

@ -24,18 +24,23 @@ Primary functions:
## Current State Snapshot ## Current State Snapshot
As of 2026-07-04, the project is a local-first Linux IDS/IPS/EDR agent at As of 2026-07-24, the project is a local-first Linux IDS/IPS/EDR agent at
package version `0.7.0`. The branch has a working stdlib-only daemon, CLI, package version `0.7.0`. The branch has a working stdlib-only daemon, CLI,
HTTPS management console, incident model, response planner, rootcheck, posture HTTPS management console, incident model, response planner, rootcheck, posture
checks, file/package integrity checks, push notification backends, and optional checks, file/package integrity checks, push notification backends, and optional
bcc/eBPF telemetry. bcc/eBPF telemetry.
An experimental, non-installing Phase 1 Go sidecar now lives under `go-agent/`. An experimental, non-installing Go sidecar now lives under `go-agent/`. It is
It is stdlib-only, emits `enodia.event.v1` JSONL, captures an injectable process stdlib-only, emits `enodia.event.v1` JSONL, and ports all current Python poll
file-descriptor, and socket view, and currently ports `reverse_shell`, detectors, the exec/syscall/typed-host rule engines, opt-in `cilium/ebpf` exec
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`, and syscall sources, hot-path plus asynchronous enrichment, and Python-schema-
`stealth_network`, and `egress`. Python remains the production agent and parity compatible incidents/snapshots/hash-chain. A `--state-dir`-gated set of
oracle; do not describe the Go path as a replacement yet. sidecar-only integrity engines (FIM, package-DB anchor, rootcheck) now also
runs asynchronously off the sweep loop. Python's dashboard has an opt-in,
explicitly read-only `/api/go-sidecar/*` consumer (`go_sidecar_state_dir`, the
Sidecar tab) that displays this evidence without starting, stopping, or
otherwise controlling the Go process. Python remains the production agent and
parity oracle; do not describe the Go path as a replacement yet.
Implemented detection coverage: Implemented detection coverage:
@ -63,9 +68,13 @@ Implemented investigation/response state:
- The dashboard is HTTPS-only, token-protected off loopback, and read-only. It - The dashboard is HTTPS-only, token-protected off loopback, and read-only. It
shows status, incidents, timelines, alerts, posture findings, shows status, incidents, timelines, alerts, posture findings,
integrity/watchdog state, event-rule metadata, event tail, dry-run response integrity/watchdog state, event-rule metadata, event tail, dry-run response
plans, and a local Settings menu with persistent themes. Web tests cover the plans, and a local Settings menu with persistent themes. An opt-in Sidecar
theme registry, integrity console wiring, and core text/status contrast tab additionally displays the isolated Go migration-sidecar's retained
checks. heartbeat, alerts, incidents, and event log when `go_sidecar_state_dir` is
configured; this consumer is schema-checked and read-only, and never starts,
stops, or controls the Go process. Web tests cover the theme registry,
integrity console wiring, sidecar consumer isolation, and core text/status
contrast checks.
- `respond plan <incident-id>` builds read-only containment/recovery plans, - `respond plan <incident-id>` builds read-only containment/recovery plans,
persists CLI-generated plans under `response-plans/`, and appends persists CLI-generated plans under `response-plans/`, and appends
`response-audit.log`. Dashboard/API plan previews do not write artifacts. `response-audit.log`. Dashboard/API plan previews do not write artifacts.
@ -164,9 +173,11 @@ Start with:
GUI-free presentation model over `tui.collect_model`; only `gui/app.py` GUI-free presentation model over `tui.collect_model`; only `gui/app.py`
(tkinter) and `gui/qt_app.py` (PySide6, `[qt]` extra) import GUI libraries, (tkinter) and `gui/qt_app.py` (PySide6, `[qt]` extra) import GUI libraries,
enforced by import-guard tests. enforced by import-guard tests.
- `go-agent/` is the isolated Phase 1 sidecar with Go config, process-state, - `go-agent/` is the isolated sidecar with Go config, process-state, detector,
detector, schema, and sweep-loop packages. It writes JSONL to stdout and schema, and sweep-loop packages, plus `fim/`, `pkgdb/`, and `rootcheck/`
must not share or mutate Python daemon state during parity development. sidecar-only integrity engines. It writes JSONL to stdout (or a private
retained tree under `--state-dir`) and must not share or mutate Python
daemon state during parity development.
## Current Threat Mapping ## Current Threat Mapping

View file

@ -430,6 +430,10 @@ sudo systemctl enable --now enodia-sentinel-web
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`, API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/integrity`, `/api/rules`, `/api/alerts`, `/api/posture`, `/api/integrity`, `/api/rules`, `/api/alerts`,
`/api/alerts/<id>`, `/api/events`. `/api/alerts/<id>`, `/api/events`.
- **Optional Go migration evidence**: set `go_sidecar_state_dir` to the
sidecar's separate state directory to expose retained heartbeat, snapshots,
incidents, and JSONL through `/api/go-sidecar/*`. These endpoints are
read-only and never replace Python dashboard data or control the sidecar.
- **Operator settings**: the self-contained dashboard includes a Settings menu - **Operator settings**: the self-contained dashboard includes a Settings menu
with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula, with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard

View file

@ -214,3 +214,6 @@ web_token = "" # bearer token; auto-generated + saved if empty on a
# point these at your own local/private CA certificate. # point these at your own local/private CA certificate.
web_tls_cert = "" web_tls_cert = ""
web_tls_key = "" web_tls_key = ""
# Optional: expose a separately retained Go validation-sidecar state tree via
# read-only /api/go-sidecar/* endpoints. Never point this at the Python log_dir.
go_sidecar_state_dir = ""

View file

@ -471,6 +471,12 @@ HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured,
Sentinel creates a self-signed pair under `log_dir`; add a browser exception for Sentinel creates a self-signed pair under `log_dir`; add a browser exception for
that certificate or replace it with a private/local CA certificate. that certificate or replace it with a private/local CA certificate.
When `go_sidecar_state_dir` is explicitly configured, the same authenticated
read-only server also exposes the isolated migration-sidecar evidence at
`/api/go-sidecar/status`, `/api/go-sidecar/alerts`, `/api/go-sidecar/events`,
and `/api/go-sidecar/incidents`. These paths never start, stop, or alter the Go
sidecar, and do not replace the Python daemon's normal API data.
The console exposes incidents, alert snapshots, posture findings, The console exposes incidents, alert snapshots, posture findings,
integrity/watchdog state, event-rule metadata, event tail, and dry-run response integrity/watchdog state, event-rule metadata, event tail, and dry-run response
plans. It remains read-only; response commands are displayed for review but not plans. It remains read-only; response commands are displayed for review but not

View file

@ -1,9 +1,15 @@
# Go Port Handoff # Go Port Handoff
Saved: 2026-07-22T04:08:40-07:00 Saved: 2026-07-22T04:08:40-07:00
Updated: 2026-07-24T04:45:55-07:00
Branch: `main` Branch: `main`
Base commit: `a25c73a` (`feat(go): report integrity anchor context`) Base commit: `a25c73a` (`feat(go): report integrity anchor context`); the FIM/
Status: current Go-port state saved in this checkpoint; separate GUI work remains uncommitted package-DB/rootcheck engines and the Python go-sidecar dashboard consumer
described below landed on top of that commit and remain uncommitted.
Status: items 1 and 2 of "Resume here" are now implemented and green; see the
updated sections below. Desktop GUI work referenced by the original checkpoint
has since been committed (`457c760`, `1d1dee7`, and related commits) and is no
longer part of this checkout's uncommitted state.
## Worktree warning ## Worktree warning
@ -14,10 +20,12 @@ continuations. Do not reset, clean, or broadly restage it.
assurance, asynchronous-enrichment, incident-reader, package-ownership, and assurance, asynchronous-enrichment, incident-reader, package-ownership, and
integrity-anchor tranches are signed commits `f85c2e8`, `538d1d9`, integrity-anchor tranches are signed commits `f85c2e8`, `538d1d9`,
`fd2bbba`, `1ab4add`, `eff31b3`, `6355fb4`, `88d7a6e`, and `a25c73a`. `fd2bbba`, `1ab4add`, `eff31b3`, `6355fb4`, `88d7a6e`, and `a25c73a`.
- At this checkpoint, `git status --short` has 11 entries with untracked - On top of that, uncommitted work now adds `go-agent/internal/{fim,pkgdb,
directories collapsed. rootcheck}/`, wires them into `agent.Sweep`/`Initialize` behind
- The Python GUI files and tests are separate pre-existing work. Preserve them `--state-dir`, and adds Python's read-only `/api/go-sidecar/*` consumer
while continuing the Go port. (`enodia_sentinel/web.py`, `schemas.GO_SIDECAR_V1`) plus a Sidecar dashboard
tab. `git status --short` currently shows this as modified files across both
trees plus the three new untracked Go package directories.
- `build/` is ignored and contains only local build output. - `build/` is ignored and contains only local build output.
## Implemented Go surface ## Implemented Go surface
@ -106,15 +114,46 @@ static binary.
- The asynchronous worker now resolves pacman/dpkg/rpm package-owner strings - The asynchronous worker now resolves pacman/dpkg/rpm package-owner strings
with a two-second bound and cache; ownership lookup never holds a detector with a two-second bound and cache; ownership lookup never holds a detector
lock or blocks alert capture. lock or blocks alert capture.
- The worker now reports FIM-baseline, package-DB-anchor, and Go hash-chain - The worker reports FIM-baseline, package-DB-anchor, and Go hash-chain
presence/age. It explicitly marks package verification and rootcheck disabled presence/age, plus the configured `pacman -Qkk` and rootcheck cadences now
until their actual engines are ported. that those engines are ported (see below). This remains metadata only:
- Richer integrity engines and notification fan-out are not yet ported. recording a snapshot never starts integrity work itself.
- Notification fan-out is not yet ported.
- `--incidents-list` and `--incident-show <id>` now read the sidecar's isolated - `--incidents-list` and `--incident-show <id>` now read the sidecar's isolated
state without starting the agent. The latter returns `enodia.incident.view.v1` state without starting the agent. The latter returns `enodia.incident.view.v1`
with the incident, available snapshots, and time-ordered timeline. with the incident, available snapshots, and time-ordered timeline.
- No live system service was installed or enabled during development. - No live system service was installed or enabled during development.
### FIM, package-DB anchor, and rootcheck engines (new since the base commit)
- `go-agent/internal/fim` is a sidecar-only engine, gated on `--state-dir`,
that persists a SHA-256 security-path baseline as `fim-baseline.json` and
performs non-overlapping background comparisons on `fim_scan_interval`,
emitting the Python-identical SIDs `100017`-`100019`. `fim_pkg_verify = true`
opts into a bounded `pacman -Qkk` verification pass emitting SID `100020`.
- `go-agent/internal/pkgdb` fingerprints pacman's local checksum database on
`pkgdb_interval` and alerts (SID `100021`) only on changes lacking a
corresponding transaction-log record; it never refreshes an unexplained
changed anchor.
- `go-agent/internal/rootcheck` bounds PID cross-view probing with
`rootcheck_pid_cap`, runs one background comparison per
`rootcheck_interval`, and reproduces the Python rootcheck SIDs (hidden
process/module/port families, promiscuous mode, module/kernel taint) from
procfs, sysfs, `ps`, and socket views.
- All three run asynchronously off the sweep loop (`agent.Sweep` calls
`MaybeStart` before detector evaluation and `Drain` after, so a slow hash
tree or `pacman` invocation never delays a sweep) and return alerts through
the existing cooldown/snapshot/incident pipeline rather than emitting
directly.
- `enodia_sentinel/web.py` adds a read-only, schema-checked consumer for this
evidence: `go_sidecar_state_dir` (config key) exposes `/api/go-sidecar/
{status,alerts,incidents,events}` and a dashboard Sidecar tab. It never
starts, stops, or writes into the Go sidecar's state directory, and a
corrupted retained event log fails the request closed rather than showing a
partial stream. The new `enodia.go.sidecar.v1` schema lives in
`enodia_sentinel/schemas.py` and is pinned by
`tests/test_schema_contracts.py::test_go_sidecar_v1_contract`.
## Last green verification ## Last green verification
Run from the repository root: Run from the repository root:
@ -132,7 +171,7 @@ make check-go-service
git diff --check git diff --check
``` ```
Results at save time: Results at original save time (`a25c73a`):
- Python: 377 tests passed. - Python: 377 tests passed.
- Go unit and race suites: all packages passed through `a25c73a`. - Go unit and race suites: all packages passed through `a25c73a`.
@ -152,19 +191,44 @@ Results at save time:
startup/quiet-period retention, process context, and Python dashboard-reader startup/quiet-period retention, process context, and Python dashboard-reader
compatibility. compatibility.
Results re-verified 2026-07-24 with the FIM/pkgdb/rootcheck engines and
go-sidecar dashboard consumer added on top:
- Python: 385 tests passed (`unittest discover`).
- Go unit and `-race` suites: all packages passed, including the new `fim`,
`pkgdb`, and `rootcheck` packages.
- `go vet` and `go mod verify`: passed.
- Parity counts are unchanged from the original checkpoint (22/5/7/10/22/3/2),
as expected: the new engines are integrity checks outside the poll/exec/
syscall/host-rule parity fixture.
- `systemd-analyze verify` and the three Go sidecar packaging/isolation tests:
passed.
- `git diff --check`: passed (no whitespace errors).
- Static cross-arch builds were not re-run this pass.
The Python suite prints three expected CLI error lines from negative tests and The Python suite prints three expected CLI error lines from negative tests and
one existing `ResourceWarning` for an implicitly cleaned-up HTTP 401 object; one existing `ResourceWarning` for an implicitly cleaned-up HTTP 401 object;
the suite still exits successfully. the suite still exits successfully.
## Resume here ## Resume here
1. Port the actual FIM/package-verification/rootcheck engines behind explicit, 1. ~~Port the actual FIM/package-verification/rootcheck engines behind
bounded acceptance gates; do not treat enrichment anchor status as detection explicit, bounded acceptance gates~~ — done: `go-agent/internal/{fim,pkgdb,
parity. rootcheck}`, gated on `--state-dir`, tested, and wired into `agent.Sweep`.
2. Add a read-only management API consumer for isolated Go snapshot/event state 2. ~~Add a read-only management API consumer for isolated Go snapshot/event
while keeping Python authoritative. state while keeping Python authoritative~~ — done: `/api/go-sidecar/*` in
`enodia_sentinel/web.py` plus the dashboard Sidecar tab, schema-checked and
never controlling the Go process.
3. Continue broader Phase 3 rule metadata/state parity before considering any 3. Continue broader Phase 3 rule metadata/state parity before considering any
default-service or package cutover. default-service or package cutover. This is the remaining item from the
original checkpoint.
4. Notification fan-out for the Go sidecar (item noted above) is still
unported; the FIM/pkgdb/rootcheck engines currently return alerts only
through the shared JSONL/snapshot/incident pipeline, not push backends.
5. Consider whether `04-integrity.md` and `05-rootcheck.md` in
`docs/behavior/` (currently "planned") should be written now that these
engines exist on both sides, so future Go-side changes have the same
reviewable behavioral contract as the poll detectors and event rules do.
Keep the validation sidecar opt-in, preserve its separate state tree, and keep Keep the validation sidecar opt-in, preserve its separate state tree, and keep
new attack/event primitives disconnected from destructive response paths. new attack/event primitives disconnected from destructive response paths.

View file

@ -75,7 +75,10 @@ Expected healthy output:
The HTTPS dashboard shows the same posture findings under the Posture tab and The HTTPS dashboard shows the same posture findings under the Posture tab and
summarizes FIM/package anchors plus heartbeat freshness under the Integrity tab summarizes FIM/package anchors plus heartbeat freshness under the Integrity tab
for quick remote review. for quick remote review. If the optional `go_sidecar_state_dir` setting points
at an isolated Go migration-sidecar state directory, a Sidecar tab also shows
that sidecar's own retained heartbeat, alert snapshots, incidents, and event
log, read-only and clearly separate from Python's production data.
For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over For SSH/tmux operators, `enodia-sentinel tui` provides a terminal dashboard over
the same local read-only management state: status, recent alerts, incidents, the same local read-only management state: status, recent alerts, incidents,

View file

@ -300,9 +300,23 @@ Exit criteria:
now provide truthful retained counts and basic process context. A private, now provide truthful retained counts and basic process context. A private,
atomic `enodia.incident.v1` index now applies the shared lineage-first/time- atomic `enodia.incident.v1` index now applies the shared lineage-first/time-
window grouping, assigns real snapshot `incident_id` values, and persists window grouping, assigns real snapshot `incident_id` values, and persists
correlation SID `100080`. The Python service remains the production default correlation SID `100080`. Hot-path enrichment adds bounded process/parent
until rich enrichment/assurance, management-console consumers, and broader context, lineage, remote-IP classification, and candidate paths synchronously,
subsystem parity land. while a 16-job asynchronous worker adds streamed executable hashes, file
metadata, and package-owner strings under a query bound. The sidecar now also
has sidecar-only, `--state-dir`-gated FIM (`fim-baseline.json`, SIDs
`100017``100020`, optional `pacman -Qkk` verification), package-DB anchor
(`pkgdb_verify`, SID `100021`), and rootcheck (procfs/sysfs/`ps`/socket/
module-taint/kernel-taint/promiscuous-mode cross-view checks) engines that
run asynchronously off the sweep loop and return alerts through the same
cooldown/snapshot/incident pipeline. Python's dashboard gained a first,
explicitly read-only management-console consumer for this migration
evidence: an opt-in `go_sidecar_state_dir` setting exposes `/api/go-sidecar/*`
endpoints and a Sidecar dashboard tab that display the Go sidecar's retained
heartbeat, alert snapshots, incidents, and event log without starting,
stopping, or otherwise controlling the Go process. The Python service remains
the production default until notification fan-out and broader subsystem
parity land.
## Backlog ## Backlog

View file

@ -139,6 +139,33 @@ Required fields:
| `hash_chain` | object | Local append-only hash-chain summary for snapshots and event logs: path, exists flag, record count, and last chain hash. | | `hash_chain` | object | Local append-only hash-chain summary for snapshots and event logs: path, exists flag, record count, and last chain hash. |
| `read_only` | boolean | Always true for this dashboard API. | | `read_only` | boolean | Always true for this dashboard API. |
## `enodia.go.sidecar.v1`
Status object returned by `/api/go-sidecar/status`, describing the optional,
separately configured Go migration-sidecar evidence tree (`go_sidecar_state_dir`).
This is a read-only view of another process's retained files: the Python web
process never starts, stops, or probes the Go sidecar, and this schema does not
appear anywhere in the production Python daemon's own state.
Required fields:
| Field | Type | Meaning |
|---|---|---|
| `schema` | string | `enodia.go.sidecar.v1`. |
| `read_only` | boolean | Always true. |
| `configured` | boolean | Whether `go_sidecar_state_dir` is set. |
| `state_dir` | string | The configured path, or `""` if unset. |
| `available` | boolean | Whether the configured directory currently exists. |
| `heartbeat` | object | `{"status": "not-configured"\|"missing"\|"fresh"\|"stale", ...}`; `fresh`/`stale` reports also include `timestamp`, `age_seconds`, and `max_age_seconds`. |
The related `/api/go-sidecar/alerts`, `/api/go-sidecar/incidents`, and
`/api/go-sidecar/events` endpoints replay the Go sidecar's own
`enodia.alert.snapshot.v1`, `enodia.incident.v1`, and `enodia.event.v1` records
after schema-checking each one; malformed or unrecognized files are dropped
(alerts/incidents) or reported as an explicit error rather than partially
displayed (events), so a damaged retained file cannot be misread as a clean
evidence stream.
## `enodia.hash_chain.v1` ## `enodia.hash_chain.v1`
JSONL record appended to `hash-chain.jsonl` when Sentinel captures forensic JSONL record appended to `hash-chain.jsonl` when Sentinel captures forensic

View file

@ -251,9 +251,13 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs.
transports. Bounded `enodia.alert.snapshot.v1` JSON/text pairs now retain transports. Bounded `enodia.alert.snapshot.v1` JSON/text pairs now retain
alerts with basic process context and feed truthful status counts. An atomic alerts with basic process context and feed truthful status counts. An atomic
`enodia.incident.v1` index supplies lineage/time grouping, snapshot IDs, and `enodia.incident.v1` index supplies lineage/time grouping, snapshot IDs, and
correlation evidence. Rich enrichment/assurance, management-consumer correlation evidence. Hot-path plus asynchronous enrichment (hashes, file
integration, and broader parity must land before the bcc/Python runtime metadata, package ownership) now populate snapshots, and sidecar-only,
dependency can be dropped. `--state-dir`-gated FIM, package-DB-anchor, and rootcheck integrity engines
now run asynchronously off the sweep loop. Python's dashboard has a first
read-only management-console consumer (`go_sidecar_state_dir`,
`/api/go-sidecar/*`) over this isolated evidence. Broader Phase 3 rule-engine
parity must land before the bcc/Python runtime dependency can be dropped.
- **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`, - **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`,
statebits (flowbits), thresholding, suppression, and — if rule count warrants statebits (flowbits), thresholding, suppression, and — if rule count warrants
— the MPM prefilter. `rules list/show/test/docs` parity. — the MPM prefilter. `rules list/show/test/docs` parity.

View file

@ -55,6 +55,26 @@ The default service is hardened and read-mostly. Enabling the bcc eBPF monitor
requires additional capabilities and memory permissions. That tradeoff is requires additional capabilities and memory permissions. That tradeoff is
explicit: stronger event visibility for a wider runtime permission set. explicit: stronger event visibility for a wider runtime permission set.
### Go Migration-Sidecar Evidence Is Isolated and Read-Only
The experimental `go-agent/` sidecar (see `docs/ROADMAP.md`'s Active Migration
Track) is a separate process with its own state directory, baselines, FIM/
package-DB/rootcheck engines, and hash-chain. It never shares or mutates the
Python daemon's `log_dir`, baselines, or anchors, and it is not the production
agent.
When an operator opts in with `go_sidecar_state_dir`, the Python dashboard adds
a read-only consumer (`/api/go-sidecar/*`, the Sidecar tab) over that directory.
It treats the Go sidecar's retained files as untrusted input from another
process rather than trusted application state: every alert snapshot and
incident record is schema-checked before display, a corrupted retained event
log fails the request closed instead of showing a partial stream, and the web
process never starts, stops, signals, or writes into the Go sidecar's state
directory. Compromising this consumer therefore requires tampering with the Go
sidecar's own retained files, which is the same class of local-tampering
problem `enodia.hash_chain.v1` and the sidecar's own FIM self-watch already
cover.
## Detection Assumptions ## Detection Assumptions
Sentinel assumes: Sentinel assumes:

View file

@ -173,6 +173,11 @@ class Config:
web_token: str = "" # bearer token; auto-generated if empty + non-local web_token: str = "" # bearer token; auto-generated if empty + non-local
web_tls_cert: str = "" # "" = auto-generate self-signed cert under log_dir web_tls_cert: str = "" # "" = auto-generate self-signed cert under log_dir
web_tls_key: str = "" # "" = auto-generate private key under log_dir web_tls_key: str = "" # "" = auto-generate private key under log_dir
# Optional, read-only Go validation-sidecar evidence tree. This must stay
# separate from log_dir so the Python daemon remains the production source
# of truth and the dashboard never mistakes migration evidence for Python
# alert state.
go_sidecar_state_dir: str = ""
# paths # paths
log_dir: Path = Path("/var/log/enodia-sentinel") log_dir: Path = Path("/var/log/enodia-sentinel")

View file

@ -58,6 +58,11 @@ def render_markdown(cfg: Config) -> str:
"Use `enodia-sentinel rules list/show/test` to inspect rules and validate " "Use `enodia-sentinel rules list/show/test` to inspect rules and validate "
"event fixtures locally.", "event fixtures locally.",
"", "",
"Built-in event-rule fixtures live in `tests/fixtures/sids/` and can be replayed",
"with `enodia-sentinel rules test tests/fixtures/sids/<sid>-<slug>.json`.",
"Non-event built-in SIDs are covered by safe offline drills in `tests/sid_drills.py`;",
"`python3 -m unittest tests.test_sid_coverage -v` verifies the full registry.",
"",
] ]
for rule in list_rules(cfg): for rule in list_rules(cfg):
doc = _RULE_DOCS.get(rule["sid"], {}) doc = _RULE_DOCS.get(rule["sid"], {})
@ -284,33 +289,61 @@ _RULE_DOCS: dict[int, dict[str, Any]] = {
], ],
"drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.", "drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port.",
}, },
100069: {
"false_positives": [
"Installer, configuration-management, or recovery scripts that legitimately write persistence paths from an interpreter.",
"Administrative SSH-key or service-unit maintenance performed by a reviewed script.",
],
"drill": "Use `rules test` with `tests/fixtures/sids/100069-interpreter-persistence-write.json`.",
},
100070: {
"false_positives": [
"Package post-install scripts or configuration-management agents adjusting mode/owner on system service, sudoers, cron, or SSH key files.",
"Manual recovery work that repairs permissions after a known-good change.",
],
"drill": "Use `rules test` with `tests/fixtures/sids/100070-persistence-permission-change.json`.",
},
100071: {
"false_positives": [
"Reviewed administrative wrappers that intentionally run interpreters across a root UID transition.",
"Privileged installer or service-management scripts during maintenance windows.",
],
"drill": "Use `rules test` with `tests/fixtures/sids/100071-interpreter-setuid-root.json`.",
},
100072: {
"false_positives": [
"Reviewed administrative wrappers that intentionally run interpreters across a root GID transition.",
"Privileged installer or service-management scripts during maintenance windows.",
],
"drill": "Use `rules test` with `tests/fixtures/sids/100072-interpreter-setgid-root.json`.",
},
100073: { 100073: {
"false_positives": [ "false_positives": [
"Developer or diagnostic scripts binding high local ports intentionally.", "Developer or diagnostic scripts binding high local ports intentionally.",
"Short-lived local service wrappers that bind before handing sockets to a supervised process.", "Short-lived local service wrappers that bind before handing sockets to a supervised process.",
], ],
"drill": "Use `rules test` with a `bind` event whose `comm` is an interpreter and whose `local_port` is not a common service port.", "drill": "Use `rules test` with `tests/fixtures/sids/100073-interpreter-unusual-bind.json`.",
}, },
100076: { 100076: {
"false_positives": [ "false_positives": [
"Developer servers or test harnesses accepting inbound public traffic from an interpreter.", "Developer servers or test harnesses accepting inbound public traffic from an interpreter.",
"Administrative troubleshooting with temporary netcat/socat listeners.", "Administrative troubleshooting with temporary netcat/socat listeners.",
], ],
"drill": "Use `rules test` with an `accept` event whose `comm` is an interpreter, public `peer_ip`, and unusual `local_port`.", "drill": "Use `rules test` with `tests/fixtures/sids/100076-interpreter-unusual-accept.json`.",
}, },
100077: { 100077: {
"false_positives": [ "false_positives": [
"Privileged maintenance scripts that intentionally adjust capabilities during controlled administration.", "Privileged maintenance scripts that intentionally adjust capabilities during controlled administration.",
"Container or network lab setup scripts using interpreters to configure namespaces or packet capture.", "Container or network lab setup scripts using interpreters to configure namespaces or packet capture.",
], ],
"drill": "Use `rules test` with a `capset` event containing a sensitive capability such as `CAP_SYS_ADMIN`.", "drill": "Use `rules test` with `tests/fixtures/sids/100077-interpreter-capset-sensitive.json`.",
}, },
100078: { 100078: {
"false_positives": [ "false_positives": [
"Kernel development labs loading locally built modules from temporary build directories.", "Kernel development labs loading locally built modules from temporary build directories.",
"Driver troubleshooting sessions that intentionally test an unsigned module from a writable path.", "Driver troubleshooting sessions that intentionally test an unsigned module from a writable path.",
], ],
"drill": "Use `rules test` with a `module_load` event whose `path` is under `/tmp`, `/var/tmp`, `/dev/shm`, or `/run/user`.", "drill": "Use `rules test` with `tests/fixtures/sids/100078-module-load-writable-path.json`.",
}, },
} }

View file

@ -19,3 +19,4 @@ RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1" RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
RECONCILE_V1 = "enodia.reconcile.v1" RECONCILE_V1 = "enodia.reconcile.v1"
EVENT_V1 = "enodia.event.v1" EVENT_V1 = "enodia.event.v1"
GO_SIDECAR_V1 = "enodia.go.sidecar.v1"

View file

@ -313,6 +313,7 @@
<button data-tab="alerts">Alerts</button> <button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button> <button data-tab="posture">Posture</button>
<button data-tab="integrity">Integrity</button> <button data-tab="integrity">Integrity</button>
<button data-tab="sidecar">Sidecar</button>
<button data-tab="rules">Rules</button> <button data-tab="rules">Rules</button>
<button data-tab="events">Events</button> <button data-tab="events">Events</button>
</div> </div>
@ -320,6 +321,7 @@
<section id="alertsTab" hidden></section> <section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section> <section id="postureTab" hidden></section>
<section id="integrityTab" hidden></section> <section id="integrityTab" hidden></section>
<section id="sidecarTab" hidden></section>
<section id="rulesTab" hidden></section> <section id="rulesTab" hidden></section>
<section id="eventsTab" hidden></section> <section id="eventsTab" hidden></section>
</main> </main>
@ -333,7 +335,7 @@
<script> <script>
const TOKEN = new URLSearchParams(location.search).get("token") || ""; const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {}; const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const TABS = new Set(["incident","alerts","posture","integrity","rules","events"]); const TABS = new Set(["incident","alerts","posture","integrity","sidecar","rules","events"]);
const THEME_OPTIONS = [ const THEME_OPTIONS = [
["console","Console"],["paper","Paper"],["contrast","Contrast"], ["console","Console"],["paper","Paper"],["contrast","Contrast"],
["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"], ["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"],
@ -346,7 +348,7 @@ function tabFromHash(){
const name = location.hash.replace(/^#/,""); const name = location.hash.replace(/^#/,"");
return TABS.has(name) ? name : "incident"; return TABS.has(name) ? name : "incident";
} }
const state = {incidents:[], alerts:[], posture:null, integrity:null, rules:null, selected:null, incident:null, plan:null, tab:tabFromHash()}; const state = {incidents:[], alerts:[], posture:null, integrity:null, rules:null, sidecar:null, selected:null, incident:null, plan:null, tab:tabFromHash()};
async function api(path){ async function api(path){
const r = await fetch(path,{headers:HDR}); const r = await fetch(path,{headers:HDR});
@ -398,10 +400,10 @@ function setSettings(open){
async function refresh(){ async function refresh(){
try{ try{
const [status, incidents, alerts, posture, integrity, rules, events] = await Promise.all([ const [status, incidents, alerts, posture, integrity, rules, events, sidecar] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/integrity"), api("/api/rules"), api("/api/events") api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/integrity"), api("/api/rules"), api("/api/events"), loadSidecar()
]); ]);
state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.integrity = integrity; state.rules = rules; state.events = events.events || []; state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.integrity = integrity; state.rules = rules; state.events = events.events || []; state.sidecar = sidecar;
renderStatus(status); renderLists(); renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id); if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false); else if(state.selected) await openIncident(state.selected, false);
@ -409,6 +411,20 @@ async function refresh(){
}catch(e){ showError(e.message); } }catch(e){ showError(e.message); }
} }
async function loadSidecar(){
// The migration evidence tree is optional. A sidecar read failure must not
// hide the Python daemon's authoritative console data or break refresh. Keep
// its requests separate so a future sidecar-only endpoint cannot accidentally
// become a dependency of the production management surface.
try{
const [status, alerts, incidents, events] = await Promise.all([
api("/api/go-sidecar/status"), api("/api/go-sidecar/alerts"),
api("/api/go-sidecar/incidents"), api("/api/go-sidecar/events")
]);
return {status, alerts, incidents, events, error:null};
}catch(e){ return {status:null, alerts:[], incidents:[], events:{events:[]}, error:e.message}; }
}
function renderStatus(st){ function renderStatus(st){
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : ""); document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat"; let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
@ -473,6 +489,7 @@ async function openAlert(name){
box.hidden = false; document.getElementById("incidentTab").hidden = true; box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true; document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
document.getElementById("integrityTab").hidden = true; document.getElementById("integrityTab").hidden = true;
document.getElementById("sidecarTab").hidden = true;
document.getElementById("eventsTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>'; box.innerHTML='<div class="empty">loading snapshot</div>';
try{ try{
@ -524,11 +541,13 @@ function renderTab(){
document.getElementById("alertsTab").hidden = state.tab !== "alerts"; document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture"; document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("integrityTab").hidden = state.tab !== "integrity"; document.getElementById("integrityTab").hidden = state.tab !== "integrity";
document.getElementById("sidecarTab").hidden = state.tab !== "sidecar";
document.getElementById("rulesTab").hidden = state.tab !== "rules"; document.getElementById("rulesTab").hidden = state.tab !== "rules";
document.getElementById("eventsTab").hidden = state.tab !== "events"; document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident(); if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture(); if(state.tab === "posture") renderPosture();
if(state.tab === "integrity") renderIntegrity(); if(state.tab === "integrity") renderIntegrity();
if(state.tab === "sidecar") renderSidecar();
if(state.tab === "rules") renderRules(); if(state.tab === "rules") renderRules();
if(state.tab === "events") renderEvents(); if(state.tab === "events") renderEvents();
} }
@ -678,6 +697,76 @@ function renderIntegrity(){
box.append(grid); box.append(grid);
} }
function renderSidecar(){
// This is intentionally a read-only ledger, not a second incident workflow.
// Operators should not mistake validation-sidecar evidence for Python's
// production response plans, acknowledgements, or remediation authority.
const box = document.getElementById("sidecarTab");
const source = state.sidecar || {status:null, alerts:[], incidents:[], events:{events:[]}};
box.innerHTML = "";
if(source.error){
box.append(el("div","err","Go sidecar evidence is unavailable: "+source.error));
return;
}
const status = source.status || {};
if(!status.configured){
const empty = el("div","card");
empty.append(el("h2",null,"Go sidecar evidence"));
empty.append(el("div","fine","No migration evidence tree is configured. Set go_sidecar_state_dir to an isolated Go sidecar state directory; this console will only read its retained heartbeat, snapshots, incidents, and event log."));
box.append(empty);
return;
}
const summary = el("div","card");
summary.append(el("h2",null,"Go sidecar evidence"));
const heartbeat = status.heartbeat || {};
summary.append(tags([
"read-only", "state "+(status.available ? "available" : "unavailable"),
"heartbeat "+(heartbeat.status || "unknown"),
"alerts "+(source.alerts||[]).length, "incidents "+(source.incidents||[]).length
]));
summary.append(el("div","fine","Migration evidence is shown separately. Python alert, incident, and response-plan data remain authoritative."));
box.append(summary);
const grid = el("div","integrity-grid");
const health = el("article","state-card "+(heartbeat.status === "fresh" ? "ok" : "review"));
health.append(el("h3",null,"Heartbeat"));
addStateLine(health,"status",heartbeat.status || "unknown");
addStateLine(health,"age",humanAge(heartbeat.age_seconds));
addStateLine(health,"max age",humanAge(heartbeat.max_age_seconds));
addStateLine(health,"state dir",status.state_dir);
grid.append(health);
const evidence = el("article","state-card ok");
evidence.append(el("h3",null,"Retained evidence"));
(source.alerts||[]).slice(0,6).forEach(alert=>{
const row = el("div","path-row");
row.append(sev(alert.severity));
row.append(el("span",null,fmt(alert.time)+" — "+(alert.signatures||[]).join(", ")));
evidence.append(row);
});
if(!(source.alerts||[]).length) evidence.append(el("div","fine","No retained sidecar alert snapshots."));
grid.append(evidence);
const incidents = el("article","state-card ok");
incidents.append(el("h3",null,"Incident index"));
(source.incidents||[]).slice(0,6).forEach(incident=>{
const row = el("div","path-row");
row.append(sev(incident.severity));
row.append(el("span",null,incident.id+" — "+(incident.signatures||[]).join(", ")));
incidents.append(row);
});
if(!(source.incidents||[]).length) incidents.append(el("div","fine","No retained sidecar incidents."));
grid.append(incidents);
box.append(grid);
const events = el("div","card");
events.append(el("h3",null,"Retained sidecar events"));
const pre = el("pre");
const rows = source.events?.events || [];
pre.textContent = rows.length ? rows.map(row=>JSON.stringify(row)).join("\n") : "No retained sidecar events.";
events.append(pre);
box.append(events);
}
function renderRules(){ function renderRules(){
const box = document.getElementById("rulesTab"); const box = document.getElementById("rulesTab");
const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}}; const catalog = state.rules || {rules:[], by_event:{}, by_severity:{}, by_origin:{}};
@ -726,6 +815,7 @@ function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("integrityTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("integrityTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("sidecarTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("rulesTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("rulesTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
} }

View file

@ -175,6 +175,199 @@ def tail_events(cfg: Config, limit: int = 100) -> list[str]:
return lines[-limit:] return lines[-limit:]
# --- isolated Go sidecar evidence (read-only migration consumer) ----------
def _go_sidecar_dir(cfg: Config) -> Path | None:
# This is an opt-in evidence boundary, not an alternate runtime root. The
# web process only derives paths beneath it and never creates or mutates it.
raw = cfg.go_sidecar_state_dir.strip()
return Path(raw) if raw else None
def go_sidecar_status(cfg: Config, now: float | None = None) -> dict:
"""Describe the separately configured Go evidence tree without touching it.
The Python daemon remains authoritative. This endpoint deliberately exposes
only whether the optional migration sidecar is configured and whether its
retained heartbeat is fresh; it never starts, stops, or probes the service.
"""
directory = _go_sidecar_dir(cfg)
report = {
"schema": schemas.GO_SIDECAR_V1,
"read_only": True,
"configured": directory is not None,
"state_dir": str(directory) if directory else "",
"available": bool(directory and directory.is_dir()),
"heartbeat": {"status": "not-configured"},
}
if directory is None:
return report
heartbeat = directory / "heartbeat"
try:
timestamp = int(heartbeat.read_text().strip())
except (OSError, ValueError):
report["heartbeat"] = {"status": "missing"}
return report
ts = time.time() if now is None else now
age = max(0.0, ts - timestamp)
report["heartbeat"] = {
"status": "fresh" if age <= cfg.heartbeat_max_age else "stale",
"timestamp": timestamp,
"age_seconds": age,
"max_age_seconds": cfg.heartbeat_max_age,
}
return report
def _go_snapshot_report(path: Path) -> dict | None:
# Sidecar files are retained forensic input, not trusted application data.
# Require the expected schema before presenting an arbitrary JSON document
# as a Sentinel alert snapshot.
try:
report = json.loads(path.read_text())
except (OSError, ValueError):
return None
if not isinstance(report, dict) or report.get("schema") != schemas.ALERT_SNAPSHOT_V1:
return None
return report
def list_go_sidecar_alerts(cfg: Config, limit: int = 200) -> list[dict]:
directory = _go_sidecar_dir(cfg)
if directory is None:
return []
alerts = []
for path in sorted(directory.glob("alert-*.json"), reverse=True)[:limit]:
report = _go_snapshot_report(path)
if report is None:
continue
records = report.get("alerts", [])
if not isinstance(records, list):
continue
alerts.append({
"name": path.with_suffix(".log").name,
"time": report.get("time", ""),
"host": report.get("host", ""),
"severity": report.get("severity", ""),
"signatures": sorted({a.get("signature", "") for a in records
if isinstance(a, dict)}),
"sids": sorted({a.get("sid", 0) for a in records
if isinstance(a, dict) and a.get("sid")}),
"count": len(records),
"source": "go-sidecar",
})
return alerts
def get_go_sidecar_alert(cfg: Config, name: str) -> dict | None:
if not isinstance(name, str) or not _ALERT_NAME.match(name):
return None
directory = _go_sidecar_dir(cfg)
if directory is None:
return None
base = directory / name
report = _go_snapshot_report(base.with_suffix(".json"))
if report is None:
return None
data = {"json": report, "source": "go-sidecar"}
try:
if base.is_file():
data["text"] = base.read_text()
except OSError:
pass
return data
def go_sidecar_events(cfg: Config, limit: int = 100) -> dict:
"""Return valid retained envelopes, failing closed if either segment corrupts."""
directory = _go_sidecar_dir(cfg)
result = {"events": [], "source": "go-sidecar", "error": None}
if directory is None:
return result
# Match the Go reader's chronology: the rotated segment precedes its active
# successor. Do not return a partial stream after corruption, because that
# could make an incomplete evidence chain look authoritative.
rows = []
for path in (directory / "events.jsonl.1", directory / "events.jsonl"):
try:
lines = path.read_text().splitlines()
except FileNotFoundError:
continue
except OSError as exc:
result["error"] = f"cannot read retained events: {exc}"
return result
for line in lines:
if not line:
continue
try:
row = json.loads(line)
except ValueError:
result["error"] = "retained event log is corrupt"
return result
if not isinstance(row, dict):
result["error"] = "retained event log contains a non-object record"
return result
rows.append(row)
result["events"] = rows[-max(0, limit):] if limit else []
return result
def list_go_sidecar_incidents(cfg: Config) -> list[dict]:
directory = _go_sidecar_dir(cfg)
if directory is None:
return []
try:
index = json.loads((directory / "incidents.json").read_text())
except (OSError, ValueError):
return []
if not isinstance(index, dict):
return []
# Atomic index replacement protects writers, but retained files can still
# be stale or manually damaged; schema filtering keeps this read-only view
# from interpreting unrelated JSON as an incident.
records = [item for item in index.values()
if isinstance(item, dict) and item.get("schema") == schemas.INCIDENT_V1]
def last_timestamp(item: dict) -> float:
try:
return float(item.get("last_ts", 0.0))
except (TypeError, ValueError):
return 0.0
return sorted(records, key=last_timestamp, reverse=True)
def get_go_sidecar_incident(cfg: Config, incident_id: str) -> dict | None:
incident = next((item for item in list_go_sidecar_incidents(cfg)
if item.get("id") == incident_id), None)
if incident is None:
return None
snapshots, timeline = [], []
for name in incident.get("snapshots", []):
if not isinstance(name, str):
timeline.append({"snapshot": str(name), "time": "?", "missing": True})
continue
alert = get_go_sidecar_alert(cfg, name)
if alert is None:
timeline.append({"snapshot": name, "time": "?", "missing": True})
continue
report = alert["json"]
snapshots.append(report)
timeline.append({
"snapshot": name,
"time": report.get("time", "?"),
"severity": report.get("severity", "?"),
"signatures": list(dict.fromkeys(
row.get("signature", "") for row in report.get("alerts", [])
if isinstance(row, dict))),
"pids": [row.get("pid") for row in report.get("processes", [])
if isinstance(row, dict) and isinstance(row.get("pid"), int)],
})
timeline.sort(key=lambda row: row.get("time", ""))
return {"schema": schemas.INCIDENT_VIEW_V1, "incident": incident,
"timeline": timeline, "snapshots": snapshots, "source": "go-sidecar"}
def daemon_status(cfg: Config) -> dict: def daemon_status(cfg: Config) -> dict:
pid_alive = False pid_alive = False
pidfile = cfg.log_dir / "sentinel.pid" pidfile = cfg.log_dir / "sentinel.pid"
@ -505,6 +698,21 @@ class _Handler(BaseHTTPRequestHandler):
self._json_or_not_found(alert, send_body=send_body) self._json_or_not_found(alert, send_body=send_body)
elif path == "/api/events": elif path == "/api/events":
self._json({"events": tail_events(cfg)}, send_body=send_body) self._json({"events": tail_events(cfg)}, send_body=send_body)
elif path == "/api/go-sidecar/status":
self._json(go_sidecar_status(cfg), send_body=send_body)
elif path == "/api/go-sidecar/alerts":
self._json(list_go_sidecar_alerts(cfg), send_body=send_body)
elif path.startswith("/api/go-sidecar/alerts/"):
alert = get_go_sidecar_alert(cfg, path.rsplit("/", 1)[-1])
self._json_or_not_found(alert, send_body=send_body)
elif path == "/api/go-sidecar/events":
self._json(go_sidecar_events(cfg), send_body=send_body)
elif path == "/api/go-sidecar/incidents":
self._json(list_go_sidecar_incidents(cfg), send_body=send_body)
elif path.startswith("/api/go-sidecar/incidents/"):
iid = unquote(path.rsplit("/", 1)[-1])
report = get_go_sidecar_incident(cfg, iid)
self._json_or_not_found(report, send_body=send_body)
elif path == "/api/posture": elif path == "/api/posture":
self._json(posture_report(cfg), send_body=send_body) self._json(posture_report(cfg), send_body=send_body)
elif path == "/api/rules": elif path == "/api/rules":

View file

@ -20,6 +20,17 @@ Current scope:
- an opt-in live baseline lifecycle (`--state-dir`) that builds listener/SUID - an opt-in live baseline lifecycle (`--state-dir`) that builds listener/SUID
baselines, honors the startup grace gate, scans persistence paths, and baselines, honors the startup grace gate, scans persistence paths, and
atomically retains `first-seen.json` using the Python-compatible schema; atomically retains `first-seen.json` using the Python-compatible schema;
- a sidecar-only FIM engine, enabled only with `--state-dir`, that persists a
SHA-256 security-path baseline as `fim-baseline.json`, performs
non-overlapping background comparisons, and can opt in to a bounded
`pacman -Qkk` package verification pass with `fim_pkg_verify = true`;
- a sidecar-only rootcheck engine that bounds PID cross-view probing with
`rootcheck_pid_cap`, runs one background comparison per interval, and checks
procfs, sysfs, `ps`, socket, module-taint, kernel-taint, and promiscuous-mode
views without blocking the polling loop;
- a sidecar-only package-DB anchor (`pkgdb_verify`) that fingerprints pacman's
local checksum database and alerts only on changes that lack a corresponding
transaction-log record; it never refreshes an unexplained changed anchor;
- pure exec and syscall event models plus replayable rule engines: built-in - pure exec and syscall event models plus replayable rule engines: built-in
exec SIDs `100001``100004`, configured `[[exec_rules]]`, and syscall SIDs exec SIDs `100001``100004`, configured `[[exec_rules]]`, and syscall SIDs
`100060``100066` now emit Python-identical alert envelopes without requiring `100060``100066` now emit Python-identical alert envelopes without requiring
@ -52,10 +63,12 @@ Current scope:
- a shared fixture checked against the Python detector by - a shared fixture checked against the Python detector by
`scripts/check-go-parity.py`. `scripts/check-go-parity.py`.
It does not yet provide Python's full enrichment, notification fan-out, or It does not yet provide Python's full enrichment or notification fan-out, and
management consumers for those snapshots, and it does not replace it does not replace `enodia-sentinel`. Python's optional `/api/go-sidecar/*`
`enodia-sentinel`. The module requires Go 1.25 or newer; development is currently consumer can display this sidecar's isolated retained evidence, but remains
verified with Go 1.26.5. Run a single terminal-visible stateless sweep: read-only and never starts or controls the Go process. The module requires Go
1.25 or newer; development is currently verified with Go 1.26.5. Run a single
terminal-visible stateless sweep:
```bash ```bash
cd go-agent cd go-agent
@ -118,10 +131,12 @@ process/parent context, lineage, remote-IP classification, and candidate paths
from already-captured data without new I/O. A 16-job asynchronous worker then from already-captured data without new I/O. A 16-job asynchronous worker then
adds streamed SHA-256 values for regular executables up to 8 MiB, file metadata, adds streamed SHA-256 values for regular executables up to 8 MiB, file metadata,
and pacman/dpkg/rpm package-owner strings with a two-second query bound; and pacman/dpkg/rpm package-owner strings with a two-second query bound;
saturation leaves fields unknown rather than delaying alerts. Richer integrity saturation leaves fields unknown rather than delaying alerts. The sidecar also
engines and notifications remain future parity work. The worker does report has separate, bounded FIM/package-verification, package-DB-anchor, and
truthful FIM-baseline, package-DB-anchor, and Go hash-chain presence/age while rootcheck engines; notification fan-out remains future parity work. The worker
explicitly marking unported package verification and rootcheck as disabled. reports truthful FIM-baseline, package-DB-anchor, and Go hash-chain
presence/age, plus the configured `pacman -Qkk` and rootcheck cadences. This is
metadata only: recording a snapshot never starts integrity work.
Snapshot text/JSON Snapshot text/JSON
revisions append synchronized `enodia.hash_chain.v1` revisions append synchronized `enodia.hash_chain.v1`
records to a private local `hash-chain.jsonl`. records to a private local `hash-chain.jsonl`.

View file

@ -27,9 +27,12 @@ import (
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/fim"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/pkgdb"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/rootcheck"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot"
@ -203,6 +206,21 @@ func run() error {
runner.Lifecycle = baseline.New(cfg, func() []string { runner.Lifecycle = baseline.New(cfg, func() []string {
return system.ScanSUIDBinaries(*suidRoot, cfg.SUIDScanExtraDirs) return system.ScanSUIDBinaries(*suidRoot, cfg.SUIDScanExtraDirs)
}) })
// The FIM engine shares only the sidecar's explicitly selected state
// directory. It never reads or rewrites Python's production baseline.
if cfg.FIMEnabled {
runner.FIM = fim.New(cfg)
}
if cfg.PackageDBVerify {
// The anchor resides in the same selected sidecar tree as its FIM
// baseline; it never reads or updates Python's production anchor.
runner.PackageDB = pkgdb.New(cfg)
}
if cfg.RootcheckEnabled {
// Rootcheck has no persistent baseline, but remains sidecar-only too:
// fixture and parity runs must stay hermetic and free of host probes.
runner.Rootcheck = rootcheck.New(cfg)
}
} }
if *host != "" { if *host != "" {
runner.Host = func() (string, error) { return *host, nil } runner.Host = func() (string, error) { return *host, nil }
@ -265,6 +283,9 @@ func run() error {
); err != nil { ); err != nil {
return err return err
} }
// Persist only settings for actually-attached integrity engines. The slow
// enrichment worker remains observational and never runs an engine.
snapshotStore.ConfigureIntegrity(cfg, runner.FIM != nil, runner.Rootcheck != nil)
snapshotStore.EnableSlowEnrichment() snapshotStore.EnableSlowEnrichment()
defer snapshotStore.Close() defer snapshotStore.Close()
} }

View file

@ -15,7 +15,10 @@ import (
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/detectors" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/detectors"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/fim"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/pkgdb"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/rootcheck"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
) )
@ -37,6 +40,18 @@ type Agent struct {
// baseline manager so startup grace and durable first-seen state match the // baseline manager so startup grace and durable first-seen state match the
// Python oracle without contaminating parity inputs. // Python oracle without contaminating parity inputs.
Lifecycle *baseline.Manager Lifecycle *baseline.Manager
// FIM is optional even when the sidecar is stateful. It owns slow hashing
// and package verification and hands completed alerts back to Sweep, which
// keeps the existing cooldown and snapshot pipeline authoritative.
FIM *fim.Manager
// PackageDB watches the mutable package checksum database itself. It is kept
// separate from FIM because a package-manager database edit can otherwise
// make pacman-based file verification appear clean.
PackageDB *pkgdb.Manager
// Rootcheck is likewise a sidecar-only asynchronous integrity engine. It
// uses the captured socket state but collects independent proc/sys/tool views
// outside this sweep's detector and event-output locks.
Rootcheck *rootcheck.Manager
cooldownMu sync.Mutex cooldownMu sync.Mutex
cooldowns map[string]time.Time cooldowns map[string]time.Time
@ -76,6 +91,21 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
return nil, err return nil, err
} }
} }
if a.FIM != nil {
// Scheduling is deliberately before detector evaluation, but FIM itself
// runs asynchronously so a large hash tree never delays this sweep.
a.FIM.MaybeStart(now)
}
if a.PackageDB != nil {
// Like FIM, DB fingerprinting happens away from detector evaluation. A
// completed tamper result still returns through the shared alert pipeline.
a.PackageDB.MaybeStart(now)
}
if a.Rootcheck != nil {
// Capture provides the socket view rootcheck compares with procfs and ps;
// pass this immutable sweep snapshot to avoid a second full capture.
a.Rootcheck.MaybeStart(now, state)
}
host, err := a.Host() host, err := a.Host()
if err != nil { if err != nil {
return nil, err return nil, err
@ -131,6 +161,17 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
return nil, err return nil, err
} }
} }
if a.FIM != nil {
// Background engines return results here instead of emitting directly, so
// they use this Agent's shared cooldown, snapshot, and event ordering.
alerts = append(alerts, a.FIM.Drain()...)
}
if a.PackageDB != nil {
alerts = append(alerts, a.PackageDB.Drain()...)
}
if a.Rootcheck != nil {
alerts = append(alerts, a.Rootcheck.Drain()...)
}
events, err := a.alertEvents(alerts, now, host, timestamp) events, err := a.alertEvents(alerts, now, host, timestamp)
if err != nil { if err != nil {
return nil, err return nil, err
@ -223,6 +264,22 @@ func (a *Agent) Initialize() error {
// otherwise fixed-clock tests could arm against wall time by accident. // otherwise fixed-clock tests could arm against wall time by accident.
a.Lifecycle.Now = a.Now a.Lifecycle.Now = a.Now
a.initializeErr = a.Lifecycle.Initialize(baseline.CaptureFunc(a.Capture)) a.initializeErr = a.Lifecycle.Initialize(baseline.CaptureFunc(a.Capture))
if a.initializeErr != nil {
return a.initializeErr
}
}
if a.FIM != nil {
// FIM must establish its immutable baseline before its first scheduled
// scan. The manager writes only the caller-selected sidecar LogDir.
a.initializeErr = a.FIM.Initialize()
if a.initializeErr != nil {
return a.initializeErr
}
}
if a.PackageDB != nil {
// Establishing the anchor precedes monitoring, just as the Python daemon
// does. Later unexplained changes deliberately do not refresh it.
a.initializeErr = a.PackageDB.Initialize()
} }
return a.initializeErr return a.initializeErr
} }

View file

@ -53,11 +53,25 @@ var defaultMemoryObfuscationAllowComms = []string{
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More // Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
// fields are added as their detectors are ported. // fields are added as their detectors are ported.
type Config struct { type Config struct {
SampleInterval time.Duration SampleInterval time.Duration
Cooldown time.Duration Cooldown time.Duration
BaselineGrace time.Duration BaselineGrace time.Duration
SUIDRefresh time.Duration SUIDRefresh time.Duration
SUIDScanInterval time.Duration SUIDScanInterval time.Duration
// Integrity engines are parsed with the shared configuration for parity,
// but main attaches them only to an explicitly stateful Go sidecar. That
// preserves Python's production baselines and management authority.
FIMEnabled bool
FIMPaths []string
FIMScanInterval time.Duration
FIMPackageVerify bool
FIMPackageVerifyInterval time.Duration
PackageDBVerify bool
PackageDBInterval time.Duration
RootcheckEnabled bool
RootcheckInterval time.Duration
RootcheckPIDCap int
RootcheckModuleAllow map[string]bool
HeartbeatMaxAge int HeartbeatMaxAge int
MaxSnapshots int MaxSnapshots int
MaxSnapshotAgeDays int MaxSnapshotAgeDays int
@ -97,6 +111,17 @@ func Default() Config {
BaselineGrace: 10 * time.Second, BaselineGrace: 10 * time.Second,
SUIDRefresh: time.Hour, SUIDRefresh: time.Hour,
SUIDScanInterval: time.Minute, SUIDScanInterval: time.Minute,
FIMEnabled: true,
FIMPaths: []string{},
FIMScanInterval: 5 * time.Minute,
FIMPackageVerify: false,
FIMPackageVerifyInterval: 6 * time.Hour,
PackageDBVerify: true,
PackageDBInterval: 10 * time.Minute,
RootcheckEnabled: true,
RootcheckInterval: 5 * time.Minute,
RootcheckPIDCap: 65536,
RootcheckModuleAllow: map[string]bool{},
HeartbeatMaxAge: 120, HeartbeatMaxAge: 120,
MaxSnapshots: 300, MaxSnapshots: 300,
MaxSnapshotAgeDays: 60, MaxSnapshotAgeDays: 60,
@ -192,6 +217,72 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("suid_scan_interval: %w", err) return Config{}, fmt.Errorf("suid_scan_interval: %w", err)
} }
cfg.SUIDScanInterval = duration cfg.SUIDScanInterval = duration
case "fim_enabled":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("fim_enabled: %w", err)
}
cfg.FIMEnabled = enabled
case "fim_paths":
paths, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("fim_paths: %w", err)
}
cfg.FIMPaths = paths
case "fim_scan_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("fim_scan_interval: %w", err)
}
cfg.FIMScanInterval = duration
case "fim_pkg_verify":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("fim_pkg_verify: %w", err)
}
cfg.FIMPackageVerify = enabled
case "fim_pkg_verify_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("fim_pkg_verify_interval: %w", err)
}
cfg.FIMPackageVerifyInterval = duration
case "pkgdb_verify":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("pkgdb_verify: %w", err)
}
cfg.PackageDBVerify = enabled
case "pkgdb_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("pkgdb_interval: %w", err)
}
cfg.PackageDBInterval = duration
case "rootcheck_enabled":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("rootcheck_enabled: %w", err)
}
cfg.RootcheckEnabled = enabled
case "rootcheck_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("rootcheck_interval: %w", err)
}
cfg.RootcheckInterval = duration
case "rootcheck_pid_cap":
cap, err := nonNegativeInt(value)
if err != nil {
return Config{}, fmt.Errorf("rootcheck_pid_cap: %w", err)
}
cfg.RootcheckPIDCap = cap
case "rootcheck_module_allow":
modules, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("rootcheck_module_allow: %w", err)
}
cfg.RootcheckModuleAllow = stringSet(modules)
case "heartbeat_max_age": case "heartbeat_max_age":
age, err := strconv.Atoi(value) age, err := strconv.Atoi(value)
if err != nil || age < 0 { if err != nil || age < 0 {

View file

@ -14,6 +14,11 @@ func TestDefaultsMatchPython(t *testing.T) {
if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second || if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second ||
cfg.BaselineGrace != 10*time.Second || cfg.BaselineGrace != 10*time.Second ||
cfg.SUIDRefresh != time.Hour || cfg.SUIDScanInterval != time.Minute || cfg.SUIDRefresh != time.Hour || cfg.SUIDScanInterval != time.Minute ||
!cfg.FIMEnabled || cfg.FIMScanInterval != 5*time.Minute ||
cfg.FIMPackageVerify || cfg.FIMPackageVerifyInterval != 6*time.Hour ||
!cfg.PackageDBVerify || cfg.PackageDBInterval != 10*time.Minute ||
!cfg.RootcheckEnabled || cfg.RootcheckInterval != 5*time.Minute ||
cfg.RootcheckPIDCap != 65536 || len(cfg.RootcheckModuleAllow) != 0 ||
cfg.HeartbeatMaxAge != 120 || cfg.MaxSnapshots != 300 || cfg.HeartbeatMaxAge != 120 || cfg.MaxSnapshots != 300 ||
cfg.MaxSnapshotAgeDays != 60 || !cfg.IncidentTracking || cfg.MaxSnapshotAgeDays != 60 || !cfg.IncidentTracking ||
cfg.IncidentWindow != 1800 || cfg.IncidentLineageDepth != 8 || cfg.IncidentWindow != 1800 || cfg.IncidentLineageDepth != 8 ||
@ -33,6 +38,17 @@ cooldown = 30
baseline_grace = 2.5 baseline_grace = 2.5
suid_refresh = 120 suid_refresh = 120
suid_scan_interval = 15 suid_scan_interval = 15
fim_enabled = false
fim_paths = ["/srv/critical"]
fim_scan_interval = 25
fim_pkg_verify = true
fim_pkg_verify_interval = 300
pkgdb_verify = false
pkgdb_interval = 75
rootcheck_enabled = false
rootcheck_interval = 40
rootcheck_pid_cap = 1234
rootcheck_module_allow = ["vendor_gpu"]
heartbeat_max_age = 45 heartbeat_max_age = 45
max_snapshots = 25 max_snapshots = 25
max_snapshot_age_days = 7 max_snapshot_age_days = 7
@ -72,6 +88,12 @@ unknown_future_key = true
if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second || if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second ||
cfg.BaselineGrace != 2500*time.Millisecond || cfg.BaselineGrace != 2500*time.Millisecond ||
cfg.SUIDRefresh != 2*time.Minute || cfg.SUIDScanInterval != 15*time.Second || cfg.SUIDRefresh != 2*time.Minute || cfg.SUIDScanInterval != 15*time.Second ||
cfg.FIMEnabled || len(cfg.FIMPaths) != 1 || cfg.FIMPaths[0] != "/srv/critical" ||
cfg.FIMScanInterval != 25*time.Second || !cfg.FIMPackageVerify ||
cfg.FIMPackageVerifyInterval != 5*time.Minute ||
cfg.PackageDBVerify || cfg.PackageDBInterval != 75*time.Second ||
cfg.RootcheckEnabled || cfg.RootcheckInterval != 40*time.Second ||
cfg.RootcheckPIDCap != 1234 || !cfg.RootcheckModuleAllow["vendor_gpu"] ||
cfg.HeartbeatMaxAge != 45 || cfg.MaxSnapshots != 25 || cfg.MaxSnapshotAgeDays != 7 || cfg.HeartbeatMaxAge != 45 || cfg.MaxSnapshots != 25 || cfg.MaxSnapshotAgeDays != 7 ||
cfg.IncidentTracking || cfg.IncidentWindow != 90 || cfg.IncidentLineageDepth != 3 { cfg.IncidentTracking || cfg.IncidentWindow != 90 || cfg.IncidentLineageDepth != 3 {
t.Fatalf("unexpected parsed config: %+v", cfg) t.Fatalf("unexpected parsed config: %+v", cfg)

View file

@ -50,6 +50,22 @@ type SlowResult struct {
Integrity map[string]any Integrity map[string]any
} }
// IntegritySettings describes engines that are already configured by the
// sidecar. It is metadata only: CollectSlow must never start a hash scan,
// package-manager process, or rootcheck while it is enriching an alert.
//
// Durations are carried as durations here and converted to integer seconds at
// the snapshot boundary so the retained JSON stays easy to consume alongside
// Python's enrichment records.
type IntegritySettings struct {
FIMEnabled bool
FIMScanInterval time.Duration
PackageVerifyEnabled bool
PackageVerifyInterval time.Duration
RootcheckEnabled bool
RootcheckInterval time.Duration
}
func CollectSlow(processes []Process, paths []string, owner ...func(string) string) SlowResult { func CollectSlow(processes []Process, paths []string, owner ...func(string) string) SlowResult {
result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}} result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}}
packageOwner := func(string) string { return "" } packageOwner := func(string) string { return "" }
@ -71,17 +87,37 @@ func CollectSlow(processes []Process, paths []string, owner ...func(string) stri
return result return result
} }
// IntegrityAnchors reports durable state presence without running FIM, package // IntegrityAnchors reports durable state and configured engine cadence without
// verification, or rootcheck on the alert path. Those engines stay disabled // running FIM, package verification, or rootcheck on the alert path. A caller
// until their own Go parity tranches land. // can omit settings for stateless/parity uses; explicit sidecar mode supplies
func IntegrityAnchors(directory string) map[string]any { // them after it has attached the bounded background managers.
return map[string]any{ func IntegrityAnchors(directory string, configured ...IntegritySettings) map[string]any {
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")), settings := IntegritySettings{}
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")), if len(configured) > 0 {
"package_verify": map[string]any{"enabled": false, "sample": 0}, settings = configured[0]
"rootcheck": map[string]any{"enabled": false, "interval": 0},
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
} }
return map[string]any{
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")),
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")),
// Go uses pacman -Qkk rather than Python's sampled signed-cache verifier.
// Keep sample at zero to make that distinction explicit to consumers.
"package_verify": map[string]any{
"enabled": settings.PackageVerifyEnabled, "engine": "pacman-qkk",
"interval": durationSeconds(settings.PackageVerifyInterval), "sample": 0,
},
"rootcheck": map[string]any{
"enabled": settings.RootcheckEnabled,
"interval": durationSeconds(settings.RootcheckInterval),
},
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
}
}
func durationSeconds(value time.Duration) int64 {
if value <= 0 {
return 0
}
return int64(value / time.Second)
} }
func pathStatus(path string) map[string]any { func pathStatus(path string) map[string]any {

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
) )
@ -77,9 +78,16 @@ func TestCollectSlowAddsInjectedPackageOwnership(t *testing.T) {
func TestIntegrityAnchorsAreTruthfulAndReadOnly(t *testing.T) { func TestIntegrityAnchorsAreTruthfulAndReadOnly(t *testing.T) {
directory := t.TempDir() directory := t.TempDir()
anchors := IntegrityAnchors(directory) anchors := IntegrityAnchors(directory, IntegritySettings{
FIMEnabled: true, FIMScanInterval: 5 * time.Minute,
PackageVerifyEnabled: true, PackageVerifyInterval: 6 * time.Hour,
RootcheckEnabled: true, RootcheckInterval: 5 * time.Minute,
})
if anchors["fim_baseline"].(map[string]any)["status"] != "missing" || if anchors["fim_baseline"].(map[string]any)["status"] != "missing" ||
anchors["rootcheck"].(map[string]any)["enabled"] != false { anchors["rootcheck"].(map[string]any)["enabled"] != true ||
anchors["rootcheck"].(map[string]any)["interval"] != int64(300) ||
anchors["package_verify"].(map[string]any)["engine"] != "pacman-qkk" ||
anchors["package_verify"].(map[string]any)["interval"] != int64(21600) {
t.Fatalf("anchors=%#v", anchors) t.Fatalf("anchors=%#v", anchors)
} }
if err := os.WriteFile(filepath.Join(directory, "hash-chain.jsonl"), []byte("{}\n"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(directory, "hash-chain.jsonl"), []byte("{}\n"), 0o600); err != nil {

View file

@ -0,0 +1,153 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package fim ports the Python file-integrity engine: a SHA-256 baseline over
// security-critical paths the package manager does not track, and the diff that
// turns baseline drift into alerts.
//
// Content hashing is the upgrade over mtime checks — it catches a file swapped
// for a malicious copy even when the timestamp is preserved.
package fim
import (
"sort"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// SIDs 100017+ are FIM signatures, matching Python.
const (
SIDModified = 100017
SIDAdded = 100018
SIDRemoved = 100019
SIDPackage = 100020
)
// Entry is one path's integrity record. SHA256 and Link are pointers so that
// "absent" and "present but empty" stay distinguishable: Python builds these
// dicts with optional keys and compares them with dict.get, so a regular file
// carries no link key at all while a symlink may legitimately carry "".
type Entry struct {
Mode int `json:"mode"`
UID int `json:"uid"`
GID int `json:"gid"`
Size int64 `json:"size"`
SHA256 *string `json:"sha256,omitempty"`
Link *string `json:"link,omitempty"`
}
// Change is one modified path and the fields that differ, in Python's order.
type Change struct {
Path string `json:"path"`
Fields []string `json:"fields"`
}
// Result is one baseline-versus-live comparison.
type Result struct {
Added []string `json:"added"`
Removed []string `json:"removed"`
Modified []Change `json:"modified"`
}
// comparedFields is Python's tuple order and drives the detail string. Size is
// deliberately absent: a content change already surfaces as a sha256 change.
var comparedFields = []string{"sha256", "mode", "uid", "gid", "link"}
func sameOptional(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func changedFields(old, current Entry) []string {
changed := make([]string, 0, len(comparedFields))
for _, field := range comparedFields {
differs := false
switch field {
case "sha256":
differs = !sameOptional(old.SHA256, current.SHA256)
case "mode":
differs = old.Mode != current.Mode
case "uid":
differs = old.UID != current.UID
case "gid":
differs = old.GID != current.GID
case "link":
differs = !sameOptional(old.Link, current.Link)
}
if differs {
changed = append(changed, field)
}
}
return changed
}
// Diff compares a baseline against a live scan. Every category is sorted so
// alert order is stable across runs and matches the Python oracle.
func Diff(old, current map[string]Entry) Result {
added := make([]string, 0)
removed := make([]string, 0)
shared := make([]string, 0)
for path := range current {
if _, ok := old[path]; !ok {
added = append(added, path)
}
}
for path := range old {
if _, ok := current[path]; ok {
shared = append(shared, path)
} else {
removed = append(removed, path)
}
}
sort.Strings(added)
sort.Strings(removed)
sort.Strings(shared)
modified := make([]Change, 0)
for _, path := range shared {
if fields := changedFields(old[path], current[path]); len(fields) > 0 {
modified = append(modified, Change{Path: path, Fields: fields})
}
}
return Result{Added: added, Removed: removed, Modified: modified}
}
// DiffAlerts turns a comparison into alerts, emitting modified, then added,
// then removed, to match Python's generator order.
func DiffAlerts(result Result) []model.Alert {
alerts := make([]model.Alert, 0, len(result.Modified)+len(result.Added)+len(result.Removed))
for _, change := range result.Modified {
// Content or permission changes are the ones that let an attacker
// substitute or escalate; ownership alone is weaker evidence.
severity := "MEDIUM"
for _, field := range change.Fields {
if field == "sha256" || field == "mode" {
severity = "HIGH"
break
}
}
alerts = append(alerts, model.Alert{
SID: SIDModified, Severity: severity, Signature: "fim_modified",
Classtype: "integrity-violation", Key: "fim:mod:" + change.Path,
Detail: "integrity change (" + strings.Join(change.Fields, ", ") + "): " + change.Path,
PIDs: []int{},
})
}
for _, path := range result.Added {
alerts = append(alerts, model.Alert{
SID: SIDAdded, Severity: "MEDIUM", Signature: "fim_added",
Classtype: "integrity-violation", Key: "fim:add:" + path,
Detail: "new file in monitored path: " + path, PIDs: []int{},
})
}
for _, path := range result.Removed {
alerts = append(alerts, model.Alert{
SID: SIDRemoved, Severity: "MEDIUM", Signature: "fim_removed",
Classtype: "integrity-violation", Key: "fim:del:" + path,
Detail: "monitored file removed: " + path, PIDs: []int{},
})
}
return alerts
}

View file

@ -0,0 +1,168 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"reflect"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func ptr(value string) *string { return &value }
func TestDiffClassifiesAddedRemovedAndModified(t *testing.T) {
old := map[string]Entry{
"/etc/passwd": {Mode: 0o644, UID: 0, GID: 0, Size: 10, SHA256: ptr("aaa")},
"/etc/removed": {Mode: 0o644, SHA256: ptr("bbb")},
}
current := map[string]Entry{
"/etc/passwd": {Mode: 0o644, UID: 0, GID: 0, Size: 12, SHA256: ptr("ccc")},
"/etc/added": {Mode: 0o600, SHA256: ptr("ddd")},
}
result := Diff(old, current)
if !reflect.DeepEqual(result.Added, []string{"/etc/added"}) {
t.Errorf("added = %v, want [/etc/added]", result.Added)
}
if !reflect.DeepEqual(result.Removed, []string{"/etc/removed"}) {
t.Errorf("removed = %v, want [/etc/removed]", result.Removed)
}
want := []Change{{Path: "/etc/passwd", Fields: []string{"sha256"}}}
if !reflect.DeepEqual(result.Modified, want) {
t.Errorf("modified = %v, want %v", result.Modified, want)
}
}
// Python reports changed fields in a fixed order and size is deliberately not
// one of them: a content change already shows up as a sha256 change.
func TestDiffReportsFieldsInPythonOrderAndIgnoresSize(t *testing.T) {
old := map[string]Entry{"/f": {Mode: 0o644, UID: 0, GID: 0, Size: 1, SHA256: ptr("a")}}
current := map[string]Entry{"/f": {Mode: 0o755, UID: 1, GID: 2, Size: 999, SHA256: ptr("b")}}
result := Diff(old, current)
want := []Change{{Path: "/f", Fields: []string{"sha256", "mode", "uid", "gid"}}}
if !reflect.DeepEqual(result.Modified, want) {
t.Errorf("modified = %v, want %v", result.Modified, want)
}
}
// Python compares with dict.get, so an absent hash and a null hash are equal.
// Getting this wrong would alert on every unreadable file, every scan.
func TestDiffTreatsAbsentAndNullHashAsEqual(t *testing.T) {
old := map[string]Entry{"/f": {Mode: 0o644, SHA256: nil}}
current := map[string]Entry{"/f": {Mode: 0o644, SHA256: nil}}
if got := Diff(old, current).Modified; len(got) != 0 {
t.Errorf("modified = %v, want none", got)
}
}
// An empty symlink target is not the same as no symlink target at all.
func TestDiffDistinguishesEmptyLinkFromAbsentLink(t *testing.T) {
old := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("")}}
current := map[string]Entry{"/l": {Mode: 0o777, Link: nil}}
want := []Change{{Path: "/l", Fields: []string{"link"}}}
if got := Diff(old, current).Modified; !reflect.DeepEqual(got, want) {
t.Errorf("modified = %v, want %v", got, want)
}
}
func TestDiffDetectsSymlinkRetarget(t *testing.T) {
old := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("/bin/sh")}}
current := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("/tmp/evil")}}
want := []Change{{Path: "/l", Fields: []string{"link"}}}
if got := Diff(old, current).Modified; !reflect.DeepEqual(got, want) {
t.Errorf("modified = %v, want %v", got, want)
}
}
func TestDiffSortsEachCategory(t *testing.T) {
old := map[string]Entry{"/z": {Size: 1}, "/a": {Size: 1}}
current := map[string]Entry{"/y": {Size: 1}, "/b": {Size: 1}}
result := Diff(old, current)
if !reflect.DeepEqual(result.Added, []string{"/b", "/y"}) {
t.Errorf("added = %v, want sorted [/b /y]", result.Added)
}
if !reflect.DeepEqual(result.Removed, []string{"/a", "/z"}) {
t.Errorf("removed = %v, want sorted [/a /z]", result.Removed)
}
}
func TestDiffEmitsEmptyNotNilSlices(t *testing.T) {
result := Diff(map[string]Entry{}, map[string]Entry{})
if result.Added == nil || result.Removed == nil || result.Modified == nil {
t.Fatalf("Diff must return empty, non-nil slices, got %+v", result)
}
}
func TestDiffAlertsOrdersModifiedThenAddedThenRemoved(t *testing.T) {
result := Result{
Added: []string{"/new"},
Removed: []string{"/gone"},
Modified: []Change{{Path: "/changed", Fields: []string{"uid"}}},
}
alerts := DiffAlerts(result)
want := []model.Alert{
{
SID: 100017, Severity: "MEDIUM", Signature: "fim_modified",
Classtype: "integrity-violation", Key: "fim:mod:/changed",
Detail: "integrity change (uid): /changed", PIDs: []int{},
},
{
SID: 100018, Severity: "MEDIUM", Signature: "fim_added",
Classtype: "integrity-violation", Key: "fim:add:/new",
Detail: "new file in monitored path: /new", PIDs: []int{},
},
{
SID: 100019, Severity: "MEDIUM", Signature: "fim_removed",
Classtype: "integrity-violation", Key: "fim:del:/gone",
Detail: "monitored file removed: /gone", PIDs: []int{},
},
}
if !reflect.DeepEqual(alerts, want) {
t.Errorf("alerts = %+v, want %+v", alerts, want)
}
}
// Content and permission changes are HIGH; ownership-only changes are MEDIUM.
func TestDiffAlertsEscalatesContentAndModeChanges(t *testing.T) {
cases := []struct {
fields []string
severity string
detail string
}{
{[]string{"sha256"}, "HIGH", "integrity change (sha256): /f"},
{[]string{"mode"}, "HIGH", "integrity change (mode): /f"},
{[]string{"sha256", "mode"}, "HIGH", "integrity change (sha256, mode): /f"},
{[]string{"uid", "gid"}, "MEDIUM", "integrity change (uid, gid): /f"},
{[]string{"link"}, "MEDIUM", "integrity change (link): /f"},
}
for _, testCase := range cases {
alerts := DiffAlerts(Result{Modified: []Change{{Path: "/f", Fields: testCase.fields}}})
if len(alerts) != 1 {
t.Fatalf("%v: got %d alerts, want 1", testCase.fields, len(alerts))
}
if alerts[0].Severity != testCase.severity {
t.Errorf("%v: severity = %s, want %s", testCase.fields, alerts[0].Severity, testCase.severity)
}
if alerts[0].Detail != testCase.detail {
t.Errorf("%v: detail = %q, want %q", testCase.fields, alerts[0].Detail, testCase.detail)
}
}
}
func TestDiffAlertsReturnsEmptySliceForCleanScan(t *testing.T) {
if alerts := DiffAlerts(Diff(nil, nil)); len(alerts) != 0 || alerts == nil {
t.Errorf("alerts = %v, want empty non-nil slice", alerts)
}
}

View file

@ -0,0 +1,188 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
baselineFilename = "fim-baseline.json"
pacmanTimeout = 10 * time.Minute
)
// Manager owns the deliberately separate, slow integrity work. Its scans run
// in at most one goroutine per engine and only hand completed alerts back to
// the polling loop. That keeps filesystem hashing and pacman from holding a
// detector, snapshot, or event-output lock.
type Manager struct {
Config config.Config
Now func() time.Time
Scan func([]string) map[string]Entry
Verify func(context.Context) (string, error)
mu sync.Mutex
baseline map[string]Entry
initialized bool
lastScan time.Time
lastVerify time.Time
scanRunning bool
verifyRunning bool
pendingAlerts []model.Alert
}
// New builds a manager with real host collection. Tests replace Scan or Verify
// so they never read the host or execute a package manager.
func New(cfg config.Config) *Manager {
return &Manager{
Config: cfg,
Now: time.Now,
Scan: ScanPaths,
Verify: PacmanVerify,
}
}
// Initialize loads a valid non-empty baseline or builds it once. Like Python,
// baseline creation is a local operator-state action: regular scans never
// silently accept a changed file as the new truth. The caller supplies LogDir
// only in explicit sidecar mode, so this baseline can never share or rewrite
// the Python service's production state.
func (m *Manager) Initialize() error {
if !m.Config.FIMEnabled {
return nil
}
m.mu.Lock()
if m.initialized {
m.mu.Unlock()
return nil
}
m.mu.Unlock()
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
return err
}
baseline := loadBaseline(filepath.Join(m.Config.LogDir, baselineFilename))
if len(baseline) == 0 {
// A missing, unreadable, malformed, or empty baseline has no trustworthy
// historical truth. Rebuilding it is safer than treating every file as a
// change, and it happens only during initialization—not a later scan.
baseline = m.Scan(PathList(m.Config.FIMPaths))
if err := writeBaseline(filepath.Join(m.Config.LogDir, baselineFilename), baseline); err != nil {
return err
}
}
m.mu.Lock()
defer m.mu.Unlock()
if !m.initialized {
m.baseline = baseline
m.initialized = true
}
return nil
}
// MaybeStart schedules due work and returns immediately. A short interval does
// not start overlapping scans, which bounds both disk pressure and pacman
// process count even if a scan takes longer than the configured cadence.
func (m *Manager) MaybeStart(now time.Time) {
if !m.Config.FIMEnabled {
return
}
m.mu.Lock()
if !m.initialized {
m.mu.Unlock()
return
}
if !m.scanRunning && (m.lastScan.IsZero() || now.Sub(m.lastScan) >= m.Config.FIMScanInterval) {
m.scanRunning = true
m.lastScan = now
// Copy state while protected, then release the mutex before filesystem
// I/O. Holding the lock through hashing would block Drain and could make
// regular detector sweeps wait behind a large tree.
baseline := cloneEntries(m.baseline)
paths := append([]string(nil), PathList(m.Config.FIMPaths)...)
go m.runScan(baseline, paths)
}
if m.Config.FIMPackageVerify && !m.verifyRunning &&
(m.lastVerify.IsZero() || now.Sub(m.lastVerify) >= m.Config.FIMPackageVerifyInterval) {
m.verifyRunning = true
m.lastVerify = now
go m.runVerify()
}
m.mu.Unlock()
}
func (m *Manager) runScan(baseline map[string]Entry, paths []string) {
// The baseline intentionally remains immutable after initialization. A
// completed scan reports drift; it never turns observed drift into truth.
alerts := DiffAlerts(Diff(baseline, m.Scan(paths)))
m.mu.Lock()
m.pendingAlerts = append(m.pendingAlerts, alerts...)
m.scanRunning = false
m.mu.Unlock()
}
func (m *Manager) runVerify() {
// pacman can block on its database or a slow disk. Its own deadline keeps
// the optional verification feature from accumulating background workers.
ctx, cancel := context.WithTimeout(context.Background(), pacmanTimeout)
defer cancel()
alerts := VerifyAlerts(func() (string, error) { return m.Verify(ctx) })
m.mu.Lock()
m.pendingAlerts = append(m.pendingAlerts, alerts...)
m.verifyRunning = false
m.mu.Unlock()
}
// Drain returns completed background results once. The caller must route them
// through the shared Agent cooldown and snapshot paths before exposing them.
func (m *Manager) Drain() []model.Alert {
m.mu.Lock()
defer m.mu.Unlock()
alerts := append([]model.Alert(nil), m.pendingAlerts...)
m.pendingAlerts = nil
return alerts
}
func loadBaseline(path string) map[string]Entry {
entries := make(map[string]Entry)
raw, err := os.ReadFile(path)
if err != nil || json.Unmarshal(raw, &entries) != nil {
return map[string]Entry{}
}
return entries
}
func writeBaseline(path string, entries map[string]Entry) error {
raw, err := json.Marshal(entries)
if err != nil {
return err
}
raw = append(raw, '\n')
// Write and rename in the same directory so a crash cannot leave a partly
// written JSON file at the baseline path. The restrictive mode keeps hashes
// and file metadata from becoming world-readable state.
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
return os.Chmod(path, 0o640)
}
func cloneEntries(source map[string]Entry) map[string]Entry {
result := make(map[string]Entry, len(source))
for path, entry := range source {
result[path] = entry
}
return result
}

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
)
func TestManagerBuildsBaselineAndReportsDriftWithoutReplacingIt(t *testing.T) {
directory := t.TempDir()
watched := filepath.Join(directory, "watched")
writeFile(t, watched, "trusted")
state := filepath.Join(directory, "state")
cfg := config.Default()
cfg.LogDir = state
cfg.FIMPaths = []string{watched}
manager := New(cfg)
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
baselineHash := *manager.baseline[watched].SHA256
if _, err := os.Stat(filepath.Join(state, baselineFilename)); err != nil {
t.Fatalf("baseline not persisted: %v", err)
}
writeFile(t, watched, "tampered")
manager.runScan(cloneEntries(manager.baseline), PathList(cfg.FIMPaths))
alerts := manager.Drain()
if len(alerts) != 1 || alerts[0].SID != SIDModified || alerts[0].Severity != "HIGH" {
t.Fatalf("alerts = %+v, want one high modified alert", alerts)
}
if got := *manager.baseline[watched].SHA256; got != baselineHash {
t.Fatalf("scan silently replaced baseline %q with %q", baselineHash, got)
}
}
func TestManagerPackageVerificationIsBoundedAndDrained(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
cfg.FIMPaths = []string{filepath.Join(t.TempDir(), "absent")}
manager := New(cfg)
manager.Verify = func(ctx context.Context) (string, error) {
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > pacmanTimeout {
t.Fatal("package verification did not receive a bounded context")
}
return "openssh: /usr/bin/ssh (SHA256 checksum mismatch)", nil
}
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
manager.runVerify()
alerts := manager.Drain()
if len(alerts) != 1 || alerts[0].SID != SIDPackage || alerts[0].Severity != "CRITICAL" {
t.Fatalf("alerts = %+v, want one critical package alert", alerts)
}
}
func TestDisabledManagerDoesNotCreateAStateDirectory(t *testing.T) {
cfg := config.Default()
cfg.FIMEnabled = false
cfg.LogDir = filepath.Join(t.TempDir(), "state")
manager := New(cfg)
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg.LogDir); !os.IsNotExist(err) {
t.Fatalf("disabled FIM created state directory: %v", err)
}
}

View file

@ -0,0 +1,99 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"context"
"os/exec"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// PackageFinding is one meaningful package-manager integrity discrepancy.
// Package verification is intentionally separate from the FIM baseline: it
// relies on distribution-owned hashes for files that Sentinel should not
// silently re-baseline after an upgrade.
type PackageFinding struct {
Package string `json:"package"`
Path string `json:"path"`
Reason string `json:"reason"`
}
// ParsePacmanVerify mirrors Python's parse_pacman_verify. pacman emits both
// routine summaries and diagnostics to stdout/stderr; only content, mode, or
// ownership mismatches are security findings. A timestamp-only difference is
// deliberately ignored because a harmless touch must not page an operator.
func ParsePacmanVerify(output string) []PackageFinding {
findings := make([]PackageFinding, 0)
for _, raw := range strings.Split(output, "\n") {
line := strings.TrimSpace(raw)
open, close := strings.LastIndex(line, "("), strings.LastIndex(line, ")")
if open < 0 || close <= open {
continue
}
reason := strings.TrimSpace(line[open+1 : close])
lower := strings.ToLower(reason)
if !containsAny(lower, "checksum", "sha", "size", "permission", "ownership", "uid", "gid") {
continue
}
head := strings.TrimSpace(line[:open])
packageName, path, ok := strings.Cut(head, ": /")
if !ok {
continue
}
packageName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(packageName), "warning:"))
path = "/" + strings.TrimSpace(path)
if packageName == "" || path == "/" {
continue
}
findings = append(findings, PackageFinding{Package: packageName, Path: path, Reason: reason})
}
return findings
}
func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
// PackageAlert converts a package-owned integrity discrepancy to the same
// critical Sentinel alert as Python. The path is the stable cooldown key, so
// multiple pacman diagnostics for one path do not cause repeated pages.
func PackageAlert(finding PackageFinding) model.Alert {
return model.Alert{
SID: SIDPackage, Severity: "CRITICAL", Signature: "fim_pkg_modified",
Classtype: "integrity-violation", Key: "fim:pkg:" + finding.Path,
Detail: "package file altered (" + finding.Package + ": " + finding.Reason + "): " + finding.Path,
PIDs: []int{},
}
}
// VerifyAlerts executes an injected verification command and turns any
// parseable output into alerts. A missing package manager or a timeout fails
// open; a non-zero exit with diagnostics is still parsed because pacman uses
// that exit status to report changed files.
func VerifyAlerts(run func() (string, error)) []model.Alert {
alerts := make([]model.Alert, 0)
if run == nil {
return alerts
}
output, _ := run()
for _, finding := range ParsePacmanVerify(output) {
alerts = append(alerts, PackageAlert(finding))
}
return alerts
}
// PacmanVerify runs pacman without a shell and joins stdout/stderr because the
// tool may use either stream for discrepancies. The caller supplies a bounded
// context; this function has no unbounded host operation of its own.
func PacmanVerify(ctx context.Context) (string, error) {
command := exec.CommandContext(ctx, "pacman", "-Qkk")
output, err := command.CombinedOutput()
return string(output), err
}

View file

@ -0,0 +1,151 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"errors"
"reflect"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestParsePacmanVerifyExtractsPackagePathAndReason(t *testing.T) {
output := "coreutils: /usr/bin/ls (Size mismatch)"
got := ParsePacmanVerify(output)
want := []PackageFinding{{Package: "coreutils", Path: "/usr/bin/ls", Reason: "Size mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
func TestParsePacmanVerifyStripsWarningPrefix(t *testing.T) {
got := ParsePacmanVerify("warning: openssh: /usr/bin/ssh (SHA256 checksum mismatch)")
want := []PackageFinding{{Package: "openssh", Path: "/usr/bin/ssh", Reason: "SHA256 checksum mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
// A `touch` does not alter content, so a modification-time-only mismatch is
// benign noise that must never reach an operator.
func TestParsePacmanVerifyIgnoresModificationTimeOnlyMismatches(t *testing.T) {
got := ParsePacmanVerify("coreutils: /usr/bin/ls (Modification time mismatch)")
if len(got) != 0 {
t.Errorf("findings = %+v, want none", got)
}
}
func TestParsePacmanVerifyKeepsEveryContentAndOwnershipReason(t *testing.T) {
reasons := []string{
"Size mismatch", "SHA256 checksum mismatch", "Permissions mismatch",
"UID mismatch", "GID mismatch", "Ownership mismatch",
}
for _, reason := range reasons {
got := ParsePacmanVerify("pkg: /usr/bin/x (" + reason + ")")
if len(got) != 1 {
t.Errorf("reason %q: got %d findings, want 1", reason, len(got))
}
}
}
func TestParsePacmanVerifyMatchesReasonCaseInsensitively(t *testing.T) {
if got := ParsePacmanVerify("pkg: /usr/bin/x (size MISMATCH)"); len(got) != 1 {
t.Errorf("findings = %+v, want 1", got)
}
}
func TestParsePacmanVerifyIgnoresSummaryAndUnownedLines(t *testing.T) {
output := `warning: No package owns /usr/local/bin/custom
coreutils: 468 total files, 0 altered files
pacman: /usr/bin/pacman (Size mismatch)`
got := ParsePacmanVerify(output)
want := []PackageFinding{{Package: "pacman", Path: "/usr/bin/pacman", Reason: "Size mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
func TestParsePacmanVerifyIgnoresLinesWithoutAnAbsolutePath(t *testing.T) {
if got := ParsePacmanVerify("something odd (Size mismatch)"); len(got) != 0 {
t.Errorf("findings = %+v, want none", got)
}
}
func TestParsePacmanVerifyReadsTheLastParenthesisGroup(t *testing.T) {
got := ParsePacmanVerify("pkg: /usr/bin/x (1.0-1) (Size mismatch)")
if len(got) != 1 || got[0].Reason != "Size mismatch" {
t.Errorf("findings = %+v, want reason 'Size mismatch'", got)
}
}
func TestParsePacmanVerifyHandlesEmptyOutput(t *testing.T) {
if got := ParsePacmanVerify(""); got == nil || len(got) != 0 {
t.Errorf("findings = %+v, want empty non-nil slice", got)
}
}
func TestPackageAlertIsCriticalWithDistroReason(t *testing.T) {
got := PackageAlert(PackageFinding{Package: "openssh", Path: "/usr/bin/ssh", Reason: "SHA256 checksum mismatch"})
want := model.Alert{
SID: 100020, Severity: "CRITICAL", Signature: "fim_pkg_modified",
Classtype: "integrity-violation", Key: "fim:pkg:/usr/bin/ssh",
Detail: "package file altered (openssh: SHA256 checksum mismatch): /usr/bin/ssh",
PIDs: []int{},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("alert = %+v, want %+v", got, want)
}
}
func TestVerifyAlertsConvertsEveryFinding(t *testing.T) {
run := func() (string, error) {
return "a: /usr/bin/a (Size mismatch)\nb: /usr/bin/b (UID mismatch)", nil
}
alerts := VerifyAlerts(run)
if len(alerts) != 2 {
t.Fatalf("alerts = %+v, want 2", alerts)
}
if alerts[0].Key != "fim:pkg:/usr/bin/a" || alerts[1].Key != "fim:pkg:/usr/bin/b" {
t.Errorf("unexpected alert keys: %s, %s", alerts[0].Key, alerts[1].Key)
}
}
// A host without pacman, or a verification that times out, must degrade to no
// alerts rather than reporting a false integrity violation.
func TestVerifyAlertsFailsQuietWhenVerificationCannotRun(t *testing.T) {
run := func() (string, error) { return "", errors.New("pacman: not found") }
alerts := VerifyAlerts(run)
if len(alerts) != 0 || alerts == nil {
t.Errorf("alerts = %+v, want empty non-nil slice", alerts)
}
}
// pacman writes diagnostics to stderr, so a non-zero exit still carries
// findings that must be parsed rather than discarded.
func TestVerifyAlertsParsesOutputEvenWithFindingsPresentOnFailureExit(t *testing.T) {
run := func() (string, error) {
return "openssh: /usr/bin/ssh (Size mismatch)", errors.New("exit status 1")
}
alerts := VerifyAlerts(run)
if len(alerts) != 1 {
t.Fatalf("alerts = %+v, want 1", alerts)
}
if alerts[0].Severity != "CRITICAL" {
t.Errorf("severity = %s, want CRITICAL", alerts[0].Severity)
}
}

View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// DefaultPaths mirrors Python's DEFAULT_FIM_PATHS: security-critical files the
// package manager does not track, so package verification cannot cover them.
var DefaultPaths = []string{
"/usr/local/bin", "/usr/local/sbin", "/usr/local/lib",
"/etc/systemd/system", "/etc/ld.so.preload", "/etc/ld.so.conf",
"/etc/ld.so.conf.d", "/etc/pam.d", "/etc/ssh/sshd_config",
"/etc/sudoers", "/etc/sudoers.d", "/etc/profile", "/etc/profile.d",
"/etc/bash.bashrc", "/etc/crontab", "/etc/cron.d", "/etc/cron.daily",
"/etc/cron.hourly", "/etc/passwd", "/etc/shadow", "/etc/group",
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
}
// SelfPaths mirrors Python's SELF_PATHS. Sentinel's own footprint is always
// monitored so tampering with the agent trips the agent.
var SelfPaths = []string{
"/usr/bin/enodia-sentinel", "/usr/local/bin/enodia-sentinel",
"/usr/bin/sentinel-redteam", "/usr/local/bin/sentinel-redteam",
"/usr/lib/enodia-sentinel", "/usr/local/lib/enodia-sentinel",
"/etc/enodia-sentinel.toml",
"/etc/systemd/system/enodia-sentinel.service",
"/etc/systemd/system/enodia-sentinel-web.service",
"/usr/lib/systemd/system/enodia-sentinel.service",
"/usr/lib/systemd/system/enodia-sentinel-web.service",
"/etc/pacman.d/hooks/enodia-sentinel-fim.hook",
"/usr/share/libalpm/hooks/enodia-sentinel-fim.hook",
}
const hashBufferSize = 1 << 16
// PathList returns the configured or default monitored paths plus Sentinel's
// own footprint, matching Python's Config.fim_path_list.
func PathList(configured []string) []string {
base := configured
if len(base) == 0 {
base = DefaultPaths
}
paths := make([]string, 0, len(base)+len(SelfPaths))
paths = append(paths, base...)
paths = append(paths, SelfPaths...)
return paths
}
// HashFile streams a file's SHA-256. The second return is false when the file
// cannot be read, which the caller records as an absent hash rather than an
// alert: an unreadable file is not evidence of tampering by itself.
func HashFile(path string) (string, bool) {
file, err := os.Open(path)
if err != nil {
return "", false
}
defer file.Close()
digest := sha256.New()
if _, err := io.CopyBuffer(digest, file, make([]byte, hashBufferSize)); err != nil {
return "", false
}
return hex.EncodeToString(digest.Sum(nil)), true
}
// entryFor builds one integrity record, or false for paths that carry no
// meaningful integrity state (sockets, devices, fifos).
func entryFor(path string) (Entry, bool) {
info, err := os.Lstat(path)
if err != nil {
return Entry{}, false
}
entry := Entry{Size: info.Size()}
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
// Permission bits alone would drop setuid/setgid/sticky, so take the
// same 0o7777 mask Python's stat.S_IMODE applies.
entry.Mode = int(stat.Mode & 0o7777)
entry.UID = int(stat.Uid)
entry.GID = int(stat.Gid)
}
switch {
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
target = ""
}
entry.Link = &target
case info.Mode().IsRegular():
if hash, ok := HashFile(path); ok {
entry.SHA256 = &hash
}
default:
return Entry{}, false
}
return entry, true
}
// ScanPaths maps path -> entry for every regular file and symlink under the
// given roots. Unreadable subtrees are skipped rather than failing the sweep.
func ScanPaths(paths []string) map[string]Entry {
entries := make(map[string]Entry)
for _, root := range paths {
info, err := os.Lstat(root)
if err != nil {
continue
}
// Python tests islink() before isdir(), so a symlink handed in
// directly is recorded as a link even when it points at a directory.
if !info.IsDir() {
if entry, ok := entryFor(root); ok {
entries[root] = entry
}
continue
}
walkRoot(root, entries)
}
return entries
}
func walkRoot(root string, entries map[string]Entry) {
_ = filepath.WalkDir(root, func(path string, dirEntry fs.DirEntry, err error) error {
if err != nil {
// Matches os.walk(onerror=...) swallowing permission errors.
return nil
}
if dirEntry.IsDir() {
return nil
}
// os.walk classifies with is_dir(), which follows symlinks: a symlink
// to a directory is listed as a directory and never scanned as a file.
if dirEntry.Type()&fs.ModeSymlink != 0 {
if target, err := os.Stat(path); err == nil && target.IsDir() {
return nil
}
}
if entry, ok := entryFor(path); ok {
entries[path] = entry
}
return nil
})
}

View file

@ -0,0 +1,245 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"syscall"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestHashFileStreamsSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "f")
writeFile(t, path, "sentinel")
sum := sha256.Sum256([]byte("sentinel"))
got, ok := HashFile(path)
if !ok {
t.Fatal("HashFile reported failure for a readable file")
}
if want := hex.EncodeToString(sum[:]); got != want {
t.Errorf("hash = %s, want %s", got, want)
}
}
func TestHashFileReportsFailureForMissingPath(t *testing.T) {
if _, ok := HashFile(filepath.Join(t.TempDir(), "absent")); ok {
t.Error("HashFile reported success for a missing file")
}
}
func TestScanPathsRecordsRegularFileWithHashAndMetadata(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f")
writeFile(t, path, "x")
entries := ScanPaths([]string{path})
entry, ok := entries[path]
if !ok {
t.Fatalf("no entry for %s, got %v", path, entries)
}
if entry.SHA256 == nil {
t.Error("regular file entry has no hash")
}
if entry.Link != nil {
t.Error("regular file entry must not carry a link target")
}
if entry.Size != 1 {
t.Errorf("size = %d, want 1", entry.Size)
}
if entry.Mode != 0o644 {
t.Errorf("mode = %o, want 644", entry.Mode)
}
}
// Permission bits alone are not enough: losing the setuid bit here would mean
// a binary silently gaining setuid never trips the baseline.
func TestScanPathsPreservesSetuidBit(t *testing.T) {
path := filepath.Join(t.TempDir(), "suid")
writeFile(t, path, "x")
// syscall.Chmod takes the raw mode; os.Chmod would need os.ModeSetuid and
// silently drops a bare 0o4000.
if err := syscall.Chmod(path, 0o4755); err != nil {
t.Fatal(err)
}
entry := ScanPaths([]string{path})[path]
if entry.Mode != 0o4755 {
t.Errorf("mode = %o, want 4755", entry.Mode)
}
}
func TestScanPathsRecordsSymlinkTargetWithoutHashing(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
link := filepath.Join(dir, "link")
writeFile(t, target, "x")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
entry, ok := ScanPaths([]string{link})[link]
if !ok {
t.Fatalf("no entry for symlink %s", link)
}
if entry.Link == nil || *entry.Link != target {
t.Errorf("link = %v, want %s", entry.Link, target)
}
if entry.SHA256 != nil {
t.Error("symlink entry must not be hashed")
}
}
func TestScanPathsRecordsBrokenSymlink(t *testing.T) {
dir := t.TempDir()
link := filepath.Join(dir, "broken")
if err := os.Symlink(filepath.Join(dir, "nowhere"), link); err != nil {
t.Fatal(err)
}
if _, ok := ScanPaths([]string{link})[link]; !ok {
t.Error("broken symlink was not recorded")
}
}
func TestScanPathsWalksDirectoriesRecursively(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "top"), "a")
writeFile(t, filepath.Join(dir, "sub", "nested"), "b")
entries := ScanPaths([]string{dir})
for _, want := range []string{filepath.Join(dir, "top"), filepath.Join(dir, "sub", "nested")} {
if _, ok := entries[want]; !ok {
t.Errorf("missing %s, got %v", want, entries)
}
}
if _, ok := entries[filepath.Join(dir, "sub")]; ok {
t.Error("directories themselves must not be recorded")
}
}
// Python's os.walk classifies with is_dir(), which follows symlinks, so a
// symlink to a directory lands in dirs and is never scanned as a file.
func TestScanPathsSkipsSymlinkToDirectoryDuringWalk(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "real", "f"), "a")
if err := os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "dirlink")); err != nil {
t.Fatal(err)
}
entries := ScanPaths([]string{dir})
if _, ok := entries[filepath.Join(dir, "dirlink")]; ok {
t.Error("symlink to a directory must not be recorded during a walk")
}
if _, ok := entries[filepath.Join(dir, "dirlink", "f")]; ok {
t.Error("symlinks to directories must not be followed")
}
}
// At the top level Python checks islink() before isdir(), so the same symlink
// passed directly as a root IS recorded.
func TestScanPathsRecordsSymlinkToDirectoryGivenAsRoot(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "real"), 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "dirlink")
if err := os.Symlink(filepath.Join(dir, "real"), link); err != nil {
t.Fatal(err)
}
entry, ok := ScanPaths([]string{link})[link]
if !ok {
t.Fatal("symlink root was not recorded")
}
if entry.Link == nil {
t.Error("symlink root recorded without a link target")
}
}
func TestScanPathsSkipsFifosAndOtherSpecialFiles(t *testing.T) {
dir := t.TempDir()
fifo := filepath.Join(dir, "pipe")
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
t.Skipf("cannot create fifo: %v", err)
}
if _, ok := ScanPaths([]string{dir})[fifo]; ok {
t.Error("fifo must be skipped")
}
}
func TestScanPathsIgnoresMissingRoots(t *testing.T) {
entries := ScanPaths([]string{filepath.Join(t.TempDir(), "absent")})
if len(entries) != 0 {
t.Errorf("entries = %v, want empty", entries)
}
}
// A single unreadable file must not abort the sweep — the rest of the tree
// still has to be recorded.
func TestScanPathsContinuesPastUnreadableFile(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can read mode 000 files")
}
dir := t.TempDir()
secret := filepath.Join(dir, "secret")
writeFile(t, secret, "x")
writeFile(t, filepath.Join(dir, "readable"), "y")
if err := os.Chmod(secret, 0o000); err != nil {
t.Fatal(err)
}
entries := ScanPaths([]string{dir})
if _, ok := entries[filepath.Join(dir, "readable")]; !ok {
t.Error("readable sibling was dropped")
}
entry, ok := entries[secret]
if !ok {
t.Fatal("unreadable file must still be recorded via lstat")
}
if entry.SHA256 != nil {
t.Error("unreadable file must record a nil hash, not a bogus one")
}
}
func TestScanPathsReturnsEmptyMapForNoPaths(t *testing.T) {
if entries := ScanPaths(nil); entries == nil || len(entries) != 0 {
t.Errorf("entries = %v, want empty non-nil map", entries)
}
}
func TestDefaultPathsCoverCriticalUnpackagedLocations(t *testing.T) {
required := []string{"/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/usr/local/bin"}
present := make(map[string]bool, len(DefaultPaths))
for _, path := range DefaultPaths {
present[path] = true
}
for _, path := range required {
if !present[path] {
t.Errorf("DefaultPaths missing %s", path)
}
}
}

View file

@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package pkgdb
import (
"os"
"path/filepath"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// Manager owns the slow package-database fingerprint outside the polling path.
// One check runs at a time; an unavailable pacman database or log fails open
// rather than turning an environmental read error into a tamper claim.
type Manager struct {
Config config.Config
Now func() time.Time
Fingerprint func(string) (string, error)
LastTransaction func(string) (time.Time, bool)
DBDir string
PacmanLog string
mu sync.Mutex
initialized bool
lastRun time.Time
running bool
pendingAlerts []model.Alert
}
func New(cfg config.Config) *Manager {
return &Manager{
Config: cfg, Now: time.Now, Fingerprint: Fingerprint,
LastTransaction: LastTransactionTime, DBDir: defaultDBDir, PacmanLog: defaultPacmanLog,
}
}
func (m *Manager) anchorPath() string {
return filepath.Join(m.Config.LogDir, anchorFilename)
}
// Initialize records the first available fingerprint synchronously. This is a
// one-time baseline action before events are emitted; subsequent checks stay
// asynchronous and never rewrite unexplained drift.
func (m *Manager) Initialize() error {
if !m.Config.PackageDBVerify {
return nil
}
m.mu.Lock()
if m.initialized {
m.mu.Unlock()
return nil
}
m.mu.Unlock()
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
return err
}
if err := m.evaluate(m.Now()); err != nil {
return err
}
m.mu.Lock()
m.initialized = true
m.mu.Unlock()
return nil
}
// MaybeStart starts a due fingerprint in the background. Setting lastRun
// before launching makes a stalled disk scan self-throttling instead of
// spawning one goroutine per polling interval.
func (m *Manager) MaybeStart(now time.Time) {
if !m.Config.PackageDBVerify {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if !m.initialized || m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.PackageDBInterval) {
return
}
m.running, m.lastRun = true, now
go m.run(now)
}
func (m *Manager) run(now time.Time) {
_ = m.evaluate(now) // Collection errors are intentionally fail-open.
m.mu.Lock()
m.running = false
m.mu.Unlock()
}
func (m *Manager) evaluate(now time.Time) error {
current, err := m.Fingerprint(m.DBDir)
if err != nil {
return nil
}
anchor, hasAnchor := loadAnchor(m.anchorPath())
transaction, hasTransaction := m.LastTransaction(m.PacmanLog)
alert, replacement := check(current, anchor, hasAnchor, transaction, hasTransaction, now)
if replacement != nil {
if err := saveAnchor(m.anchorPath(), *replacement); err != nil {
return err
}
}
if alert != nil {
m.mu.Lock()
m.pendingAlerts = append(m.pendingAlerts, *alert)
m.mu.Unlock()
}
return nil
}
// Drain hands completed findings to Agent, which applies the shared cooldown,
// snapshot, and event-stream ordering before any finding becomes observable.
func (m *Manager) Drain() []model.Alert {
m.mu.Lock()
defer m.mu.Unlock()
alerts := append([]model.Alert(nil), m.pendingAlerts...)
m.pendingAlerts = nil
return alerts
}

View file

@ -0,0 +1,152 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package pkgdb ports Sentinel's layer-one pacman database tamper evidence.
// It does not treat the mutable local database as a root of trust: it records
// a baseline fingerprint, permits only transaction-correlated change, and
// retains the old anchor when a change is unexplained.
package pkgdb
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
SIDTamper = 100021
defaultDBDir = "/var/lib/pacman/local"
defaultPacmanLog = "/var/log/pacman.log"
anchorFilename = "pkgdb-anchor.json"
transactionSlop = 120 * time.Second
)
// Anchor is deliberately compatible with Python's small JSON record. Time is
// an epoch value so both implementations can compare it with pacman's log
// timestamp without formatting or local-time ambiguity.
type Anchor struct {
Fingerprint string `json:"fingerprint"`
Time float64 `json:"time"`
}
// Fingerprint computes the deterministic SHA-256 used for the local package
// database. It reads each non-directory entry in lexical walk order; a read
// failure makes the whole check unavailable rather than producing a partial
// fingerprint that could become an unsafe new baseline.
func Fingerprint(directory string) (string, error) {
hash := sha256.New()
err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if _, err := io.WriteString(hash, path+"\x00"); err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(hash, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// LastTransactionTime returns the final pacman transaction marker. A missing
// or unparseable log is intentionally inconclusive; it is never treated as a
// successful transaction that could authorize a changed database.
func LastTransactionTime(path string) (time.Time, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return time.Time{}, false
}
var latest time.Time
for _, line := range strings.Split(string(raw), "\n") {
if !strings.Contains(line, "transaction completed") && !strings.Contains(line, "transaction started") {
continue
}
end := strings.IndexByte(line, ']')
if !strings.HasPrefix(line, "[") || end < 2 {
continue
}
stamp := line[1:end]
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700"} {
if parsed, err := time.Parse(layout, stamp); err == nil {
latest = parsed
break
}
}
}
return latest, !latest.IsZero()
}
func tamperAlert() model.Alert {
return model.Alert{
SID: SIDTamper, Severity: "CRITICAL", Signature: "pkgdb_tamper",
Classtype: "anti-tamper", Key: "pkgdb:tamper", PIDs: []int{},
Detail: "pacman local DB changed with no corresponding transaction — the package checksum database may have been altered to mask file tampering",
}
}
func loadAnchor(path string) (Anchor, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return Anchor{}, false
}
var anchor Anchor
if err := json.Unmarshal(raw, &anchor); err != nil || anchor.Fingerprint == "" || anchor.Time <= 0 {
return Anchor{}, false
}
return anchor, true
}
func saveAnchor(path string, anchor Anchor) error {
raw, err := json.Marshal(anchor)
if err != nil {
return err
}
raw = append(raw, '\n')
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
return os.Chmod(path, 0o640)
}
// check returns an alert only for changed state that no later pacman
// transaction can explain. It deliberately returns a replacement anchor only
// for first observation or a transaction-correlated change.
func check(current string, anchor Anchor, hasAnchor bool, transaction time.Time, hasTransaction bool, now time.Time) (*model.Alert, *Anchor) {
if !hasAnchor {
fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())}
return nil, &fresh
}
if current == anchor.Fingerprint {
return nil, nil
}
if hasTransaction && !transaction.Before(time.Unix(int64(anchor.Time), 0).Add(-transactionSlop)) {
fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())}
return nil, &fresh
}
alert := tamperAlert()
return &alert, nil
}

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package pkgdb
import (
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
)
func write(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o640); err != nil {
t.Fatal(err)
}
}
func TestFingerprintIsDeterministicAndChangesWithContent(t *testing.T) {
directory := t.TempDir()
write(t, filepath.Join(directory, "b", "desc"), "second")
write(t, filepath.Join(directory, "a", "desc"), "first")
first, err := Fingerprint(directory)
if err != nil {
t.Fatal(err)
}
again, err := Fingerprint(directory)
if err != nil || first != again {
t.Fatalf("fingerprints %q %q err=%v", first, again, err)
}
write(t, filepath.Join(directory, "a", "desc"), "changed")
changed, err := Fingerprint(directory)
if err != nil || changed == first {
t.Fatalf("changed fingerprint %q original %q err=%v", changed, first, err)
}
}
func TestLastTransactionTimeAcceptsPacmanTimestampLayouts(t *testing.T) {
path := filepath.Join(t.TempDir(), "pacman.log")
write(t, path, "[2026-07-24T10:00:00-0700] [ALPM] transaction started\n[2026-07-24T10:01:00-07:00] [ALPM] transaction completed\n")
got, ok := LastTransactionTime(path)
if !ok || got.Format(time.RFC3339) != "2026-07-24T10:01:00-07:00" {
t.Fatalf("transaction=%v ok=%v", got, ok)
}
}
func TestManagerPreservesUnexplainedChangedAnchorAndAcceptsTransaction(t *testing.T) {
directory := t.TempDir()
database := filepath.Join(directory, "pacman-db")
write(t, filepath.Join(database, "pkg", "desc"), "trusted")
cfg := config.Default()
cfg.LogDir = filepath.Join(directory, "state")
manager := New(cfg)
manager.DBDir = database
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
manager.Now = func() time.Time { return now }
manager.LastTransaction = func(string) (time.Time, bool) { return time.Time{}, false }
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
initial, ok := loadAnchor(manager.anchorPath())
if !ok {
t.Fatal("missing initial anchor")
}
write(t, filepath.Join(database, "pkg", "desc"), "tampered")
if err := manager.evaluate(now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
alerts := manager.Drain()
if len(alerts) != 1 || alerts[0].SID != SIDTamper || alerts[0].Severity != "CRITICAL" {
t.Fatalf("alerts=%+v", alerts)
}
afterTamper, _ := loadAnchor(manager.anchorPath())
if afterTamper.Fingerprint != initial.Fingerprint {
t.Fatal("unexplained change silently replaced anchor")
}
manager.LastTransaction = func(string) (time.Time, bool) { return now.Add(time.Minute), true }
if err := manager.evaluate(now.Add(2 * time.Minute)); err != nil {
t.Fatal(err)
}
afterTransaction, _ := loadAnchor(manager.anchorPath())
if afterTransaction.Fingerprint == initial.Fingerprint {
t.Fatal("transaction-correlated change did not refresh anchor")
}
}

View file

@ -0,0 +1,351 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package rootcheck
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
procRoot = "/proc"
sysRoot = "/sys"
)
// Collect obtains the independent local views used by Check. Every source is
// best-effort: unavailable procfs/sysfs files or a missing ps binary remove
// only that comparison, never turn a collection error into a rootkit alert.
func Collect(ctx context.Context, state model.State, pidCap int) Views {
visible := procPIDs()
ps, psAvailable := psPIDs(ctx)
return Views{
VisiblePIDs: visible,
AlivePIDs: alivePIDs(ctx, pidCap),
PSPIDs: ps,
PSAvailable: psAvailable,
PIDAlive: pidAlive,
ProcPIDExists: procPIDExists,
ProcModules: procModules(),
SysLiveModules: sysLiveModules(),
ModuleTaints: moduleTaints(),
KernelTaint: kernelTaint(),
ProcTCPPorts: procTCPPorts(),
SSTCPPorts: ssTCPPorts(state),
ProcUDPPorts: procUDPPorts(),
SSUDPPorts: ssUDPPorts(state),
ProcRawProtocols: procRawProtocols(),
SSRawProtocols: ssRawProtocols(state),
ProcProtocolKinds: procProtocolKinds(),
SSProtocolKinds: ssProtocolKinds(state),
PromiscuousIfaces: promiscuousIfaces(),
}
}
// Run collects a bounded live view then evaluates it against operator policy.
func Run(ctx context.Context, state model.State, pidCap int, allowed map[string]bool) []model.Alert {
return Check(Collect(ctx, state, pidCap), Options{AllowedModules: allowed})
}
func procPIDs() map[int]bool {
result := map[int]bool{}
entries, err := os.ReadDir(procRoot)
if err != nil {
return result
}
for _, entry := range entries {
if pid, err := strconv.Atoi(entry.Name()); err == nil && pid >= 0 {
result[pid] = true
}
}
return result
}
func alivePIDs(ctx context.Context, cap int) map[int]bool {
// kill(pid, 0) asks the kernel whether a PID exists without signalling it;
// that is intentionally independent of the procfs directory view. Limit
// the sweep because pid_max may be very large, and return partial results on
// cancellation rather than delaying every other integrity comparison.
result := map[int]bool{}
if cap <= 0 {
return result
}
maximum := cap
if raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/pid_max")); err == nil {
if value, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil && value < maximum {
maximum = value
}
}
for pid := 1; pid <= maximum; pid++ {
select {
case <-ctx.Done():
return result
default:
}
err := syscall.Kill(pid, 0)
if err == nil || errors.Is(err, syscall.EPERM) {
result[pid] = true
}
}
return result
}
func pidAlive(pid int) bool {
// EPERM still proves that the kernel knows about the PID; it only means the
// sidecar lacks permission to inspect or signal that process.
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
func procPIDExists(pid int) bool {
// Keep this separate from pidAlive: rootcheck alerts on disagreement between
// kernel process lookup and the procfs namespace it is expected to expose.
_, err := os.Stat(filepath.Join(procRoot, strconv.Itoa(pid)))
return err == nil
}
func psPIDs(ctx context.Context) (map[int]bool, bool) {
// ps provides a third, userspace view. A missing or failing command disables
// only this comparison because an unavailable tool is not evidence of a
// hidden process.
command := exec.CommandContext(ctx, "ps", "-e", "-o", "pid=")
raw, err := command.Output()
if err != nil {
return map[int]bool{}, false
}
result := map[int]bool{}
for _, line := range strings.Split(string(raw), "\n") {
if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
result[pid] = true
}
}
return result, true
}
func procModules() map[string]bool {
result := map[string]bool{}
raw, err := os.ReadFile(filepath.Join(procRoot, "modules"))
if err != nil {
return result
}
for _, line := range strings.Split(string(raw), "\n") {
if fields := strings.Fields(line); len(fields) > 0 {
result[fields[0]] = true
}
}
return result
}
func sysLiveModules() map[string]bool {
result := map[string]bool{}
entries, err := os.ReadDir(filepath.Join(sysRoot, "module"))
if err != nil {
return result
}
for _, entry := range entries {
raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "initstate"))
if err == nil && strings.TrimSpace(string(raw)) == "live" {
result[entry.Name()] = true
}
}
return result
}
func moduleTaints() map[string]string {
result := map[string]string{}
entries, err := os.ReadDir(filepath.Join(sysRoot, "module"))
if err != nil {
return result
}
for _, entry := range entries {
raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "taint"))
taint := strings.TrimSpace(string(raw))
if err == nil && taint != "" && taint != "0" {
result[entry.Name()] = taint
}
}
return result
}
func kernelTaint() int {
raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/tainted"))
if err != nil {
return 0
}
value, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return 0
}
return value
}
func procTCPPorts() map[int]bool {
return unionInts(readSocketPorts("net/tcp", true), readSocketPorts("net/tcp6", true))
}
func procUDPPorts() map[int]bool {
return unionInts(readSocketPorts("net/udp", false), readSocketPorts("net/udp6", false))
}
func procRawProtocols() map[int]bool {
return unionInts(readSocketPorts("net/raw", false), readSocketPorts("net/raw6", false))
}
func readSocketPorts(relative string, listenOnly bool) map[int]bool {
// /proc/net/{tcp,udp,raw} encodes the local endpoint as hex ADDRESS:PORT.
// TCP's 0A state is LISTEN; UDP and raw entries have no equivalent listener
// state, so callers retain every bound protocol/port for comparison.
result := map[int]bool{}
raw, err := os.ReadFile(filepath.Join(procRoot, relative))
if err != nil {
return result
}
lines := strings.Split(string(raw), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 4 || (listenOnly && fields[3] != "0A") {
continue
}
_, portText, ok := strings.Cut(fields[1], ":")
if !ok {
continue
}
port, err := strconv.ParseInt(portText, 16, 32)
if err == nil && port != 0 {
result[int(port)] = true
}
}
return result
}
func procProtocolKinds() map[string]bool {
checks := map[string][]string{
"sctp": {"net/sctp/eps", "net/sctp/assocs", "net/sctp/remaddr"},
"dccp": {"net/dccp", "net/dccp6"}, "packet": {"net/packet"},
"tipc": {"net/tipc/socket"}, "xdp": {"net/xdp"},
}
result := map[string]bool{}
for kind, paths := range checks {
for _, path := range paths {
if socketRows(path) {
result[kind] = true
break
}
}
}
return result
}
func socketRows(relative string) bool {
raw, err := os.ReadFile(filepath.Join(procRoot, relative))
if err != nil {
return false
}
lines := strings.Split(string(raw), "\n")
for _, line := range lines[1:] {
if strings.TrimSpace(line) != "" {
return true
}
}
return false
}
func ssTCPPorts(state model.State) map[int]bool {
result := map[int]bool{}
for _, socket := range state.Sockets {
if socket.State == "LISTEN" {
if port, ok := endpointPort(socket.Local); ok {
result[port] = true
}
}
}
return result
}
func ssUDPPorts(state model.State) map[int]bool {
result := map[int]bool{}
for _, socket := range state.Sockets {
if socket.State == "UNCONN" || socket.State == "ESTAB" {
if port, ok := endpointPort(socket.Local); ok {
result[port] = true
}
}
}
return result
}
func ssRawProtocols(state model.State) map[int]bool {
result := map[int]bool{}
for _, socket := range state.Sockets {
if socket.Kind == "raw" {
if protocol, ok := endpointProtocol(socket.Local); ok {
result[protocol] = true
}
}
}
return result
}
func ssProtocolKinds(state model.State) map[string]bool {
result := map[string]bool{}
for _, socket := range state.Sockets {
if socket.Kind != "" {
result[socket.Kind] = true
}
}
return result
}
func endpointPort(value string) (int, bool) {
index := strings.LastIndex(value, ":")
if index < 0 {
return 0, false
}
tail := value[index+1:]
port, err := strconv.Atoi(tail)
return port, err == nil
}
func endpointProtocol(value string) (int, bool) {
index := strings.LastIndex(value, ":")
if index < 0 {
return 0, false
}
tail := value[index+1:]
tail = strings.ToLower(strings.Trim(tail, "[]"))
if value, err := strconv.Atoi(tail); err == nil {
return value, true
}
protocol, ok := rawProtocolNames[tail]
return protocol, ok
}
func promiscuousIfaces() []string {
result := make([]string, 0)
entries, err := os.ReadDir(filepath.Join(sysRoot, "class/net"))
if err != nil {
return result
}
for _, entry := range entries {
raw, err := os.ReadFile(filepath.Join(sysRoot, "class/net", entry.Name(), "flags"))
if err != nil {
continue
}
flags, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 0, 64)
if err == nil && flags&0x100 != 0 {
result = append(result, entry.Name())
}
}
return result
}
func unionInts(left, right map[int]bool) map[int]bool {
result := map[int]bool{}
for value := range left {
result[value] = true
}
for value := range right {
result[value] = true
}
return result
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package rootcheck
import (
"context"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const runTimeout = 30 * time.Second
// Manager schedules the expensive cross-view collection independently from
// polling. One bounded check may run at a time; completed alerts return to the
// Agent's existing cooldown, incident, and snapshot path on the next sweep.
type Manager struct {
Config config.Config
Now func() time.Time
Run func(context.Context, model.State) []model.Alert
mu sync.Mutex
lastRun time.Time
running bool
pending []model.Alert
}
func New(cfg config.Config) *Manager {
manager := &Manager{Config: cfg, Now: time.Now}
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
return Run(ctx, state, cfg.RootcheckPIDCap, cfg.RootcheckModuleAllow)
}
return manager
}
func (m *Manager) MaybeStart(now time.Time, state model.State) {
if !m.Config.RootcheckEnabled {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.RootcheckInterval) {
return
}
// Record the start before launching the goroutine. This prevents a fast
// polling loop from scheduling duplicates while collection is still running
// or while a timed-out external command unwinds.
m.running, m.lastRun = true, now
go m.run(state)
}
func (m *Manager) run(state model.State) {
// Rootcheck compares several host views and may invoke ps; its deadline
// makes that diagnostic work bounded even on a degraded host.
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
defer cancel()
alerts := m.Run(ctx, state)
m.mu.Lock()
m.pending = append(m.pending, alerts...)
m.running = false
m.mu.Unlock()
}
func (m *Manager) Drain() []model.Alert {
m.mu.Lock()
defer m.mu.Unlock()
alerts := append([]model.Alert(nil), m.pending...)
m.pending = nil
return alerts
}

View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package rootcheck
import (
"context"
"sync/atomic"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestManagerRunsAtMostOneBoundedCheckAndDrainsResults(t *testing.T) {
cfg := config.Default()
cfg.RootcheckInterval = time.Hour
manager := New(cfg)
started := make(chan struct{})
release := make(chan struct{})
var calls atomic.Int32
var sawDeadline atomic.Bool
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
_, ok := ctx.Deadline()
sawDeadline.Store(ok)
calls.Add(1)
close(started)
<-release
return []model.Alert{{SID: SIDHiddenModule, Key: "rk:test", PIDs: []int{}}}
}
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
manager.MaybeStart(now, model.State{})
<-started
manager.MaybeStart(now.Add(2*time.Hour), model.State{})
if got := calls.Load(); got != 1 {
t.Fatalf("concurrent checks = %d, want 1", got)
}
close(release)
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
if alerts := manager.Drain(); len(alerts) == 1 {
if !sawDeadline.Load() {
t.Fatal("rootcheck did not receive a bounded context")
}
if alerts[0].SID != SIDHiddenModule {
t.Fatalf("alerts = %+v", alerts)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("completed rootcheck result was not drained")
}
func TestDisabledManagerNeverSchedules(t *testing.T) {
cfg := config.Default()
cfg.RootcheckEnabled = false
manager := New(cfg)
var calls atomic.Int32
manager.Run = func(context.Context, model.State) []model.Alert { calls.Add(1); return nil }
manager.MaybeStart(time.Now(), model.State{})
if calls.Load() != 0 {
t.Fatal("disabled rootcheck scheduled work")
}
}

View file

@ -0,0 +1,287 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package rootcheck ports Sentinel's user-space anti-rootkit cross-view
// checks. It cannot prove a kernel free of a rootkit: a sufficiently capable
// implant can lie consistently to every local view. It does catch common
// hiding faults by comparing independently collected kernel, procfs, sysfs,
// socket-tool, and interface views.
package rootcheck
import (
"sort"
"strconv"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
SIDHiddenProcess = 100022
SIDHiddenModule = 100023
SIDHiddenPort = 100024
SIDPromiscuous = 100025
SIDKnownRootkitModule = 100028
SIDTaintedModule = 100029
SIDKernelTainted = 100030
SIDHiddenUDPPort = 100031
SIDHiddenRawSocket = 100034
SIDRawICMPSocket = 100035
SIDHiddenProtocolSocket = 100037
SIDPSHiddenProcess = 100038
)
var knownModules = map[string]bool{
"adore": true, "adore_ng": true, "azazel": true, "diamorphine": true,
"ipsecs_kbeast": true, "kbeast": true, "knark": true, "reptile": true,
"suterusu": true, "syslogk": true,
}
var taintFlags = map[int]string{
0: "proprietary-module", 1: "forced-module-load", 2: "unsafe-smp",
3: "forced-module-unload", 4: "machine-check", 5: "bad-page",
6: "user-tainted", 7: "kernel-died-recently", 8: "acpi-override",
9: "kernel-warning", 10: "staging-driver", 11: "firmware-workaround",
12: "out-of-tree-module", 13: "unsigned-module", 14: "soft-lockup",
15: "live-patched", 16: "auxiliary-taint", 17: "randstruct-plugin",
}
var highRiskTaintBits = map[int]bool{1: true, 3: true, 7: true, 12: true, 13: true}
var rawProtocolNames = map[string]int{"icmp": 1, "ipv6-icmp": 58, "icmp6": 58, "sctp": 132}
// Views contains independently collected host observations. The two PID
// callbacks preserve Python's race checks: a hidden-process candidate must
// still be alive through the kernel while remaining absent from procfs; a
// ps-only candidate must still be present in procfs.
type Views struct {
VisiblePIDs map[int]bool
AlivePIDs map[int]bool
PSPIDs map[int]bool
PSAvailable bool
PIDAlive func(int) bool
ProcPIDExists func(int) bool
ProcModules map[string]bool
SysLiveModules map[string]bool
ModuleTaints map[string]string
KernelTaint int
ProcTCPPorts map[int]bool
SSTCPPorts map[int]bool
ProcUDPPorts map[int]bool
SSUDPPorts map[int]bool
ProcRawProtocols map[int]bool
SSRawProtocols map[int]bool
ProcProtocolKinds map[string]bool
SSProtocolKinds map[string]bool
PromiscuousIfaces []string
}
// Options supplies explicit operator policy. The PID cap is enforced by the
// collector; Check is deliberately pure so parity tests do not touch a host.
type Options struct {
AllowedModules map[string]bool
}
// Check turns a captured set of cross-views into Python-compatible alerts.
func Check(views Views, options Options) []model.Alert {
alerts := make([]model.Alert, 0)
confirmed := make([]int, 0)
for _, pid := range sortedIntDifference(views.AlivePIDs, views.VisiblePIDs) {
alive := views.PIDAlive == nil || views.PIDAlive(pid)
hidden := views.ProcPIDExists == nil || !views.ProcPIDExists(pid)
if alive && hidden {
confirmed = append(confirmed, pid)
}
}
if len(confirmed) > 0 {
shown := confirmed
if len(shown) > 20 {
shown = shown[:20]
}
alerts = append(alerts, model.Alert{SID: SIDHiddenProcess, Severity: "CRITICAL",
Signature: "rootkit_hidden_process", Classtype: "rootkit-hidden-process",
Key: "rk:hidproc:" + itoa(shown[0]),
Detail: "process(es) alive but hidden from /proc: " + joinInts(shown), PIDs: shown})
}
if views.PSAvailable {
missing := make([]int, 0)
for _, pid := range sortedIntDifference(views.VisiblePIDs, views.PSPIDs) {
if views.ProcPIDExists == nil || views.ProcPIDExists(pid) {
missing = append(missing, pid)
}
}
if len(missing) > 0 {
shown := missing
if len(shown) > 20 {
shown = shown[:20]
}
alerts = append(alerts, model.Alert{SID: SIDPSHiddenProcess, Severity: "HIGH",
Signature: "rootkit_ps_hidden_process", Classtype: "rootkit-hidden-process",
Key: "rk:pshidproc:" + itoa(shown[0]),
Detail: "process(es) visible in /proc but missing from ps output (process tool may be hooked): " + joinInts(shown), PIDs: shown})
}
}
for _, module := range sortedStringDifference(views.SysLiveModules, views.ProcModules) {
alerts = append(alerts, alert(SIDHiddenModule, "CRITICAL", "rootkit_hidden_module", "rootkit-hidden-module", "rk:hidmod:"+module,
"kernel module live in /sys/module but hidden from /proc/modules: "+module))
}
allModules := unionStrings(views.ProcModules, views.SysLiveModules)
known := map[string]bool{}
for module := range allModules {
if knownModules[normalizeModule(module)] {
known[module] = true
alerts = append(alerts, alert(SIDKnownRootkitModule, "CRITICAL", "rootkit_known_module", "rootkit-known-module", "rk:knownmod:"+module,
"known LKM rootkit module name loaded or visible: "+module))
}
}
for _, module := range sortedKeysString(views.ModuleTaints) {
if moduleAllowed(options.AllowedModules, module) || known[module] {
continue
}
taint := views.ModuleTaints[module]
alerts = append(alerts, alert(SIDTaintedModule, "HIGH", "rootkit_tainted_module", "rootkit-tainted-module", "rk:taintmod:"+module,
"kernel module has taint marker '"+taint+"' (out-of-tree/proprietary/unsigned module): "+module))
}
if views.KernelTaint != 0 {
severity := "MEDIUM"
for bit := range highRiskTaintBits {
if views.KernelTaint&(1<<bit) != 0 {
severity = "HIGH"
break
}
}
flags := decodeTaint(views.KernelTaint)
label := strings.Join(flags, ", ")
if label == "" {
label = "unknown"
}
alerts = append(alerts, alert(SIDKernelTainted, severity, "kernel_tainted", "kernel-integrity", "rk:ktaint:"+itoa(views.KernelTaint),
"kernel taint value "+itoa(views.KernelTaint)+" ("+label+") — may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"))
}
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcTCPPorts, views.SSTCPPorts), SIDHiddenPort, "rootkit_hidden_port", "rk:hidport:",
"listening port(s) in /proc/net/tcp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcUDPPorts, views.SSUDPPorts), SIDHiddenUDPPort, "rootkit_hidden_udp_port", "rk:hidudp:",
"UDP port(s) in /proc/net/udp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
raw := sortedIntDifference(views.ProcRawProtocols, views.SSRawProtocols)
alerts = appendPortAlert(alerts, raw, SIDHiddenRawSocket, "rootkit_hidden_raw_socket", "rk:hidraw:",
"raw socket protocol(s) in /proc/net/raw not reported by ss (tool may be hooked): ", "rootkit-hidden-socket")
icmp := make([]int, 0)
for _, protocol := range sortedKeys(views.ProcRawProtocols) {
if protocol == 1 || protocol == 58 {
icmp = append(icmp, protocol)
}
}
if len(icmp) > 0 {
alerts = append(alerts, alert(SIDRawICMPSocket, "HIGH", "raw_icmp_socket", "raw-socket-backdoor", "rk:rawicmp:"+itoa(icmp[0]),
"persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "+joinInts(icmp)+" — validate authorized ping/monitoring tools or ICMP knockers"))
}
protocols := sortedStringDifference(views.ProcProtocolKinds, views.SSProtocolKinds)
if len(protocols) > 0 {
alerts = append(alerts, alert(SIDHiddenProtocolSocket, "HIGH", "rootkit_hidden_protocol_socket", "rootkit-hidden-socket", "rk:hidproto:"+protocols[0],
"special protocol socket family present in /proc/net but absent from ss output (tool may be hooked): "+strings.Join(protocols, ", ")))
}
for _, iface := range sortedStrings(views.PromiscuousIfaces) {
alerts = append(alerts, alert(SIDPromiscuous, "MEDIUM", "promiscuous_interface", "network-sniffer", "rk:promisc:"+iface,
"interface in promiscuous mode (possible sniffer): "+iface))
}
return alerts
}
func appendPortAlert(alerts []model.Alert, values []int, sid int, signature, keyPrefix, detailPrefix, class string) []model.Alert {
if len(values) == 0 {
return alerts
}
return append(alerts, alert(sid, "HIGH", signature, class, keyPrefix+itoa(values[0]), detailPrefix+joinInts(values)))
}
func alert(sid int, severity, signature, class, key, detail string) model.Alert {
return model.Alert{SID: sid, Severity: severity, Signature: signature, Classtype: class, Key: key, Detail: detail, PIDs: []int{}}
}
func normalizeModule(value string) string {
return strings.ReplaceAll(strings.ToLower(value), "-", "_")
}
func moduleAllowed(allowed map[string]bool, module string) bool {
for name := range allowed {
if normalizeModule(name) == normalizeModule(module) {
return true
}
}
return false
}
func itoa(value int) string { return strconv.Itoa(value) }
func sortedIntDifference(left, right map[int]bool) []int {
values := make([]int, 0)
for value := range left {
if !right[value] {
values = append(values, value)
}
}
sort.Ints(values)
return values
}
func sortedStringDifference(left, right map[string]bool) []string {
values := make([]string, 0)
for value := range left {
if !right[value] {
values = append(values, value)
}
}
sort.Strings(values)
return values
}
func sortedKeys[V any](source map[int]V) []int {
values := make([]int, 0, len(source))
for value := range source {
values = append(values, value)
}
sort.Ints(values)
return values
}
func sortedStrings(values []string) []string {
result := append([]string(nil), values...)
sort.Strings(result)
return result
}
func sortedKeysString[V any](source map[string]V) []string {
values := make([]string, 0, len(source))
for value := range source {
values = append(values, value)
}
sort.Strings(values)
return values
}
func unionStrings(left, right map[string]bool) map[string]bool {
result := map[string]bool{}
for value := range left {
result[value] = true
}
for value := range right {
result[value] = true
}
return result
}
func decodeTaint(value int) []string {
keys := make([]int, 0, len(taintFlags))
for bit := range taintFlags {
keys = append(keys, bit)
}
sort.Ints(keys)
values := make([]string, 0)
for _, bit := range keys {
if value&(1<<bit) != 0 {
values = append(values, taintFlags[bit])
}
}
return values
}
func joinInts(values []int) string {
rendered := make([]string, len(values))
for i, value := range values {
rendered[i] = itoa(value)
}
return strings.Join(rendered, ", ")
}

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package rootcheck
import (
"reflect"
"testing"
)
func setInts(values ...int) map[int]bool {
result := map[int]bool{}
for _, value := range values {
result[value] = true
}
return result
}
func setStrings(values ...string) map[string]bool {
result := map[string]bool{}
for _, value := range values {
result[value] = true
}
return result
}
func TestCheckIsQuietWhenCrossViewsAgree(t *testing.T) {
alerts := Check(Views{
VisiblePIDs: setInts(1, 2), AlivePIDs: setInts(1, 2),
PSPIDs: setInts(1, 2), PSAvailable: true,
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4"),
}, Options{})
if len(alerts) != 0 {
t.Fatalf("alerts = %+v, want none", alerts)
}
}
func TestCheckReportsRootkitCrossViewSignalsInPythonOrder(t *testing.T) {
alerts := Check(Views{
VisiblePIDs: setInts(1), AlivePIDs: setInts(1), PSPIDs: setInts(1), PSAvailable: true,
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4", "diamorphine"),
ModuleTaints: map[string]string{"vendor_gpu": "OE"}, KernelTaint: (1 << 12) | (1 << 13),
ProcTCPPorts: setInts(22, 31337), SSTCPPorts: setInts(22),
ProcUDPPorts: setInts(53, 4444), SSUDPPorts: setInts(53),
ProcRawProtocols: setInts(1, 58), SSRawProtocols: setInts(58),
ProcProtocolKinds: setStrings("sctp", "packet"), SSProtocolKinds: setStrings("packet"),
PromiscuousIfaces: []string{"eth0"},
}, Options{})
got := make([]int, len(alerts))
for index, alert := range alerts {
got[index] = alert.SID
}
want := []int{SIDHiddenModule, SIDKnownRootkitModule, SIDTaintedModule, SIDKernelTainted,
SIDHiddenPort, SIDHiddenUDPPort, SIDHiddenRawSocket, SIDRawICMPSocket,
SIDHiddenProtocolSocket, SIDPromiscuous}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SIDs = %v, want %v", got, want)
}
if alerts[0].Severity != "CRITICAL" || alerts[1].Signature != "rootkit_known_module" ||
alerts[3].Severity != "HIGH" || alerts[7].Key != "rk:rawicmp:1" {
t.Fatalf("unexpected alert detail: %+v", alerts)
}
}
func TestCheckRechecksProcessCandidatesAndLimitsRenderedPIDs(t *testing.T) {
alive := map[int]bool{}
for pid := 1; pid <= 25; pid++ {
alive[pid] = true
}
alerts := Check(Views{AlivePIDs: alive, VisiblePIDs: map[int]bool{},
PIDAlive: func(pid int) bool { return pid != 1 }, ProcPIDExists: func(int) bool { return false },
}, Options{})
if len(alerts) != 1 {
t.Fatalf("alerts = %+v, want one", alerts)
}
if len(alerts[0].PIDs) != 20 || alerts[0].PIDs[0] != 2 || alerts[0].PIDs[19] != 21 {
t.Fatalf("pids = %v, want [2..21]", alerts[0].PIDs)
}
}
func TestCheckHonorsNormalizedModuleAllowlist(t *testing.T) {
alerts := Check(Views{ModuleTaints: map[string]string{"vendor-gpu": "OE"}}, Options{
AllowedModules: setStrings("vendor_gpu"),
})
if len(alerts) != 0 {
t.Fatalf("alerts = %+v, want none", alerts)
}
}

View file

@ -17,6 +17,7 @@ import (
"time" "time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
@ -83,6 +84,7 @@ type Store struct {
Incidents *incident.Store Incidents *incident.Store
LineageDepth int LineageDepth int
Assurance *assurance.Chain Assurance *assurance.Chain
Integrity enrich.IntegritySettings
slowJobs chan slowJob slowJobs chan slowJob
slowClosed bool slowClosed bool
@ -152,6 +154,21 @@ func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error
return nil return nil
} }
// ConfigureIntegrity copies settings for engines main actually attached into
// retained snapshot metadata. This does not attach an engine or perform
// collection: `--snapshot-dir` alone must not make default config values look
// like active FIM/rootcheck managers.
func (s *Store) ConfigureIntegrity(cfg config.Config, fimActive, rootcheckActive bool) {
s.Integrity = enrich.IntegritySettings{
FIMEnabled: fimActive,
FIMScanInterval: cfg.FIMScanInterval,
PackageVerifyEnabled: fimActive && cfg.FIMPackageVerify,
PackageVerifyInterval: cfg.FIMPackageVerifyInterval,
RootcheckEnabled: rootcheckActive,
RootcheckInterval: cfg.RootcheckInterval,
}
}
// EnableSlowEnrichment starts one bounded worker. It deliberately never runs // EnableSlowEnrichment starts one bounded worker. It deliberately never runs
// package-manager commands: queue saturation or collector failure only leaves // package-manager commands: queue saturation or collector failure only leaves
// fields unknown and can never delay or reject an alert event. // fields unknown and can never delay or reject an alert event.
@ -291,7 +308,7 @@ func (s *Store) applySlowEnrichment(job slowJob) {
processes := enrichmentProcesses(report.Processes) processes := enrichmentProcesses(report.Processes)
paths := enrichmentPaths(report.Enrichment) paths := enrichmentPaths(report.Enrichment)
updates := enrich.CollectSlow(processes, paths, provenance.PackageOwner) updates := enrich.CollectSlow(processes, paths, provenance.PackageOwner)
updates.Integrity = enrich.IntegrityAnchors(s.Dir) updates.Integrity = enrich.IntegrityAnchors(s.Dir, s.Integrity)
// All slow I/O has completed. Take the lock only to merge into the newest // All slow I/O has completed. Take the lock only to merge into the newest
// same-second report, so a concurrent alert capture never loses evidence. // same-second report, so a concurrent alert capture never loses evidence.

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
) )
@ -182,6 +183,50 @@ func TestSlowEnrichmentAddsBoundedHashAndFileMetadata(t *testing.T) {
} }
} }
func TestSlowEnrichmentReportsConfiguredIntegrityWithoutRunningIt(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.FIMPackageVerify = true
store.ConfigureIntegrity(cfg, true, true)
store.EnableSlowEnrichment()
defer store.Close()
store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} }
if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{
SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "integrity", Detail: "test", PIDs: []int{42},
})); err != nil {
t.Fatal(err)
}
store.FlushSlow()
report, err := LoadReport(filepath.Join(store.Dir, "alert-20260722-101112.json"))
if err != nil {
t.Fatal(err)
}
integrity, ok := report.Enrichment["integrity"].(map[string]any)
if !ok {
t.Fatalf("integrity=%#v", report.Enrichment["integrity"])
}
if integrity["package_verify"].(map[string]any)["enabled"] != true ||
integrity["rootcheck"].(map[string]any)["enabled"] != true {
t.Fatalf("integrity=%#v", integrity)
}
}
func TestConfigureIntegrityDoesNotReportUnattachedManagers(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.FIMPackageVerify = true
store.ConfigureIntegrity(cfg, false, false)
if store.Integrity.FIMEnabled || store.Integrity.PackageVerifyEnabled || store.Integrity.RootcheckEnabled {
t.Fatalf("inactive managers reported as configured: %#v", store.Integrity)
}
}
func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) { func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 2, 1) store, err := New(t.TempDir(), "/unused", 2, 1)
if err != nil { if err != nil {

View file

@ -195,6 +195,14 @@ exec_comm = ["id"]
self.assertIn("Drill or fixture:", text) self.assertIn("Drill or fixture:", text)
self.assertIn("`argv_regex`", text) self.assertIn("`argv_regex`", text)
def test_checked_in_rules_md_matches_generator(self):
# docs/RULES.md is a checked-in copy of `rules docs` output; a mismatch
# here means someone hand-edited the doc, or a rule's metadata, without
# updating the other.
repo_root = Path(__file__).resolve().parent.parent
checked_in = (repo_root / "docs" / "RULES.md").read_text()
self.assertEqual(ruleops.render_markdown(Config()), checked_in)
class TestRulesCli(unittest.TestCase): class TestRulesCli(unittest.TestCase):
def setUp(self): def setUp(self):

View file

@ -220,6 +220,20 @@ class TestSchemaContracts(unittest.TestCase):
"read_only": bool, "read_only": bool,
}) })
def test_go_sidecar_v1_contract(self):
report = web.go_sidecar_status(self.cfg, now=100.0)
self.assertEqual(report["schema"], schemas.GO_SIDECAR_V1)
_assert_types(self, report, {
"schema": str,
"read_only": bool,
"configured": bool,
"state_dir": str,
"available": bool,
"heartbeat": dict,
})
self.assertTrue(report["read_only"])
self.assertFalse(report["configured"])
def test_hash_chain_v1_contract(self): def test_hash_chain_v1_contract(self):
artifact = self.tmp / "artifact.json" artifact = self.tmp / "artifact.json"
artifact.write_text("{}") artifact.write_text("{}")

View file

@ -37,6 +37,19 @@ def _write_alert(tmp: Path, name: str, severity: str, sigs):
(tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n") (tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n")
def _write_go_alert(tmp: Path, name: str = "alert-20260722-101112"):
(tmp / f"{name}.json").write_text(json.dumps({
"schema": "enodia.alert.snapshot.v1",
"time": "2026-07-22T10:11:12Z", "host": "go-host", "severity": "HIGH",
"incident_id": "inc-go-1",
"alerts": [{"sid": 100017, "severity": "HIGH", "signature": "fim_modified",
"classtype": "integrity-violation", "key": "fim:mod:/etc/passwd",
"detail": "changed", "pids": []}],
"processes": [{"pid": 42}], "enrichment": {"source": "enodia-sentinel-go"},
}))
(tmp / f"{name}.log").write_text("go sidecar alert\n")
def _incident_alert(sig: str, pids=()): def _incident_alert(sig: str, pids=()):
return Alert(Severity.CRITICAL, sig, f"k:{sig}", f"{sig} detail", return Alert(Severity.CRITICAL, sig, f"k:{sig}", f"{sig} detail",
tuple(pids), sid=100010, classtype="test") tuple(pids), sid=100010, classtype="test")
@ -133,6 +146,39 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(st["ebpf_exec"], "enabled") self.assertEqual(st["ebpf_exec"], "enabled")
self.assertEqual(st["ebpf_syscall"], "disabled (not running as root)") self.assertEqual(st["ebpf_syscall"], "disabled (not running as root)")
def test_go_sidecar_data_is_separate_read_only_evidence(self):
sidecar = self.tmp / "go-state"
sidecar.mkdir()
self.cfg.go_sidecar_state_dir = str(sidecar)
(sidecar / "heartbeat").write_text("1000\n")
_write_go_alert(sidecar)
(sidecar / "incidents.json").write_text(json.dumps({
"inc-go-1": {"schema": "enodia.incident.v1", "id": "inc-go-1",
"last_ts": 1000.0, "snapshots": ["alert-20260722-101112.log"]},
}))
(sidecar / "events.jsonl.1").write_text('{"sequence": 1}\n')
(sidecar / "events.jsonl").write_text('{"sequence": 2}\n')
status = web.go_sidecar_status(self.cfg, now=1010)
self.assertTrue(status["configured"])
self.assertEqual(status["heartbeat"]["status"], "fresh")
alerts = web.list_go_sidecar_alerts(self.cfg)
self.assertEqual(alerts[0]["source"], "go-sidecar")
self.assertEqual(web.get_go_sidecar_alert(self.cfg, "../../etc/passwd"), None)
self.assertEqual(web.go_sidecar_events(self.cfg)["events"][-1]["sequence"], 2)
incident_view = web.get_go_sidecar_incident(self.cfg, "inc-go-1")
self.assertEqual(incident_view["source"], "go-sidecar")
self.assertEqual(incident_view["timeline"][0]["pids"], [42])
def test_go_sidecar_events_fail_closed_on_corruption(self):
sidecar = self.tmp / "go-state"
sidecar.mkdir()
self.cfg.go_sidecar_state_dir = str(sidecar)
(sidecar / "events.jsonl").write_text("not json\n")
report = web.go_sidecar_events(self.cfg)
self.assertEqual(report["events"], [])
self.assertIn("corrupt", report["error"])
def test_incident_and_response_plan_data(self): def test_incident_and_response_plan_data(self):
_write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"]) _write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"])
iid = incident.record(self.cfg, "alert-20260531-000001.log", iid = incident.record(self.cfg, "alert-20260531-000001.log",
@ -267,6 +313,14 @@ class TestDataLayer(unittest.TestCase):
self.assertIn('function renderIntegrity()', html) self.assertIn('function renderIntegrity()', html)
self.assertIn('Integrity state', html) self.assertIn('Integrity state', html)
def test_dashboard_has_sidecar_evidence_console(self):
html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('data-tab="sidecar"', html)
self.assertIn('id="sidecarTab"', html)
self.assertIn('function renderSidecar()', html)
self.assertIn('/api/go-sidecar/status', html)
self.assertIn('Migration evidence is shown separately', html)
class TestNetworkHelpers(unittest.TestCase): class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self): def test_is_loopback(self):
@ -371,6 +425,17 @@ class TestAuth(unittest.TestCase):
self.assertIn("anchors", data) self.assertIn("anchors", data)
self.assertTrue(data["read_only"]) self.assertTrue(data["read_only"])
def test_go_sidecar_endpoints(self):
sidecar = self.tmp / "go-state"
sidecar.mkdir()
self.cfg.go_sidecar_state_dir = str(sidecar)
(sidecar / "heartbeat").write_text("1000\n")
_write_go_alert(sidecar)
status = json.loads(self._get("/api/go-sidecar/status", token="secret-token").read())
self.assertTrue(status["configured"])
alerts = json.loads(self._get("/api/go-sidecar/alerts", token="secret-token").read())
self.assertEqual(alerts[0]["source"], "go-sidecar")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()