# M33 — Malware Detection Stack

**Status:** ALL 10 STEPS SHIPPED 2026-04-27 on `m33/malware-detection`
**Branch:** `m33/malware-detection` (not yet merged to main)
**Plan owner:** dispatcher
**Created:** 2026-04-27
**Adversarial review:** advisor pre-Opus pass folded 4 blockers; Opus pass still optional

## Step status (live)

- [x] **Step 1** SHIPPED `63b5dc3` — schema (000076), 5 models, 5 repos, ADR-0072 draft
- [x] **Step 2** SHIPPED `3c62e22` — install.sh `install_malware_stack` (ClamAV + LMD 1.6.6 sha256-pinned + YARA + sysctl + drop-in config)
- [x] **Step 3** SHIPPED `a4224b1` — 10 agent `security.malware.*` commands incl. transient systemd-run scan, path validation, ULID minter, event ingest contract
- [x] **Step 4** SHIPPED `69a23a9` — 11 panel-api routes via `RegisterSecurityMalwareRoutes` + Deps wiring; `/event` gated by `RequireLocalhost`
- [x] **Step 5** SHIPPED `566646e` — `Malware` tab (between crowdsec/ufw) + 5 sub-tab cards (Overview/Quarantine/Events/Manual scan/Settings) + `useSecurityMalware` hook
- [x] **Step 6** SHIPPED `6c85407` — agent sessionwatcher (5s poll on `/usr/local/maldetect/sess/`) + parser + DispatchMaldetSession glue; events table now populates
- [x] **Step 7** SHIPPED `970689d` — `jabali-maldet-monitor.service` + 2 sig/scan timers + `eventsources/malware.go` (30s ticker) + severity-tier gating + Tetragon always-critical override
- [x] **Step 8** SHIPPED `bc88f1a` — YARA admin: 4 agent commands (yara.{list,upload,toggle,delete}) + 4 panel-api routes + UI YARA card with upload Drawer + parse-validation
- [x] **Step 9** SHIPPED `2fe439c` — Tetragon: install.sh BTF probe + GitHub release tarball v1.6.1 + 4 default TracingPolicies + tetragon.service + 2 agent commands + 2 panel-api routes + UI Tetragon card with disabled-state Alert
- [x] **Step 10** SHIPPED — `jabali malware-purge` cobra cmd + jabali-malware-quarantine-purge.timer + runbook at `plans/m33-malware-detection-runbook.md` + ADR-0072 status flipped to accepted + ADR README index row + BLUEPRINT M33 row updated to all-10-steps

## Deferred follow-ups

- **Reconciler hook on user create/delete** — LMD `inotify_minutes=45` re-evaluates the user list periodically; explicit hook is an optimization not a correctness requirement. Track separately if real-time tenant onboarding becomes important.
- **tetragon-relay shim** — Tetragon JSON log tail → POST to `/admin/security/malware/event` with source=tetragon. Shim binary not yet written; until then operators see Tetragon policy matches via `journalctl -u tetragon` only.
- **Playwright E2E** — happy-path test (login admin → tab → manual scan → quarantine row → restore) not yet committed. Manual VM smoke is the current verification.

---

## Objective

Integrate a defence-in-depth malware-detection stack into the Jabali Security
tab and wire its alerts through the existing M14 notification dispatcher:

- **ClamAV** — signature engine (freshclam daily updates), used as LMD's scanner backend
- **Linux Malware Detect (maldet/LMD)** — orchestrator + quarantine + per-user inotify watcher
- **YARA rules** — additional signature pack (rfxn YARA + admin-editable drop-in)
- **Inotify real-time** — `maldet --monitor USERS` per Jabali user docroot
- **rfxn signatures** — daily `maldet -u` pulls latest signature pack
- **Tetragon** — eBPF runtime detection (exec-from-/tmp, chmod+x in docroots, curl|bash, ptrace/kexec)

Surface area: **`/jabali-admin/security?tab=malware`** new tab inserted between
`crowdsec` and `ufw`. Alerts route through `notifications.Dispatcher.Send` →
six already-shipped channels (email/ntfy/slack/discord/webpush/in-app bell).

Goal use-cases on shared hosting: catch web-shell uploads, miner deployments,
backdoor drops, and suspicious process exec on tenant docroots **in real time**,
with admin notification + audit trail + restorable quarantine.

---

## Constraints (from existing codebase)

| Topic                         | Reality                                                                                                                  |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Migration numbering           | Next free = **000076** (000075 = panel_certificate from M32, just shipped)                                               |
| ADR numbering                 | Next free = **0067** (0066 = LE panel cert)                                                                              |
| Route registration            | `panel-api/internal/app/app.go:494` — add `api.RegisterSecurityMalwareRoutes(v1, deps.Agent, ...)` next to existing 3    |
| Route function naming         | `RegisterSecurityMalwareRoutes` (matches `RegisterSecurityCrowdSecRoutes` casing)                                        |
| Tab key insertion             | `panel-ui/src/shells/admin/security/AdminSecurityPage.tsx:20` — `["crowdsec", "ufw"]` → `["crowdsec", "malware", "ufw"]` |
| Internal event POST           | `RequireLocalhost()` middleware (`panel-api/internal/middleware/localhost.go`) — agent dials over loopback               |
| Long-running scans            | `systemd-run --quiet --uid=root --gid=root --unit=jabali-maldet-scan-<id>.service` (M29 transient pattern)               |
| Notifications dispatcher      | Reuse `panel-api/internal/notifications` — register new sources in `eventsources/malware.go`                             |
| Sender channels               | Reuse `notifications/senders/{email,ntfy,slack,discord,webhook,webpush}.go` — no new senders                             |
| Tetragon kernel requirement   | ≥ 5.10 with BPF + BTF — install.sh probes `bpftool btf list` ; on fail, skip Tetragon + UI shows "Disabled (no BTF)"     |
| Quarantine path               | `/usr/local/maldetect/quarantine` (LMD default) — must NOT live under user docroot (inotify loop)                        |
| ClamAV freshclam              | systemd unit `clamav-freshclam.service` only — never co-install with `/usr/bin/freshclam` cron                           |
| Inotify watch reload          | On user create/delete (M5 reconciler), call `security.malware.monitor.reload`                                            |
| Path display                  | UI strips `/home/<user>/` prefix (store full path in DB) to avoid disclosing tenant layout to admins                     |
| systemd hardening             | Per CONVENTIONS.md non-negotiable: jabali-maldet-monitor unit keeps `ProtectSystem=strict` + read-write only `/home`, `/usr/local/maldetect`, `/var/log/maldet` |
| VM testbed                    | `ssh root@192.168.100.150` (port 22, mx.jabali-panel.com)                                                                |

---

## Architecture decision (ADR-0072 target)

**Decision:** ClamAV + maldet + YARA + Tetragon are deployed as a single
defence-in-depth pipeline. All four feed a unified event/quarantine model
in MariaDB. UI surface is one `Malware` tab. Alerts route through M14.

**Rejected alternatives:**

- **ClamAV-only** — no real-time inotify, no YARA, no eBPF. Weak coverage.
- **Wazuh / OSSEC** — heavyweight HIDS with own server, agent, dashboard.
  Overlaps M14 notifications + admin UI; we'd duplicate dispatch + storage.
- **Tetragon-only** — strong runtime detection, no file-signature catch
  for tenants who upload a known web-shell but never exec it.
- **maldet without ClamAV backend** — LMD's own scanner is slower and
  has weaker hit rate on common WordPress shells per upstream notes.

**Why all four:** they cover orthogonal failure modes:

