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 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.io).
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.io) installed
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 plugins install /path/to/openclaw-sentinel
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 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
+ });
@@ -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 = 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;