openclaw-sentinel 0.1.0 → 0.2.1
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 +41 -7
- package/dist/__tests__/alerts.test.js +5 -6
- package/dist/__tests__/analyzer.test.js +10 -6
- package/dist/__tests__/log-stream.test.d.ts +1 -0
- package/dist/__tests__/log-stream.test.js +165 -0
- package/dist/__tests__/query-safety.test.d.ts +1 -0
- package/dist/__tests__/query-safety.test.js +86 -0
- package/dist/__tests__/suppressions.test.d.ts +1 -0
- package/dist/__tests__/suppressions.test.js +196 -0
- package/dist/alerts.js +11 -6
- package/dist/analyzer.js +9 -6
- package/dist/config.d.ts +2 -2
- package/dist/index.js +201 -12
- package/dist/log-stream.d.ts +27 -0
- package/dist/log-stream.js +452 -0
- package/dist/osquery.js +1 -1
- package/dist/suppressions.d.ts +67 -0
- package/dist/suppressions.js +154 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
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
4
|
|
|
5
|
-
A security monitoring plugin for [OpenClaw](https://github.com/openclaw/openclaw), powered by [osquery](https://osquery
|
|
5
|
+
A security monitoring plugin for [OpenClaw](https://github.com/openclaw/openclaw), powered by [osquery](https://github.com/osquery/osquery).
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -33,7 +33,7 @@ Sentinel **does not** run osqueryd itself (it requires root). You start osqueryd
|
|
|
33
33
|
## Prerequisites
|
|
34
34
|
|
|
35
35
|
- **macOS** (Apple Silicon or Intel) or **Linux** (systemd-based)
|
|
36
|
-
- [osquery](https://osquery
|
|
36
|
+
- [osquery](https://github.com/osquery/osquery) installed
|
|
37
37
|
- [OpenClaw](https://github.com/openclaw/openclaw) running
|
|
38
38
|
|
|
39
39
|
### Install osquery
|
|
@@ -66,8 +66,45 @@ sudo yum install osquery
|
|
|
66
66
|
|
|
67
67
|
## Installation
|
|
68
68
|
|
|
69
|
+
### From npm (recommended)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install -g openclaw-sentinel
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then add the plugin to your `~/.openclaw/openclaw.json`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"plugins": {
|
|
80
|
+
"entries": {
|
|
81
|
+
"sentinel": {
|
|
82
|
+
"enabled": true,
|
|
83
|
+
"module": "openclaw-sentinel",
|
|
84
|
+
"config": {
|
|
85
|
+
"alertChannel": "signal",
|
|
86
|
+
"alertTo": "+1234567890",
|
|
87
|
+
"alertSeverity": "high"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Restart your gateway:
|
|
96
|
+
|
|
69
97
|
```bash
|
|
70
|
-
openclaw
|
|
98
|
+
openclaw gateway restart
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### From source (development)
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/sunil-sadasivan/openclaw-sentinel.git
|
|
105
|
+
cd openclaw-sentinel
|
|
106
|
+
npm install && npm run build
|
|
107
|
+
openclaw plugins install .
|
|
71
108
|
openclaw gateway restart
|
|
72
109
|
```
|
|
73
110
|
|
|
@@ -296,10 +333,7 @@ cd openclaw-sentinel
|
|
|
296
333
|
npm install
|
|
297
334
|
npm run build # Compile TypeScript
|
|
298
335
|
npm run dev # Watch mode
|
|
299
|
-
|
|
300
|
-
# Install locally for testing
|
|
301
|
-
openclaw plugins install .
|
|
302
|
-
openclaw gateway restart
|
|
336
|
+
npm test # Run tests (60 tests)
|
|
303
337
|
```
|
|
304
338
|
|
|
305
339
|
## Project structure
|
|
@@ -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
|
|
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 +
|
|
27
|
-
assert.equal(shouldAlert(makeEvent("Same Event"), state, now +
|
|
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
|
|
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
|
-
|
|
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, "
|
|
130
|
+
assert.equal(events[0].category, "ssh_login");
|
|
131
131
|
});
|
|
132
|
-
it("
|
|
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,
|
|
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("
|
|
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,
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
/**
|
|
4
|
+
* Test the query safety checks that protect sentinel_query from
|
|
5
|
+
* osqueryi meta-command injection and dangerous table access.
|
|
6
|
+
*/
|
|
7
|
+
const BLOCKED_TABLES = ["carves", "curl", "curl_certificate"];
|
|
8
|
+
const BLOCKED_PATTERNS = [
|
|
9
|
+
/^\s*\./, // Any dot-command
|
|
10
|
+
/;\s*\./, // Dot-command after semicolon
|
|
11
|
+
/ATTACH\s/i, // ATTACH database
|
|
12
|
+
/LOAD\s/i, // Load extension
|
|
13
|
+
];
|
|
14
|
+
function isBlocked(sql) {
|
|
15
|
+
const patternMatch = BLOCKED_PATTERNS.find((p) => p.test(sql));
|
|
16
|
+
if (patternMatch)
|
|
17
|
+
return "meta-command";
|
|
18
|
+
const sqlLower = sql.toLowerCase();
|
|
19
|
+
const tableMatch = BLOCKED_TABLES.find((t) => sqlLower.includes(t));
|
|
20
|
+
if (tableMatch)
|
|
21
|
+
return tableMatch;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
describe("sentinel_query safety checks", () => {
|
|
25
|
+
describe("blocks osqueryi meta-commands", () => {
|
|
26
|
+
it("blocks .shell command", () => {
|
|
27
|
+
assert.ok(isBlocked(".shell ls -la /"));
|
|
28
|
+
});
|
|
29
|
+
it("blocks .shell with leading whitespace", () => {
|
|
30
|
+
assert.ok(isBlocked(" .shell cat /etc/passwd"));
|
|
31
|
+
});
|
|
32
|
+
it("blocks .output (file exfiltration)", () => {
|
|
33
|
+
assert.ok(isBlocked(".output /tmp/exfil.txt"));
|
|
34
|
+
});
|
|
35
|
+
it("blocks .read (file read)", () => {
|
|
36
|
+
assert.ok(isBlocked(".read /etc/shadow"));
|
|
37
|
+
});
|
|
38
|
+
it("blocks .mode", () => {
|
|
39
|
+
assert.ok(isBlocked(".mode csv"));
|
|
40
|
+
});
|
|
41
|
+
it("blocks .headers", () => {
|
|
42
|
+
assert.ok(isBlocked(".headers on"));
|
|
43
|
+
});
|
|
44
|
+
it("blocks dot-command after semicolon", () => {
|
|
45
|
+
assert.ok(isBlocked("SELECT 1; .shell whoami"));
|
|
46
|
+
});
|
|
47
|
+
it("blocks ATTACH database", () => {
|
|
48
|
+
assert.ok(isBlocked("ATTACH '/tmp/evil.db' AS evil"));
|
|
49
|
+
});
|
|
50
|
+
it("blocks LOAD extension", () => {
|
|
51
|
+
assert.ok(isBlocked("LOAD /tmp/evil.so"));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("blocks dangerous tables", () => {
|
|
55
|
+
it("blocks carves table", () => {
|
|
56
|
+
assert.equal(isBlocked("SELECT * FROM carves"), "carves");
|
|
57
|
+
});
|
|
58
|
+
it("blocks curl table", () => {
|
|
59
|
+
assert.equal(isBlocked("SELECT * FROM curl WHERE url='http://evil.com'"), "curl");
|
|
60
|
+
});
|
|
61
|
+
it("blocks curl_certificate table", () => {
|
|
62
|
+
// "curl" substring matches first — both are blocked, exact match doesn't matter
|
|
63
|
+
assert.ok(isBlocked("SELECT * FROM curl_certificate"));
|
|
64
|
+
});
|
|
65
|
+
it("blocks case-insensitive table names", () => {
|
|
66
|
+
assert.equal(isBlocked("SELECT * FROM CURL"), "curl");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("allows legitimate queries", () => {
|
|
70
|
+
it("allows process listing", () => {
|
|
71
|
+
assert.equal(isBlocked("SELECT * FROM processes"), null);
|
|
72
|
+
});
|
|
73
|
+
it("allows listening ports", () => {
|
|
74
|
+
assert.equal(isBlocked("SELECT * FROM listening_ports WHERE port > 0"), null);
|
|
75
|
+
});
|
|
76
|
+
it("allows shell_history (contains 'shell' but not a meta-command)", () => {
|
|
77
|
+
assert.equal(isBlocked("SELECT * FROM shell_history"), null);
|
|
78
|
+
});
|
|
79
|
+
it("allows logged_in_users", () => {
|
|
80
|
+
assert.equal(isBlocked("SELECT * FROM logged_in_users"), null);
|
|
81
|
+
});
|
|
82
|
+
it("allows complex JOINs", () => {
|
|
83
|
+
assert.equal(isBlocked("SELECT p.name, lp.port FROM listening_ports lp JOIN processes p ON lp.pid = p.pid"), null);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { SuppressionStore } from "../suppressions.js";
|
|
7
|
+
function makeEvent(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
id: "test-1",
|
|
10
|
+
timestamp: Date.now(),
|
|
11
|
+
severity: "high",
|
|
12
|
+
category: "ssh_login",
|
|
13
|
+
title: "SSH login from unknown host",
|
|
14
|
+
description: 'User "root" logged in from unknown host: 203.0.113.42',
|
|
15
|
+
details: { user: "root", host: "203.0.113.42", type: "publickey" },
|
|
16
|
+
hostname: "test-host",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe("SuppressionStore", () => {
|
|
21
|
+
let tmpDir;
|
|
22
|
+
let store;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sentinel-test-"));
|
|
25
|
+
store = new SuppressionStore(tmpDir);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
it("starts with no rules", async () => {
|
|
31
|
+
const rules = await store.list();
|
|
32
|
+
assert.equal(rules.length, 0);
|
|
33
|
+
});
|
|
34
|
+
it("adds and lists rules", async () => {
|
|
35
|
+
await store.add({
|
|
36
|
+
scope: "title",
|
|
37
|
+
title: "SSH login detected",
|
|
38
|
+
reason: "Known Tailscale logins",
|
|
39
|
+
expiresAt: null,
|
|
40
|
+
});
|
|
41
|
+
const rules = await store.list();
|
|
42
|
+
assert.equal(rules.length, 1);
|
|
43
|
+
assert.equal(rules[0].scope, "title");
|
|
44
|
+
assert.equal(rules[0].title, "SSH login detected");
|
|
45
|
+
assert.equal(rules[0].reason, "Known Tailscale logins");
|
|
46
|
+
assert.equal(rules[0].suppressCount, 0);
|
|
47
|
+
});
|
|
48
|
+
it("suppresses by title", async () => {
|
|
49
|
+
await store.add({
|
|
50
|
+
scope: "title",
|
|
51
|
+
title: "SSH login from unknown host",
|
|
52
|
+
reason: "Expected",
|
|
53
|
+
expiresAt: null,
|
|
54
|
+
});
|
|
55
|
+
const evt = makeEvent();
|
|
56
|
+
const match = store.isSuppressed(evt);
|
|
57
|
+
assert.ok(match);
|
|
58
|
+
assert.equal(match.reason, "Expected");
|
|
59
|
+
assert.equal(match.suppressCount, 1);
|
|
60
|
+
});
|
|
61
|
+
it("does not suppress non-matching title", async () => {
|
|
62
|
+
await store.add({
|
|
63
|
+
scope: "title",
|
|
64
|
+
title: "sudo command executed",
|
|
65
|
+
reason: "Normal",
|
|
66
|
+
expiresAt: null,
|
|
67
|
+
});
|
|
68
|
+
const evt = makeEvent({ title: "SSH login from unknown host" });
|
|
69
|
+
assert.equal(store.isSuppressed(evt), null);
|
|
70
|
+
});
|
|
71
|
+
it("suppresses by category", async () => {
|
|
72
|
+
await store.add({
|
|
73
|
+
scope: "category",
|
|
74
|
+
category: "ssh_login",
|
|
75
|
+
reason: "All SSH is fine",
|
|
76
|
+
expiresAt: null,
|
|
77
|
+
});
|
|
78
|
+
const evt = makeEvent();
|
|
79
|
+
const match = store.isSuppressed(evt);
|
|
80
|
+
assert.ok(match);
|
|
81
|
+
});
|
|
82
|
+
it("suppresses by field match", async () => {
|
|
83
|
+
await store.add({
|
|
84
|
+
scope: "field",
|
|
85
|
+
field: "user",
|
|
86
|
+
fieldValue: "sunil",
|
|
87
|
+
reason: "Sunil's own sudo",
|
|
88
|
+
expiresAt: null,
|
|
89
|
+
});
|
|
90
|
+
const evt = makeEvent({
|
|
91
|
+
title: "sudo command executed",
|
|
92
|
+
category: "privilege",
|
|
93
|
+
details: { user: "sunil", command: "/usr/bin/ls" },
|
|
94
|
+
});
|
|
95
|
+
assert.ok(store.isSuppressed(evt));
|
|
96
|
+
// Different user should not be suppressed
|
|
97
|
+
const evt2 = makeEvent({
|
|
98
|
+
title: "sudo command executed",
|
|
99
|
+
category: "privilege",
|
|
100
|
+
details: { user: "attacker", command: "/bin/bash" },
|
|
101
|
+
});
|
|
102
|
+
assert.equal(store.isSuppressed(evt2), null);
|
|
103
|
+
});
|
|
104
|
+
it("suppresses by exact match", async () => {
|
|
105
|
+
await store.add({
|
|
106
|
+
scope: "exact",
|
|
107
|
+
title: "SSH login from unknown host",
|
|
108
|
+
description: 'User "root" logged in from unknown host: 203.0.113.42',
|
|
109
|
+
reason: "Known scanner",
|
|
110
|
+
expiresAt: null,
|
|
111
|
+
});
|
|
112
|
+
const evt = makeEvent();
|
|
113
|
+
assert.ok(store.isSuppressed(evt));
|
|
114
|
+
// Same title, different description should not match
|
|
115
|
+
const evt2 = makeEvent({ description: "Different host" });
|
|
116
|
+
assert.equal(store.isSuppressed(evt2), null);
|
|
117
|
+
});
|
|
118
|
+
it("respects expiry", async () => {
|
|
119
|
+
await store.add({
|
|
120
|
+
scope: "title",
|
|
121
|
+
title: "SSH login from unknown host",
|
|
122
|
+
reason: "Temporary",
|
|
123
|
+
expiresAt: Date.now() - 1000, // Already expired
|
|
124
|
+
});
|
|
125
|
+
const evt = makeEvent();
|
|
126
|
+
assert.equal(store.isSuppressed(evt), null);
|
|
127
|
+
});
|
|
128
|
+
it("removes rules", async () => {
|
|
129
|
+
const rule = await store.add({
|
|
130
|
+
scope: "title",
|
|
131
|
+
title: "test",
|
|
132
|
+
reason: "test",
|
|
133
|
+
expiresAt: null,
|
|
134
|
+
});
|
|
135
|
+
assert.equal((await store.list()).length, 1);
|
|
136
|
+
const removed = await store.remove(rule.id);
|
|
137
|
+
assert.equal(removed, true);
|
|
138
|
+
assert.equal((await store.list()).length, 0);
|
|
139
|
+
});
|
|
140
|
+
it("removes returns false for unknown id", async () => {
|
|
141
|
+
const removed = await store.remove("nonexistent");
|
|
142
|
+
assert.equal(removed, false);
|
|
143
|
+
});
|
|
144
|
+
it("cleans up expired rules", async () => {
|
|
145
|
+
await store.add({
|
|
146
|
+
scope: "title",
|
|
147
|
+
title: "old",
|
|
148
|
+
reason: "expired",
|
|
149
|
+
expiresAt: Date.now() - 1000,
|
|
150
|
+
});
|
|
151
|
+
await store.add({
|
|
152
|
+
scope: "title",
|
|
153
|
+
title: "current",
|
|
154
|
+
reason: "active",
|
|
155
|
+
expiresAt: null,
|
|
156
|
+
});
|
|
157
|
+
const cleaned = await store.cleanup();
|
|
158
|
+
assert.equal(cleaned, 1);
|
|
159
|
+
const remaining = await store.list();
|
|
160
|
+
assert.equal(remaining.length, 1);
|
|
161
|
+
assert.equal(remaining[0].title, "current");
|
|
162
|
+
});
|
|
163
|
+
it("persists across instances", async () => {
|
|
164
|
+
await store.add({
|
|
165
|
+
scope: "title",
|
|
166
|
+
title: "persisted",
|
|
167
|
+
reason: "test persistence",
|
|
168
|
+
expiresAt: null,
|
|
169
|
+
});
|
|
170
|
+
// New store instance pointing at same dir
|
|
171
|
+
const store2 = new SuppressionStore(tmpDir);
|
|
172
|
+
const rules = await store2.list();
|
|
173
|
+
assert.equal(rules.length, 1);
|
|
174
|
+
assert.equal(rules[0].title, "persisted");
|
|
175
|
+
});
|
|
176
|
+
it("describes rules in plain English", () => {
|
|
177
|
+
assert.equal(SuppressionStore.describe({ scope: "title", title: "sudo command executed" }), 'All alerts titled "sudo command executed"');
|
|
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: "sunil" }), 'Alerts where user = "sunil"');
|
|
180
|
+
assert.equal(SuppressionStore.describe({ scope: "exact", title: "test" }), 'Exact match: "test" with specific description');
|
|
181
|
+
});
|
|
182
|
+
it("increments suppress count on repeated matches", async () => {
|
|
183
|
+
await store.add({
|
|
184
|
+
scope: "title",
|
|
185
|
+
title: "SSH login from unknown host",
|
|
186
|
+
reason: "Expected",
|
|
187
|
+
expiresAt: null,
|
|
188
|
+
});
|
|
189
|
+
const evt = makeEvent();
|
|
190
|
+
store.isSuppressed(evt);
|
|
191
|
+
store.isSuppressed(evt);
|
|
192
|
+
store.isSuppressed(evt);
|
|
193
|
+
const rules = await store.list();
|
|
194
|
+
assert.equal(rules[0].suppressCount, 3);
|
|
195
|
+
});
|
|
196
|
+
});
|
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 =
|
|
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 (
|
|
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
|
-
//
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
81
|
+
// Check if Tailscale CGNAT range (100.64.0.0/10)
|
|
82
82
|
const octets = host.split(".").map(Number);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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;
|