| Surface              | Caught by              |
| -------------------- | ---------------------- |
| Web-shell drop       | maldet+ClamAV+inotify  |
| Known-bad signature  | ClamAV + freshclam     |
| Behavior-pattern     | YARA custom rules      |
| Runtime exec abuse   | Tetragon (eBPF)        |

Costs documented: ~150 MB extra RAM (clamd ~80MB + maldet+inotify ~30MB + tetragon ~40MB).
Disk: signature DBs + quarantine retention. Both gated by Settings card.

---

## Schema (migration 000081)

```sql
-- 000081_malware_detection.up.sql

CREATE TABLE malware_quarantine (
  id              VARCHAR(26) PRIMARY KEY,
  user_id         VARCHAR(26) NULL,                    -- NULL for system-path hits
  original_path   VARCHAR(2048) NOT NULL,
  quarantine_path VARCHAR(2048) NOT NULL,
  signature       VARCHAR(255) NOT NULL,
  scanner         ENUM('maldet','clamav','yara','tetragon') NOT NULL,
  sha256          CHAR(64) NOT NULL,
  size_bytes      BIGINT UNSIGNED NOT NULL,
  detected_at     TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  restored_at     TIMESTAMP NULL,
  restored_reason VARCHAR(500) NULL,
  restored_by     VARCHAR(26) NULL,
  deleted_at      TIMESTAMP NULL,
  INDEX idx_quarantine_user (user_id, detected_at),
  INDEX idx_quarantine_active (deleted_at, restored_at, detected_at),
  INDEX idx_quarantine_sha (sha256)
) ENGINE=InnoDB;

CREATE TABLE malware_events (
  id           VARCHAR(26) PRIMARY KEY,
  event_type   VARCHAR(64) NOT NULL,                       -- file_hit / process_exec_alert / chmod_suspicious / scan_completed
  source       ENUM('maldet','clamav','yara','tetragon') NOT NULL,
  severity     ENUM('info','warn','critical') NOT NULL,
  user_id      VARCHAR(26) NULL,
  target_path  VARCHAR(2048) NULL,
  target_pid   INT UNSIGNED NULL,
  signature    VARCHAR(255) NULL,
  raw_json     JSON NOT NULL,
  notified     TINYINT(1) NOT NULL DEFAULT 0,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_events_created (created_at),
  INDEX idx_events_source_severity (source, severity, created_at),
  INDEX idx_events_user (user_id, created_at)
) ENGINE=InnoDB;

CREATE TABLE yara_custom_rules (
  id           VARCHAR(26) PRIMARY KEY,
  filename     VARCHAR(255) NOT NULL UNIQUE,
  enabled      TINYINT(1) NOT NULL DEFAULT 1,
  uploaded_by  VARCHAR(26) NOT NULL,
  sha256       CHAR(64) NOT NULL,
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE tetragon_policy_state (
  policy_name  VARCHAR(128) PRIMARY KEY,
  enabled      TINYINT(1) NOT NULL DEFAULT 1,
  updated_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  updated_by   VARCHAR(26) NULL
) ENGINE=InnoDB;

CREATE TABLE malware_settings (
  id                     TINYINT UNSIGNED PRIMARY KEY DEFAULT 1,
  schedule_cron          VARCHAR(64) NOT NULL DEFAULT '0 3 * * *',     -- daily 03:00
  quarantine_retain_days INT UNSIGNED NOT NULL DEFAULT 30,
  max_scan_mb            INT UNSIGNED NOT NULL DEFAULT 256,
  notify_threshold       ENUM('info','warn','critical') NOT NULL DEFAULT 'warn',
  realtime_enabled       TINYINT(1) NOT NULL DEFAULT 1,
  tetragon_enabled       TINYINT(1) NOT NULL DEFAULT 1,
  CONSTRAINT chk_singleton CHECK (id = 1)
) ENGINE=InnoDB;

INSERT INTO malware_settings (id) VALUES (1);
```

`down.sql` drops all five tables in reverse dependency order.

**Anti-pattern guard (from feedback memory):** migrations must NOT seed from
app-populated tables. The singleton row is statically inserted with literal
defaults — no SELECT from any other table.

---

## Wave plan

| Wave | Steps    | Dispatch model | Notes                                                        |
| ---- | -------- | -------------- | ------------------------------------------------------------ |
| A    | 1, 2     | serial         | Foundations: schema + base install                           |
| B    | 3, 4, 5  | **parallel**   | Independent file sets: agent / panel-api routes / UI layout  |
| C    | 6, 7     | serial         | Event ingestion → realtime monitor + M14 wiring              |
| D    | 8, 9     | **parallel**   | YARA admin + Tetragon (different files, no shared symbols)   |
| E    | 10       | serial         | Closeout: timers + retention + E2E + runbook + ADR finalize  |

Per `feedback_never_agents.md`: dispatcher executes every step **inline**.
"Parallel" here means files are non-overlapping — dispatcher can decide
order freely. No Agent/Task tool dispatch.

---

## Step 1 — Schema, models, repositories, ADR-0072 draft

**Goal:** Land the data model + ADR draft before any code consumes it.

**Files:**

- `panel-api/internal/db/migrations/000081_malware_detection.up.sql` (new)
- `panel-api/internal/db/migrations/000081_malware_detection.down.sql` (new)
- `panel-api/internal/models/malware.go` (new) — 5 GORM structs
- `panel-api/internal/repository/malware_quarantine_repository.go` (new)
- `panel-api/internal/repository/malware_event_repository.go` (new)
- `panel-api/internal/repository/yara_custom_rule_repository.go` (new)
- `panel-api/internal/repository/tetragon_policy_state_repository.go` (new)
- `panel-api/internal/repository/malware_settings_repository.go` (new) — singleton; `EnsureDefault(ctx)` guarded by row=1
- `agentwire/security_malware.go` (new) — **wire-contract types lifted into Step 1** (advisor finding: prevents the M21-Wave-A drift pattern when Steps 3/4/5 run in parallel). All request/response types here, single source of truth before any handler/route consumes them.
- `docs/adr/0072-malware-detection-stack.md` (new — DRAFT status)
- `panel-api/internal/db/migrations/migrate_test.go` — extend with up/down round-trip for 000076
- Repository unit tests for each (table-driven, sqlite in-memory)

**Verification:**

```bash
go test ./panel-api/internal/repository/... -run Malware -v
go test ./panel-api/internal/db/migrations/... -v
golangci-lint run ./panel-api/internal/{models,repository,db}/...
```

**Exit criteria:**

- migration 000081 round-trips up/down cleanly against MariaDB 11.4
- 5 repositories with interfaces + GORM impls
- ADR-0072 draft committed with Context + Decision + Alternatives
- `git log main..m33/malware-detection` shows 1 commit

**Rollback:** Pure additive. `git reset --hard main` and re-run migrations.

---

## Step 2 — install.sh: ClamAV + maldet + YARA base install

**Goal:** Provision the binaries, configs, and signature stores. **No
inotify start, no Tetragon, no monitor unit yet** — those land in Wave C/D.

**Files:**

- `install.sh` — new `install_malware_stack` function called between
  current security stack (CrowdSec) and timer setup
- `install.sh` — extend `install_apt_packages` with `clamav clamav-daemon clamav-freshclam yara`
- `install.sh` — new helper `install_lmd_from_rfxn` (downloads tarball, verifies SHA256 against pinned constant, runs upstream `./install.sh`, removes installer)
- `install.sh` — config drop-in writer: `/etc/jabali/maldet/conf.maldet.d/00-jabali.conf` with the documented values (scan_clamscan=1, quarantine_hits=1, quarantine_clean=0, email_alert=0, inotify_minutes=45, inotify_base_watches=15000, scan_user_access=1)
- `install.sh` — `merge_maldet_config` helper: regenerates `/usr/local/maldetect/conf.maldet` from upstream + drop-ins (idempotent on `jabali update`)
- `install.sh` — guard: refuse to install if `/usr/bin/freshclam` cron exists; `_warn` and skip
- `install.sh` — version pin constant `LMD_VERSION="1.6.5"` + `LMD_SHA256="..."`
- `install.sh` — `_log "install_malware_stack: clamav $(clamscan -V | head -1) ; maldet $(maldet --version)"`

