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:
parent
1d1dee7f6e
commit
d835386381
43 changed files with 3419 additions and 72 deletions
37
CLAUDE.md
37
CLAUDE.md
|
|
@ -24,18 +24,23 @@ Primary functions:
|
|||
|
||||
## 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,
|
||||
HTTPS management console, incident model, response planner, rootcheck, posture
|
||||
checks, file/package integrity checks, push notification backends, and optional
|
||||
bcc/eBPF telemetry.
|
||||
|
||||
An experimental, non-installing Phase 1 Go sidecar now lives under `go-agent/`.
|
||||
It is stdlib-only, emits `enodia.event.v1` JSONL, captures an injectable process
|
||||
file-descriptor, and socket view, and currently ports `reverse_shell`,
|
||||
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
|
||||
`stealth_network`, and `egress`. Python remains the production agent and parity
|
||||
oracle; do not describe the Go path as a replacement yet.
|
||||
An experimental, non-installing Go sidecar now lives under `go-agent/`. It is
|
||||
stdlib-only, emits `enodia.event.v1` JSONL, and ports all current Python poll
|
||||
detectors, the exec/syscall/typed-host rule engines, opt-in `cilium/ebpf` exec
|
||||
and syscall sources, hot-path plus asynchronous enrichment, and Python-schema-
|
||||
compatible incidents/snapshots/hash-chain. A `--state-dir`-gated set of
|
||||
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:
|
||||
|
||||
|
|
@ -63,9 +68,13 @@ Implemented investigation/response state:
|
|||
- The dashboard is HTTPS-only, token-protected off loopback, and read-only. It
|
||||
shows status, incidents, timelines, alerts, posture findings,
|
||||
integrity/watchdog state, event-rule metadata, event tail, dry-run response
|
||||
plans, and a local Settings menu with persistent themes. Web tests cover the
|
||||
theme registry, integrity console wiring, and core text/status contrast
|
||||
checks.
|
||||
plans, and a local Settings menu with persistent themes. An opt-in Sidecar
|
||||
tab additionally displays the isolated Go migration-sidecar's retained
|
||||
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,
|
||||
persists CLI-generated plans under `response-plans/`, and appends
|
||||
`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`
|
||||
(tkinter) and `gui/qt_app.py` (PySide6, `[qt]` extra) import GUI libraries,
|
||||
enforced by import-guard tests.
|
||||
- `go-agent/` is the isolated Phase 1 sidecar with Go config, process-state,
|
||||
detector, schema, and sweep-loop packages. It writes JSONL to stdout and
|
||||
must not share or mutate Python daemon state during parity development.
|
||||
- `go-agent/` is the isolated sidecar with Go config, process-state, detector,
|
||||
schema, and sweep-loop packages, plus `fim/`, `pkgdb/`, and `rootcheck/`
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,10 @@ sudo systemctl enable --now enodia-sentinel-web
|
|||
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
|
||||
`/api/posture`, `/api/integrity`, `/api/rules`, `/api/alerts`,
|
||||
`/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
|
||||
with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula,
|
||||
Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard
|
||||
|
|
|
|||
|
|
@ -214,3 +214,6 @@ web_token = "" # bearer token; auto-generated + saved if empty on a
|
|||
# point these at your own local/private CA certificate.
|
||||
web_tls_cert = ""
|
||||
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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
# Go Port Handoff
|
||||
|
||||
Saved: 2026-07-22T04:08:40-07:00
|
||||
Updated: 2026-07-24T04:45:55-07:00
|
||||
Branch: `main`
|
||||
Base commit: `a25c73a` (`feat(go): report integrity anchor context`)
|
||||
Status: current Go-port state saved in this checkpoint; separate GUI work remains uncommitted
|
||||
Base commit: `a25c73a` (`feat(go): report integrity anchor context`); the FIM/
|
||||
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
|
||||
|
||||
|
|
@ -14,10 +20,12 @@ continuations. Do not reset, clean, or broadly restage it.
|
|||
assurance, asynchronous-enrichment, incident-reader, package-ownership, and
|
||||
integrity-anchor tranches are signed commits `f85c2e8`, `538d1d9`,
|
||||
`fd2bbba`, `1ab4add`, `eff31b3`, `6355fb4`, `88d7a6e`, and `a25c73a`.
|
||||
- At this checkpoint, `git status --short` has 11 entries with untracked
|
||||
directories collapsed.
|
||||
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
||||
while continuing the Go port.
|
||||
- On top of that, uncommitted work now adds `go-agent/internal/{fim,pkgdb,
|
||||
rootcheck}/`, wires them into `agent.Sweep`/`Initialize` behind
|
||||
`--state-dir`, and adds Python's read-only `/api/go-sidecar/*` consumer
|
||||
(`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.
|
||||
|
||||
## Implemented Go surface
|
||||
|
|
@ -106,15 +114,46 @@ static binary.
|
|||
- The asynchronous worker now resolves pacman/dpkg/rpm package-owner strings
|
||||
with a two-second bound and cache; ownership lookup never holds a detector
|
||||
lock or blocks alert capture.
|
||||
- The worker now reports FIM-baseline, package-DB-anchor, and Go hash-chain
|
||||
presence/age. It explicitly marks package verification and rootcheck disabled
|
||||
until their actual engines are ported.
|
||||
- Richer integrity engines and notification fan-out are not yet ported.
|
||||
- The worker reports FIM-baseline, package-DB-anchor, and Go hash-chain
|
||||
presence/age, plus the configured `pacman -Qkk` and rootcheck cadences now
|
||||
that those engines are ported (see below). This remains metadata only:
|
||||
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
|
||||
state without starting the agent. The latter returns `enodia.incident.view.v1`
|
||||
with the incident, available snapshots, and time-ordered timeline.
|
||||
- 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
|
||||
|
||||
Run from the repository root:
|
||||
|
|
@ -132,7 +171,7 @@ make check-go-service
|
|||
git diff --check
|
||||
```
|
||||
|
||||
Results at save time:
|
||||
Results at original save time (`a25c73a`):
|
||||
|
||||
- Python: 377 tests passed.
|
||||
- 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
|
||||
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
|
||||
one existing `ResourceWarning` for an implicitly cleaned-up HTTP 401 object;
|
||||
the suite still exits successfully.
|
||||
|
||||
## Resume here
|
||||
|
||||
1. Port the actual FIM/package-verification/rootcheck engines behind explicit,
|
||||
bounded acceptance gates; do not treat enrichment anchor status as detection
|
||||
parity.
|
||||
2. Add a read-only management API consumer for isolated Go snapshot/event state
|
||||
while keeping Python authoritative.
|
||||
1. ~~Port the actual FIM/package-verification/rootcheck engines behind
|
||||
explicit, bounded acceptance gates~~ — done: `go-agent/internal/{fim,pkgdb,
|
||||
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 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
|
||||
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
|
||||
new attack/event primitives disconnected from destructive response paths.
|
||||
|
|
|
|||
|
|
@ -75,7 +75,10 @@ Expected healthy output:
|
|||
|
||||
The HTTPS dashboard shows the same posture findings under the Posture tab and
|
||||
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
|
||||
the same local read-only management state: status, recent alerts, incidents,
|
||||
|
|
|
|||
|
|
@ -300,9 +300,23 @@ Exit criteria:
|
|||
now provide truthful retained counts and basic process context. A private,
|
||||
atomic `enodia.incident.v1` index now applies the shared lineage-first/time-
|
||||
window grouping, assigns real snapshot `incident_id` values, and persists
|
||||
correlation SID `100080`. The Python service remains the production default
|
||||
until rich enrichment/assurance, management-console consumers, and broader
|
||||
subsystem parity land.
|
||||
correlation SID `100080`. Hot-path enrichment adds bounded process/parent
|
||||
context, lineage, remote-IP classification, and candidate paths synchronously,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
| `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`
|
||||
|
||||
JSONL record appended to `hash-chain.jsonl` when Sentinel captures forensic
|
||||
|
|
|
|||
|
|
@ -251,9 +251,13 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs.
|
|||
transports. Bounded `enodia.alert.snapshot.v1` JSON/text pairs now retain
|
||||
alerts with basic process context and feed truthful status counts. An atomic
|
||||
`enodia.incident.v1` index supplies lineage/time grouping, snapshot IDs, and
|
||||
correlation evidence. Rich enrichment/assurance, management-consumer
|
||||
integration, and broader parity must land before the bcc/Python runtime
|
||||
dependency can be dropped.
|
||||
correlation evidence. Hot-path plus asynchronous enrichment (hashes, file
|
||||
metadata, package ownership) now populate snapshots, and sidecar-only,
|
||||
`--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`,
|
||||
statebits (flowbits), thresholding, suppression, and — if rule count warrants
|
||||
— the MPM prefilter. `rules list/show/test/docs` parity.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
Sentinel assumes:
|
||||
|
|
|
|||
|
|
@ -173,6 +173,11 @@ class Config:
|
|||
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_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
|
||||
log_dir: Path = Path("/var/log/enodia-sentinel")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ def render_markdown(cfg: Config) -> str:
|
|||
"Use `enodia-sentinel rules list/show/test` to inspect rules and validate "
|
||||
"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):
|
||||
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.",
|
||||
},
|
||||
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: {
|
||||
"false_positives": [
|
||||
"Developer or diagnostic scripts binding high local ports intentionally.",
|
||||
"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: {
|
||||
"false_positives": [
|
||||
"Developer servers or test harnesses accepting inbound public traffic from an interpreter.",
|
||||
"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: {
|
||||
"false_positives": [
|
||||
"Privileged maintenance scripts that intentionally adjust capabilities during controlled administration.",
|
||||
"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: {
|
||||
"false_positives": [
|
||||
"Kernel development labs loading locally built modules from temporary build directories.",
|
||||
"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`.",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
|
|||
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
|
||||
RECONCILE_V1 = "enodia.reconcile.v1"
|
||||
EVENT_V1 = "enodia.event.v1"
|
||||
GO_SIDECAR_V1 = "enodia.go.sidecar.v1"
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@
|
|||
<button data-tab="alerts">Alerts</button>
|
||||
<button data-tab="posture">Posture</button>
|
||||
<button data-tab="integrity">Integrity</button>
|
||||
<button data-tab="sidecar">Sidecar</button>
|
||||
<button data-tab="rules">Rules</button>
|
||||
<button data-tab="events">Events</button>
|
||||
</div>
|
||||
|
|
@ -320,6 +321,7 @@
|
|||
<section id="alertsTab" hidden></section>
|
||||
<section id="postureTab" hidden></section>
|
||||
<section id="integrityTab" hidden></section>
|
||||
<section id="sidecarTab" hidden></section>
|
||||
<section id="rulesTab" hidden></section>
|
||||
<section id="eventsTab" hidden></section>
|
||||
</main>
|
||||
|
|
@ -333,7 +335,7 @@
|
|||
<script>
|
||||
const TOKEN = new URLSearchParams(location.search).get("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 = [
|
||||
["console","Console"],["paper","Paper"],["contrast","Contrast"],
|
||||
["pride","LGBTQ"],["trans","Trans"],["dracula","Dracula"],
|
||||
|
|
@ -346,7 +348,7 @@ function tabFromHash(){
|
|||
const name = location.hash.replace(/^#/,"");
|
||||
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){
|
||||
const r = await fetch(path,{headers:HDR});
|
||||
|
|
@ -398,10 +400,10 @@ function setSettings(open){
|
|||
|
||||
async function refresh(){
|
||||
try{
|
||||
const [status, incidents, alerts, posture, integrity, rules, events] = await Promise.all([
|
||||
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/integrity"), api("/api/rules"), api("/api/events")
|
||||
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"), 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();
|
||||
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
|
||||
else if(state.selected) await openIncident(state.selected, false);
|
||||
|
|
@ -409,6 +411,20 @@ async function refresh(){
|
|||
}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){
|
||||
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";
|
||||
|
|
@ -473,6 +489,7 @@ async function openAlert(name){
|
|||
box.hidden = false; document.getElementById("incidentTab").hidden = true;
|
||||
document.getElementById("postureTab").hidden = true; document.getElementById("rulesTab").hidden = true;
|
||||
document.getElementById("integrityTab").hidden = true;
|
||||
document.getElementById("sidecarTab").hidden = true;
|
||||
document.getElementById("eventsTab").hidden = true;
|
||||
box.innerHTML='<div class="empty">loading snapshot</div>';
|
||||
try{
|
||||
|
|
@ -524,11 +541,13 @@ function renderTab(){
|
|||
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
|
||||
document.getElementById("postureTab").hidden = state.tab !== "posture";
|
||||
document.getElementById("integrityTab").hidden = state.tab !== "integrity";
|
||||
document.getElementById("sidecarTab").hidden = state.tab !== "sidecar";
|
||||
document.getElementById("rulesTab").hidden = state.tab !== "rules";
|
||||
document.getElementById("eventsTab").hidden = state.tab !== "events";
|
||||
if(state.tab === "incident") renderIncident();
|
||||
if(state.tab === "posture") renderPosture();
|
||||
if(state.tab === "integrity") renderIntegrity();
|
||||
if(state.tab === "sidecar") renderSidecar();
|
||||
if(state.tab === "rules") renderRules();
|
||||
if(state.tab === "events") renderEvents();
|
||||
}
|
||||
|
|
@ -678,6 +697,76 @@ function renderIntegrity(){
|
|||
|
||||
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(){
|
||||
const box = document.getElementById("rulesTab");
|
||||
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("postureTab").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("plan").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,199 @@ def tail_events(cfg: Config, limit: int = 100) -> list[str]:
|
|||
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:
|
||||
pid_alive = False
|
||||
pidfile = cfg.log_dir / "sentinel.pid"
|
||||
|
|
@ -505,6 +698,21 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
self._json_or_not_found(alert, send_body=send_body)
|
||||
elif path == "/api/events":
|
||||
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":
|
||||
self._json(posture_report(cfg), send_body=send_body)
|
||||
elif path == "/api/rules":
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ Current scope:
|
|||
- an opt-in live baseline lifecycle (`--state-dir`) that builds listener/SUID
|
||||
baselines, honors the startup grace gate, scans persistence paths, and
|
||||
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
|
||||
exec SIDs `100001`–`100004`, configured `[[exec_rules]]`, and syscall SIDs
|
||||
`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
|
||||
`scripts/check-go-parity.py`.
|
||||
|
||||
It does not yet provide Python's full enrichment, notification fan-out, or
|
||||
management consumers for those snapshots, and it does not replace
|
||||
`enodia-sentinel`. The module requires Go 1.25 or newer; development is currently
|
||||
verified with Go 1.26.5. Run a single terminal-visible stateless sweep:
|
||||
It does not yet provide Python's full enrichment or notification fan-out, and
|
||||
it does not replace `enodia-sentinel`. Python's optional `/api/go-sidecar/*`
|
||||
consumer can display this sidecar's isolated retained evidence, but remains
|
||||
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
|
||||
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
|
||||
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;
|
||||
saturation leaves fields unknown rather than delaying alerts. Richer integrity
|
||||
engines and notifications remain future parity work. The worker does report
|
||||
truthful FIM-baseline, package-DB-anchor, and Go hash-chain presence/age while
|
||||
explicitly marking unported package verification and rootcheck as disabled.
|
||||
saturation leaves fields unknown rather than delaying alerts. The sidecar also
|
||||
has separate, bounded FIM/package-verification, package-DB-anchor, and
|
||||
rootcheck engines; notification fan-out remains future parity work. The worker
|
||||
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
|
||||
revisions append synchronized `enodia.hash_chain.v1`
|
||||
records to a private local `hash-chain.jsonl`.
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ import (
|
|||
"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/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/incident"
|
||||
"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/sdnotify"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot"
|
||||
|
|
@ -203,6 +206,21 @@ func run() error {
|
|||
runner.Lifecycle = baseline.New(cfg, func() []string {
|
||||
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 != "" {
|
||||
runner.Host = func() (string, error) { return *host, nil }
|
||||
|
|
@ -265,6 +283,9 @@ func run() error {
|
|||
); err != nil {
|
||||
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()
|
||||
defer snapshotStore.Close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import (
|
|||
"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/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/pkgdb"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/rootcheck"
|
||||
"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
|
||||
// Python oracle without contaminating parity inputs.
|
||||
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
|
||||
cooldowns map[string]time.Time
|
||||
|
|
@ -76,6 +91,21 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
|||
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()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -131,6 +161,17 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
|||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -223,6 +264,22 @@ func (a *Agent) Initialize() error {
|
|||
// otherwise fixed-clock tests could arm against wall time by accident.
|
||||
a.Lifecycle.Now = a.Now
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,25 @@ var defaultMemoryObfuscationAllowComms = []string{
|
|||
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
|
||||
// fields are added as their detectors are ported.
|
||||
type Config struct {
|
||||
SampleInterval time.Duration
|
||||
Cooldown time.Duration
|
||||
BaselineGrace time.Duration
|
||||
SUIDRefresh time.Duration
|
||||
SUIDScanInterval time.Duration
|
||||
SampleInterval time.Duration
|
||||
Cooldown time.Duration
|
||||
BaselineGrace time.Duration
|
||||
SUIDRefresh 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
|
||||
MaxSnapshots int
|
||||
MaxSnapshotAgeDays int
|
||||
|
|
@ -97,6 +111,17 @@ func Default() Config {
|
|||
BaselineGrace: 10 * time.Second,
|
||||
SUIDRefresh: time.Hour,
|
||||
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,
|
||||
MaxSnapshots: 300,
|
||||
MaxSnapshotAgeDays: 60,
|
||||
|
|
@ -192,6 +217,72 @@ func Load(path string) (Config, error) {
|
|||
return Config{}, fmt.Errorf("suid_scan_interval: %w", err)
|
||||
}
|
||||
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":
|
||||
age, err := strconv.Atoi(value)
|
||||
if err != nil || age < 0 {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ func TestDefaultsMatchPython(t *testing.T) {
|
|||
if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second ||
|
||||
cfg.BaselineGrace != 10*time.Second ||
|
||||
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.MaxSnapshotAgeDays != 60 || !cfg.IncidentTracking ||
|
||||
cfg.IncidentWindow != 1800 || cfg.IncidentLineageDepth != 8 ||
|
||||
|
|
@ -33,6 +38,17 @@ cooldown = 30
|
|||
baseline_grace = 2.5
|
||||
suid_refresh = 120
|
||||
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
|
||||
max_snapshots = 25
|
||||
max_snapshot_age_days = 7
|
||||
|
|
@ -72,6 +88,12 @@ unknown_future_key = true
|
|||
if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second ||
|
||||
cfg.BaselineGrace != 2500*time.Millisecond ||
|
||||
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.IncidentTracking || cfg.IncidentWindow != 90 || cfg.IncidentLineageDepth != 3 {
|
||||
t.Fatalf("unexpected parsed config: %+v", cfg)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,22 @@ type SlowResult struct {
|
|||
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 {
|
||||
result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}}
|
||||
packageOwner := func(string) string { return "" }
|
||||
|
|
@ -71,17 +87,37 @@ func CollectSlow(processes []Process, paths []string, owner ...func(string) stri
|
|||
return result
|
||||
}
|
||||
|
||||
// IntegrityAnchors reports durable state presence without running FIM, package
|
||||
// verification, or rootcheck on the alert path. Those engines stay disabled
|
||||
// until their own Go parity tranches land.
|
||||
func IntegrityAnchors(directory string) map[string]any {
|
||||
return map[string]any{
|
||||
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")),
|
||||
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")),
|
||||
"package_verify": map[string]any{"enabled": false, "sample": 0},
|
||||
"rootcheck": map[string]any{"enabled": false, "interval": 0},
|
||||
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
|
||||
// IntegrityAnchors reports durable state and configured engine cadence without
|
||||
// running FIM, package verification, or rootcheck on the alert path. A caller
|
||||
// can omit settings for stateless/parity uses; explicit sidecar mode supplies
|
||||
// them after it has attached the bounded background managers.
|
||||
func IntegrityAnchors(directory string, configured ...IntegritySettings) map[string]any {
|
||||
settings := IntegritySettings{}
|
||||
if len(configured) > 0 {
|
||||
settings = configured[0]
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
|
@ -77,9 +78,16 @@ func TestCollectSlowAddsInjectedPackageOwnership(t *testing.T) {
|
|||
|
||||
func TestIntegrityAnchorsAreTruthfulAndReadOnly(t *testing.T) {
|
||||
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" ||
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "hash-chain.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
|
|
|
|||
153
go-agent/internal/fim/fim.go
Normal file
153
go-agent/internal/fim/fim.go
Normal 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
|
||||
}
|
||||
168
go-agent/internal/fim/fim_test.go
Normal file
168
go-agent/internal/fim/fim_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
188
go-agent/internal/fim/manager.go
Normal file
188
go-agent/internal/fim/manager.go
Normal 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
|
||||
}
|
||||
75
go-agent/internal/fim/manager_test.go
Normal file
75
go-agent/internal/fim/manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
go-agent/internal/fim/pkgverify.go
Normal file
99
go-agent/internal/fim/pkgverify.go
Normal 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
|
||||
}
|
||||
151
go-agent/internal/fim/pkgverify_test.go
Normal file
151
go-agent/internal/fim/pkgverify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
150
go-agent/internal/fim/scan.go
Normal file
150
go-agent/internal/fim/scan.go
Normal 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
|
||||
})
|
||||
}
|
||||
245
go-agent/internal/fim/scan_test.go
Normal file
245
go-agent/internal/fim/scan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
121
go-agent/internal/pkgdb/manager.go
Normal file
121
go-agent/internal/pkgdb/manager.go
Normal 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
|
||||
}
|
||||
152
go-agent/internal/pkgdb/pkgdb.go
Normal file
152
go-agent/internal/pkgdb/pkgdb.go
Normal 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
|
||||
}
|
||||
90
go-agent/internal/pkgdb/pkgdb_test.go
Normal file
90
go-agent/internal/pkgdb/pkgdb_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
351
go-agent/internal/rootcheck/collect.go
Normal file
351
go-agent/internal/rootcheck/collect.go
Normal 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
|
||||
}
|
||||
72
go-agent/internal/rootcheck/manager.go
Normal file
72
go-agent/internal/rootcheck/manager.go
Normal 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
|
||||
}
|
||||
64
go-agent/internal/rootcheck/manager_test.go
Normal file
64
go-agent/internal/rootcheck/manager_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
287
go-agent/internal/rootcheck/rootcheck.go
Normal file
287
go-agent/internal/rootcheck/rootcheck.go
Normal 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, ", ")
|
||||
}
|
||||
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal file
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"time"
|
||||
|
||||
"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/enrich"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
||||
|
|
@ -83,6 +84,7 @@ type Store struct {
|
|||
Incidents *incident.Store
|
||||
LineageDepth int
|
||||
Assurance *assurance.Chain
|
||||
Integrity enrich.IntegritySettings
|
||||
|
||||
slowJobs chan slowJob
|
||||
slowClosed bool
|
||||
|
|
@ -152,6 +154,21 @@ func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error
|
|||
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
|
||||
// package-manager commands: queue saturation or collector failure only leaves
|
||||
// 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)
|
||||
paths := enrichmentPaths(report.Enrichment)
|
||||
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
|
||||
// same-second report, so a concurrent alert capture never loses evidence.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -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) {
|
||||
store, err := New(t.TempDir(), "/unused", 2, 1)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,14 @@ exec_comm = ["id"]
|
|||
self.assertIn("Drill or fixture:", 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):
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -220,6 +220,20 @@ class TestSchemaContracts(unittest.TestCase):
|
|||
"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):
|
||||
artifact = self.tmp / "artifact.json"
|
||||
artifact.write_text("{}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
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=()):
|
||||
return Alert(Severity.CRITICAL, sig, f"k:{sig}", f"{sig} detail",
|
||||
tuple(pids), sid=100010, classtype="test")
|
||||
|
|
@ -133,6 +146,39 @@ class TestDataLayer(unittest.TestCase):
|
|||
self.assertEqual(st["ebpf_exec"], "enabled")
|
||||
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):
|
||||
_write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"])
|
||||
iid = incident.record(self.cfg, "alert-20260531-000001.log",
|
||||
|
|
@ -267,6 +313,14 @@ class TestDataLayer(unittest.TestCase):
|
|||
self.assertIn('function renderIntegrity()', 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):
|
||||
def test_is_loopback(self):
|
||||
|
|
@ -371,6 +425,17 @@ class TestAuth(unittest.TestCase):
|
|||
self.assertIn("anchors", data)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue