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