**Important:**

- `cleanup_modsecurity` (already present) runs BEFORE `install_malware_stack`
- `clamav-freshclam` enabled, NOT manual cron
- maldet's own cron disabled — we run it via systemd timers in Step 10
- Drop-in pattern + `merge_maldet_config` per `feedback_install_sh_is_truth.md` — config regeneration is idempotent on every `jabali update`

**Verification:**

```bash
# On VM (192.168.100.150):
ssh root@192.168.100.150 'jabali update'
ssh root@192.168.100.150 'maldet --version && clamscan --version | head -1'
ssh root@192.168.100.150 'grep -E "^(scan_clamscan|quarantine_hits|email_alert|inotify_base_watches)=" /usr/local/maldetect/conf.maldet'
ssh root@192.168.100.150 'systemctl is-active clamav-daemon clamav-freshclam'
ssh root@192.168.100.150 'cat /etc/jabali/maldet/conf.maldet.d/00-jabali.conf'
```

**Exit criteria:**

- `clamav-daemon` + `clamav-freshclam` active on fresh install
- maldet binary present + config has Jabali values
- No `/usr/bin/freshclam` cron entry created
- `install.sh` exits 0 with the new packages

**Rollback:** `apt-get remove --purge clamav clamav-daemon clamav-freshclam yara && /usr/local/maldetect/uninstall.sh`. install.sh additions are guarded by `apt list --installed` checks (idempotent).

---

## Step 3 — Agent commands (parallel with 4, 5)

**Goal:** Land the panel-agent surface for status, scanning, quarantine
ops, and signature updates. **No event hook yet** (Step 6).

**Files:**

- `panel-agent/internal/commands/security_malware.go` (new)
- `panel-agent/internal/commands/security_malware_test.go` (new)
- `agentwire/security_malware.go` — **already landed in Step 1** (wire types only; Step 3 imports them)

**Commands registered (init() block):**

- `security.malware.status` — runs `clamscan --version`, `freshclam --version`, `maldet --version`; reads `/var/lib/clamav/main.cvd` mtime; reads `/usr/local/maldetect/sigs/sigs.ver`; checks `systemctl is-active clamav-daemon clamav-freshclam jabali-maldet-monitor`; counts inotify watches via `cat /proc/sys/fs/inotify/max_user_watches` + `ls /usr/local/maldetect/sess/ | wc -l`
- `security.malware.scan_path` — body `{path string, async bool}` — validates path is absolute and not in `/usr/local/maldetect`, spawns `systemd-run --quiet --collect --unit=jabali-maldet-scan-<ulid>.service maldet -a <path>`, returns `{session_id}`
- `security.malware.scan_user` — body `{user string}` — resolves user docroot via `/etc/jabali/users.d/<user>.toml`, falls through to scan_path
- `security.malware.scan_status` — body `{session_id string}` — `systemctl show jabali-maldet-scan-<id>.service` ; `journalctl -u jabali-maldet-scan-<id>.service --no-pager` last 200 lines; returns `{state, exit_code, log_tail, hits []}` parsing maldet's `SCAN ID:` and `HIT:` markers
- `security.malware.quarantine.list` — `maldet --quarantine-list` JSON-ish parsing, sha256 each entry
- `security.malware.quarantine.restore` — body `{quarantine_path, original_path, reason}` — `maldet --restore <quarantine_path>` ; refuse if reason empty
- `security.malware.quarantine.delete` — body `{quarantine_path}` — `rm` (validated under `/usr/local/maldetect/quarantine/`)
- `security.malware.update_signatures` — runs `freshclam --quiet` then `maldet -u` ; returns timing + sig version delta

**Validation guards:**

- `scan_path` rejects paths outside allowed roots: `/home/`, `/var/www/`, `/usr/share/jabali-applications/`. Hard-fail otherwise.
- `quarantine.delete` enforces prefix `/usr/local/maldetect/quarantine/` (no `..`). Hard-fail otherwise.
- All shell-out via `exec.Command(name, args...)` — never `sh -c "..."` (per feedback_no_shell_interp.md spirit).

**Verification:**

```bash
go test ./panel-agent/internal/commands/... -run SecurityMalware -v
golangci-lint run ./panel-agent/internal/commands/security_malware.go
# Live agent on VM (after Step 2 ran):
ssh root@192.168.100.150 'echo "{\"command\":\"security.malware.status\",\"id\":\"t1\"}" | nc -U /run/jabali-agent.sock'
```

**Exit criteria:**

- 9 commands registered + table-driven tests for each handler
- agentwire types exported + used by panel-api in Step 4
- All shell-outs use argv form, no string interpolation

**Rollback:** Delete the new file and its test + agentwire entry. Compile-clean.

---

## Step 4 — Panel-api routes (parallel with 3, 5)

**Goal:** REST surface for the Malware tab. Routes mounted via
`RegisterSecurityMalwareRoutes(rg, deps.Agent, deps.MalwareQuarantine, deps.MalwareEvents, deps.MalwareSettings, deps.Users)`.

**Files:**

- `panel-api/internal/api/security_malware.go` (new)
- `panel-api/internal/api/security_malware_test.go` (new)
- `panel-api/internal/app/app.go` — extend `Deps{}` with `MalwareQuarantine`, `MalwareEvents`, `YARARules`, `TetragonPolicies`, `MalwareSettings` repos; add `api.RegisterSecurityMalwareRoutes(v1, deps.Agent, ...)` at line ~495
- `panel-api/cmd/server/serve.go` — wire the 5 new repos into `Deps{}`
- `panel-api/internal/api/sse_signed_url.go` (new) — small helper for 5-min signed download URL: `signQuarantineDownload(id, ttl) -> token` (HMAC-SHA256 of `id|expiry` with server secret), `verifyQuarantineDownload(id, token, expiry)`

**Routes (admin-only via existing RequireAdmin middleware):**

| Method | Path                                                            | Handler                  |
| ------ | --------------------------------------------------------------- | ------------------------ |
| GET    | `/admin/security/malware/status`                                | `getStatus`              |
| GET    | `/admin/security/malware/quarantine`                            | `listQuarantine`         |
| POST   | `/admin/security/malware/quarantine/:id/restore`                | `restoreQuarantine`      |
| DELETE | `/admin/security/malware/quarantine/:id`                        | `deleteQuarantine`       |
| GET    | `/admin/security/malware/quarantine/:id/download?token=&exp=`   | `downloadQuarantine` (token-protected; bypass admin so a popped tab works, but verifies HMAC) |
| GET    | `/admin/security/malware/events?source=&severity=&user=&page=`  | `listEvents`             |
| POST   | `/admin/security/malware/scan`                                  | `startScan` (returns `{session_id}`) |
| GET    | `/admin/security/malware/scan/:id/stream`                       | `streamScan` (SSE)       |
| POST   | `/admin/security/malware/update-signatures`                     | `updateSignatures`       |
| GET    | `/admin/security/malware/settings`                              | `getSettings`            |
| PUT    | `/admin/security/malware/settings`                              | `putSettings`            |

**SSE pattern (first SSE in panel-api, document it):**

