Skip to content

Commit 785bf5f

Browse files
pxivory-maxpxivory-maxclaude
authored
feat: add ssh-login-monitor module boilerplate (#1)
Adds a new community module that monitors SSH authentication logs for brute-force patterns and emits ThreatEvents. Follows the same structure as the existing free-module boilerplate (mod.toml, src/, package.json). Co-authored-by: pxivory-max <pxivory.max@gmail.com> Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
1 parent bb7df53 commit 785bf5f

7 files changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
dist/
3+
*.js.map
4+
.env
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 pxivory-max
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ThreatCrush Module — SSH Login Monitor
2+
3+
A ThreatCrush security module that monitors SSH authentication logs for
4+
suspicious login activity and emits `ThreatEvent`s for brute-force detection.
5+
6+
## What it does
7+
8+
- Tails `/var/log/auth.log` (configurable) for SSH events
9+
- Detects failed login attempts and successful logins
10+
- Emits `high`/`critical` severity events when brute-force patterns are detected
11+
(configurable threshold)
12+
- Emits `info` events for successful SSH logins (useful for audit trails)
13+
- Persists file offset via `ctx.setState` to avoid re-processing on restart
14+
15+
## Configuration
16+
17+
In `mod.toml`:
18+
19+
```toml
20+
[module.config.defaults]
21+
auth_log_path = "/var/log/auth.log"
22+
failed_threshold = 5
23+
poll_interval_seconds = 30
24+
```
25+
26+
- `auth_log_path` — path to the sshd auth log
27+
- `failed_threshold` — number of failed attempts from a single IP in one poll
28+
cycle before escalating to `high` severity
29+
- `poll_interval_seconds` — how often to check for new log entries
30+
31+
## Install
32+
33+
```bash
34+
git clone https://github.com/pxivory-max/threatcrush-ssh-monitor
35+
cd threatcrush-ssh-monitor
36+
pnpm install
37+
pnpm build
38+
```
39+
40+
## License
41+
42+
MIT
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[module]
2+
name = "ssh-login-monitor"
3+
version = "0.1.0"
4+
description = "Monitors auth logs for SSH login attempts and emits ThreatEvents for failed/suspicious logins"
5+
author = "pxivory-max"
6+
license = "MIT"
7+
homepage = "https://github.com/pxivory-max/threatcrush-ssh-monitor"
8+
9+
[module.pricing]
10+
type = "free"
11+
12+
[module.requirements]
13+
threatcrush = ">=0.1.0"
14+
os = ["linux"]
15+
capabilities = ["fs:read"]
16+
17+
[module.config.defaults]
18+
enabled = true
19+
# Path to auth log file
20+
auth_log_path = "/var/log/auth.log"
21+
# Number of failed attempts before escalating severity
22+
failed_threshold = 5
23+
# Poll interval in seconds
24+
poll_interval_seconds = 30
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "threatcrush-module-ssh-monitor",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "ThreatCrush module — monitors SSH login attempts and emits security events",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"scripts": {
10+
"build": "tsc -p tsconfig.json",
11+
"clean": "rm -rf dist"
12+
},
13+
"dependencies": {
14+
"@threatcrush/sdk": "^0.1.0"
15+
},
16+
"devDependencies": {
17+
"@types/node": "^22.0.0",
18+
"typescript": "^5.9.3"
19+
}
20+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* SSH Login Monitor Module — ThreatCrush module.
3+
*
4+
* Tails /var/log/auth.log (configurable) for SSH login events, parses
5+
* failed and successful attempts, and emits ThreatEvents when suspicious
6+
* activity is detected (e.g., brute-force patterns, logins from new IPs).
7+
*/
8+
9+
import { readFile, stat } from 'node:fs/promises';
10+
import type {
11+
ThreatCrushModule,
12+
ModuleContext,
13+
ThreatEvent,
14+
EventSeverity,
15+
} from '@threatcrush/sdk';
16+
17+
interface LoginAttempt {
18+
timestamp: string;
19+
user: string;
20+
ip: string;
21+
success: boolean;
22+
method?: string;
23+
}
24+
25+
export default class SshLoginMonitorModule implements ThreatCrushModule {
26+
name = 'ssh-login-monitor';
27+
version = '0.1.0';
28+
description = 'Monitors SSH auth logs for failed/suspicious login attempts';
29+
30+
private ctx!: ModuleContext;
31+
private timer: NodeJS.Timeout | null = null;
32+
private running = false;
33+
34+
async init(ctx: ModuleContext): Promise<void> {
35+
this.ctx = ctx;
36+
this.ctx.logger.info('[%s] initialized', this.name);
37+
}
38+
39+
async start(): Promise<void> {
40+
const intervalSec =
41+
(this.ctx.config.poll_interval_seconds as number | undefined) ?? 30;
42+
this.ctx.logger.info(
43+
'[%s] starting auth log monitor (every %ds)',
44+
this.name,
45+
intervalSec,
46+
);
47+
this.running = true;
48+
void this.tick();
49+
this.timer = setInterval(() => void this.tick(), intervalSec * 1000);
50+
}
51+
52+
async stop(): Promise<void> {
53+
this.running = false;
54+
if (this.timer) {
55+
clearInterval(this.timer);
56+
this.timer = null;
57+
}
58+
this.ctx.logger.info('[%s] stopped', this.name);
59+
}
60+
61+
private async tick(): Promise<void> {
62+
if (!this.running) return;
63+
64+
const logPath =
65+
(this.ctx.config.auth_log_path as string | undefined) ??
66+
'/var/log/auth.log';
67+
68+
let fileSize: number;
69+
try {
70+
const s = await stat(logPath);
71+
fileSize = s.size;
72+
} catch (err) {
73+
this.ctx.logger.error('[%s] cannot stat %s: %s', this.name, logPath, String(err));
74+
return;
75+
}
76+
77+
const lastOffset = (this.ctx.getState('last_offset') as number) ?? 0;
78+
79+
// If file was rotated (smaller than last offset), reset
80+
const readFrom = fileSize < lastOffset ? 0 : lastOffset;
81+
82+
if (readFrom >= fileSize) return; // no new data
83+
84+
let content: string;
85+
try {
86+
const buf = await readFile(logPath, { encoding: 'utf8' });
87+
content = buf.slice(readFrom);
88+
} catch (err) {
89+
this.ctx.logger.error('[%s] read error: %s', this.name, String(err));
90+
return;
91+
}
92+
93+
const attempts = this.parseAuthLog(content);
94+
const failedByIp = new Map<string, number>();
95+
96+
const threshold =
97+
(this.ctx.config.failed_threshold as number | undefined) ?? 5;
98+
99+
for (const attempt of attempts) {
100+
if (!attempt.success) {
101+
const count = (failedByIp.get(attempt.ip) ?? 0) + 1;
102+
failedByIp.set(attempt.ip, count);
103+
}
104+
105+
// Emit individual events for successful logins (potential compromise)
106+
if (attempt.success) {
107+
const event: ThreatEvent = {
108+
timestamp: new Date(),
109+
module: this.name,
110+
category: 'auth',
111+
severity: 'info',
112+
message: `SSH login success: ${attempt.user}@${attempt.ip}`,
113+
details: {
114+
user: attempt.user,
115+
ip: attempt.ip,
116+
method: attempt.method,
117+
},
118+
};
119+
this.ctx.emit(event);
120+
}
121+
}
122+
123+
// Emit high-severity events for brute-force patterns
124+
for (const [ip, count] of failedByIp) {
125+
if (count >= threshold) {
126+
const severity: EventSeverity = count >= threshold * 2 ? 'critical' : 'high';
127+
const event: ThreatEvent = {
128+
timestamp: new Date(),
129+
module: this.name,
130+
category: 'auth',
131+
severity,
132+
message: `SSH brute-force detected: ${count} failed attempts from ${ip}`,
133+
details: {
134+
ip,
135+
failed_count: count,
136+
threshold,
137+
},
138+
};
139+
this.ctx.emit(event);
140+
}
141+
}
142+
143+
this.ctx.setState('last_offset', fileSize);
144+
this.ctx.logger.info(
145+
'[%s] processed %d bytes, found %d attempts',
146+
this.name,
147+
fileSize - readFrom,
148+
attempts.length,
149+
);
150+
}
151+
152+
private parseAuthLog(content: string): LoginAttempt[] {
153+
const attempts: LoginAttempt[] = [];
154+
155+
for (const line of content.split('\n')) {
156+
// Failed password
157+
const failedMatch = line.match(
158+
/^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+sshd\[\d+\]:\s+Failed password for (?:invalid user )?(\S+) from ([\d.]+)/,
159+
);
160+
if (failedMatch) {
161+
attempts.push({
162+
timestamp: failedMatch[1],
163+
user: failedMatch[2],
164+
ip: failedMatch[3],
165+
success: false,
166+
});
167+
continue;
168+
}
169+
170+
// Accepted login
171+
const acceptMatch = line.match(
172+
/^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+sshd\[\d+\]:\s+Accepted (\S+) for (\S+) from ([\d.]+)/,
173+
);
174+
if (acceptMatch) {
175+
attempts.push({
176+
timestamp: acceptMatch[1],
177+
user: acceptMatch[3],
178+
ip: acceptMatch[4],
179+
success: true,
180+
method: acceptMatch[2],
181+
});
182+
continue;
183+
}
184+
}
185+
186+
return attempts;
187+
}
188+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"outDir": "dist",
7+
"rootDir": "src",
8+
"declaration": true,
9+
"strict": true,
10+
"esModuleInterop": true,
11+
"skipLibCheck": true
12+
},
13+
"include": ["src/**/*.ts"]
14+
}

0 commit comments

Comments
 (0)