openclaw-sentinel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,7 @@ sudo yum install osquery
67
67
  ## Installation
68
68
 
69
69
  ```bash
70
- openclaw plugins install /path/to/openclaw-sentinel
70
+ openclaw plugins install openclaw-sentinel
71
71
  openclaw gateway restart
72
72
  ```
73
73
 
@@ -18,20 +18,19 @@ describe("shouldAlert", () => {
18
18
  const state = createAlertState();
19
19
  assert.equal(shouldAlert(makeEvent("Test"), state), true);
20
20
  });
21
- it("deduplicates same title within 5 minutes", () => {
21
+ it("deduplicates same title within 1 minute", () => {
22
22
  const state = createAlertState();
23
23
  const now = Date.now();
24
24
  assert.equal(shouldAlert(makeEvent("Same Event"), state, now), true);
25
25
  assert.equal(shouldAlert(makeEvent("Same Event"), state, now + 1000), false);
26
- assert.equal(shouldAlert(makeEvent("Same Event"), state, now + 60_000), false);
27
- assert.equal(shouldAlert(makeEvent("Same Event"), state, now + 299_000), false);
26
+ assert.equal(shouldAlert(makeEvent("Same Event"), state, now + 30_000), false);
27
+ assert.equal(shouldAlert(makeEvent("Same Event"), state, now + 59_000), false);
28
28
  });
29
- it("allows same title after 5 minute window (entries expire from rate limit window)", () => {
29
+ it("allows same title after 1 minute window", () => {
30
30
  const state = createAlertState();
31
31
  const now = Date.now();
32
32
  shouldAlert(makeEvent("Recurring"), state, now);
33
- // After 5+ minutes AND after the 1-min rate limit window cleans up
34
- assert.equal(shouldAlert(makeEvent("Recurring"), state, now + 301_000), true);
33
+ assert.equal(shouldAlert(makeEvent("Recurring"), state, now + 61_000), true);
35
34
  });
36
35
  it("allows different titles", () => {
37
36
  const state = createAlertState();
@@ -120,30 +120,34 @@ describe("analyzeProcessEvents", () => {
120
120
  });
121
121
  describe("analyzeLoginEvents", () => {
122
122
  const knownHosts = new Set(["192.168.1.100", "100.64.0.1"]);
123
- it("detects unknown remote host", () => {
123
+ it("detects unknown remote host as high severity", () => {
124
124
  const rows = [
125
125
  { user: "root", host: "203.0.113.42", type: "user" },
126
126
  ];
127
127
  const events = analyzeLoginEvents(rows, knownHosts);
128
128
  assert.equal(events.length, 1);
129
129
  assert.equal(events[0].severity, "high");
130
- assert.equal(events[0].category, "auth");
130
+ assert.equal(events[0].category, "ssh_login");
131
131
  });
132
- it("skips known hosts", () => {
132
+ it("emits info event for known hosts", () => {
133
133
  const rows = [
134
134
  { user: "sunil", host: "192.168.1.100", type: "user" },
135
135
  ];
136
136
  const events = analyzeLoginEvents(rows, knownHosts);
137
- assert.equal(events.length, 0);
137
+ assert.equal(events.length, 1);
138
+ assert.equal(events[0].severity, "info");
139
+ assert.equal(events[0].category, "ssh_login");
138
140
  });
139
- it("skips Tailscale CGNAT range (100.64-127.x.x)", () => {
141
+ it("emits info event for Tailscale CGNAT range (100.64-127.x.x)", () => {
140
142
  const rows = [
141
143
  { user: "sunil", host: "100.79.207.74", type: "user" },
142
144
  { user: "sunil", host: "100.94.48.17", type: "user" },
143
145
  { user: "sunil", host: "100.127.255.255", type: "user" },
144
146
  ];
145
147
  const events = analyzeLoginEvents(rows, new Set());
146
- assert.equal(events.length, 0);
148
+ assert.equal(events.length, 3);
149
+ assert.equal(events[0].severity, "info");
150
+ assert.ok(events[0].description.includes("Tailscale"));
147
151
  });
148
152
  it("does NOT skip non-Tailscale 100.x IPs", () => {
149
153
  const rows = [
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ // We can't easily test the spawned process, but we can test the parsing logic
4
+ // by importing and testing the module's parse functions.
5
+ // Since parse functions are not exported, we test via the public class behavior.
6
+ // Instead, test the SSH event patterns that the parser handles:
7
+ describe("SSH log line patterns", () => {
8
+ // These patterns match what sshd outputs and our parser expects
9
+ it("matches 'Accepted publickey' pattern", () => {
10
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2';
11
+ const match = line.match(/sshd.*?:\s+Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
12
+ assert.ok(match);
13
+ assert.equal(match[1], "publickey");
14
+ assert.equal(match[2], "sunil");
15
+ assert.equal(match[3], "100.79.207.74");
16
+ assert.equal(match[4], "52341");
17
+ });
18
+ it("matches 'Accepted password' pattern", () => {
19
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted password for root from 203.0.113.42 port 22 ssh2';
20
+ const match = line.match(/sshd.*?:\s+Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
21
+ assert.ok(match);
22
+ assert.equal(match[1], "password");
23
+ assert.equal(match[2], "root");
24
+ assert.equal(match[3], "203.0.113.42");
25
+ });
26
+ it("matches 'Failed password' pattern", () => {
27
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for sunil from 203.0.113.42 port 22 ssh2';
28
+ const match = line.match(/sshd.*?:\s+Failed\s+password\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
29
+ assert.ok(match);
30
+ assert.equal(match[1], "sunil");
31
+ assert.equal(match[2], "203.0.113.42");
32
+ });
33
+ it("matches 'Failed password for invalid user' pattern", () => {
34
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for invalid user admin from 203.0.113.42 port 22 ssh2';
35
+ const match = line.match(/sshd.*?:\s+Failed\s+password\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
36
+ assert.ok(match);
37
+ assert.equal(match[1], "admin");
38
+ assert.equal(match[2], "203.0.113.42");
39
+ });
40
+ it("matches 'Invalid user' pattern", () => {
41
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Invalid user admin from 203.0.113.42 port 22';
42
+ const match = line.match(/sshd.*?:\s+Invalid\s+user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
43
+ assert.ok(match);
44
+ assert.equal(match[1], "admin");
45
+ assert.equal(match[2], "203.0.113.42");
46
+ });
47
+ it("matches macOS sshd-session USER_PROCESS pattern", () => {
48
+ const line = "Feb 22 16:39:32 sunils-mac-mini sshd-session: sunil [priv][58912]: USER_PROCESS: 58916 ttys001";
49
+ const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
50
+ assert.ok(match);
51
+ assert.equal(match[1], "sunil");
52
+ assert.equal(match[2], "58912");
53
+ assert.equal(match[3], "58916");
54
+ assert.equal(match[4], "ttys001");
55
+ });
56
+ it("does not match sshd-session DEAD_PROCESS", () => {
57
+ const line = "Feb 22 16:38:12 sunils-mac-mini sshd-session: sunil [priv][53930]: DEAD_PROCESS: 53934 ttys012";
58
+ const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
59
+ assert.equal(match, null);
60
+ });
61
+ it("does not match unrelated sshd lines", () => {
62
+ const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Connection closed by 100.79.207.74 port 52341';
63
+ const accepted = line.match(/sshd.*?:\s+Accepted/);
64
+ const failed = line.match(/sshd.*?:\s+Failed\s+password/);
65
+ const invalid = line.match(/sshd.*?:\s+Invalid\s+user/);
66
+ assert.equal(accepted, null);
67
+ assert.equal(failed, null);
68
+ assert.equal(invalid, null);
69
+ });
70
+ });
71
+ describe("Tailscale IP detection", () => {
72
+ function isTailscaleIP(host) {
73
+ const octets = host.split(".").map(Number);
74
+ return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
75
+ }
76
+ it("identifies Tailscale IPs", () => {
77
+ assert.equal(isTailscaleIP("100.79.207.74"), true);
78
+ assert.equal(isTailscaleIP("100.94.48.17"), true);
79
+ assert.equal(isTailscaleIP("100.64.0.1"), true);
80
+ assert.equal(isTailscaleIP("100.127.255.255"), true);
81
+ });
82
+ it("rejects non-Tailscale IPs", () => {
83
+ assert.equal(isTailscaleIP("100.63.255.255"), false);
84
+ assert.equal(isTailscaleIP("100.128.0.1"), false);
85
+ assert.equal(isTailscaleIP("192.168.1.1"), false);
86
+ assert.equal(isTailscaleIP("203.0.113.42"), false);
87
+ });
88
+ });
89
+ describe("Linux sudo log patterns", () => {
90
+ it("matches sudo command execution", () => {
91
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: sunil : TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/apt update";
92
+ const match = line.match(/sudo\[\d+\]:\s+(\S+)\s*:\s*(?:.*?;\s*)?TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
93
+ assert.ok(match);
94
+ assert.equal(match[1], "sunil");
95
+ assert.equal(match[2], "pts/0");
96
+ assert.equal(match[4], "root");
97
+ assert.equal(match[5].trim(), "/usr/bin/apt update");
98
+ });
99
+ it("matches sudo with incorrect password attempts", () => {
100
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: sunil : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/rm -rf /";
101
+ const match = line.match(/sudo\[\d+\]:\s+(\S+)\s*:\s*(?:.*?;\s*)?TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
102
+ assert.ok(match);
103
+ assert.equal(match[1], "sunil");
104
+ assert.ok(line.includes("incorrect password"));
105
+ });
106
+ it("matches sudo PAM session opened", () => {
107
+ const line = "Feb 22 16:30:00 myhost sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by sunil(uid=1000)";
108
+ const match = line.match(/sudo\[\d+\]:\s+pam_unix\(sudo:session\):\s+session\s+opened\s+for\s+user\s+(\S+).*?by\s+(\S+)/);
109
+ assert.ok(match);
110
+ assert.equal(match[1], "root(uid=0)");
111
+ assert.equal(match[2], "sunil(uid=1000)");
112
+ });
113
+ });
114
+ describe("Linux user account log patterns", () => {
115
+ it("matches useradd new user (strips trailing comma)", () => {
116
+ const line = "Feb 22 16:30:00 myhost useradd[1234]: new user: name=backdoor, UID=1001, GID=1001, home=/home/backdoor, shell=/bin/bash";
117
+ const match = line.match(/useradd\[\d+\]:\s+new\s+user:\s+name=([^,\s]+)/);
118
+ assert.ok(match);
119
+ assert.equal(match[1], "backdoor");
120
+ });
121
+ it("matches userdel delete user", () => {
122
+ const line = "Feb 22 16:30:00 myhost userdel[1234]: delete user 'olduser'";
123
+ const match = line.match(/userdel\[\d+\]:\s+delete\s+user\s+'(\S+)'/);
124
+ assert.ok(match);
125
+ assert.equal(match[1], "olduser");
126
+ });
127
+ it("matches passwd password changed", () => {
128
+ const line = "Feb 22 16:30:00 myhost passwd[1234]: pam_unix(passwd:chauthtok): password changed for sunil";
129
+ const match = line.match(/passwd\[\d+\]:\s+pam_unix\(passwd:chauthtok\):\s+password\s+changed\s+for\s+(\S+)/);
130
+ assert.ok(match);
131
+ assert.equal(match[1], "sunil");
132
+ });
133
+ it("matches usermod change user", () => {
134
+ const line = "Feb 22 16:30:00 myhost usermod[1234]: change user 'sunil' shell";
135
+ const match = line.match(/usermod\[\d+\]:\s+change\s+user\s+'(\S+)'/);
136
+ assert.ok(match);
137
+ assert.equal(match[1], "sunil");
138
+ });
139
+ it("matches groupadd new group (strips trailing comma)", () => {
140
+ const line = "Feb 22 16:30:00 myhost groupadd[1234]: new group: name=newgroup, GID=1002";
141
+ const match = line.match(/groupadd\[\d+\]:\s+new\s+group:\s+name=([^,\s]+)/);
142
+ assert.ok(match);
143
+ assert.equal(match[1], "newgroup");
144
+ });
145
+ });
146
+ describe("Linux remote desktop log patterns", () => {
147
+ it("matches xrdp client connection", () => {
148
+ const line = "Feb 22 16:30:00 myhost xrdp[1234]: connected client: 203.0.113.42";
149
+ const match = line.match(/xrdp\[\d+\]:\s+.*?(?:connected|connection).*?(\d+\.\d+\.\d+\.\d+)/i);
150
+ assert.ok(match);
151
+ assert.equal(match[1], "203.0.113.42");
152
+ });
153
+ it("matches xrdp session started", () => {
154
+ const line = "Feb 22 16:30:00 myhost xrdp-sesman[1234]: session started for user sunil";
155
+ const match = line.match(/xrdp-sesman\[\d+\]:\s+.*?session\s+started.*?user\s+(\S+)/i);
156
+ assert.ok(match);
157
+ assert.equal(match[1], "sunil");
158
+ });
159
+ it("matches VNC connection", () => {
160
+ const line = "Feb 22 16:30:00 myhost x11vnc[1234]: Got connection from client 203.0.113.42";
161
+ const match = line.match(/(?:x11vnc|vnc|Xvnc)\[\d+\]:\s+.*?(?:connection|connect).*?(\d+\.\d+\.\d+\.\d+)/i);
162
+ assert.ok(match);
163
+ assert.equal(match[1], "203.0.113.42");
164
+ });
165
+ });
package/dist/alerts.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { SEVERITY_ORDER } from "./config.js";
5
5
  const MAX_ALERTS_PER_MINUTE = 10;
6
- const ALERT_DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
6
+ const ALERT_DEDUP_WINDOW_MS = 60 * 1000; // 1 minute
7
7
  export function createAlertState() {
8
8
  return { recentAlerts: [] };
9
9
  }
@@ -11,17 +11,22 @@ export function createAlertState() {
11
11
  * Check if an alert should be sent (rate limit + dedup).
12
12
  */
13
13
  export function shouldAlert(evt, alertState, now = Date.now()) {
14
- // Clean entries older than the dedup window (5 min)
14
+ // Clean entries older than the dedup window (1 min)
15
15
  alertState.recentAlerts = alertState.recentAlerts.filter((a) => now - a.time < ALERT_DEDUP_WINDOW_MS);
16
16
  // Rate limit: max alerts per minute (count only last 60s)
17
17
  const recentCount = alertState.recentAlerts.filter((a) => now - a.time < 60_000).length;
18
18
  if (recentCount >= MAX_ALERTS_PER_MINUTE) {
19
19
  return false;
20
20
  }
21
- // Dedup: same title within window
22
- const isDupe = alertState.recentAlerts.some((a) => a.title === evt.title && now - a.time < ALERT_DEDUP_WINDOW_MS);
23
- if (isDupe)
24
- return false;
21
+ // Skip dedup for failed auth — every attempt matters
22
+ const skipDedup = evt.category === "ssh_login" &&
23
+ (evt.title.includes("failed") || evt.title.includes("Failed") || evt.title.includes("invalid") || evt.title.includes("Invalid"));
24
+ if (!skipDedup) {
25
+ // Dedup: same title within window
26
+ const isDupe = alertState.recentAlerts.some((a) => a.title === evt.title && now - a.time < ALERT_DEDUP_WINDOW_MS);
27
+ if (isDupe)
28
+ return false;
29
+ }
25
30
  alertState.recentAlerts.push({ time: now, title: evt.title });
26
31
  return true;
27
32
  }
package/dist/analyzer.js CHANGED
@@ -78,13 +78,16 @@ export function analyzeLoginEvents(rows, knownHosts) {
78
78
  // Skip local logins
79
79
  if (!host || host === "localhost" || host === "::1" || host === "127.0.0.1")
80
80
  continue;
81
- // Skip Tailscale CGNAT range (100.64.0.0/10)
81
+ // Check if Tailscale CGNAT range (100.64.0.0/10)
82
82
  const octets = host.split(".").map(Number);
83
- if (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127)
84
- continue;
85
- // New remote login from unknown host
86
- if (!knownHosts.has(host)) {
87
- events.push(event("high", "auth", "SSH login from unknown host", `User "${user}" logged in from unknown host: ${host}\nLogin type: ${type}`, { user, host, type }));
83
+ const isTailscale = octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
84
+ if (isTailscale || knownHosts.has(host)) {
85
+ // Known host / Tailscale login info-level awareness
86
+ events.push(event("info", "ssh_login", "SSH login detected", `User "${user}" logged in from ${isTailscale ? "Tailscale" : "known"} host: ${host}\nLogin type: ${type}`, { user, host, type, tailscale: isTailscale }));
87
+ }
88
+ else {
89
+ // Unknown host — high severity
90
+ events.push(event("high", "ssh_login", "SSH login from unknown host", `User "${user}" logged in from unknown host: ${host}\nLogin type: ${type}`, { user, host, type }));
88
91
  }
89
92
  }
90
93
  return events;
package/dist/config.d.ts CHANGED
@@ -25,9 +25,9 @@ export interface SecurityEvent {
25
25
  id: string;
26
26
  timestamp: number;
27
27
  severity: Severity;
28
- category: "process" | "network" | "file" | "auth" | "privilege";
28
+ category: "process" | "network" | "file" | "auth" | "privilege" | "ssh_login";
29
29
  title: string;
30
30
  description: string;
31
- details: Record<string, unknown>;
31
+ details: string | Record<string, unknown>;
32
32
  hostname: string;
33
33
  }
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * Note: osqueryd requires root/sudo — it must be started separately
14
14
  * (e.g., via launchd). This plugin only watches the result logs.
15
15
  */
16
- import { existsSync } from "node:fs";
16
+ import { existsSync, readFileSync } from "node:fs";
17
17
  import { readFile } from "node:fs/promises";
18
18
  import { join } from "node:path";
19
19
  import { homedir } from "node:os";
@@ -22,6 +22,10 @@ import { findOsquery, query } from "./osquery.js";
22
22
  import { shouldAlert, meetsThreshold, createAlertState } from "./alerts.js";
23
23
  import { EventStore } from "./persistence.js";
24
24
  import { ResultLogWatcher } from "./watcher.js";
25
+ import { LogStreamWatcher } from "./log-stream.js";
26
+ import { execFile } from "node:child_process";
27
+ import { promisify } from "node:util";
28
+ const execFileAsync = promisify(execFile);
25
29
  import { analyzeProcessEvents, analyzeLoginEvents, analyzeFailedAuth, analyzeListeningPorts, analyzeFileEvents, formatAlert, } from "./analyzer.js";
26
30
  // ── State ──
27
31
  const state = {
@@ -129,19 +133,35 @@ function handleResult(result, config, sendAlert) {
129
133
  * OpenClaw plugin entry point.
130
134
  */
131
135
  export default function sentinel(api) {
132
- const pluginConfig = api.getConfig?.() ?? {};
136
+ // api.getConfig() may not return all fields — merge with config file as fallback
137
+ const apiConfig = api.getConfig?.() ?? {};
138
+ let fileConfig = {};
139
+ try {
140
+ const cfgPath = join(homedir(), ".openclaw", "openclaw.json");
141
+ const raw = JSON.parse(readFileSync(cfgPath, "utf8"));
142
+ fileConfig = raw?.plugins?.entries?.sentinel?.config ?? {};
143
+ }
144
+ catch { /* ignore */ }
145
+ const pluginConfig = { ...fileConfig, ...apiConfig };
146
+ console.log(`[sentinel] Config: alertSeverity=${pluginConfig.alertSeverity}, alertChannel=${pluginConfig.alertChannel}`);
133
147
  let watcher = null;
148
+ let logStreamWatcher = null;
134
149
  const sentinelDir = pluginConfig.logPath ?? SENTINEL_DIR_DEFAULT;
135
150
  const sendAlert = async (text) => {
151
+ const channel = pluginConfig.alertChannel;
152
+ const to = pluginConfig.alertTo;
153
+ if (!channel || !to) {
154
+ console.error("[sentinel] Alert skipped: no alertChannel/alertTo configured");
155
+ return;
156
+ }
136
157
  try {
137
- await api.sendMessage?.({
138
- channel: pluginConfig.alertChannel,
139
- to: pluginConfig.alertTo,
140
- message: text,
141
- });
158
+ // Use openclaw CLI for reliable message delivery
159
+ const args = ["message", "send", "--channel", channel, "--target", to, "--message", text];
160
+ await execFileAsync("openclaw", args, { timeout: 15_000 });
161
+ console.log(`[sentinel] Alert sent via ${channel} to ${to}`);
142
162
  }
143
- catch {
144
- console.error("[sentinel] Alert delivery failed:", text);
163
+ catch (err) {
164
+ console.error("[sentinel] Alert delivery failed:", err?.message ?? err, text.slice(0, 200));
145
165
  }
146
166
  };
147
167
  // ── Agent tool: sentinel_status ──
@@ -269,6 +289,10 @@ export default function sentinel(api) {
269
289
  watcher = null;
270
290
  state.watching = false;
271
291
  }
292
+ if (logStreamWatcher) {
293
+ logStreamWatcher.stop();
294
+ logStreamWatcher = null;
295
+ }
272
296
  };
273
297
  process.on("exit", cleanup);
274
298
  process.on("SIGTERM", cleanup);
@@ -326,6 +350,18 @@ export default function sentinel(api) {
326
350
  console.log("[sentinel] Event-driven monitoring active ⚡");
327
351
  });
328
352
  await watcher.start();
353
+ // Start real-time log stream watcher for SSH events
354
+ logStreamWatcher = new LogStreamWatcher((evt) => {
355
+ logEvent(evt);
356
+ if (meetsThreshold(evt.severity, pluginConfig.alertSeverity) &&
357
+ shouldAlert(evt, alertRateState)) {
358
+ sendAlert(formatAlert(evt)).catch((err) => {
359
+ console.error("[sentinel] alert failed:", err);
360
+ });
361
+ }
362
+ console.log(`[sentinel] [real-time] ${evt.severity}/${evt.category}: ${evt.title}`);
363
+ }, state.knownHosts);
364
+ logStreamWatcher.start();
329
365
  }
330
366
  catch (err) {
331
367
  console.error("[sentinel] Failed to start:", err);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * LogStreamWatcher — Real-time event monitoring via macOS `log stream` or Linux `journalctl`.
3
+ *
4
+ * Spawns a long-running subprocess that tails system logs for specific events
5
+ * (SSH login, failed password, sudo) and emits SecurityEvents in real-time.
6
+ */
7
+ import type { SecurityEvent } from "./config.js";
8
+ type EventCallback = (event: SecurityEvent) => void;
9
+ export declare class LogStreamWatcher {
10
+ private process;
11
+ private callback;
12
+ private knownHosts;
13
+ private platform;
14
+ private running;
15
+ constructor(callback: EventCallback, knownHosts: Set<string>, platform?: string);
16
+ start(): void;
17
+ private syslogProcess;
18
+ private startMacOS;
19
+ private startMacOSUnifiedLog;
20
+ private startLinux;
21
+ private wireUp;
22
+ stop(): void;
23
+ /** Update known hosts (e.g. after baseline refresh) */
24
+ updateKnownHosts(hosts: Set<string>): void;
25
+ }
26
+ export {};
@@ -0,0 +1,414 @@
1
+ /**
2
+ * LogStreamWatcher — Real-time event monitoring via macOS `log stream` or Linux `journalctl`.
3
+ *
4
+ * Spawns a long-running subprocess that tails system logs for specific events
5
+ * (SSH login, failed password, sudo) and emits SecurityEvents in real-time.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { createInterface } from "node:readline";
9
+ function event(severity, category, title, description, details) {
10
+ return {
11
+ id: `ls-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
12
+ timestamp: Date.now(),
13
+ severity,
14
+ category,
15
+ title,
16
+ description,
17
+ details: details ?? {},
18
+ hostname: "",
19
+ };
20
+ }
21
+ /**
22
+ * Parse a macOS `log stream` line for SSH events.
23
+ * Lines look like:
24
+ * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2
25
+ * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for sunil from 203.0.113.42 port 22 ssh2
26
+ * 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Invalid user admin from 203.0.113.42 port 22
27
+ */
28
+ function parseMacOSLogLine(line, knownHosts) {
29
+ // Match "Accepted" logins
30
+ const acceptedMatch = line.match(/sshd.*?:\s+Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
31
+ if (acceptedMatch) {
32
+ const [, method, user, host, port] = acceptedMatch;
33
+ const isTailscale = isTailscaleIP(host);
34
+ const isKnown = knownHosts.has(host) || isTailscale;
35
+ return event(isKnown ? "info" : "high", "ssh_login", isKnown ? "SSH login detected" : "SSH login from unknown host", `User "${user}" logged in via ${method} from ${isTailscale ? "Tailscale" : isKnown ? "known" : "UNKNOWN"} host: ${host}:${port}`, { user, host, port, method, tailscale: isTailscale, known: isKnown });
36
+ }
37
+ // Match "Failed password"
38
+ const failedMatch = line.match(/sshd.*?:\s+Failed\s+password\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
39
+ if (failedMatch) {
40
+ const [, user, host, port] = failedMatch;
41
+ return event("high", "ssh_login", "SSH failed password attempt", `Failed password for "${user}" from ${host}:${port}`, { user, host, port, type: "failed_password" });
42
+ }
43
+ // Match "Invalid user"
44
+ const invalidMatch = line.match(/sshd.*?:\s+Invalid\s+user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
45
+ if (invalidMatch) {
46
+ const [, user, host, port] = invalidMatch;
47
+ return event("high", "ssh_login", "SSH invalid user attempt", `Invalid user "${user}" from ${host}:${port}`, { user, host, port, type: "invalid_user" });
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Parse a Linux journalctl line for SSH events.
53
+ * Lines look like:
54
+ * Feb 22 16:30:00 hostname sshd[1234]: Accepted publickey for sunil from 100.79.207.74 port 52341 ssh2
55
+ */
56
+ function parseLinuxLogLine(line, knownHosts) {
57
+ // Same patterns work for both — sshd output is consistent
58
+ return parseMacOSLogLine(line, knownHosts);
59
+ }
60
+ /**
61
+ * Parse Linux sudo events from journalctl / auth.log.
62
+ * Lines look like:
63
+ * Feb 22 16:30:00 hostname sudo[1234]: sunil : TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/apt update
64
+ * Feb 22 16:30:00 hostname sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by sunil(uid=1000)
65
+ * Feb 22 16:30:00 hostname sudo[1234]: sunil : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/sunil ; USER=root ; COMMAND=/usr/bin/rm -rf /
66
+ */
67
+ function parseLinuxSudo(line) {
68
+ // Standard sudo command log
69
+ const cmdMatch = line.match(/sudo\[\d+\]:\s+(\S+)\s*:\s*(?:.*?;\s*)?TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
70
+ if (cmdMatch) {
71
+ const [, user, tty, pwd, targetUser, command] = cmdMatch;
72
+ const hasFailure = line.includes("incorrect password");
73
+ return event(hasFailure ? "high" : "medium", "privilege", hasFailure ? "sudo authentication failure" : "sudo command executed", `${user} → ${targetUser}: ${command.trim()} (TTY ${tty})`, { user, targetUser, command: command.trim(), tty, pwd, failed: hasFailure });
74
+ }
75
+ // PAM session opened for sudo
76
+ const sessionMatch = line.match(/sudo\[\d+\]:\s+pam_unix\(sudo:session\):\s+session\s+opened\s+for\s+user\s+(\S+).*?by\s+(\S+)/);
77
+ if (sessionMatch) {
78
+ const [, targetUser, user] = sessionMatch;
79
+ return event("info", "privilege", "sudo session started", `sudo session opened: ${user} → ${targetUser}`, { user: user.replace(/\(.*\)/, ""), targetUser: targetUser.replace(/\(.*\)/, ""), source: "pam" });
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Parse Linux user account change events.
85
+ * Lines from useradd/userdel/usermod/passwd via journalctl or auth.log:
86
+ * Feb 22 16:30:00 hostname useradd[1234]: new user: name=backdoor, UID=1001, GID=1001, home=/home/backdoor, shell=/bin/bash
87
+ * Feb 22 16:30:00 hostname userdel[1234]: delete user 'olduser'
88
+ * Feb 22 16:30:00 hostname usermod[1234]: change user 'sunil' password
89
+ * Feb 22 16:30:00 hostname passwd[1234]: pam_unix(passwd:chauthtok): password changed for sunil
90
+ * Feb 22 16:30:00 hostname groupadd[1234]: new group: name=newgroup, GID=1002
91
+ */
92
+ function parseLinuxUserAccount(line) {
93
+ // New user created (name field ends with comma in useradd output)
94
+ const useraddMatch = line.match(/useradd\[\d+\]:\s+new\s+user:\s+name=([^,\s]+)/);
95
+ if (useraddMatch) {
96
+ return event("critical", "auth", "User account created", `New user account created: ${useraddMatch[1]}`, { user: useraddMatch[1], type: "user_created" });
97
+ }
98
+ // User deleted
99
+ const userdelMatch = line.match(/userdel\[\d+\]:\s+delete\s+user\s+'(\S+)'/);
100
+ if (userdelMatch) {
101
+ return event("high", "auth", "User account deleted", `User account deleted: ${userdelMatch[1]}`, { user: userdelMatch[1], type: "user_deleted" });
102
+ }
103
+ // Password changed via passwd command
104
+ const passwdMatch = line.match(/passwd\[\d+\]:\s+pam_unix\(passwd:chauthtok\):\s+password\s+changed\s+for\s+(\S+)/);
105
+ if (passwdMatch) {
106
+ return event("high", "auth", "User password changed", `Password changed for user: ${passwdMatch[1]}`, { user: passwdMatch[1], type: "password_changed" });
107
+ }
108
+ // User modified (usermod)
109
+ const usermodMatch = line.match(/usermod\[\d+\]:\s+change\s+user\s+'(\S+)'/);
110
+ if (usermodMatch) {
111
+ return event("high", "auth", "User account modified", `User account modified: ${usermodMatch[1]}`, { user: usermodMatch[1], type: "user_modified" });
112
+ }
113
+ // New group (often accompanies user creation)
114
+ const groupaddMatch = line.match(/groupadd\[\d+\]:\s+new\s+group:\s+name=([^,\s]+)/);
115
+ if (groupaddMatch) {
116
+ return event("medium", "auth", "Group created", `New group created: ${groupaddMatch[1]}`, { group: groupaddMatch[1], type: "group_created" });
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * Parse Linux remote desktop / VNC events.
122
+ * Lines from xrdp, vncserver, x11vnc:
123
+ * Feb 22 16:30:00 hostname xrdp[1234]: connected client: 203.0.113.42
124
+ * Feb 22 16:30:00 hostname xrdp-sesman[1234]: session started for user sunil
125
+ * Feb 22 16:30:00 hostname x11vnc[1234]: Got connection from client 203.0.113.42
126
+ */
127
+ function parseLinuxRemoteDesktop(line) {
128
+ // xrdp client connection
129
+ const xrdpMatch = line.match(/xrdp\[\d+\]:\s+.*?(?:connected|connection).*?(\d+\.\d+\.\d+\.\d+)/i);
130
+ if (xrdpMatch) {
131
+ return event("high", "auth", "RDP connection detected", `RDP connection from ${xrdpMatch[1]}`, { type: "rdp", host: xrdpMatch[1] });
132
+ }
133
+ // xrdp session started
134
+ const xrdpSessionMatch = line.match(/xrdp-sesman\[\d+\]:\s+.*?session\s+started.*?user\s+(\S+)/i);
135
+ if (xrdpSessionMatch) {
136
+ return event("high", "auth", "RDP session started", `RDP session started for user: ${xrdpSessionMatch[1]}`, { type: "rdp_session", user: xrdpSessionMatch[1] });
137
+ }
138
+ // VNC connection (x11vnc, tigervnc, etc.)
139
+ const vncMatch = line.match(/(?:x11vnc|vnc|Xvnc)\[\d+\]:\s+.*?(?:connection|connect).*?(\d+\.\d+\.\d+\.\d+)/i);
140
+ if (vncMatch) {
141
+ return event("high", "auth", "VNC connection detected", `VNC connection from ${vncMatch[1]}`, { type: "vnc", host: vncMatch[1] });
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * Parse macOS /var/log/system.log lines for SSH events.
147
+ * Lines look like:
148
+ * Feb 22 16:39:32 sunils-mac-mini sshd-session: sunil [priv][58912]: USER_PROCESS: 58916 ttys001
149
+ * Feb 22 16:38:12 sunils-mac-mini sshd-session: sunil [priv][53930]: DEAD_PROCESS: 53934 ttys012
150
+ * Feb 22 16:51:16 sunils-mac-mini sshd[60738]: Failed password for sunil from 100.79.207.74 port 52341 ssh2
151
+ * Feb 22 16:51:16 sunils-mac-mini sshd[60738]: Invalid user admin from 100.79.207.74 port 52341
152
+ */
153
+ function parseMacOSSyslog(line, knownHosts) {
154
+ // Match sshd-session USER_PROCESS (successful login)
155
+ const sessionMatch = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
156
+ if (sessionMatch) {
157
+ const [, user, parentPid, pid, tty] = sessionMatch;
158
+ // We don't have the source IP from syslog — query utmpx for it
159
+ return event("info", "ssh_login", "SSH session started", `User "${user}" started SSH session (PID ${pid}, TTY ${tty})`, { user, pid, parentPid, tty, source: "syslog" });
160
+ }
161
+ // Match Failed password (if sshd logs this to system.log)
162
+ const failedMatch = line.match(/sshd\[\d+\]:\s+Failed\s+password\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
163
+ if (failedMatch) {
164
+ const [, user, host, port] = failedMatch;
165
+ return event("high", "ssh_login", "SSH failed password attempt", `Failed password for "${user}" from ${host}:${port}`, { user, host, port, type: "failed_password" });
166
+ }
167
+ // Match Invalid user
168
+ const invalidMatch = line.match(/sshd\[\d+\]:\s+Invalid\s+user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
169
+ if (invalidMatch) {
170
+ const [, user, host, port] = invalidMatch;
171
+ return event("high", "ssh_login", "SSH invalid user attempt", `Invalid user "${user}" from ${host}:${port}`, { user, host, port, type: "invalid_user" });
172
+ }
173
+ // Match sudo from system.log
174
+ const sudoMatch = line.match(/sudo\[(\d+)\]:\s+(\S+)\s*:\s*TTY=(\S+)\s*;\s*PWD=(\S+)\s*;\s*USER=(\S+)\s*;\s*COMMAND=(.*)/);
175
+ if (sudoMatch) {
176
+ const [, pid, user, tty, pwd, targetUser, command] = sudoMatch;
177
+ return event("medium", "privilege", "sudo command executed", `${user} → ${targetUser}: ${command.trim()} (TTY ${tty}, PID ${pid})`, { user, targetUser, command: command.trim(), tty, pwd, pid });
178
+ }
179
+ // Match sudo USER_PROCESS from system.log (macOS Sequoia format)
180
+ const sudoSessionMatch = line.match(/sudo\[(\d+)\]:\s+USER_PROCESS/);
181
+ if (sudoSessionMatch) {
182
+ return event("info", "privilege", "sudo session started", `sudo session started (PID ${sudoSessionMatch[1]})`, { pid: sudoSessionMatch[1], source: "syslog" });
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * Parse macOS unified log lines for PAM authentication errors.
188
+ * Line format:
189
+ * 2026-02-22 16:56:51.705020-0500 0x14e5ecb Default 0x0 62761 0 sshd-session: error: PAM: authentication error for sunil from 100.79.207.74
190
+ */
191
+ function parseMacOSAuthError(line, _knownHosts) {
192
+ // PAM authentication error (valid user, wrong password)
193
+ const authMatch = line.match(/sshd-session.*?PAM:\s+authentication\s+error\s+for\s+(\S+)\s+from\s+(\S+)/);
194
+ if (authMatch) {
195
+ const [, user, host] = authMatch;
196
+ return event("high", "ssh_login", "SSH failed authentication", `Failed authentication (PAM) for "${user}" from ${host}`, { user, host, type: "pam_auth_error" });
197
+ }
198
+ // PAM unknown user (invalid username)
199
+ const unknownMatch = line.match(/sshd-session.*?PAM:\s+unknown\s+user\s+for\s+illegal\s+user\s+(\S+)\s+from\s+(\S+)/);
200
+ if (unknownMatch) {
201
+ const [, user, host] = unknownMatch;
202
+ return event("high", "ssh_login", "SSH invalid user attempt", `Unknown user "${user}" attempted login from ${host}`, { user, host, type: "unknown_user" });
203
+ }
204
+ // Invalid user line
205
+ const invalidMatch = line.match(/sshd-session.*?Invalid\s+user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
206
+ if (invalidMatch) {
207
+ const [, user, host, port] = invalidMatch;
208
+ return event("high", "ssh_login", "SSH invalid user attempt", `Invalid user "${user}" from ${host}:${port}`, { user, host, port, type: "invalid_user" });
209
+ }
210
+ // Failed keyboard-interactive/pam
211
+ const failedMatch = line.match(/sshd-session.*?Failed\s+\S+\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
212
+ if (failedMatch) {
213
+ const [, user, host, port] = failedMatch;
214
+ return event("high", "ssh_login", "SSH failed authentication", `Failed login for "${user}" from ${host}:${port}`, { user, host, port, type: "failed_auth" });
215
+ }
216
+ return null;
217
+ }
218
+ /**
219
+ * Parse screen sharing / VNC connection events.
220
+ */
221
+ function parseScreenSharing(line) {
222
+ // Authentication attempt
223
+ const authMatch = line.match(/screensharingd.*?Authentication:\s+(.*)/);
224
+ if (authMatch) {
225
+ return event("high", "auth", "Screen sharing authentication", `Screen sharing auth attempt: ${authMatch[1]}`, { type: "screen_sharing", detail: authMatch[1] });
226
+ }
227
+ // VNC connection
228
+ const vncMatch = line.match(/screensharingd.*?VNC.*?(\d+\.\d+\.\d+\.\d+)/);
229
+ if (vncMatch) {
230
+ return event("high", "auth", "VNC connection detected", `VNC connection from ${vncMatch[1]}`, { type: "vnc", host: vncMatch[1] });
231
+ }
232
+ // Client connection
233
+ const clientMatch = line.match(/screensharingd.*?client\s+(\S+)\s+connected/i);
234
+ if (clientMatch) {
235
+ return event("high", "auth", "Screen sharing client connected", `Screen sharing client connected: ${clientMatch[1]}`, { type: "screen_sharing_client", client: clientMatch[1] });
236
+ }
237
+ return null;
238
+ }
239
+ /**
240
+ * Parse user account change events from opendirectoryd.
241
+ */
242
+ function parseUserAccountChange(line) {
243
+ const createdMatch = line.match(/opendirectoryd.*?(?:user|record)\s+(\S+).*?created/i);
244
+ if (createdMatch) {
245
+ return event("critical", "auth", "User account created", `New user account created: ${createdMatch[1]}`, { user: createdMatch[1], type: "user_created" });
246
+ }
247
+ const deletedMatch = line.match(/opendirectoryd.*?(?:user|record)\s+(\S+).*?deleted/i);
248
+ if (deletedMatch) {
249
+ return event("high", "auth", "User account deleted", `User account deleted: ${deletedMatch[1]}`, { user: deletedMatch[1], type: "user_deleted" });
250
+ }
251
+ const passwordMatch = line.match(/opendirectoryd.*?(?:user|record)\s+(\S+).*?password\s+changed/i);
252
+ if (passwordMatch) {
253
+ return event("high", "auth", "User password changed", `Password changed for user: ${passwordMatch[1]}`, { user: passwordMatch[1], type: "password_changed" });
254
+ }
255
+ return null;
256
+ }
257
+ function isTailscaleIP(host) {
258
+ const octets = host.split(".").map(Number);
259
+ return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
260
+ }
261
+ export class LogStreamWatcher {
262
+ process = null;
263
+ callback;
264
+ knownHosts;
265
+ platform;
266
+ running = false;
267
+ constructor(callback, knownHosts, platform) {
268
+ this.callback = callback;
269
+ this.knownHosts = knownHosts;
270
+ this.platform = platform ?? process.platform;
271
+ }
272
+ start() {
273
+ if (this.running)
274
+ return;
275
+ this.running = true;
276
+ if (this.platform === "darwin") {
277
+ this.startMacOS();
278
+ }
279
+ else {
280
+ this.startLinux();
281
+ }
282
+ }
283
+ syslogProcess = null;
284
+ startMacOS() {
285
+ // Two sources on modern macOS:
286
+ // 1. /var/log/system.log — successful SSH sessions (sshd-session USER_PROCESS)
287
+ // 2. unified log stream — failed auth (PAM errors from sshd-session)
288
+ // Source 1: tail system.log for successful logins
289
+ this.process = spawn("tail", ["-F", "-n", "0", "/var/log/system.log"], {
290
+ stdio: ["ignore", "pipe", "ignore"],
291
+ });
292
+ console.log("[sentinel] LogStreamWatcher started (macOS system.log tail, SSH sessions)");
293
+ this.wireUp((line) => parseMacOSSyslog(line, this.knownHosts));
294
+ // Source 2: log stream for failed SSH auth + sudo + screen sharing + user account changes
295
+ const predicate = [
296
+ // SSH failed auth
297
+ '(process == "sshd-session" AND (eventMessage CONTAINS "authentication error" OR eventMessage CONTAINS "unknown user" OR eventMessage CONTAINS "Invalid user" OR eventMessage CONTAINS "Failed"))',
298
+ // Screen sharing connections
299
+ '(process == "screensharingd" AND (eventMessage CONTAINS "Authentication" OR eventMessage CONTAINS "client" OR eventMessage CONTAINS "VNC"))',
300
+ // User account changes (creation, deletion, modification)
301
+ '(process == "opendirectoryd" AND (eventMessage CONTAINS "created" OR eventMessage CONTAINS "deleted" OR eventMessage CONTAINS "password changed"))',
302
+ ].join(" OR ");
303
+ this.syslogProcess = spawn("log", ["stream", "--predicate", predicate, "--style", "default", "--info"], {
304
+ stdio: ["ignore", "pipe", "ignore"],
305
+ });
306
+ if (this.syslogProcess.stdout) {
307
+ const rl = createInterface({ input: this.syslogProcess.stdout });
308
+ rl.on("line", (line) => {
309
+ const evt = parseMacOSAuthError(line, this.knownHosts)
310
+ ?? parseScreenSharing(line)
311
+ ?? parseUserAccountChange(line);
312
+ if (evt)
313
+ this.callback(evt);
314
+ });
315
+ }
316
+ console.log("[sentinel] LogStreamWatcher started (macOS log stream, auth + screen sharing + user accounts)");
317
+ this.syslogProcess.on("exit", (code) => {
318
+ console.log(`[sentinel] LogStream (unified log) exited (code ${code})`);
319
+ if (this.running) {
320
+ setTimeout(() => this.startMacOSUnifiedLog(), 5000);
321
+ }
322
+ });
323
+ }
324
+ startMacOSUnifiedLog() {
325
+ const predicate = [
326
+ '(process == "sshd-session" AND (eventMessage CONTAINS "authentication error" OR eventMessage CONTAINS "unknown user" OR eventMessage CONTAINS "Invalid user" OR eventMessage CONTAINS "Failed"))',
327
+ '(process == "screensharingd" AND (eventMessage CONTAINS "Authentication" OR eventMessage CONTAINS "client" OR eventMessage CONTAINS "VNC"))',
328
+ '(process == "opendirectoryd" AND (eventMessage CONTAINS "created" OR eventMessage CONTAINS "deleted" OR eventMessage CONTAINS "password changed"))',
329
+ ].join(" OR ");
330
+ this.syslogProcess = spawn("log", ["stream", "--predicate", predicate, "--style", "default", "--info"], {
331
+ stdio: ["ignore", "pipe", "ignore"],
332
+ });
333
+ if (this.syslogProcess.stdout) {
334
+ const rl = createInterface({ input: this.syslogProcess.stdout });
335
+ rl.on("line", (line) => {
336
+ const evt = parseMacOSAuthError(line, this.knownHosts)
337
+ ?? parseScreenSharing(line)
338
+ ?? parseUserAccountChange(line);
339
+ if (evt)
340
+ this.callback(evt);
341
+ });
342
+ }
343
+ }
344
+ startLinux() {
345
+ // Watch multiple systemd units for comprehensive monitoring:
346
+ // - sshd/ssh: SSH login/failure events
347
+ // - sudo: privilege escalation
348
+ // - systemd-logind: session events
349
+ // - xrdp/vnc: remote desktop access
350
+ // Also use SYSLOG_IDENTIFIER for useradd/userdel/usermod/passwd/groupadd
351
+ this.process = spawn("journalctl", [
352
+ "-f", "--no-pager", "-o", "short",
353
+ "-u", "sshd", "-u", "ssh",
354
+ "-u", "sudo",
355
+ "-u", "systemd-logind",
356
+ "-u", "xrdp", "-u", "xrdp-sesman",
357
+ // Catch useradd/userdel/passwd via syslog identifiers
358
+ "-t", "useradd", "-t", "userdel", "-t", "usermod", "-t", "passwd", "-t", "groupadd",
359
+ // VNC servers
360
+ "-t", "x11vnc", "-t", "Xvnc",
361
+ ], {
362
+ stdio: ["ignore", "pipe", "ignore"],
363
+ });
364
+ console.log("[sentinel] LogStreamWatcher started (Linux journalctl, SSH + sudo + user accounts + remote desktop)");
365
+ this.wireUp((line) => {
366
+ return parseLinuxLogLine(line, this.knownHosts)
367
+ ?? parseLinuxSudo(line)
368
+ ?? parseLinuxUserAccount(line)
369
+ ?? parseLinuxRemoteDesktop(line);
370
+ });
371
+ }
372
+ wireUp(parser) {
373
+ if (!this.process?.stdout)
374
+ return;
375
+ const rl = createInterface({ input: this.process.stdout });
376
+ rl.on("line", (line) => {
377
+ const evt = parser(line);
378
+ if (evt) {
379
+ this.callback(evt);
380
+ }
381
+ });
382
+ this.process.on("exit", (code) => {
383
+ console.log(`[sentinel] LogStreamWatcher exited (code ${code})`);
384
+ if (this.running) {
385
+ // Auto-restart after 5 seconds
386
+ setTimeout(() => {
387
+ console.log("[sentinel] LogStreamWatcher restarting...");
388
+ if (this.platform === "darwin")
389
+ this.startMacOS();
390
+ else
391
+ this.startLinux();
392
+ }, 5000);
393
+ }
394
+ });
395
+ this.process.on("error", (err) => {
396
+ console.error("[sentinel] LogStreamWatcher error:", err.message);
397
+ });
398
+ }
399
+ stop() {
400
+ this.running = false;
401
+ if (this.process) {
402
+ this.process.kill("SIGTERM");
403
+ this.process = null;
404
+ }
405
+ if (this.syslogProcess) {
406
+ this.syslogProcess.kill("SIGTERM");
407
+ this.syslogProcess = null;
408
+ }
409
+ }
410
+ /** Update known hosts (e.g. after baseline refresh) */
411
+ updateKnownHosts(hosts) {
412
+ this.knownHosts = hosts;
413
+ }
414
+ }
package/dist/osquery.js CHANGED
@@ -64,7 +64,7 @@ export function generateOsqueryConfig(config, platform) {
64
64
  const schedule = {
65
65
  logged_in_users: {
66
66
  query: "SELECT type, user, host, time, pid FROM logged_in_users;",
67
- interval: 60,
67
+ interval: 10,
68
68
  removed: false,
69
69
  description: "Currently logged-in users",
70
70
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-sentinel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Real-time endpoint security monitoring plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",