```go
func (h *malwareHandler) streamScan(c *gin.Context) {
    id := c.Param("id")
    c.Writer.Header().Set("Content-Type", "text/event-stream")
    c.Writer.Header().Set("Cache-Control", "no-cache")
    c.Writer.Header().Set("X-Accel-Buffering", "no")  // nginx hint
    // Bulwark: verify proxy doesn't buffer text/event-stream — see Step 4 verification.
    // If Bulwark buffers SSE, add explicit pass-through rule in panel-ui/server-unix.js (M25 file)
    // OR fall back to long-poll endpoint /scan/:id/poll returning incremental tail.
    ctx := c.Request.Context()
    ticker := time.NewTicker(750 * time.Millisecond)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            resp, err := h.agent.Send(ctx, "security.malware.scan_status", map[string]any{"session_id": id})
            // ... fmt.Fprintf(c.Writer, "data: %s\n\n", json.Marshal(resp)); c.Writer.Flush()
            if resp.State == "completed" || resp.State == "failed" { return }
        }
    }
}
```

**Quarantine download flow:**

1. UI calls `GET /quarantine/:id/download` with admin session
2. Handler validates id exists + admin role + creates 5-min token
3. Returns `{download_url}` containing `?token=...&exp=...`
4. UI does `window.open(download_url)` — token-validated handler streams raw quarantined file with `Content-Disposition: attachment; filename="malware-<id>.bin"` + `Content-Type: application/octet-stream` (no inline render)
5. Audit log entry per download per `code-review.md` (security-sensitive file access)

**Tests:**

- Each handler: happy path + admin-required + 404 + bad input
- SSE: assert headers, assert close on agent error
- Signed URL: HMAC verify rejects tampered token + expired token

**Verification:**

```bash
go test ./panel-api/internal/api/... -run SecurityMalware -v
golangci-lint run ./panel-api/internal/api/security_malware.go
go vet ./panel-api/...
# SSE Bulwark check (do this on VM after panel-ui rebuilt):
# Open SSE stream from browser DevTools to /scan/<id>/stream and confirm
# events arrive incrementally (not all-at-end). If buffered, patch
# panel-ui/server-unix.js (M25 Bulwark proxy) to disable response buffering
# on the text/event-stream content-type.
```

**Exit criteria:**

- 11 routes registered, all admin-gated except `/download` (token-gated)
- Signed-URL download tested end-to-end
- SSE handler closes on agent state=completed/failed
- Deps wiring updated in app.go + serve.go
- `go test ./panel-api/...` green

**Rollback:** Delete the route file + revert app.go + serve.go diff.

---

## Step 5 — UI shell + Overview/Quarantine/ManualScan/Settings cards (parallel with 3, 4)

**Goal:** Land the React shell + 4 of the 7 cards. Recent Events,
Realtime Monitor, YARA, and Tetragon cards land in Steps 6/7/8/9.

**Files:**

- `panel-ui/src/shells/admin/security/AdminSecurityPage.tsx` — extend `TAB_KEYS = ["crowdsec", "malware", "ufw"]`, add Malware tab content branch
- `panel-ui/src/shells/admin/security/AdminSecurityMalware.tsx` (new) — page component
- `panel-ui/src/shells/admin/security/malware/MalwareOverviewCard.tsx` (new)
- `panel-ui/src/shells/admin/security/malware/MalwareQuarantineCard.tsx` (new) — uses `SearchableTable`
- `panel-ui/src/shells/admin/security/malware/MalwareManualScanCard.tsx` (new) — uses `EventSource` for SSE log tail
- `panel-ui/src/shells/admin/security/malware/MalwareSettingsCard.tsx` (new) — Form with retain_days, schedule_cron, max_scan_mb, notify_threshold, realtime_enabled
- `panel-ui/src/hooks/useSecurityMalware.ts` (new) — TanStack Query hooks: `useMalwareStatus`, `useMalwareQuarantine`, `useRestoreQuarantine`, `useDeleteQuarantine`, `useStartMalwareScan`, `useMalwareScanStream`, `useMalwareSettings`, `useUpdateMalwareSettings`, `useUpdateSignatures`
- `panel-ui/src/api/securityMalware.ts` (new) — typed wrappers around `/admin/security/malware/*` endpoints
- `panel-ui/src/shells/admin/security/__tests__/MalwareQuarantineCard.test.tsx` (new) — vitest

**Path-display rule:** every component uses helper
`displayPath(full: string, user?: string): string` that strips
`/home/<user>/` to `~/` if user provided. Full path goes in title-attr/Tooltip
(per CONVENTIONS.md "Tooltip + ellipsis").

**Quarantine table columns:**

| Column        | Notes                                                          |
| ------------- | -------------------------------------------------------------- |
| File path     | `displayPath` + Tooltip with full path; `ellipsis={{showTitle:false}}` |
| User          | Username link (or "system")                                    |
| Signature     | AntD Tag, color by scanner                                     |
| Scanner       | Tag (maldet/clamav/yara/tetragon)                              |
| Detected      | `formatRelativeTime`                                           |
| Size          | `formatBytes`                                                  |
| Actions       | Restore (Drawer with reason text), Delete (Popconfirm), Download |

**Manual scan card:**

- Input: path or username (radio toggle)
- Submit → `POST /scan` → returns `session_id`
- Connect to `EventSource('/api/v1/admin/security/malware/scan/<id>/stream')`
- Live-tail in `<pre>` block, max 500 lines
- "Stop" button calls `DELETE` (close SSE)
- On state=completed: badge with hit count + "View hits" → opens Drawer

**Settings card:**

- Form with: Schedule cron (text + cron parser preview), Retention days (number 1-365), Max scan size MB (number 1-10240), Notify threshold (Select: info/warn/critical), Realtime monitor (Switch — disabled until Step 7)
- Save button posts to `/settings`

**tableLayout="fixed"** + explicit width on action column (per
`feedback_subagent_contract_drift.md`-adjacent UI guidance).

**Verification:**

```bash
cd panel-ui && npm run build
cd panel-ui && npm test -- malware
cd panel-ui && npx tsc --noEmit
```

**Exit criteria:**

- New tab visible at `/jabali-admin/security?tab=malware`
- 4 cards render against MSW-mocked API (no real backend needed)
- Quarantine table search + restore drawer + delete popconfirm work
- Manual scan accepts input + opens SSE + shows stream
- Settings form saves + reloads
- `npm run build` succeeds, no `tsc --noEmit` errors

**Rollback:** Delete new files + revert TAB_KEYS line. Build still succeeds.

---

## Step 6 — Event ingestion path (Wave C, serial after Wave B)

**Goal:** Wire maldet session-watcher + agent ingestion + panel-api
ingestion + Recent Events card. Single event flow end-to-end, **but no
inotify monitor running yet** — events come from manual scans only at
this point.

**Note (advisor finding, verified against upstream):** LMD has **no
`scan_hook` directive**. Real surfaces are:

- `/usr/local/maldetect/sess/session.last` — symlink to most recent session file (one per scan)
- `/usr/local/maldetect/sess/session.<scanid>` — `SCAN ID:`, `TOTAL FILES:`, `TOTAL HITS:`, `HIT:` lines
- `/var/log/maldet/event_log` — append-only log, `time {hostname} maldet({pid}): {action} {detail}`
- `/usr/local/maldetect/quarantine/` — quarantined files land here

So ingestion is agent-pull (inotify-watch + parse), not LMD-push (hook).

**Files:**

