openclaw-sentinel 0.3.2 → 2026.3.5
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 +36 -1
- package/dist/__tests__/alert-tailer.test.d.ts +1 -0
- package/dist/__tests__/alert-tailer.test.js +261 -0
- package/dist/__tests__/analyzer.test.js +8 -8
- package/dist/__tests__/log-stream.test.js +19 -19
- package/dist/__tests__/suppressions.test.js +3 -3
- package/dist/alert-tailer.d.ts +39 -0
- package/dist/alert-tailer.js +174 -0
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +45 -57
- package/dist/log-stream.js +14 -14
- package/dist/suppressions.d.ts +1 -1
- package/dist/suppressions.js +1 -1
- package/openclaw.plugin.json +26 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 🛡️ OpenClaw Sentinel
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/openclaw-sentinel)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](https://github.com/openclaw/openclaw)
|
|
7
|
+
[](https://github.com/osquery/osquery)
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="docs/sentinel-alerts-screenshot.png" alt="Sentinel real-time alerts via Signal" width="500">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
3
13
|
OpenClaw agents run with elevated privileges on your machine — shell access, file operations, network connections. Sentinel continuously monitors for unauthorized access, suspicious processes, privilege escalation, and system anomalies, alerting you in real-time through any OpenClaw channel.
|
|
4
14
|
|
|
5
15
|
A security monitoring plugin for [OpenClaw](https://github.com/openclaw/openclaw), powered by [osquery](https://github.com/osquery/osquery).
|
|
@@ -66,7 +76,32 @@ sudo yum install osquery
|
|
|
66
76
|
|
|
67
77
|
## Installation
|
|
68
78
|
|
|
69
|
-
###
|
|
79
|
+
### Using OpenClaw CLI (recommended)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
openclaw plugins install openclaw-sentinel
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This pulls the package from npm, installs it into `~/.openclaw/extensions/sentinel/`, and registers it in your config automatically.
|
|
86
|
+
|
|
87
|
+
Then configure and restart:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
openclaw gateway restart
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
You can also manage it with:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
openclaw plugins list # See installed plugins
|
|
97
|
+
openclaw plugins info sentinel # Plugin details
|
|
98
|
+
openclaw plugins update # Update all npm-installed plugins
|
|
99
|
+
openclaw plugins uninstall sentinel # Remove
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### From npm (manual)
|
|
103
|
+
|
|
104
|
+
If you prefer manual setup:
|
|
70
105
|
|
|
71
106
|
```bash
|
|
72
107
|
npm install -g openclaw-sentinel
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFileSync, mkdirSync, appendFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { AlertTailer } from "../alert-tailer.js";
|
|
7
|
+
const TEST_DIR = join(tmpdir(), `sentinel-alert-tailer-test-${Date.now()}`);
|
|
8
|
+
const EVENTS_PATH = join(TEST_DIR, "events.jsonl");
|
|
9
|
+
function makeEvent(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
severity: "high",
|
|
14
|
+
category: "process",
|
|
15
|
+
title: "Suspicious command detected",
|
|
16
|
+
description: "test command",
|
|
17
|
+
hostname: "test-host",
|
|
18
|
+
details: {},
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function makeConfig(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
alertSeverity: "info",
|
|
25
|
+
alertChannel: "signal",
|
|
26
|
+
alertTo: "+1234567890",
|
|
27
|
+
clawAssess: false,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
describe("AlertTailer", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
34
|
+
// Create empty events file
|
|
35
|
+
writeFileSync(EVENTS_PATH, "");
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
it("starts at end of file and ignores existing events", async () => {
|
|
41
|
+
// Write some pre-existing events
|
|
42
|
+
const oldEvent = makeEvent({ title: "Old event" });
|
|
43
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(oldEvent) + "\n");
|
|
44
|
+
const alerts = [];
|
|
45
|
+
const tailer = new AlertTailer({
|
|
46
|
+
eventsPath: EVENTS_PATH,
|
|
47
|
+
config: makeConfig(),
|
|
48
|
+
suppressionStore: null,
|
|
49
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
50
|
+
clawAssessEvent: async () => null,
|
|
51
|
+
});
|
|
52
|
+
await tailer.start();
|
|
53
|
+
// Wait for fs.watch to settle
|
|
54
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
55
|
+
assert.equal(alerts.length, 0, "Should not alert on pre-existing events");
|
|
56
|
+
tailer.stop();
|
|
57
|
+
});
|
|
58
|
+
it("alerts on new events written after start", async () => {
|
|
59
|
+
const alerts = [];
|
|
60
|
+
const tailer = new AlertTailer({
|
|
61
|
+
eventsPath: EVENTS_PATH,
|
|
62
|
+
config: makeConfig(),
|
|
63
|
+
suppressionStore: null,
|
|
64
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
65
|
+
clawAssessEvent: async () => null,
|
|
66
|
+
});
|
|
67
|
+
await tailer.start();
|
|
68
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
69
|
+
// Write a new event
|
|
70
|
+
const evt = makeEvent({ title: "New suspicious command" });
|
|
71
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n");
|
|
72
|
+
// Wait for debounce (100ms) + processing
|
|
73
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
74
|
+
assert.ok(alerts.length > 0, "Should alert on new events");
|
|
75
|
+
assert.ok(alerts[0].includes("New suspicious command"));
|
|
76
|
+
tailer.stop();
|
|
77
|
+
});
|
|
78
|
+
it("respects severity threshold", async () => {
|
|
79
|
+
const alerts = [];
|
|
80
|
+
const tailer = new AlertTailer({
|
|
81
|
+
eventsPath: EVENTS_PATH,
|
|
82
|
+
config: makeConfig({ alertSeverity: "high" }),
|
|
83
|
+
suppressionStore: null,
|
|
84
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
85
|
+
clawAssessEvent: async () => null,
|
|
86
|
+
});
|
|
87
|
+
await tailer.start();
|
|
88
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
89
|
+
// Write info event (below threshold)
|
|
90
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info event" })) + "\n");
|
|
91
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
92
|
+
assert.equal(alerts.length, 0, "Should not alert on info when threshold is high");
|
|
93
|
+
// Write high event (meets threshold)
|
|
94
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "high", title: "High event" })) + "\n");
|
|
95
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
96
|
+
assert.equal(alerts.length, 1, "Should alert on high severity");
|
|
97
|
+
tailer.stop();
|
|
98
|
+
});
|
|
99
|
+
it("deduplicates same-title events within window", async () => {
|
|
100
|
+
const alerts = [];
|
|
101
|
+
const tailer = new AlertTailer({
|
|
102
|
+
eventsPath: EVENTS_PATH,
|
|
103
|
+
config: makeConfig(),
|
|
104
|
+
suppressionStore: null,
|
|
105
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
106
|
+
clawAssessEvent: async () => null,
|
|
107
|
+
});
|
|
108
|
+
await tailer.start();
|
|
109
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
110
|
+
// Write same event twice quickly
|
|
111
|
+
const evt = makeEvent({ title: "Duplicate event", category: "network" });
|
|
112
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n" + JSON.stringify(evt) + "\n");
|
|
113
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
114
|
+
assert.equal(alerts.length, 1, "Should deduplicate same-title events");
|
|
115
|
+
tailer.stop();
|
|
116
|
+
});
|
|
117
|
+
it("does not deduplicate SSH failed auth events", async () => {
|
|
118
|
+
const alerts = [];
|
|
119
|
+
const tailer = new AlertTailer({
|
|
120
|
+
eventsPath: EVENTS_PATH,
|
|
121
|
+
config: makeConfig(),
|
|
122
|
+
suppressionStore: null,
|
|
123
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
124
|
+
clawAssessEvent: async () => null,
|
|
125
|
+
});
|
|
126
|
+
await tailer.start();
|
|
127
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
128
|
+
const evt = makeEvent({ title: "SSH failed authentication", category: "ssh_login" });
|
|
129
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n" + JSON.stringify(evt) + "\n");
|
|
130
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
131
|
+
assert.equal(alerts.length, 2, "SSH failed auth should not be deduped");
|
|
132
|
+
tailer.stop();
|
|
133
|
+
});
|
|
134
|
+
it("includes Claw assessment when enabled", async () => {
|
|
135
|
+
const alerts = [];
|
|
136
|
+
const tailer = new AlertTailer({
|
|
137
|
+
eventsPath: EVENTS_PATH,
|
|
138
|
+
config: makeConfig({ clawAssess: true }),
|
|
139
|
+
suppressionStore: null,
|
|
140
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
141
|
+
clawAssessEvent: async () => "This is a benign agent command.",
|
|
142
|
+
});
|
|
143
|
+
await tailer.start();
|
|
144
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
145
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
|
|
146
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
147
|
+
assert.ok(alerts.length > 0);
|
|
148
|
+
assert.ok(alerts[0].includes("benign agent command"), "Should include Claw assessment");
|
|
149
|
+
tailer.stop();
|
|
150
|
+
});
|
|
151
|
+
it("still alerts when Claw assessment fails", async () => {
|
|
152
|
+
const alerts = [];
|
|
153
|
+
const tailer = new AlertTailer({
|
|
154
|
+
eventsPath: EVENTS_PATH,
|
|
155
|
+
config: makeConfig({ clawAssess: true }),
|
|
156
|
+
suppressionStore: null,
|
|
157
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
158
|
+
clawAssessEvent: async () => { throw new Error("LLM timeout"); },
|
|
159
|
+
});
|
|
160
|
+
await tailer.start();
|
|
161
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
162
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
|
|
163
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
164
|
+
assert.ok(alerts.length > 0, "Should still alert even if Claw assessment fails");
|
|
165
|
+
tailer.stop();
|
|
166
|
+
});
|
|
167
|
+
it("handles multiple events in a batch", async () => {
|
|
168
|
+
const alerts = [];
|
|
169
|
+
const tailer = new AlertTailer({
|
|
170
|
+
eventsPath: EVENTS_PATH,
|
|
171
|
+
config: makeConfig(),
|
|
172
|
+
suppressionStore: null,
|
|
173
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
174
|
+
clawAssessEvent: async () => null,
|
|
175
|
+
});
|
|
176
|
+
await tailer.start();
|
|
177
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
178
|
+
// Write 3 different events at once
|
|
179
|
+
const lines = [
|
|
180
|
+
JSON.stringify(makeEvent({ title: "Event A", category: "network" })),
|
|
181
|
+
JSON.stringify(makeEvent({ title: "Event B", category: "file" })),
|
|
182
|
+
JSON.stringify(makeEvent({ title: "Event C", category: "auth" })),
|
|
183
|
+
].join("\n") + "\n";
|
|
184
|
+
appendFileSync(EVENTS_PATH, lines);
|
|
185
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
186
|
+
assert.equal(alerts.length, 3, "Should process all events in batch");
|
|
187
|
+
tailer.stop();
|
|
188
|
+
});
|
|
189
|
+
it("skips malformed JSON lines", async () => {
|
|
190
|
+
const alerts = [];
|
|
191
|
+
const tailer = new AlertTailer({
|
|
192
|
+
eventsPath: EVENTS_PATH,
|
|
193
|
+
config: makeConfig(),
|
|
194
|
+
suppressionStore: null,
|
|
195
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
196
|
+
clawAssessEvent: async () => null,
|
|
197
|
+
});
|
|
198
|
+
await tailer.start();
|
|
199
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
200
|
+
appendFileSync(EVENTS_PATH, "not valid json\n" + JSON.stringify(makeEvent({ title: "Valid event" })) + "\n");
|
|
201
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
202
|
+
assert.equal(alerts.length, 1, "Should skip bad lines and process valid ones");
|
|
203
|
+
assert.ok(alerts[0].includes("Valid event"));
|
|
204
|
+
tailer.stop();
|
|
205
|
+
});
|
|
206
|
+
it("stops cleanly", async () => {
|
|
207
|
+
const tailer = new AlertTailer({
|
|
208
|
+
eventsPath: EVENTS_PATH,
|
|
209
|
+
config: makeConfig(),
|
|
210
|
+
suppressionStore: null,
|
|
211
|
+
sendAlert: async () => { },
|
|
212
|
+
clawAssessEvent: async () => null,
|
|
213
|
+
});
|
|
214
|
+
await tailer.start();
|
|
215
|
+
tailer.stop();
|
|
216
|
+
// Write event after stop — should not be processed
|
|
217
|
+
const alerts = [];
|
|
218
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent()) + "\n");
|
|
219
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
220
|
+
// No crash, no processing after stop
|
|
221
|
+
assert.ok(true, "Should stop without errors");
|
|
222
|
+
});
|
|
223
|
+
it("handles events file not existing at start", async () => {
|
|
224
|
+
rmSync(EVENTS_PATH, { force: true });
|
|
225
|
+
const tailer = new AlertTailer({
|
|
226
|
+
eventsPath: EVENTS_PATH,
|
|
227
|
+
config: makeConfig(),
|
|
228
|
+
suppressionStore: null,
|
|
229
|
+
sendAlert: async () => { },
|
|
230
|
+
clawAssessEvent: async () => null,
|
|
231
|
+
});
|
|
232
|
+
// Should not throw
|
|
233
|
+
await tailer.start();
|
|
234
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
235
|
+
tailer.stop();
|
|
236
|
+
assert.ok(true, "Should handle missing file gracefully");
|
|
237
|
+
});
|
|
238
|
+
it("updateConfig changes threshold at runtime", async () => {
|
|
239
|
+
const alerts = [];
|
|
240
|
+
const tailer = new AlertTailer({
|
|
241
|
+
eventsPath: EVENTS_PATH,
|
|
242
|
+
config: makeConfig({ alertSeverity: "high" }),
|
|
243
|
+
suppressionStore: null,
|
|
244
|
+
sendAlert: async (text) => { alerts.push(text); },
|
|
245
|
+
clawAssessEvent: async () => null,
|
|
246
|
+
});
|
|
247
|
+
await tailer.start();
|
|
248
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
249
|
+
// Info event — should be filtered
|
|
250
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info 1" })) + "\n");
|
|
251
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
252
|
+
assert.equal(alerts.length, 0);
|
|
253
|
+
// Update config to info threshold
|
|
254
|
+
tailer.updateConfig(makeConfig({ alertSeverity: "info" }));
|
|
255
|
+
// Info event — should now alert
|
|
256
|
+
appendFileSync(EVENTS_PATH, JSON.stringify(makeEvent({ severity: "info", title: "Info 2" })) + "\n");
|
|
257
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
258
|
+
assert.equal(alerts.length, 1);
|
|
259
|
+
tailer.stop();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -131,7 +131,7 @@ describe("analyzeLoginEvents", () => {
|
|
|
131
131
|
});
|
|
132
132
|
it("emits info event for known hosts", () => {
|
|
133
133
|
const rows = [
|
|
134
|
-
{ user: "
|
|
134
|
+
{ user: "alice", host: "192.168.1.100", type: "user" },
|
|
135
135
|
];
|
|
136
136
|
const events = analyzeLoginEvents(rows, knownHosts);
|
|
137
137
|
assert.equal(events.length, 1);
|
|
@@ -140,9 +140,9 @@ describe("analyzeLoginEvents", () => {
|
|
|
140
140
|
});
|
|
141
141
|
it("emits info event for Tailscale CGNAT range (100.64-127.x.x)", () => {
|
|
142
142
|
const rows = [
|
|
143
|
-
{ user: "
|
|
144
|
-
{ user: "
|
|
145
|
-
{ user: "
|
|
143
|
+
{ user: "alice", host: "100.79.207.74", type: "user" },
|
|
144
|
+
{ user: "alice", host: "100.94.48.17", type: "user" },
|
|
145
|
+
{ user: "alice", host: "100.127.255.255", type: "user" },
|
|
146
146
|
];
|
|
147
147
|
const events = analyzeLoginEvents(rows, new Set());
|
|
148
148
|
assert.equal(events.length, 3);
|
|
@@ -159,10 +159,10 @@ describe("analyzeLoginEvents", () => {
|
|
|
159
159
|
});
|
|
160
160
|
it("skips localhost and empty hosts", () => {
|
|
161
161
|
const rows = [
|
|
162
|
-
{ user: "
|
|
163
|
-
{ user: "
|
|
164
|
-
{ user: "
|
|
165
|
-
{ user: "
|
|
162
|
+
{ user: "alice", host: "", type: "user" },
|
|
163
|
+
{ user: "alice", host: "localhost", type: "user" },
|
|
164
|
+
{ user: "alice", host: "127.0.0.1", type: "user" },
|
|
165
|
+
{ user: "alice", host: "::1", type: "user" },
|
|
166
166
|
];
|
|
167
167
|
const events = analyzeLoginEvents(rows, new Set());
|
|
168
168
|
assert.equal(events.length, 0);
|
|
@@ -7,11 +7,11 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
describe("SSH log line patterns", () => {
|
|
8
8
|
// These patterns match what sshd outputs and our parser expects
|
|
9
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
|
|
10
|
+
const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for alice from 100.79.207.74 port 52341 ssh2';
|
|
11
11
|
const match = line.match(/sshd.*?:\s+Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)/);
|
|
12
12
|
assert.ok(match);
|
|
13
13
|
assert.equal(match[1], "publickey");
|
|
14
|
-
assert.equal(match[2], "
|
|
14
|
+
assert.equal(match[2], "alice");
|
|
15
15
|
assert.equal(match[3], "100.79.207.74");
|
|
16
16
|
assert.equal(match[4], "52341");
|
|
17
17
|
});
|
|
@@ -24,10 +24,10 @@ describe("SSH log line patterns", () => {
|
|
|
24
24
|
assert.equal(match[3], "203.0.113.42");
|
|
25
25
|
});
|
|
26
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
|
|
27
|
+
const line = '2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for alice from 203.0.113.42 port 22 ssh2';
|
|
28
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
29
|
assert.ok(match);
|
|
30
|
-
assert.equal(match[1], "
|
|
30
|
+
assert.equal(match[1], "alice");
|
|
31
31
|
assert.equal(match[2], "203.0.113.42");
|
|
32
32
|
});
|
|
33
33
|
it("matches 'Failed password for invalid user' pattern", () => {
|
|
@@ -45,16 +45,16 @@ describe("SSH log line patterns", () => {
|
|
|
45
45
|
assert.equal(match[2], "203.0.113.42");
|
|
46
46
|
});
|
|
47
47
|
it("matches macOS sshd-session USER_PROCESS pattern", () => {
|
|
48
|
-
const line = "Feb 22 16:39:32
|
|
48
|
+
const line = "Feb 22 16:39:32 my-hostname sshd-session: alice [priv][58912]: USER_PROCESS: 58916 ttys001";
|
|
49
49
|
const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
|
|
50
50
|
assert.ok(match);
|
|
51
|
-
assert.equal(match[1], "
|
|
51
|
+
assert.equal(match[1], "alice");
|
|
52
52
|
assert.equal(match[2], "58912");
|
|
53
53
|
assert.equal(match[3], "58916");
|
|
54
54
|
assert.equal(match[4], "ttys001");
|
|
55
55
|
});
|
|
56
56
|
it("does not match sshd-session DEAD_PROCESS", () => {
|
|
57
|
-
const line = "Feb 22 16:38:12
|
|
57
|
+
const line = "Feb 22 16:38:12 my-hostname sshd-session: alice [priv][53930]: DEAD_PROCESS: 53934 ttys012";
|
|
58
58
|
const match = line.match(/sshd-session:\s+(\S+)\s+\[priv\]\[(\d+)\]:\s+USER_PROCESS:\s+(\d+)\s+(\S+)/);
|
|
59
59
|
assert.equal(match, null);
|
|
60
60
|
});
|
|
@@ -88,27 +88,27 @@ describe("Tailscale IP detection", () => {
|
|
|
88
88
|
});
|
|
89
89
|
describe("Linux sudo log patterns", () => {
|
|
90
90
|
it("matches sudo command execution", () => {
|
|
91
|
-
const line = "Feb 22 16:30:00 myhost sudo[1234]:
|
|
91
|
+
const line = "Feb 22 16:30:00 myhost sudo[1234]: alice : TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/apt update";
|
|
92
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
93
|
assert.ok(match);
|
|
94
|
-
assert.equal(match[1], "
|
|
94
|
+
assert.equal(match[1], "alice");
|
|
95
95
|
assert.equal(match[2], "pts/0");
|
|
96
96
|
assert.equal(match[4], "root");
|
|
97
97
|
assert.equal(match[5].trim(), "/usr/bin/apt update");
|
|
98
98
|
});
|
|
99
99
|
it("matches sudo with incorrect password attempts", () => {
|
|
100
|
-
const line = "Feb 22 16:30:00 myhost sudo[1234]:
|
|
100
|
+
const line = "Feb 22 16:30:00 myhost sudo[1234]: alice : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/rm -rf /";
|
|
101
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
102
|
assert.ok(match);
|
|
103
|
-
assert.equal(match[1], "
|
|
103
|
+
assert.equal(match[1], "alice");
|
|
104
104
|
assert.ok(line.includes("incorrect password"));
|
|
105
105
|
});
|
|
106
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
|
|
107
|
+
const line = "Feb 22 16:30:00 myhost sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by alice(uid=1000)";
|
|
108
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
109
|
assert.ok(match);
|
|
110
110
|
assert.equal(match[1], "root(uid=0)");
|
|
111
|
-
assert.equal(match[2], "
|
|
111
|
+
assert.equal(match[2], "alice(uid=1000)");
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
describe("Linux user account log patterns", () => {
|
|
@@ -125,16 +125,16 @@ describe("Linux user account log patterns", () => {
|
|
|
125
125
|
assert.equal(match[1], "olduser");
|
|
126
126
|
});
|
|
127
127
|
it("matches passwd password changed", () => {
|
|
128
|
-
const line = "Feb 22 16:30:00 myhost passwd[1234]: pam_unix(passwd:chauthtok): password changed for
|
|
128
|
+
const line = "Feb 22 16:30:00 myhost passwd[1234]: pam_unix(passwd:chauthtok): password changed for alice";
|
|
129
129
|
const match = line.match(/passwd\[\d+\]:\s+pam_unix\(passwd:chauthtok\):\s+password\s+changed\s+for\s+(\S+)/);
|
|
130
130
|
assert.ok(match);
|
|
131
|
-
assert.equal(match[1], "
|
|
131
|
+
assert.equal(match[1], "alice");
|
|
132
132
|
});
|
|
133
133
|
it("matches usermod change user", () => {
|
|
134
|
-
const line = "Feb 22 16:30:00 myhost usermod[1234]: change user '
|
|
134
|
+
const line = "Feb 22 16:30:00 myhost usermod[1234]: change user 'alice' shell";
|
|
135
135
|
const match = line.match(/usermod\[\d+\]:\s+change\s+user\s+'(\S+)'/);
|
|
136
136
|
assert.ok(match);
|
|
137
|
-
assert.equal(match[1], "
|
|
137
|
+
assert.equal(match[1], "alice");
|
|
138
138
|
});
|
|
139
139
|
it("matches groupadd new group (strips trailing comma)", () => {
|
|
140
140
|
const line = "Feb 22 16:30:00 myhost groupadd[1234]: new group: name=newgroup, GID=1002";
|
|
@@ -151,10 +151,10 @@ describe("Linux remote desktop log patterns", () => {
|
|
|
151
151
|
assert.equal(match[1], "203.0.113.42");
|
|
152
152
|
});
|
|
153
153
|
it("matches xrdp session started", () => {
|
|
154
|
-
const line = "Feb 22 16:30:00 myhost xrdp-sesman[1234]: session started for user
|
|
154
|
+
const line = "Feb 22 16:30:00 myhost xrdp-sesman[1234]: session started for user alice";
|
|
155
155
|
const match = line.match(/xrdp-sesman\[\d+\]:\s+.*?session\s+started.*?user\s+(\S+)/i);
|
|
156
156
|
assert.ok(match);
|
|
157
|
-
assert.equal(match[1], "
|
|
157
|
+
assert.equal(match[1], "alice");
|
|
158
158
|
});
|
|
159
159
|
it("matches VNC connection", () => {
|
|
160
160
|
const line = "Feb 22 16:30:00 myhost x11vnc[1234]: Got connection from client 203.0.113.42";
|
|
@@ -83,14 +83,14 @@ describe("SuppressionStore", () => {
|
|
|
83
83
|
await store.add({
|
|
84
84
|
scope: "field",
|
|
85
85
|
field: "user",
|
|
86
|
-
fieldValue: "
|
|
86
|
+
fieldValue: "alice",
|
|
87
87
|
reason: "Sunil's own sudo",
|
|
88
88
|
expiresAt: null,
|
|
89
89
|
});
|
|
90
90
|
const evt = makeEvent({
|
|
91
91
|
title: "sudo command executed",
|
|
92
92
|
category: "privilege",
|
|
93
|
-
details: { user: "
|
|
93
|
+
details: { user: "alice", command: "/usr/bin/ls" },
|
|
94
94
|
});
|
|
95
95
|
assert.ok(store.isSuppressed(evt));
|
|
96
96
|
// Different user should not be suppressed
|
|
@@ -176,7 +176,7 @@ describe("SuppressionStore", () => {
|
|
|
176
176
|
it("describes rules in plain English", () => {
|
|
177
177
|
assert.equal(SuppressionStore.describe({ scope: "title", title: "sudo command executed" }), 'All alerts titled "sudo command executed"');
|
|
178
178
|
assert.equal(SuppressionStore.describe({ scope: "category", category: "ssh_login" }), 'All alerts in category "ssh_login"');
|
|
179
|
-
assert.equal(SuppressionStore.describe({ scope: "field", field: "user", fieldValue: "
|
|
179
|
+
assert.equal(SuppressionStore.describe({ scope: "field", field: "user", fieldValue: "alice" }), 'Alerts where user = "alice"');
|
|
180
180
|
assert.equal(SuppressionStore.describe({ scope: "exact", title: "test" }), 'Exact match: "test" with specific description');
|
|
181
181
|
});
|
|
182
182
|
it("increments suppress count on repeated matches", async () => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlertTailer — Tails events.jsonl and handles all alerting logic.
|
|
3
|
+
*
|
|
4
|
+
* Decouples detection (log stream watchers, osquery) from alerting
|
|
5
|
+
* (rate limiting, dedup, suppression, Claw assessment, delivery).
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* Watchers → logEvent() → events.jsonl → AlertTailer → assess → deliver
|
|
9
|
+
*/
|
|
10
|
+
import type { SecurityEvent, SentinelConfig } from "./config.js";
|
|
11
|
+
import { SuppressionStore } from "./suppressions.js";
|
|
12
|
+
export interface AlertTailerOptions {
|
|
13
|
+
eventsPath: string;
|
|
14
|
+
config: SentinelConfig;
|
|
15
|
+
suppressionStore: SuppressionStore | null;
|
|
16
|
+
sendAlert: (text: string) => Promise<void>;
|
|
17
|
+
clawAssessEvent: (evt: SecurityEvent) => Promise<string | null>;
|
|
18
|
+
}
|
|
19
|
+
export declare class AlertTailer {
|
|
20
|
+
private eventsPath;
|
|
21
|
+
private config;
|
|
22
|
+
private suppressionStore;
|
|
23
|
+
private sendAlert;
|
|
24
|
+
private clawAssessEvent;
|
|
25
|
+
private alertState;
|
|
26
|
+
private fileOffset;
|
|
27
|
+
private watcher;
|
|
28
|
+
private running;
|
|
29
|
+
private debounceTimer;
|
|
30
|
+
private pollInterval;
|
|
31
|
+
constructor(opts: AlertTailerOptions);
|
|
32
|
+
start(): Promise<void>;
|
|
33
|
+
stop(): void;
|
|
34
|
+
private debouncedProcessNew;
|
|
35
|
+
private processNewLines;
|
|
36
|
+
private processEvent;
|
|
37
|
+
/** Update config at runtime (e.g., after reload) */
|
|
38
|
+
updateConfig(config: SentinelConfig): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlertTailer — Tails events.jsonl and handles all alerting logic.
|
|
3
|
+
*
|
|
4
|
+
* Decouples detection (log stream watchers, osquery) from alerting
|
|
5
|
+
* (rate limiting, dedup, suppression, Claw assessment, delivery).
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* Watchers → logEvent() → events.jsonl → AlertTailer → assess → deliver
|
|
9
|
+
*/
|
|
10
|
+
import { watch } from "node:fs";
|
|
11
|
+
import { stat } from "node:fs/promises";
|
|
12
|
+
import { createReadStream } from "node:fs";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { shouldAlert, meetsThreshold, createAlertState } from "./alerts.js";
|
|
15
|
+
import { SuppressionStore } from "./suppressions.js";
|
|
16
|
+
import { formatAlert } from "./analyzer.js";
|
|
17
|
+
export class AlertTailer {
|
|
18
|
+
eventsPath;
|
|
19
|
+
config;
|
|
20
|
+
suppressionStore;
|
|
21
|
+
sendAlert;
|
|
22
|
+
clawAssessEvent;
|
|
23
|
+
alertState;
|
|
24
|
+
fileOffset = 0;
|
|
25
|
+
watcher = null;
|
|
26
|
+
running = false;
|
|
27
|
+
debounceTimer = null;
|
|
28
|
+
pollInterval = null;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.eventsPath = opts.eventsPath;
|
|
31
|
+
this.config = opts.config;
|
|
32
|
+
this.suppressionStore = opts.suppressionStore;
|
|
33
|
+
this.sendAlert = opts.sendAlert;
|
|
34
|
+
this.clawAssessEvent = opts.clawAssessEvent;
|
|
35
|
+
this.alertState = createAlertState();
|
|
36
|
+
}
|
|
37
|
+
async start() {
|
|
38
|
+
if (this.running)
|
|
39
|
+
return;
|
|
40
|
+
this.running = true;
|
|
41
|
+
// Start at end of file (only process new events)
|
|
42
|
+
try {
|
|
43
|
+
const st = await stat(this.eventsPath);
|
|
44
|
+
this.fileOffset = st.size;
|
|
45
|
+
console.log(`[sentinel] AlertTailer started, offset=${this.fileOffset}`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
this.fileOffset = 0;
|
|
49
|
+
console.log("[sentinel] AlertTailer started, events file not yet created");
|
|
50
|
+
}
|
|
51
|
+
// Watch for changes
|
|
52
|
+
try {
|
|
53
|
+
this.watcher = watch(this.eventsPath, { persistent: false }, (eventType) => {
|
|
54
|
+
if (eventType === "change") {
|
|
55
|
+
this.debouncedProcessNew();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
this.watcher.on("error", (err) => {
|
|
59
|
+
console.warn(`[sentinel] AlertTailer watcher error: ${err.message}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// File might not exist yet — poll for it
|
|
64
|
+
this.pollInterval = setInterval(async () => {
|
|
65
|
+
try {
|
|
66
|
+
await stat(this.eventsPath);
|
|
67
|
+
if (this.pollInterval)
|
|
68
|
+
clearInterval(this.pollInterval);
|
|
69
|
+
this.pollInterval = null;
|
|
70
|
+
if (this.running)
|
|
71
|
+
this.start();
|
|
72
|
+
}
|
|
73
|
+
catch { /* keep waiting */ }
|
|
74
|
+
}, 5000);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
this.running = false;
|
|
79
|
+
if (this.watcher) {
|
|
80
|
+
this.watcher.close();
|
|
81
|
+
this.watcher = null;
|
|
82
|
+
}
|
|
83
|
+
if (this.debounceTimer) {
|
|
84
|
+
clearTimeout(this.debounceTimer);
|
|
85
|
+
this.debounceTimer = null;
|
|
86
|
+
}
|
|
87
|
+
if (this.pollInterval) {
|
|
88
|
+
clearInterval(this.pollInterval);
|
|
89
|
+
this.pollInterval = null;
|
|
90
|
+
}
|
|
91
|
+
console.log("[sentinel] AlertTailer stopped");
|
|
92
|
+
}
|
|
93
|
+
debouncedProcessNew() {
|
|
94
|
+
// Debounce: multiple writes within 100ms get batched
|
|
95
|
+
if (this.debounceTimer)
|
|
96
|
+
clearTimeout(this.debounceTimer);
|
|
97
|
+
this.debounceTimer = setTimeout(() => {
|
|
98
|
+
this.processNewLines().catch((err) => {
|
|
99
|
+
console.error(`[sentinel] AlertTailer processing error: ${err.message}`);
|
|
100
|
+
});
|
|
101
|
+
}, 100);
|
|
102
|
+
}
|
|
103
|
+
async processNewLines() {
|
|
104
|
+
if (!this.running)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
const st = await stat(this.eventsPath);
|
|
108
|
+
if (st.size <= this.fileOffset)
|
|
109
|
+
return; // No new data
|
|
110
|
+
// Read only new bytes
|
|
111
|
+
const stream = createReadStream(this.eventsPath, {
|
|
112
|
+
start: this.fileOffset,
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
});
|
|
115
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
116
|
+
const newEvents = [];
|
|
117
|
+
for await (const line of rl) {
|
|
118
|
+
if (!line.trim())
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
const evt = JSON.parse(line);
|
|
122
|
+
newEvents.push(evt);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Skip malformed lines
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.fileOffset = st.size;
|
|
129
|
+
// Process each event through the alert pipeline
|
|
130
|
+
for (const evt of newEvents) {
|
|
131
|
+
await this.processEvent(evt);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(`[sentinel] AlertTailer read error: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async processEvent(evt) {
|
|
139
|
+
const meets = meetsThreshold(evt.severity, this.config.alertSeverity);
|
|
140
|
+
if (!meets)
|
|
141
|
+
return;
|
|
142
|
+
const should = shouldAlert(evt, this.alertState);
|
|
143
|
+
if (!should)
|
|
144
|
+
return;
|
|
145
|
+
// Check suppression
|
|
146
|
+
const suppressed = this.suppressionStore?.isSuppressed(evt);
|
|
147
|
+
if (suppressed) {
|
|
148
|
+
console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Claw assessment
|
|
152
|
+
if (this.config.clawAssess) {
|
|
153
|
+
console.log(`[sentinel] Claw assessment enabled, calling for: ${evt.title}`);
|
|
154
|
+
try {
|
|
155
|
+
const assessment = await this.clawAssessEvent(evt);
|
|
156
|
+
console.log(`[sentinel] Claw assessment result: ${assessment?.slice(0, 80) ?? "(null)"}`);
|
|
157
|
+
await this.sendAlert(formatAlert(evt, assessment));
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.warn(`[sentinel] Claw assessment failed: ${err.message ?? err}`);
|
|
161
|
+
await this.sendAlert(formatAlert(evt)).catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
await this.sendAlert(formatAlert(evt)).catch((err) => {
|
|
166
|
+
console.error(`[sentinel] Alert delivery failed: ${err.message ?? err}`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Update config at runtime (e.g., after reload) */
|
|
171
|
+
updateConfig(config) {
|
|
172
|
+
this.config = config;
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -19,8 +19,8 @@ export interface SentinelConfig {
|
|
|
19
19
|
trustedPaths?: string[];
|
|
20
20
|
trustedCommandPatterns?: string[];
|
|
21
21
|
watchPaths?: string[];
|
|
22
|
-
/** Add a one-line
|
|
23
|
-
|
|
22
|
+
/** Add a one-line Claw assessment to alerts before sending (requires openclaw CLI) */
|
|
23
|
+
clawAssess?: boolean;
|
|
24
24
|
}
|
|
25
25
|
export declare const DEFAULT_CONFIG: Required<Pick<SentinelConfig, "pollIntervalMs" | "enableProcessMonitor" | "enableFileIntegrity" | "enableNetworkMonitor" | "trustedSigningIds" | "trustedPaths" | "watchPaths">>;
|
|
26
26
|
/** A security event detected by Sentinel */
|
package/dist/index.d.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* Uses event-driven log tailing for sub-second alerting.
|
|
6
6
|
*
|
|
7
7
|
* Architecture:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Watchers (log stream + osquery) → events.jsonl → AlertTailer → assess → deliver
|
|
9
|
+
*
|
|
10
|
+
* Detection is decoupled from alerting — watchers only parse and persist,
|
|
11
|
+
* while AlertTailer handles rate limiting, dedup, suppression, Claw assessment,
|
|
12
|
+
* and delivery by tailing events.jsonl.
|
|
12
13
|
*
|
|
13
14
|
* Note: osqueryd requires root/sudo — it must be started separately
|
|
14
15
|
* (e.g., via launchd). This plugin only watches the result logs.
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* Uses event-driven log tailing for sub-second alerting.
|
|
6
6
|
*
|
|
7
7
|
* Architecture:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Watchers (log stream + osquery) → events.jsonl → AlertTailer → assess → deliver
|
|
9
|
+
*
|
|
10
|
+
* Detection is decoupled from alerting — watchers only parse and persist,
|
|
11
|
+
* while AlertTailer handles rate limiting, dedup, suppression, Claw assessment,
|
|
12
|
+
* and delivery by tailing events.jsonl.
|
|
12
13
|
*
|
|
13
14
|
* Note: osqueryd requires root/sudo — it must be started separately
|
|
14
15
|
* (e.g., via launchd). This plugin only watches the result logs.
|
|
@@ -19,7 +20,8 @@ import { join } from "node:path";
|
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import { Type } from "@sinclair/typebox";
|
|
21
22
|
import { findOsquery, query } from "./osquery.js";
|
|
22
|
-
import {
|
|
23
|
+
import { createAlertState } from "./alerts.js";
|
|
24
|
+
import { AlertTailer } from "./alert-tailer.js";
|
|
23
25
|
import { EventStore } from "./persistence.js";
|
|
24
26
|
import { ResultLogWatcher } from "./watcher.js";
|
|
25
27
|
import { LogStreamWatcher } from "./log-stream.js";
|
|
@@ -27,6 +29,12 @@ import { SuppressionStore } from "./suppressions.js";
|
|
|
27
29
|
import { execFile } from "node:child_process";
|
|
28
30
|
import { promisify } from "node:util";
|
|
29
31
|
const execFileAsync = promisify(execFile);
|
|
32
|
+
// Use globalThis so state survives module re-evaluation on SIGUSR1 restarts.
|
|
33
|
+
const G = globalThis;
|
|
34
|
+
const SENTINEL_NS = "__sentinel__";
|
|
35
|
+
if (!G[SENTINEL_NS])
|
|
36
|
+
G[SENTINEL_NS] = { cleanup: null, initialized: false };
|
|
37
|
+
const _g = G[SENTINEL_NS];
|
|
30
38
|
import { analyzeProcessEvents, analyzeLoginEvents, analyzeFailedAuth, analyzeListeningPorts, analyzeFileEvents, formatAlert, } from "./analyzer.js";
|
|
31
39
|
// ── State ──
|
|
32
40
|
const state = {
|
|
@@ -98,7 +106,7 @@ async function checkDaemon(sentinelDir) {
|
|
|
98
106
|
* assessment of a security event. Returns the assessment string, or null
|
|
99
107
|
* on failure.
|
|
100
108
|
*/
|
|
101
|
-
async function
|
|
109
|
+
async function clawAssessEvent(evt) {
|
|
102
110
|
const details = typeof evt.details === "string" ? evt.details : JSON.stringify(evt.details);
|
|
103
111
|
const prompt = `You are a security-savvy AI agent named Claw monitoring your human's machine. A security event was detected:
|
|
104
112
|
|
|
@@ -108,7 +116,7 @@ Category: ${evt.category}
|
|
|
108
116
|
Description: ${evt.description}
|
|
109
117
|
Details: ${details}
|
|
110
118
|
|
|
111
|
-
Context: This machine runs OpenClaw (an AI assistant platform) which frequently spawns commands via heartbeats, cron jobs, and agent tasks — python one-liners, curl/wget API calls, bq queries, git, npm/node, etc. The user is
|
|
119
|
+
Context: This machine runs OpenClaw (an AI assistant platform) which frequently spawns commands via heartbeats, cron jobs, and agent tasks — python one-liners, curl/wget API calls, bq queries, git, npm/node, etc. The user is the machine owner.
|
|
112
120
|
|
|
113
121
|
Reply with ONLY a single short sentence (under 30 words) giving your honest take on whether this is a real problem or likely benign. Be direct, opinionated, and useful — like a senior engineer glancing at an alert. No preamble.`;
|
|
114
122
|
try {
|
|
@@ -134,7 +142,7 @@ Reply with ONLY a single short sentence (under 30 words) giving your honest take
|
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
catch (err) {
|
|
137
|
-
console.warn(`[sentinel]
|
|
145
|
+
console.warn(`[sentinel] Claw assessment failed: ${err?.message ?? err}`);
|
|
138
146
|
return null;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
@@ -166,31 +174,7 @@ function handleResult(result, config, sendAlert) {
|
|
|
166
174
|
}
|
|
167
175
|
for (const evt of events) {
|
|
168
176
|
logEvent(evt);
|
|
169
|
-
|
|
170
|
-
const suppressed = suppressionStore?.isSuppressed(evt);
|
|
171
|
-
if (suppressed) {
|
|
172
|
-
console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
|
|
173
|
-
}
|
|
174
|
-
else if (config.llmAlertAssessment) {
|
|
175
|
-
// Get LLM assessment and include it in the alert
|
|
176
|
-
console.log(`[sentinel] LLM assessment enabled, calling for: ${evt.title}`);
|
|
177
|
-
llmAssessEvent(evt).then((assessment) => {
|
|
178
|
-
console.log(`[sentinel] LLM assessment result: ${assessment?.slice(0, 80) ?? "(null)"}`);
|
|
179
|
-
sendAlert(formatAlert(evt, assessment)).catch((err) => {
|
|
180
|
-
console.error("[sentinel] alert failed:", err);
|
|
181
|
-
});
|
|
182
|
-
}).catch((err) => {
|
|
183
|
-
console.warn(`[sentinel] LLM assessment promise rejected: ${err}`);
|
|
184
|
-
sendAlert(formatAlert(evt)).catch(() => { });
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
console.log(`[sentinel] LLM assessment NOT enabled (llmAlertAssessment=${config.llmAlertAssessment})`);
|
|
189
|
-
sendAlert(formatAlert(evt)).catch((err) => {
|
|
190
|
-
console.error("[sentinel] alert failed:", err);
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
}
|
|
177
|
+
// AlertTailer handles alerting by tailing events.jsonl
|
|
194
178
|
}
|
|
195
179
|
if (events.length > 0) {
|
|
196
180
|
console.log(`[sentinel] ${events.length} events from ${result.name}`);
|
|
@@ -210,9 +194,10 @@ export default function sentinel(api) {
|
|
|
210
194
|
}
|
|
211
195
|
catch { /* ignore */ }
|
|
212
196
|
const pluginConfig = { ...fileConfig, ...apiConfig };
|
|
213
|
-
console.log(`[sentinel] Config
|
|
197
|
+
console.log(`[sentinel] Config v2026.2.26-1: alertSeverity=${pluginConfig.alertSeverity}, alertChannel=${pluginConfig.alertChannel}, clawAssess=${pluginConfig.clawAssess}, trustedPatterns=${(pluginConfig.trustedCommandPatterns ?? []).length}`);
|
|
214
198
|
let watcher = null;
|
|
215
199
|
let logStreamWatcher = null;
|
|
200
|
+
let alertTailer = null;
|
|
216
201
|
const sentinelDir = pluginConfig.logPath ?? SENTINEL_DIR_DEFAULT;
|
|
217
202
|
const sendAlert = async (text) => {
|
|
218
203
|
const channel = pluginConfig.alertChannel;
|
|
@@ -373,7 +358,7 @@ export default function sentinel(api) {
|
|
|
373
358
|
name: "sentinel_suppress",
|
|
374
359
|
description: "Manage alert suppression rules. Actions: 'add' (create a suppression), 'list' (show all), 'remove' (delete by id), 'cleanup' (remove expired). " +
|
|
375
360
|
"Scopes: 'title' (suppress all alerts with this title), 'category' (suppress entire category like ssh_login/privilege/auth), " +
|
|
376
|
-
"'field' (suppress when a specific detail field matches, e.g. field=user fieldValue=
|
|
361
|
+
"'field' (suppress when a specific detail field matches, e.g. field=user fieldValue=alice), 'exact' (suppress only this exact title+description). " +
|
|
377
362
|
"Always explain to the user what will be suppressed before adding a rule.",
|
|
378
363
|
parameters: Type.Object({
|
|
379
364
|
action: Type.Union([
|
|
@@ -482,8 +467,19 @@ export default function sentinel(api) {
|
|
|
482
467
|
}
|
|
483
468
|
},
|
|
484
469
|
});
|
|
470
|
+
// ── Clean up previous instance if re-initializing (SIGUSR1 restart) ──
|
|
471
|
+
if (typeof _g.cleanup === "function") {
|
|
472
|
+
console.log("[sentinel] Cleaning up previous instance before re-init");
|
|
473
|
+
_g.cleanup();
|
|
474
|
+
_g.cleanup = null;
|
|
475
|
+
_g.initialized = false;
|
|
476
|
+
}
|
|
485
477
|
// ── Cleanup on process exit ──
|
|
486
478
|
const cleanup = () => {
|
|
479
|
+
if (alertTailer) {
|
|
480
|
+
alertTailer.stop();
|
|
481
|
+
alertTailer = null;
|
|
482
|
+
}
|
|
487
483
|
if (watcher) {
|
|
488
484
|
watcher.stop();
|
|
489
485
|
watcher = null;
|
|
@@ -494,14 +490,16 @@ export default function sentinel(api) {
|
|
|
494
490
|
logStreamWatcher = null;
|
|
495
491
|
}
|
|
496
492
|
};
|
|
493
|
+
_g.cleanup = cleanup;
|
|
497
494
|
process.on("exit", cleanup);
|
|
498
495
|
process.on("SIGTERM", cleanup);
|
|
499
496
|
process.on("SIGINT", cleanup);
|
|
500
497
|
// ── Start monitoring immediately (fire-and-forget) ──
|
|
501
|
-
if (
|
|
498
|
+
if (_g.initialized) {
|
|
502
499
|
console.log("[sentinel] Already initialized, skipping double-init");
|
|
503
500
|
return;
|
|
504
501
|
}
|
|
502
|
+
_g.initialized = true;
|
|
505
503
|
(async () => {
|
|
506
504
|
try {
|
|
507
505
|
const osqueryiPath = findOsquery(pluginConfig.osqueryPath);
|
|
@@ -559,30 +557,20 @@ export default function sentinel(api) {
|
|
|
559
557
|
// Start real-time log stream watcher for SSH events
|
|
560
558
|
logStreamWatcher = new LogStreamWatcher((evt) => {
|
|
561
559
|
logEvent(evt);
|
|
562
|
-
|
|
563
|
-
shouldAlert(evt, alertRateState)) {
|
|
564
|
-
const suppressed = suppressionStore?.isSuppressed(evt);
|
|
565
|
-
if (suppressed) {
|
|
566
|
-
console.log(`[sentinel] Alert suppressed by rule "${suppressed.reason}" (${SuppressionStore.describe(suppressed)})`);
|
|
567
|
-
}
|
|
568
|
-
else if (pluginConfig.llmAlertAssessment) {
|
|
569
|
-
llmAssessEvent(evt).then((assessment) => {
|
|
570
|
-
sendAlert(formatAlert(evt, assessment)).catch((err) => {
|
|
571
|
-
console.error("[sentinel] alert failed:", err);
|
|
572
|
-
});
|
|
573
|
-
}).catch(() => {
|
|
574
|
-
sendAlert(formatAlert(evt)).catch(() => { });
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
else {
|
|
578
|
-
sendAlert(formatAlert(evt)).catch((err) => {
|
|
579
|
-
console.error("[sentinel] alert failed:", err);
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}
|
|
560
|
+
// AlertTailer handles alerting by tailing events.jsonl
|
|
583
561
|
console.log(`[sentinel] [real-time] ${evt.severity}/${evt.category}: ${evt.title}`);
|
|
584
562
|
}, state.knownHosts);
|
|
585
563
|
logStreamWatcher.start();
|
|
564
|
+
// Start AlertTailer — single pipeline for all alerting
|
|
565
|
+
const eventsPath = join(sentinelDir, "events.jsonl");
|
|
566
|
+
alertTailer = new AlertTailer({
|
|
567
|
+
eventsPath,
|
|
568
|
+
config: pluginConfig,
|
|
569
|
+
suppressionStore,
|
|
570
|
+
sendAlert,
|
|
571
|
+
clawAssessEvent,
|
|
572
|
+
});
|
|
573
|
+
await alertTailer.start();
|
|
586
574
|
}
|
|
587
575
|
catch (err) {
|
|
588
576
|
console.error("[sentinel] Failed to start:", err);
|
package/dist/log-stream.js
CHANGED
|
@@ -21,8 +21,8 @@ function event(severity, category, title, description, details) {
|
|
|
21
21
|
/**
|
|
22
22
|
* Parse a macOS `log stream` line for SSH events.
|
|
23
23
|
* Lines look like:
|
|
24
|
-
* 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for
|
|
25
|
-
* 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Failed password for
|
|
24
|
+
* 2026-02-22 16:30:00.123456-0500 0x1234 Default 0x0 0 sshd: Accepted publickey for alice 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 alice from 203.0.113.42 port 22 ssh2
|
|
26
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
27
|
*/
|
|
28
28
|
function parseMacOSLogLine(line, knownHosts) {
|
|
@@ -51,7 +51,7 @@ function parseMacOSLogLine(line, knownHosts) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Parse a Linux journalctl line for SSH events.
|
|
53
53
|
* Lines look like:
|
|
54
|
-
* Feb 22 16:30:00 hostname sshd[1234]: Accepted publickey for
|
|
54
|
+
* Feb 22 16:30:00 hostname sshd[1234]: Accepted publickey for alice from 100.79.207.74 port 52341 ssh2
|
|
55
55
|
*/
|
|
56
56
|
function parseLinuxLogLine(line, knownHosts) {
|
|
57
57
|
// Same patterns work for both — sshd output is consistent
|
|
@@ -60,9 +60,9 @@ function parseLinuxLogLine(line, knownHosts) {
|
|
|
60
60
|
/**
|
|
61
61
|
* Parse Linux sudo events from journalctl / auth.log.
|
|
62
62
|
* Lines look like:
|
|
63
|
-
* Feb 22 16:30:00 hostname sudo[1234]:
|
|
64
|
-
* Feb 22 16:30:00 hostname sudo[1234]: pam_unix(sudo:session): session opened for user root(uid=0) by
|
|
65
|
-
* Feb 22 16:30:00 hostname sudo[1234]:
|
|
63
|
+
* Feb 22 16:30:00 hostname sudo[1234]: alice : TTY=pts/0 ; PWD=/home/alice ; 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 alice(uid=1000)
|
|
65
|
+
* Feb 22 16:30:00 hostname sudo[1234]: alice : 3 incorrect password attempts ; TTY=pts/0 ; PWD=/home/alice ; USER=root ; COMMAND=/usr/bin/rm -rf /
|
|
66
66
|
*/
|
|
67
67
|
function parseLinuxSudo(line) {
|
|
68
68
|
// Standard sudo command log
|
|
@@ -85,8 +85,8 @@ function parseLinuxSudo(line) {
|
|
|
85
85
|
* Lines from useradd/userdel/usermod/passwd via journalctl or auth.log:
|
|
86
86
|
* Feb 22 16:30:00 hostname useradd[1234]: new user: name=backdoor, UID=1001, GID=1001, home=/home/backdoor, shell=/bin/bash
|
|
87
87
|
* Feb 22 16:30:00 hostname userdel[1234]: delete user 'olduser'
|
|
88
|
-
* Feb 22 16:30:00 hostname usermod[1234]: change user '
|
|
89
|
-
* Feb 22 16:30:00 hostname passwd[1234]: pam_unix(passwd:chauthtok): password changed for
|
|
88
|
+
* Feb 22 16:30:00 hostname usermod[1234]: change user 'alice' password
|
|
89
|
+
* Feb 22 16:30:00 hostname passwd[1234]: pam_unix(passwd:chauthtok): password changed for alice
|
|
90
90
|
* Feb 22 16:30:00 hostname groupadd[1234]: new group: name=newgroup, GID=1002
|
|
91
91
|
*/
|
|
92
92
|
function parseLinuxUserAccount(line) {
|
|
@@ -121,7 +121,7 @@ function parseLinuxUserAccount(line) {
|
|
|
121
121
|
* Parse Linux remote desktop / VNC events.
|
|
122
122
|
* Lines from xrdp, vncserver, x11vnc:
|
|
123
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
|
|
124
|
+
* Feb 22 16:30:00 hostname xrdp-sesman[1234]: session started for user alice
|
|
125
125
|
* Feb 22 16:30:00 hostname x11vnc[1234]: Got connection from client 203.0.113.42
|
|
126
126
|
*/
|
|
127
127
|
function parseLinuxRemoteDesktop(line) {
|
|
@@ -145,10 +145,10 @@ function parseLinuxRemoteDesktop(line) {
|
|
|
145
145
|
/**
|
|
146
146
|
* Parse macOS /var/log/system.log lines for SSH events.
|
|
147
147
|
* Lines look like:
|
|
148
|
-
* Feb 22 16:39:32
|
|
149
|
-
* Feb 22 16:38:12
|
|
150
|
-
* Feb 22 16:51:16
|
|
151
|
-
* Feb 22 16:51:16
|
|
148
|
+
* Feb 22 16:39:32 my-hostname sshd-session: alice [priv][58912]: USER_PROCESS: 58916 ttys001
|
|
149
|
+
* Feb 22 16:38:12 my-hostname sshd-session: alice [priv][53930]: DEAD_PROCESS: 53934 ttys012
|
|
150
|
+
* Feb 22 16:51:16 my-hostname sshd[60738]: Failed password for alice from 100.79.207.74 port 52341 ssh2
|
|
151
|
+
* Feb 22 16:51:16 my-hostname sshd[60738]: Invalid user admin from 100.79.207.74 port 52341
|
|
152
152
|
*/
|
|
153
153
|
function parseMacOSSyslog(line, knownHosts) {
|
|
154
154
|
// Match sshd-session USER_PROCESS (successful login)
|
|
@@ -186,7 +186,7 @@ function parseMacOSSyslog(line, knownHosts) {
|
|
|
186
186
|
/**
|
|
187
187
|
* Parse macOS unified log lines for PAM authentication errors.
|
|
188
188
|
* Line format:
|
|
189
|
-
* 2026-02-22 16:56:51.705020-0500 0x14e5ecb Default 0x0 62761 0 sshd-session: error: PAM: authentication error for
|
|
189
|
+
* 2026-02-22 16:56:51.705020-0500 0x14e5ecb Default 0x0 62761 0 sshd-session: error: PAM: authentication error for alice from 100.79.207.74
|
|
190
190
|
*/
|
|
191
191
|
function parseMacOSAuthError(line, _knownHosts) {
|
|
192
192
|
// PAM authentication error (valid user, wrong password)
|
package/dist/suppressions.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - "exact": matches title + description exactly
|
|
6
6
|
* - "title": matches events with the same title
|
|
7
7
|
* - "category": matches all events in a category (e.g., all ssh_login)
|
|
8
|
-
* - "field": matches a specific field value (e.g., details.user === "
|
|
8
|
+
* - "field": matches a specific field value (e.g., details.user === "alice")
|
|
9
9
|
*/
|
|
10
10
|
import type { SecurityEvent } from "./config.js";
|
|
11
11
|
export interface SuppressionRule {
|
package/dist/suppressions.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - "exact": matches title + description exactly
|
|
6
6
|
* - "title": matches events with the same title
|
|
7
7
|
* - "category": matches all events in a category (e.g., all ssh_login)
|
|
8
|
-
* - "field": matches a specific field value (e.g., details.user === "
|
|
8
|
+
* - "field": matches a specific field value (e.g., details.user === "alice")
|
|
9
9
|
*/
|
|
10
10
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
11
|
import { existsSync } from "node:fs";
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "sentinel",
|
|
3
3
|
"name": "Sentinel",
|
|
4
|
-
"description": "Real-time endpoint security monitoring for macOS and Linux
|
|
5
|
-
"version": "
|
|
6
|
-
"skills": [
|
|
4
|
+
"description": "Real-time endpoint security monitoring for macOS and Linux \u2014 process execution, SSH connections, privilege escalation, and file integrity alerts via OpenClaw.",
|
|
5
|
+
"version": "2026.3.5",
|
|
6
|
+
"skills": [
|
|
7
|
+
"skills/sentinel"
|
|
8
|
+
],
|
|
7
9
|
"configSchema": {
|
|
8
10
|
"type": "object",
|
|
9
11
|
"additionalProperties": false,
|
|
@@ -26,7 +28,13 @@
|
|
|
26
28
|
},
|
|
27
29
|
"alertSeverity": {
|
|
28
30
|
"type": "string",
|
|
29
|
-
"enum": [
|
|
31
|
+
"enum": [
|
|
32
|
+
"critical",
|
|
33
|
+
"high",
|
|
34
|
+
"medium",
|
|
35
|
+
"low",
|
|
36
|
+
"info"
|
|
37
|
+
],
|
|
30
38
|
"description": "Minimum severity level to trigger alerts (default: high)"
|
|
31
39
|
},
|
|
32
40
|
"logPath": {
|
|
@@ -53,27 +61,35 @@
|
|
|
53
61
|
},
|
|
54
62
|
"trustedSigningIds": {
|
|
55
63
|
"type": "array",
|
|
56
|
-
"items": {
|
|
64
|
+
"items": {
|
|
65
|
+
"type": "string"
|
|
66
|
+
},
|
|
57
67
|
"description": "Signing IDs to trust (skip alerts for these)"
|
|
58
68
|
},
|
|
59
69
|
"trustedPaths": {
|
|
60
70
|
"type": "array",
|
|
61
|
-
"items": {
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "string"
|
|
73
|
+
},
|
|
62
74
|
"description": "Process paths to trust (skip alerts for these)"
|
|
63
75
|
},
|
|
64
76
|
"trustedCommandPatterns": {
|
|
65
77
|
"type": "array",
|
|
66
|
-
"items": {
|
|
78
|
+
"items": {
|
|
79
|
+
"type": "string"
|
|
80
|
+
},
|
|
67
81
|
"description": "Regex patterns for commands to skip (e.g. OpenClaw heartbeat scripts)"
|
|
68
82
|
},
|
|
69
83
|
"watchPaths": {
|
|
70
84
|
"type": "array",
|
|
71
|
-
"items": {
|
|
85
|
+
"items": {
|
|
86
|
+
"type": "string"
|
|
87
|
+
},
|
|
72
88
|
"description": "File paths to monitor for integrity changes"
|
|
73
89
|
},
|
|
74
|
-
"
|
|
90
|
+
"clawAssess": {
|
|
75
91
|
"type": "boolean",
|
|
76
|
-
"description": "Use
|
|
92
|
+
"description": "Use Claw to assess suspicious commands before alerting (reduces false positives from automation)"
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
95
|
},
|