- `panel-agent/internal/sessionwatcher/maldet.go` (new) — small package with `Run(ctx, dir, onSession func(SessionFile))` using fsnotify on `/usr/local/maldetect/sess/`. On `IN_CREATE` of `session.<scanid>`: parse file with `maldet --report <scanid> -o json` (LMD has JSON report mode) OR fall back to text parse if JSON unsupported in shipped version
- `panel-agent/internal/sessionwatcher/parser.go` (new) — text fallback parser: extracts `SCAN ID`, `TOTAL HITS`, per-hit `{signature, path, sha256, size}`
- `panel-agent/cmd/jabali-agent/main.go` — start session watcher goroutine alongside command server; on each session: dispatch `security.malware.event` ingest internally
- `panel-agent/internal/commands/security_malware.go` — add `security.malware.event` handler (also callable externally for Tetragon-relay reuse): enriches with sha256 verification + user lookup (path prefix `/home/<user>/`), POSTs to `http://127.0.0.1:<panel-api-port>/admin/security/malware/event` (loopback, RequireLocalhost)
- `panel-api/internal/api/security_malware.go` — add `POST /admin/security/malware/event` route gated by `RequireLocalhost()` (NOT RequireAdmin); handler parses payload, inserts into `malware_events` AND `malware_quarantine` (one event row per source-event, plus N quarantine rows for file_hit events)
- `install.sh` — `mkdir -p /var/log/maldet ; chown root:root /var/log/maldet` (sessionwatcher reads `/usr/local/maldetect/sess/` already 0700 root:root — agent runs as root, no permission flip needed)
- `panel-ui/src/shells/admin/security/malware/MalwareEventsCard.tsx` (new) — SearchableTable: event_type, source, severity, target_path, user, detected_at; row click → Drawer with raw_json
- `panel-ui/src/hooks/useSecurityMalware.ts` — add `useMalwareEvents` query
- `panel-ui/src/shells/admin/security/AdminSecurityMalware.tsx` — render new card after Quarantine card

**Event payload contract** (`agentwire/security_malware.go`):

```go
type MalwareEventIngest struct {
    Source     string                   `json:"source"`     // maldet|clamav|yara|tetragon
    EventType  string                   `json:"event_type"` // file_hit|process_exec_alert|chmod_suspicious|scan_completed
    Severity   string                   `json:"severity"`   // info|warn|critical (defaults: maldet=warn, tetragon=critical)
    UserID     string                   `json:"user_id,omitempty"`
    TargetPath string                   `json:"target_path,omitempty"`
    TargetPID  uint32                   `json:"target_pid,omitempty"`
    Signature  string                   `json:"signature,omitempty"`
    SHA256     string                   `json:"sha256,omitempty"`
    SizeBytes  int64                    `json:"size_bytes,omitempty"`
    Hits       []MalwareHit             `json:"hits,omitempty"` // for file_hit events with N quarantined files
    RawJSON    map[string]any           `json:"raw_json"`
    OccurredAt time.Time                `json:"occurred_at"`
}
```

**Why session-watcher (agent-pull) instead of LMD hook:**

1. LMD has no `scan_hook` directive (verified against upstream `conf.maldet`); session-files are the documented integration surface
2. Agent runs as root with full localhost trust; can read `sess/` (0700 root:root) without permission relaxation
3. Watcher is durable: missed events on agent restart can be backfilled by listing `sess/*` newer than last-seen mtime
4. Tetragon relay (Step 9) reuses the same `security.malware.event` agent command via different origin

**Path-strip helper for ingestion:**

```go
// agent: enrichEvent extracts user from /home/<user>/... ; falls back to "system" for /tmp, /proc, /var
func enrichEvent(p string) (userID string, displayPath string) {
    if strings.HasPrefix(p, "/home/") {
        rest := strings.TrimPrefix(p, "/home/")
        if i := strings.Index(rest, "/"); i > 0 { return rest[:i], rest }
    }
    return "", p  // system/non-user path; UI shows "system" badge
}
```

**Tests:**

- Sessionwatcher: feed canned `session.<id>` fixtures (real `maldet -a` output captured from VM), assert N hits → N quarantine rows + 1 event row
- Parser: table-driven with malformed lines, empty session, multi-hit sessions
- Agent handler: enrich path → user mapping (`/home/alice/x.php` → user=alice, `/tmp/x` → system)
- panel-api: `RequireLocalhost` rejects non-loopback (table-driven), happy path inserts both rows
- UI: events table renders, Drawer shows raw_json

**Verification:**

```bash
go test ./panel-agent/internal/sessionwatcher/... -v
go test ./panel-agent/internal/commands/... -run MalwareEvent -v
go test ./panel-api/internal/api/... -run MalwareEvent -v
cd panel-ui && npm test -- MalwareEvents
# VM:
ssh root@192.168.100.150 'curl -s https://secure.eicar.org/eicar.com.txt -o /tmp/eicar.com ; maldet -a /tmp/eicar.com'
ssh root@192.168.100.150 'ls -la /usr/local/maldetect/sess/ | head'
ssh root@192.168.100.150 'curl -s http://localhost:8080/api/v1/admin/security/malware/events | jq .data'
```

**Exit criteria:**

- Manual scan in UI → SSE shows hits → Recent Events card shows event row + Quarantine card shows N rows
- VM: planted EICAR test file scanned by maldet creates 1 event + 1 quarantine row via sessionwatcher
- `RequireLocalhost` rejects non-loopback in test
- Agent restart: backfills any session files newer than last-seen mtime (test with stub clock)

**Rollback:** Stop sessionwatcher goroutine in agent (feature flag in agent config). Manual scans still work; they just don't ingest events. Quarantine still happens at LMD level.

---

## Step 7 — Inotify realtime + reconciler hook + M14 wiring

**Goal:** Start `jabali-maldet-monitor.service`, hook user-create/delete to
reload watch list, wire malware events to M14 dispatcher.

**Files:**

- `install.sh` — install `/etc/systemd/system/jabali-maldet-monitor.service`:
  ```
  [Unit]
  Description=Jabali maldet inotify monitor
  After=clamav-daemon.service jabali-agent.service
  Requires=clamav-daemon.service

  [Service]
  Type=simple
  ExecStart=/usr/local/maldetect/maldet --monitor USERS
  ExecReload=/usr/local/maldetect/maldet --monitor USERS
  Restart=always
  RestartSec=10
  User=root
  Group=root
  ProtectSystem=strict
  ReadWritePaths=/home /usr/local/maldetect /var/log/maldet /run
  ProtectKernelTunables=yes
  NoNewPrivileges=yes
  CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL

  [Install]
  WantedBy=multi-user.target
  ```
- `install.sh` — `systemctl enable --now jabali-maldet-monitor.service` (only if `realtime_enabled=1` in malware_settings; otherwise enable but masked-stop)
- `install.sh` — set `inotify_minuid=1000` in `00-jabali.conf` so `--monitor USERS` auto-discovers all Jabali tenant accounts from `/etc/passwd` (Jabali users always have UID ≥ 1000 per M5)
- `panel-agent/internal/commands/security_malware.go` — add `security.malware.monitor.reload` handler: runs `maldet --monitor RELOAD` (LMD's documented config-reload signal — verified against upstream `--monitor RELOAD` flag). No user-list file needed; LMD re-scans `/etc/passwd` on RELOAD.
- `panel-api/internal/reconciler/reconciler.go` — extend post-user-create + post-user-delete hooks to dispatch `security.malware.monitor.reload` (best-effort, log-on-fail per CONVENTIONS reconciler pattern)
- `panel-api/internal/eventsources/malware.go` (new) — `runMalware(ctx, d Deps)`:
  - tick every 30s, query `malware_events WHERE notified=0 ORDER BY created_at LIMIT 100`
  - per row: dispatch via `d.Notifications.Send(ctx, envelope)` mapping severity → channels
  - mark `notified=1` after dispatch (or on permanent failure)
  - new event sources registered via `Register("malware_quarantine_added", ...)`, `Register("malware_realtime_critical", ...)`
- `panel-api/internal/eventsources/sources.go` — add `runMalware` to dispatcher init
- `panel-api/internal/notifications/templates.go` — add 2 templates per channel (12 total: 2 × 6 channels):
  - title: `"Malware detected on {user}: {signature}"`
  - body: `"Path: {path}\nScanner: {scanner}\nSeverity: {severity}\nView: {panel_url}/jabali-admin/security?tab=malware"`
- `panel-ui/src/shells/admin/security/malware/MalwareRealtimeMonitorCard.tsx` (new) — shows: monitor status (running/stopped/disabled), watch count, hits 24h, quarantined 24h, "Reload watches" button
- `panel-ui/src/shells/admin/security/malware/MalwareSettingsCard.tsx` — enable `realtime_enabled` Switch (now functional)

**Notification routing:**

| Event source              | Severity | Channels                                                                                 |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------- |
| malware_quarantine_added  | settings.notify_threshold gating | in-app bell (always) + admin's enabled channels (email/ntfy/slack/discord/webpush) |
| malware_realtime_critical | always critical                  | all admin-enabled channels (no gating)                                                   |

**Audit log:** Each notification dispatch writes to `audit_log` (M26
admin-only audit table) — already wired through Notifications.Send.

**Tests:**

- `eventsources/malware_test.go` — table-driven: severity → channel selection, notified flag flips, dedup by event ID
- Reconciler: user create dispatches `monitor.reload` (mocked agent client)
- Agent: `monitor.reload` writes file + reloads systemd (test with stub systemctl path)

**Verification:**

```bash
go test ./panel-api/internal/eventsources/... -run Malware -v
go test ./panel-api/internal/reconciler/... -run Malware -v
go test ./panel-agent/internal/commands/... -run MonitorReload -v
# VM:
ssh root@192.168.100.150 'systemctl status jabali-maldet-monitor'
ssh root@192.168.100.150 'awk -F: "\$3>=1000 && \$1!~/nobody/ {print \$1}" /etc/passwd  # what USERS-mode picks up'
# Plant EICAR in tenant docroot, watch monitor catch it:
ssh root@192.168.100.150 'sudo -u testuser sh -c "echo X5O\!\$<EICAR file content> > /home/testuser/public_html/test.txt"'
sleep 60  # inotify_minutes can be tight
ssh root@192.168.100.150 'tail /var/log/maldet/event_log'
# Verify in-app bell shows new notification + admin email arrives
```

**Exit criteria:**

- jabali-maldet-monitor.service active + watching all Jabali users
- Tenant-side EICAR drop → quarantine + event + dispatched notification ≤ 90s
- M14 in-app bell shows malware notification with deep-link to tab
- New user creation triggers monitor.reload (verified via agent log)

**Rollback:** `systemctl disable --now jabali-maldet-monitor.service ; rm event source registration ; revert reconciler hook`. Manual scans still work.

---

## Step 8 — YARA admin (parallel with 9)

**Goal:** Admin-editable YARA custom rules drop-in `/etc/jabali/yara/*.yar`,
referenced by maldet via `yara_rules=/etc/jabali/yara/*.yar` config line.

**Files:**

- `install.sh` — `mkdir -p /etc/jabali/yara` ; chmod 0755 root:root ; add `scan_yara=1` and `yara_rules="/usr/local/maldetect/sigs/rfxn.yara /etc/jabali/yara/*.yar"` to `00-jabali.conf` ; ship one example rule file `/etc/jabali/yara/00-example.yar.disabled`
- `panel-agent/internal/commands/security_malware.go` — extend with:
  - `security.malware.yara.list` — returns `[{filename, enabled, sha256, size, last_modified, parse_ok}]` from listing `/etc/jabali/yara/*.yar` + `*.yar.disabled`
  - `security.malware.yara.upload` — body `{filename, content_base64}` — validates filename matches `^[a-z0-9_-]+\.yar$` (no `..`, no path), runs `yara -w <tempfile> /dev/null` for parse check, atomic-rename into place
  - `security.malware.yara.toggle` — body `{filename, enabled}` — renames `.yar` ↔ `.yar.disabled`
  - `security.malware.yara.delete` — body `{filename}` — removes file (validated prefix)
- `panel-api/internal/api/security_malware.go` — extend with 4 routes:
  - `GET /admin/security/malware/yara`
  - `POST /admin/security/malware/yara` (multipart upload)
  - `PATCH /admin/security/malware/yara/:filename` (toggle)
  - `DELETE /admin/security/malware/yara/:filename`
- `panel-api/internal/repository/yara_custom_rule_repository.go` — extend with create/update on upload (track who uploaded + sha256)
- `panel-ui/src/shells/admin/security/malware/MalwareYARACard.tsx` (new) — list of rules, Upload button → AntD Drawer with file picker + preview, Toggle Switch per row, Delete with Popconfirm
- `panel-ui/src/hooks/useSecurityMalware.ts` — add YARA query/mutation hooks

**Validation guards:**

- Filename regex enforced server-side AND agent-side (defence-in-depth)
- Upload size cap: 64 KB (a YARA rule file > 64 KB is suspicious)
- Parse check (`yara -w`) is mandatory; reject upload on parse fail with stderr in error response
- Quota: max 100 custom rule files (avoid filling /etc disk)

**Verification:**

```bash
go test ./panel-agent/internal/commands/... -run YARA -v
go test ./panel-api/internal/api/... -run YARA -v
cd panel-ui && npm test -- YARA
# VM:
ssh root@192.168.100.150 'curl -X POST -F "file=@suspicious.yar" http://localhost:8080/api/v1/admin/security/malware/yara'
ssh root@192.168.100.150 'maldet --reload-conf && maldet -a /tmp/test-target'
```

**Exit criteria:**

- Upload + toggle + delete work via UI
- Bad YARA syntax rejected with parse error surfaced to UI
- Path-traversal upload rejected
- maldet picks up new rule file after upload (no restart needed — `yara_rules` glob expanded each scan)

**Rollback:** Delete new files + drop the 4 routes/commands. Custom rules in `/etc/jabali/yara/` remain on disk but are simply not surfaced.

---

## Step 9 — Tetragon install + policies + UI (parallel with 8)

**Goal:** Add eBPF runtime detection. Default-on if kernel supports BTF;
gracefully disabled with UI explanation if not.

**Files:**

- `install.sh` — new `install_tetragon` function:
  - Probe: `bpftool btf list 2>/dev/null | head -1` and `[ -f /sys/kernel/btf/vmlinux ]`
  - On fail: `_warn "Tetragon: kernel does not support BTF — skipping install"` ; touch sentinel `/etc/jabali/tetragon-disabled`
  - On pass: install from **GitHub release tarball** (advisor finding: no Debian apt repo at `apt.cilium.io` — Tetragon ships as K8s DaemonSet, OCI image, or release tarball). Pinned constants: `TETRAGON_VERSION="v1.6.1"` + `TETRAGON_SHA256="..."`. Pattern matches LMD install in Step 2:
    - `wget https://github.com/cilium/tetragon/releases/download/${TETRAGON_VERSION}/tetragon-${TETRAGON_VERSION}-amd64.tar.gz`
    - SHA256 verify against pinned constant
    - `tar -xzf` into `/opt/tetragon/`
    - install systemd unit `/etc/systemd/system/tetragon.service` (ExecStart=/opt/tetragon/usr/local/bin/tetragon --bpf-lib /opt/tetragon/usr/local/lib/tetragon/bpf --tracing-policy-dir /etc/tetragon/tetragon.tp.d)
    - install `tetra` CLI to `/usr/local/bin/tetra` (used by agent for policy management)
  - Deploy 4 default policies under `/etc/tetragon/tetragon.tp.d/jabali-*.yaml`:
    - `jabali-exec-from-tmp.yaml` — alert on exec from `/tmp/*` or `/dev/shm/*`
    - `jabali-chmod-x-docroot.yaml` — alert on chmod +x in `/home/*/public_html`
    - `jabali-curl-bash.yaml` — alert on `curl|bash` chain (process_exec parent=curl/wget child=bash/sh)
    - `jabali-suspicious-syscalls.yaml` — alert on ptrace / kexec_load
  - `systemctl enable --now tetragon`
- `install.sh` — install `/usr/local/sbin/jabali-tetragon-relay` (Go shim built in panel-agent build): tails `/var/log/tetragon/tetragon.log`, parses JSON lines, POSTs `MalwareEventIngest{source:"tetragon", severity:"critical", ...}` to local agent via `security.malware.event`
- `install.sh` — install `jabali-tetragon-relay.service`:
  ```
  [Unit]
  Description=Jabali Tetragon → panel relay
  After=tetragon.service jabali-agent.service
  Requires=tetragon.service

  [Service]
  Type=simple
  ExecStart=/usr/local/sbin/jabali-tetragon-relay --log /var/log/tetragon/tetragon.log
  Restart=always
  RestartSec=5
  User=root
  Group=root
  ProtectSystem=strict
  ReadOnlyPaths=/var/log/tetragon
  ReadWritePaths=/run
  NoNewPrivileges=yes
  ```
- `panel-agent/cmd/jabali-tetragon-relay/main.go` (new) — small daemon (~150 LOC): tail file, parse Tetragon events, filter by policy, send to agent
- `panel-agent/internal/commands/security_malware.go` — extend with:
  - `security.malware.tetragon.policies` — lists `/etc/tetragon/tetragon.tp.d/jabali-*.yaml{,.disabled}` ; uses `tetra tracingpolicy list -o json` if `tetra` available for live state, falls back to filesystem listing
  - `security.malware.tetragon.toggle` — body `{policy_name, enabled}` — renames `.yaml` ↔ `.yaml.disabled` + signals tetragon to re-scan policy dir (`systemctl reload tetragon` ; tetragon picks up dir changes on reload)
- `panel-api/internal/api/security_malware.go` — add:
  - `GET /admin/security/malware/tetragon/policies`
  - `POST /admin/security/malware/tetragon/policies/:name/toggle`
- `panel-api/internal/eventsources/malware.go` — extend with `tetragon_policy_match_critical` source (per-policy gating via tetragon_policy_state.enabled)
- `panel-ui/src/shells/admin/security/malware/MalwareTetragonCard.tsx` (new):
  - If `/admin/security/malware/status` returns `tetragon: {available: false, reason: "no BTF"}` → Alert "Disabled (kernel does not support eBPF tracing)"
  - Otherwise: list policies with Switch toggle per row, Recent matches per policy (count 24h)
- `panel-ui/src/hooks/useSecurityMalware.ts` — add Tetragon hooks

**Tetragon-relay event mapping:**

| Tetragon event                  | Mapped event_type           | Severity |
| ------------------------------- | --------------------------- | -------- |
| process_exec from /tmp          | process_exec_alert          | critical |
| chmod_x on /home/*/public_html  | chmod_suspicious            | warn     |
| curl-piped-to-bash chain        | process_exec_alert          | critical |
| ptrace/kexec_load               | suspicious_syscall          | critical |

**Tests:**

- Tetragon-relay: feed canned tetragon.log lines, assert correct ingest payloads
- Agent commands: policy toggle + policies list (with mocked filesystem)
- panel-api: routes + tetragon-disabled fallback
- UI: tetragon-disabled Alert renders when `available=false`

**Verification:**

```bash
go test ./panel-agent/cmd/jabali-tetragon-relay/... -v
go test ./panel-agent/internal/commands/... -run Tetragon -v
go test ./panel-api/internal/api/... -run Tetragon -v
cd panel-ui && npm test -- Tetragon
# VM (must support BTF):
ssh root@192.168.100.150 '[ -f /sys/kernel/btf/vmlinux ] && echo BTF-ok || echo BTF-missing'
ssh root@192.168.100.150 'systemctl is-active tetragon jabali-tetragon-relay'
# Trigger a policy:
ssh root@192.168.100.150 'cp /bin/echo /tmp/x && /tmp/x test'
sleep 5
ssh root@192.168.100.150 'curl -s http://localhost:8080/api/v1/admin/security/malware/events?source=tetragon | jq'
```

**Exit criteria:**

- Tetragon installed and active OR cleanly skipped on no-BTF VM
- 4 default policies loaded
- Triggering exec-from-/tmp creates event row + critical notification
- UI tetragon card renders enabled/disabled correctly
- Policy toggle survives tetragon reload

**Rollback:** `systemctl disable --now tetragon jabali-tetragon-relay ; rm /etc/tetragon/tetragon.tp.d/jabali-* ; apt-get remove tetragon`. Other malware features unaffected.

---

## Step 10 — Timers, retention purge, E2E, runbook, ADR-0072 finalize

**Goal:** Closeout — recurring jobs, cleanup, end-to-end Playwright,
operator docs, ADR finalized.

**Files:**

- `install.sh` — install systemd timers:
  - `jabali-maldet-update-signatures.service` + `.timer` (OnCalendar=daily 02:30, runs `freshclam --quiet && maldet -u`)
  - `jabali-maldet-scan-daily.service` + `.timer` (OnCalendar=daily 03:00 — but reads `malware_settings.schedule_cron` via small wrapper, defaults to 03:00)
  - `jabali-malware-quarantine-purge.service` + `.timer` (OnCalendar=daily 04:00, runs `panel-api malware-purge` honouring `malware_settings.quarantine_retain_days`)
- `panel-api/cmd/server/malware_purge_cmd.go` (new) — cobra subcommand: SELECT quarantined > N days old, `os.Remove` each `quarantine_path`, mark `deleted_at = NOW()` in DB
- `panel-api/internal/eventsources/malware.go` — add `runMalwarePurge` ticker hourly (in-process supplement to systemd timer for resilience if timer disabled)
- `e2e/specs/security/malware.spec.ts` (new — Playwright) — happy-path E2E:
  - Login as admin
  - Navigate `/jabali-admin/security?tab=malware`
  - Verify Overview card shows ClamAV version
  - Trigger manual scan against known-safe path → SSE shows progress → completes
  - Plant fixture EICAR file via test API → realtime monitor catches → Quarantine card row appears
  - Restore from quarantine with reason → row disappears
  - Toggle Tetragon policy off → API confirms persisted
- `plans/m33-malware-detection-runbook.md` (new) — operator-facing docs:
  - Architecture diagram (text)
  - Install verification commands
  - Common operations: restore quarantine, scan a user, update signatures manually
  - Troubleshooting: clamd OOM, freshclam DNS fail, maldet inotify watches exhausted, tetragon BPF load fail
  - Rollback procedure (full uninstall)
- `docs/adr/0072-malware-detection-stack.md` — finalize from draft (status: accepted, date: 2026-04-27 + ship date)
- `docs/adr/README.md` — append row: `| [0067](0072-malware-detection-stack.md) | Malware detection stack: ClamAV + maldet + YARA + Tetragon | accepted | 2026-XX-XX |`
- `docs/BLUEPRINT.md` — add M33 row to milestone catalogue
- `~/.claude/projects/-home-shuki-projects-jabali2/memory/project_m33_malware_shipped.md` (new) — session memory note + `MEMORY.md` index update

**Tests:**

- `e2e/specs/security/malware.spec.ts` runs in CI (Playwright) + on VM
- `panel-api/cmd/server/malware_purge_cmd_test.go` — table-driven retention boundary cases
- `eventsources/malware_purge_test.go` — ticker fires + purges only > retain_days

**Verification:**

```bash
go test ./...
cd panel-ui && npm run build && npm test
cd e2e && npx playwright test specs/security/malware.spec.ts
# VM full smoke:
ssh root@192.168.100.150 'systemctl list-timers | grep jabali-maldet'
ssh root@192.168.100.150 'jabali update'
# Plant aged fixture quarantine row > 30d → next purge cycle removes it
```

**Exit criteria:**

- 3 timers active and `systemctl list-timers` shows next-fire
- Purge cmd removes > N day quarantined entries
- Playwright E2E green in CI
- Runbook covers all common operator ops
- ADR-0072 status=accepted with ship date
- BLUEPRINT M33 row added
- Memory file written + MEMORY.md updated

**Rollback:** Disable 3 timers, revert `malware_purge` cmd. Stack remains operational, just no automatic cleanup or scheduled scans.

---

## Cross-cutting checks (every step)

Per CONVENTIONS.md + memory feedback:

1. **codebase-memory-mcp:** before first edit per step, run `search_graph` + `trace_path` on touched symbols (project=`home-shuki-projects-jabali2`); include blast radius in commit message
2. **detect_changes** before commit: assert affected symbols match scope
3. **install.sh logger:** only `_log/_ok/_warn/_err` (per `feedback_install_sh_logger.md`)
4. **Migrations are schema only:** no SELECT from app-populated tables (per `feedback_migration_data_seed_ordering.md`)
5. **Rebase before report:** `git fetch origin main && git rebase origin/main && go test ./...` per agent mandate
6. **No agents:** every step inline (per `feedback_never_agents.md`)
7. **No `--no-verify`:** all commits hit pre-commit hooks
8. **Trash not rm:** any local cleanup uses `trash`
9. **Argv shell-out only:** no `sh -c "..."` in agent or install.sh internals; gated `bash -c` allowed only in event-hook.sh template (which itself uses `set -euo pipefail`)
10. **`tableLayout="fixed"` + `ellipsis={{showTitle:false}}` + Tooltip** on every Table column with potential overflow

---

## Risk register

| Risk                                                                                       | Mitigation                                                                                                       |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| Tetragon kernel incompatibility (older VMs)                                                | install.sh BTF probe + UI Alert "Disabled (no BTF)" + functional pipeline minus runtime detection                |
| ClamAV memory footprint (~80MB clamd)                                                      | Documented in ADR-0072 cost section; settings.realtime_enabled toggle lets operators stop monitor (clamd stays)  |
| Inotify watches exhausted on tenants with many small files                                 | `inotify_base_watches=15000` ; `sysctl fs.inotify.max_user_watches=524288` set in install.sh; UI Overview shows count |
| Web-shell that quarantines, then admin restore re-introduces (foot-gun)                    | Restore requires reason text + audit log entry + 3-second confirm dialog with malware signature shown            |
| Quarantine dir fills disk                                                                  | Retention timer (Step 10) + UI Settings card + `df -h /usr/local/maldetect` in Overview                          |
| LMD signature pull from rfxn.com fails (network)                                           | Update timer logs to journal; status card surfaces sig age; alert if > 7 days stale                              |
| Event-hook → agent socket race during heavy scan                                           | `timeout 5 nc -U` in hook; on fail logs to syslog (no event lost in journal trail); agent retry-from-journal command available manually |
| Tenant uploads YARA-trigger fixtures to test                                               | Quarantine action does not affect web traffic (file moved aside); Restore is manual                              |
| YARA rule upload that DoS's maldet (catastrophic regex)                                    | Pre-flight `yara -w` on a 1KB sample + scan timeout per file (maldet `scan_max_filesize` already pinned)         |
| Notifications storm during attack (1000 quarantined files in seconds)                      | M14 dispatcher already has rate-limit; eventsources/malware.go batches per (user, signature) within 60s window   |
| Bulwark proxy buffering SSE → manual scan stream stalls until completion                   | Step 4 verification gate: live SSE check from browser DevTools; fallback long-poll `/scan/:id/poll` if Bulwark cannot be configured to pass-through `text/event-stream` |
| LMD `--monitor USERS` misses non-tenant accounts (UID < inotify_minuid)                    | `inotify_minuid=1000` set + Jabali user provisioning always uses UID ≥ 1000 (M5); document in runbook that ad-hoc system users won't be watched |
| Quarantine download (raw octet-stream) re-executable by confused admin                     | UI Download button sets `Content-Disposition: attachment` + filename `malware-<sha8>.bin` ; future enhancement: serve as password-protected zip with sha256 prefix as password (logged separately in audit) |
| Tetragon path strip — `/tmp/x` and `/proc/<pid>/exe` paths don't match `/home/<user>/`     | `enrichEvent()` falls back to `userID=""` + UI shows "system" badge; Tetragon events tagged `user=system` by default |
| GitHub release tarball SHA drift on jabali update (no apt repo means manual version pin)   | install.sh pins `TETRAGON_VERSION` + `TETRAGON_SHA256` constants; bumps go through normal PR review; document upgrade flow in runbook |

---

## Dependency graph

```
Step 1 ─┬─> Step 3 ─┐
        ├─> Step 4 ─┼─> Step 6 ─> Step 7 ─┬─> Step 8 ─┐
        └─> Step 5 ─┘                     └─> Step 9 ─┴─> Step 10
Step 2 ─────────────┘   (install foundations gate)
```

Wave A = {1, 2} serial.
Wave B = {3, 4, 5} parallel.
Wave C = {6} → {7} serial.
Wave D = {8, 9} parallel.
Wave E = {10} closeout.

---

## Open questions (fold or close before Wave A)

1. **Tetragon-relay vs Tetragon log-export plugin:** alternative is to use Tetragon's built-in stdout/file exporter and have panel-agent tail directly. Step 9 picks relay-shim for clarity; flag for revisit if relay LOC > 200.
2. **Per-user opt-out:** users currently can't opt out of scans on their docroot. Out of scope for M33 (admin-only feature); could add in future M33.1 with per-user_settings row.
3. **Quarantine encryption-at-rest:** quarantine dir is `0700 root:root`; M33 ships unencrypted. If LUKS-on-LUKS desired, separate ADR.
4. **WordPress integration:** no WP plugin in M33. Future: jabali-wp-plugin could ping panel-api with WAF events. Out of scope.
5. **EICAR test fixture in CI:** should CI ship an EICAR fixture (avir-bench standard) or download per-run? Pin a static signature to avoid network in CI.

---

## Mutation log

(Append future plan mutations here per blueprint protocol — empty at registration)

---

## References

- [ADR-0053 CrowdSec over fail2ban](../docs/adr/0053-crowdsec-over-fail2ban.md)
- [ADR-0066 (precedent: panel cert lifecycle)](../docs/adr/0066-le-cert-panel-hostname.md)
- [M14 Notifications runbook](./m14-notifications-runbook.md)
- [M29 transient systemd unit pattern](./m29-admin-updates-support.md)
- [docs/CONVENTIONS.md](../docs/CONVENTIONS.md)
- [Linux Malware Detect upstream](https://www.rfxn.com/projects/linux-malware-detect/)
- [Tetragon docs](https://tetragon.io/docs/)
- [ClamAV freshclam docs](https://docs.clamav.net/manual/Usage/SignatureManagement.html)
