sshsentry 0.2.0__tar.gz
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.
- sshsentry-0.2.0/LICENSE +21 -0
- sshsentry-0.2.0/MANIFEST.in +5 -0
- sshsentry-0.2.0/PKG-INFO +404 -0
- sshsentry-0.2.0/README.md +382 -0
- sshsentry-0.2.0/pyproject.toml +36 -0
- sshsentry-0.2.0/sample-auth.log +24 -0
- sshsentry-0.2.0/setup.cfg +4 -0
- sshsentry-0.2.0/sshsentry.egg-info/PKG-INFO +404 -0
- sshsentry-0.2.0/sshsentry.egg-info/SOURCES.txt +13 -0
- sshsentry-0.2.0/sshsentry.egg-info/dependency_links.txt +1 -0
- sshsentry-0.2.0/sshsentry.egg-info/entry_points.txt +2 -0
- sshsentry-0.2.0/sshsentry.egg-info/requires.txt +1 -0
- sshsentry-0.2.0/sshsentry.egg-info/top_level.txt +1 -0
- sshsentry-0.2.0/sshsentry.py +1341 -0
- sshsentry-0.2.0/sshsentry.yaml +152 -0
sshsentry-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sshsentry
|
|
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.
|
sshsentry-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshsentry
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Plain-English SSH intrusion alerts for Linux servers (detection only).
|
|
5
|
+
Author-email: sshsentry <sshsentry@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sshsentry/sshsentry
|
|
8
|
+
Project-URL: Source, https://github.com/sshsentry/sshsentry
|
|
9
|
+
Project-URL: Issues, https://github.com/sshsentry/sshsentry/issues
|
|
10
|
+
Keywords: ssh,intrusion-detection,security,auth-log,monitoring
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: PyYAML>=5.1
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# sshsentry
|
|
24
|
+
|
|
25
|
+
A free, open source, single-script intrusion **detection** agent for Linux servers. It watches your SSH auth log (plus `/etc/passwd` and `authorized_keys`) and prints plain-English alerts, optionally to email, Slack, or a webhook. It never blocks or attacks anything; pair it with fail2ban, a firewall, and key-only SSH.
|
|
26
|
+
|
|
27
|
+
## Why this exists
|
|
28
|
+
|
|
29
|
+
If you run one or a few Linux servers with SSH open, someone is probably guessing passwords on them right now, and nobody is reading the logs. Most SSH monitoring tools are enterprise suites: agents, dashboards, a database, and hours of setup. There's very little that's small and free and just watches the log and tells you when something matters.
|
|
30
|
+
|
|
31
|
+
sshsentry is one Python script. It reads your auth log, spots brute force, logins after failures, logins from new IPs or countries, and new users or SSH keys, then explains them in plain English on stdout, email, Slack, or a webhook.
|
|
32
|
+
|
|
33
|
+
It's for indie developers and small businesses. It's not for large fleets or compliance programs. It doesn't block anything (it's not a firewall or fail2ban), and it doesn't see web-app attacks. Optional learn mode can suggest a brute-force threshold from your own traffic.
|
|
34
|
+
|
|
35
|
+
## Try it in 30 seconds
|
|
36
|
+
|
|
37
|
+
No install or root needed, just Python 3.9+ and PyYAML. Replay the bundled sample log against a throwaway database:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pyyaml
|
|
41
|
+
python3 sshsentry.py -c sshsentry.yaml --db /tmp/sshsentry-try.db --replay sample-auth.log --digest-now
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You'll see the alerts (two brute-force attacks, a login after failures, and new-IP logins), followed by the daily digest for that log. Nothing is sent anywhere in `--replay` mode.
|
|
45
|
+
|
|
46
|
+
**Even quicker: the demo page.** Open `demo/index.html` in any browser (no Python, no install, nothing uploaded). It shows the same 6 alerts and digest for the bundled log, and you can paste your own log or change thresholds. It's a JavaScript *port* of the detection rules, escalation and digest, for demo purposes; the Python script is the real tool. GeoIP, the passwd/authorized_keys checks and delivery aren't in the demo. After the CRITICAL alert, the walkthrough plays a **simulated responder** (`sshsentry --respond`): it asks before each step (lock the account, block the IP, rotate the password, replace keys), runs read-only checks and ends with a verdict. Today sshsentry only detects and alerts. The responder is a planned feature, shown only as a simulation in the demo; it is **not yet in the Python tool** and is not included in this release. In the demo nothing runs on any real server. See the Disclaimer below before using --respond. It also has a **savings calculator** that puts admin time saved first and a clearly labeled breach-risk *estimate* second, with every assumption and source listed. Screenshots: `demo/screenshot.png`, `demo/responder.png` and `demo/calculator.png`. To check the demo against the Python tool and the published worked examples, run `python3 demo/verify_demo.py`, `verify_calculator.py`, `verify_walkthrough.py`, `verify_responder.py` and `verify_disclaimer.py` (they need google-chrome), or `python3 tests/run_all.py --demo`.
|
|
47
|
+
|
|
48
|
+
**Live demo:** <https://sshsentry.github.io/sshsentry/> (moving to <https://sshsentry.com> once DNS is set up). The repo publishes `demo/` with a GitHub Actions workflow (`.github/workflows/pages.yml`); see `GITHUB_SETUP.md` for the Pages, custom-domain and DNS steps. It's one static file with no external scripts, so any static host works too.
|
|
49
|
+
|
|
50
|
+
## What it alerts on
|
|
51
|
+
|
|
52
|
+
| Rule | Default | Severity |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Brute force | 5+ failed logins (incl. invalid usernames) from one IP in 10 min | MEDIUM |
|
|
55
|
+
| Login after failures | Successful login from an IP with 5+ failures in the last 60 min | HIGH |
|
|
56
|
+
| Escalation (alerting on) | Successful login from an IP already alerted for brute force (once per alert) | CRITICAL |
|
|
57
|
+
| New country | Successful login from a country never seen for that user (needs GeoLite2) | HIGH |
|
|
58
|
+
| New IP | Successful login from an IP never seen for that user | LOW |
|
|
59
|
+
| New user | A new account in `/etc/passwd` | HIGH |
|
|
60
|
+
| New SSH key | A new line in any watched `authorized_keys` file | HIGH |
|
|
61
|
+
|
|
62
|
+
All thresholds live in `sshsentry.yaml`. Repeat alerts are suppressed for `cooldown_minutes` (default 60).
|
|
63
|
+
|
|
64
|
+
**First run is quiet:** existing users and keys become the baseline, and with `learn_existing_log: true` the existing auth log is read silently to learn which IPs each user normally uses. Use `--approve IP` to whitelist an address.
|
|
65
|
+
|
|
66
|
+
**Brute-force threshold:** `rules.brute_force.failures` within `rules.brute_force.window_minutes` (default **5 failures from one IP within a sliding 10 minutes**). It's the same single threshold for stdout and every alert destination. Learn mode (below) can suggest a value; it never changes it on its own.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
sudo mkdir -p /opt/sshsentry /etc/sshsentry /var/lib/sshsentry
|
|
72
|
+
sudo cp sshsentry.py /opt/sshsentry/
|
|
73
|
+
sudo cp sshsentry.yaml /etc/sshsentry/sshsentry.yaml
|
|
74
|
+
sudo pip install pyyaml # or: sudo apt install python3-yaml / sudo dnf install python3-pyyaml
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Python 3.9+ is required. On Debian/Ubuntu with "externally managed" Python, prefer the distro package `python3-yaml` (and `python3-geoip2`) or a venv.
|
|
78
|
+
|
|
79
|
+
### Optional: country detection
|
|
80
|
+
|
|
81
|
+
1. Create a free MaxMind account: <https://www.maxmind.com/en/geolite2/signup>
|
|
82
|
+
2. Download **GeoLite2-Country** (`.mmdb`), e.g. with `geoipupdate`, to `/usr/share/GeoIP/GeoLite2-Country.mmdb` (or set `geoip.database`).
|
|
83
|
+
3. `sudo pip install geoip2`
|
|
84
|
+
|
|
85
|
+
If either the library or the database is missing, sshsentry prints a one-line note and simply skips country checks.
|
|
86
|
+
|
|
87
|
+
## Run
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sudo python3 /opt/sshsentry/sshsentry.py # tail-follow /var/log/auth.log or /var/log/secure
|
|
91
|
+
sudo python3 /opt/sshsentry/sshsentry.py --once # process new lines since last run, then exit
|
|
92
|
+
sudo python3 /opt/sshsentry/sshsentry.py --approve 198.51.100.23
|
|
93
|
+
sudo python3 /opt/sshsentry/sshsentry.py --show-shared # list everything this install has shared
|
|
94
|
+
sudo python3 /opt/sshsentry/sshsentry.py --forget-me # delete sharing history (+ remote request)
|
|
95
|
+
sudo python3 /opt/sshsentry/sshsentry.py --test-alerts # send a sample alert to each enabled destination
|
|
96
|
+
sudo python3 /opt/sshsentry/sshsentry.py --digests # last 7 daily digests (the "main screen")
|
|
97
|
+
sudo python3 /opt/sshsentry/sshsentry.py --digest 2026-10-17 # one day in full
|
|
98
|
+
sudo python3 /opt/sshsentry/sshsentry.py --export-digests 2026-10-01 2026-10-31 -o oct.zip
|
|
99
|
+
sudo python3 /opt/sshsentry/sshsentry.py --digest-now # preview the last 24 h now (sent if digest.send)
|
|
100
|
+
sudo python3 /opt/sshsentry/sshsentry.py --suggest-threshold # learn-mode threshold suggestion
|
|
101
|
+
sudo python3 /opt/sshsentry/sshsentry.py --accept-threshold # write the suggestion into the config
|
|
102
|
+
sudo python3 /opt/sshsentry/sshsentry.py --verify-chain # check the local digest hash chain
|
|
103
|
+
python3 sshsentry.py --verify-export oct.zip [--expected-head HASH] # check an export (no config needed)
|
|
104
|
+
python3 sshsentry.py -c ./sshsentry.yaml --log /path/to/auth.log --db /tmp/state.db # overrides
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**`--format json`** (default `text`) gives structured output for `--digests`, `--digest`, `--digest-now`, `--suggest-threshold`, `--show-shared`, `--export-digests`, `--verify-chain`, `--verify-export` and `--replay`. Every document has a `"schema"` field (for example `sshsentry.digests/1`, `sshsentry.digest/1`, `sshsentry.replay/1`, `sshsentry.threshold_suggestion/1`, `sshsentry.shared/1`, `sshsentry.export/1`, `sshsentry.verify/1`). The number goes up only when a field is removed or changes meaning; new fields can be added without a bump. Timestamps are UTC ISO-8601.
|
|
108
|
+
|
|
109
|
+
Run as root so it can read the auth log and every user's `authorized_keys`. Log rotation is handled automatically. Lines from both `sshd` and newer `sshd-session` processes are parsed, in classic syslog or ISO-8601 timestamp format.
|
|
110
|
+
|
|
111
|
+
Systems logging only to journald (no auth.log/secure): install rsyslog, or export with `journalctl -u ssh -o short > file` and use `--replay`.
|
|
112
|
+
|
|
113
|
+
## Alerting: email, Slack, webhook (off by default)
|
|
114
|
+
|
|
115
|
+
Every alert always goes to stdout (journald under systemd). To also send alerts elsewhere, set `alerting.enabled: true` and enable one or more destinations. Each destination has its own `enabled` flag and an optional `min_severity` (`LOW` < `MEDIUM` < `HIGH` < `CRITICAL`).
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
alerting:
|
|
119
|
+
enabled: true
|
|
120
|
+
timeout: 5 # seconds per send
|
|
121
|
+
email: {enabled: true, min_severity: HIGH} # SMTP settings: the `email:` block
|
|
122
|
+
slack: {enabled: true, min_severity: MEDIUM, webhook_url_env: SSHSENTRY_SLACK_WEBHOOK}
|
|
123
|
+
webhook: {enabled: false, min_severity: LOW, url: "https://example.org/hook",
|
|
124
|
+
headers: {Authorization: "Bearer change-me"}}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**What gets sent:**
|
|
128
|
+
- **Brute force (MEDIUM):** one short message per IP per `cooldown_minutes`, for example: `Brute force from 203.0.113.45: 5 failed SSH logins in 30 sec; users tried: admin, oracle, root, test; no successful login yet.`
|
|
129
|
+
- **Escalation (CRITICAL):** if an IP that was already alerted for brute force later logs in successfully, sshsentry sends one more alert to all destinations naming the user, the method, and the failure count. It replaces the usual "login after failures" alert for that login. It fires once per brute-force alert, and that state lives in SQLite, so it also works across `--once` runs. For example: `ESCALATION: successful login from 192.0.2.77 after brute force. User 'deploy' logged in via password after 6 failed attempts from that IP ...`
|
|
130
|
+
- All other alerts (new IP or country, new user, new key, and so on) are sent as they appear on stdout, filtered by `min_severity`.
|
|
131
|
+
|
|
132
|
+
**Error handling:** destinations are checked at startup. A bad URL (anything other than `https://`, with `http://` allowed only for localhost), missing SMTP settings, or a username without a password gets a warning and disables *only that destination*. Send failures (timeouts, HTTP errors, SMTP errors) are logged to stderr and never stop monitoring. Webhook URLs are treated as secrets: logs show only the host.
|
|
133
|
+
|
|
134
|
+
**Testing:** `--replay` never sends anything. `sshsentry.py --test-alerts` sends one sample LOW alert to each enabled destination, ignoring `min_severity`, prints the result for each, and exits 1 if any fail.
|
|
135
|
+
|
|
136
|
+
### Email
|
|
137
|
+
|
|
138
|
+
The SMTP settings live in the `email:` block. For backward compatibility, an old config with `email.enabled: true` still sends email by itself. sshsentry uses `smtplib` with STARTTLS (port 587). Keep the password out of the YAML by putting it in the environment variable named by `email.password_env` (default `SSHSENTRY_SMTP_PASSWORD`):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
sudo install -m 600 /dev/null /etc/sshsentry/env
|
|
142
|
+
echo 'SSHSENTRY_SMTP_PASSWORD=your-app-password' | sudo tee /etc/sshsentry/env >/dev/null
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> **Tip:** Right after setup, add your sshsentry sender address (the `from` address in `sshsentry.yaml`) to your email contacts or safe-senders list so alerts don't land in spam or junk. Then run `--test-alerts` to confirm one arrives in your inbox.
|
|
146
|
+
|
|
147
|
+
### Slack setup
|
|
148
|
+
|
|
149
|
+
1. Go to <https://api.slack.com/apps>, click **Create New App** → **From scratch**, name it (for example "sshsentry"), and pick your workspace.
|
|
150
|
+
2. Under **Incoming Webhooks**, switch **Activate Incoming Webhooks** on, click **Add New Webhook to Workspace**, and choose the channel.
|
|
151
|
+
3. Copy the webhook URL (`https://hooks.slack.com/services/...`). It's a secret, so put it in the environment file rather than the YAML:
|
|
152
|
+
`echo 'SSHSENTRY_SLACK_WEBHOOK=https://hooks.slack.com/services/...' | sudo tee -a /etc/sshsentry/env`
|
|
153
|
+
4. Set `alerting.enabled: true` and `alerting.slack.enabled: true`, restart, and run `--test-alerts`.
|
|
154
|
+
|
|
155
|
+
Slack receives `{"text": "[sshsentry] MEDIUM: Brute force from ..."}`. Characters Slack treats as markup (`<`, `>`, `&`) are escaped, so an attacker-chosen username such as `<!channel>` can't ping your channel.
|
|
156
|
+
|
|
157
|
+
### Generic webhook
|
|
158
|
+
|
|
159
|
+
sshsentry sends an HTTPS `POST` with `Content-Type: application/json` and any `headers` you configure. The URL can come from `$SSHSENTRY_WEBHOOK_URL` (`url_env`). Example payload:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{"severity": "MEDIUM", "rule": "brute_force", "ip": "203.0.113.45", "usernames": ["admin", "oracle", "root", "test"],
|
|
163
|
+
"success": false, "timestamp": "2026-10-17T06:10:31Z",
|
|
164
|
+
"message": "Brute force from 203.0.113.45: 5 failed SSH logins in 30 sec; users tried: admin, oracle, root, test; no successful login yet."}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`ip` is `null` and `success` is `null` for alerts with no source IP (new user or key). `timestamp` is the event time in UTC. Note that alerts sent to *your own* destinations can include usernames and IPs; that's different from opt-in sharing (below), which never sends usernames.
|
|
168
|
+
|
|
169
|
+
## Daily digest (off by default)
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
digest: {enabled: true, time: "08:00", send: true, destination: email, send_when_quiet: true}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Recording (always, once enabled):** every day at `time` (server local time), sshsentry summarizes the previous 24 hours and stores the result in SQLite (`daily_digests`: date, structured stats as JSON, rendered text, delivery status, destination, and error). With `save_text: true` (the default) it also writes a plain-text copy to `<text_dir>/YYYY-MM-DD.txt` (default: a `digests/` folder next to the state DB, i.e. `/var/lib/sshsentry/digests/`). If the machine was off, the missed days (up to 7) are generated on the next run, and only the newest is sent.
|
|
176
|
+
|
|
177
|
+
**Delivery (`send`, optional):** the digest goes to one destination (`email`, `slack`, or `webhook`), reusing that destination's settings from `alerting`; neither its own `enabled` flag nor `alerting.enabled` is needed. With `send: false`, digests are still generated and stored. Turning `send` back on delivers new days only, with no backfill; use `--resend DATE` to send an old day. Quiet days send `No incidents today.` unless `send_when_quiet: false`. A failed send is logged to stderr, stored with its error, not retried, and never stops monitoring. Webhook digests use the alert JSON shape with `"rule": "digest"`.
|
|
178
|
+
|
|
179
|
+
**Content:** one short screen, no raw log lines. It shows failed logins and distinct IPs, the most active IP with its attempt count and usernames tried, and successful logins, flagging any that came after failures. It also lists new IPs and the learn-mode suggestion if there is one. The hostname is not included unless you set `host_label`. Sample (from the bundled log):
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
sshsentry daily digest, 2026-10-17
|
|
183
|
+
Period: Fri Oct 16 08:00 to Sat Oct 17 08:00 (server time)
|
|
184
|
+
|
|
185
|
+
Failed logins: 12 from 2 IP address(es).
|
|
186
|
+
Most active: 203.0.113.45, 6 attempts (usernames tried: admin, oracle, root, test).
|
|
187
|
+
Successful logins: 4
|
|
188
|
+
- chris from 198.51.100.23 (2x) [new IP]
|
|
189
|
+
- deploy from 192.0.2.77 [new IP] << after 6 failed attempts from that IP. Check this.
|
|
190
|
+
- chris from 100.64.12.9 [new IP]
|
|
191
|
+
New IPs seen: 3 (198.51.100.23, 192.0.2.77, 100.64.12.9).
|
|
192
|
+
|
|
193
|
+
Integrity chain: 9c41e07ab2d3 (sha256 9c41e07ab2d3…)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**The "main screen":** there's no GUI. `--digests [N]` lists the last N days (default 7):
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
DATE FAILED IPS TOP OFFENDER LOGINS NEW IPS CHAIN DELIVERED
|
|
200
|
+
2026-10-17 12 2 203.0.113.45 (6) 4! 3 9c41e07ab2d3 yes
|
|
201
|
+
2026-10-16 0 0 - 0 0 51d0a8f3c6e2 no: not sent (digest.send: false)
|
|
202
|
+
(! = includes a successful login after failed attempts)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`--digest YYYY-MM-DD` shows one day in full, with its delivery status. `--digest-now` previews the last 24 hours immediately and sends it if `send` is on; it isn't stored and doesn't affect the schedule. `--replay FILE --digest-now` only prints a digest for that file. `--forget-me` never deletes digests; it only removes shared-report history.
|
|
206
|
+
|
|
207
|
+
**Scheduling:** in follow mode the time is checked about every `file_check_seconds` (default 60). With `--once` from a systemd timer or cron, the digest is generated on the first run at or after `time`. Each date is recorded once, so digests are never duplicated. On the day you enable digests, the first one comes at the *next* `time`.
|
|
208
|
+
|
|
209
|
+
### Exporting digests (for records or an auditor)
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
sshsentry.py --export-digests 2026-10-01 2026-10-31 -o sshsentry-oct.zip [--host-label web-prod]
|
|
213
|
+
sha256sum -c sshsentry-oct.zip.sha256
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
This writes a zip containing, for each stored day, both its text (`digests/YYYY-MM-DD.txt`, ending with the chain line) **and** its JSON record (`records/YYYY-MM-DD.json`: date, period, stats, text, `prev_hash`, `chain_hash`). It also contains a `manifest.json` (schema `sshsentry.export_manifest/2`) with per-day SHA-256 of both files, each day's `prev_hash` and `chain_hash`, the final `chain_head`, delivery status, generation time, the sshsentry version, and a host label only if you pass `--host-label`. Dates in the range with no digest are listed in `MISSING-DAYS.txt`. It prints the path and the SHA-256 of the whole zip, and writes `PATH.sha256` in `sha256sum` format. Bad dates or START after END exit with code 2. A range with no digests prints a message and exits 1.
|
|
217
|
+
|
|
218
|
+
**What the hash does and doesn't prove:** it shows the zip hasn't changed *since it was exported*. It does **not** prove the digests are accurate, or that the database or log files weren't altered *before* export, by someone with root on the server for example. To make the hash useful, send it to the auditor separately from the zip (a different channel) or store it somewhere the server can't write to at export time.
|
|
219
|
+
|
|
220
|
+
### Hash chain and verification
|
|
221
|
+
|
|
222
|
+
Each stored digest gets `prev_hash` and `chain_hash = sha256(prev_hash + canonical digest)`. The canonical digest is the compact, sorted-key JSON of `date`, `period_start`, `period_end`, `stats` and `text` (the delivery status is excluded, so `--resend` doesn't change the chain). The first day's `prev_hash` is the genesis value `sha256("sshsentry-genesis-v1")`. That is a **fixed public constant**, not a per-install secret, so anyone can recompute a chain from an export with no access to the server. A secret salt wouldn't add tamper protection, because whoever can edit the database can also read the salt. Upgrading from an older version adds the columns and backfills the chain for existing digests once (a note is printed). The chain covers those days from then on; it can't vouch for anything changed before the upgrade.
|
|
223
|
+
|
|
224
|
+
Every delivered digest and every text copy ends with a line like `Integrity chain: 3f9a0c1d2b4e (sha256 3f9a0c1d…full 64 hex…)`. Turn it off for deliveries with `digest.include_chain_hash: false`; text copies and exports always keep it.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
sshsentry.py --verify-chain # recompute the local DB chain (+ text copies)
|
|
228
|
+
sshsentry.py --verify-export oct.zip # sidecar, every file vs manifest, chain, head
|
|
229
|
+
sshsentry.py --verify-export oct.zip --expected-head 3f9a0c1d2b4e # hash from an emailed digest
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`--verify-export` checks the `.sha256` sidecar if one exists next to the zip. It then checks every file against the manifest and recomputes the chain day by day, so a removed, reordered or edited day is caught. It also checks the head, and it checks `--expected-head` (the full hash or at least 12 hex characters). If that hash belongs to an earlier day rather than the head, the check passes and says which day. Each check prints a `PASS` or `FAIL` line, a failure names the first day that broke, and any failure exits with code 1.
|
|
233
|
+
|
|
234
|
+
**Honest limits:** the chain is **tamper-evident, not tamper-proof**. Someone with root on the server can rewrite every digest *and* recompute the whole chain, and the result will verify on its own. What catches that is a chain hash that **already left the box**, such as the line in yesterday's emailed or Slack digest, an auditor's copy of an earlier export's head, or a hash you wrote down. If the head (or that day's hash) in the export doesn't match, the history was rewritten. So the protection is only as good as the copies stored off the server. Nothing proves the digests were accurate when they were created. Signing digests with a key kept off the server is possible future work; it's not implemented.
|
|
235
|
+
|
|
236
|
+
## Learn mode: threshold suggestion (off by default)
|
|
237
|
+
|
|
238
|
+
```yaml
|
|
239
|
+
learn_mode: {enabled: true, days: 3, percentile: 99, margin: 2, min_threshold: 3, max_threshold: 50}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
For `days` after you enable it:
|
|
243
|
+
- alerts still print to stdout, prefixed `[learning: not sent]`, but nothing is sent to destinations or shared;
|
|
244
|
+
- sshsentry records how many failed logins each IP makes in each brute-force window (SQLite table `learn_windows`).
|
|
245
|
+
|
|
246
|
+
When the period ends, it prints a suggestion once, and alerting resumes with your **manual** `rules.brute_force.failures`. The suggestion is never applied automatically. Run `--suggest-threshold` anytime to see the current suggestion:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
Threshold suggestion (learn mode; brute-force window = 10 min):
|
|
250
|
+
data: 12 failed logins from 2 IP(s) across 2 IP-window(s)
|
|
251
|
+
failures per IP per window: p50=6 p95=6 p99=6 max=6
|
|
252
|
+
suggested rules.brute_force.failures: 8 (p99=6 + margin 2; floor 3, cap 50)
|
|
253
|
+
current manual threshold: 5 (unchanged until you run --accept-threshold)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The suggestion is the chosen percentile of per-IP-per-window counts plus `margin`, clamped to `[min_threshold, max_threshold]`. With no data, it says so and suggests nothing. `--accept-threshold` rewrites only the `failures:` line under `rules: → brute_force:` in your config, keeping comments, and saves a `.bak` copy. If it can't edit safely (for example with a flow-style `{...}` mapping), it prints the exact YAML to paste instead. Restart afterwards.
|
|
257
|
+
|
|
258
|
+
**Caveat:** on an internet-facing server, the recorded failures are mostly real attackers, so a percentile-based number tends to be *higher* than 5. A higher threshold means fewer, later alerts. Treat the suggestion as information, not a target. `--replay FILE` with learn mode enabled prints a suggestion from that file, which is handy for experimenting. The old `learning.days` quiet period is deprecated and now maps onto learn mode.
|
|
259
|
+
|
|
260
|
+
## Opt-in threat sharing (off by default)
|
|
261
|
+
|
|
262
|
+
sshsentry can optionally report **attacking IPs** to a community blocklist endpoint. It is **off by default**; with `sharing.enabled: false` sshsentry never makes a network request for sharing, and behaves exactly as if the feature didn't exist.
|
|
263
|
+
|
|
264
|
+
**Enable:** in `sshsentry.yaml` set
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
sharing:
|
|
268
|
+
enabled: true
|
|
269
|
+
endpoint: "https://example.org/api/report" # must be https:// (http:// only for localhost testing)
|
|
270
|
+
timeout: 5 # seconds
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
then restart (`sudo systemctl restart sshsentry`). **Disable:** set `enabled: false` and restart; nothing more is sent.
|
|
274
|
+
|
|
275
|
+
**What is shared:** only the `brute_force` and `login_after_failures` alerts. Never shared: new-IP/new-country logins (those are usually your own users), new-user and new-SSH-key alerts (no attacker IP), alerts for IPs you approved with `--approve`, private/local attacker addresses (10/8, 172.16/12, 192.168/16, 100.64/10, loopback, link-local, IPv6 ULA), and anything during `--replay`/`--dry-run`. A report is only sent when the alert itself fires, so the alert cool-down also limits reports.
|
|
276
|
+
|
|
277
|
+
**Exact payload** (one HTTPS `POST`, `Content-Type: application/json`, sent with Python's `urllib`):
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{"ip": "203.0.113.45", "rule": "brute_force", "timestamp": "2026-10-17T06:10:00Z", "sshsentry_version": "0.2.0", "install_id": "9e155cb8-6987-4d49-8a8f-ae0b6458db13"}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
- `timestamp`: the attack time from the log, in UTC, rounded **down** to the minute.
|
|
284
|
+
- `install_id`: a random UUID generated on your machine the first time something is shared and stored in the local SQLite DB. It isn't derived from anything about you or your server.
|
|
285
|
+
- **Never sent:** usernames (including ones attackers tried), log lines, hostname, your server's IP, keys, or config. (Requests carry the header `User-Agent: sshsentry/0.2.0`. As with any HTTPS request, the endpoint can see the public IP your server connects from.)
|
|
286
|
+
|
|
287
|
+
Non-`https://` endpoints are refused at startup (sharing is disabled with a warning; monitoring continues). If a report fails (timeout, connection error, HTTP error), the failure is recorded locally and monitoring continues; failed reports are not retried.
|
|
288
|
+
|
|
289
|
+
**`--show-shared`** lists every report this install has recorded (sent or failed): attacker IP, rule, event minute (UTC), when it was sent (local time), and the status. If there are none it prints `Nothing has been shared yet`.
|
|
290
|
+
|
|
291
|
+
**`--forget-me`** deletes all shared-report records from the local DB and prints how many were deleted. It also discards the install ID, so any future reports use a new random one. If `sharing.endpoint` is set (even with sharing now disabled), it then makes a **best-effort** deletion request to the same endpoint:
|
|
292
|
+
|
|
293
|
+
```json
|
|
294
|
+
{"request": "delete", "install_id": "9e155cb8-6987-4d49-8a8f-ae0b6458db13", "sshsentry_version": "0.2.0"}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Local deletion always happens first and doesn't depend on the server. The old install ID is printed so you can ask for deletion by email if the request fails. Use `--forget-me --local-only` to skip the remote request. `--forget-me` doesn't turn sharing off; set `enabled: false` for that.
|
|
298
|
+
|
|
299
|
+
## systemd service
|
|
300
|
+
|
|
301
|
+
`/etc/systemd/system/sshsentry.service`:
|
|
302
|
+
|
|
303
|
+
```ini
|
|
304
|
+
[Unit]
|
|
305
|
+
Description=sshsentry SSH intrusion detection
|
|
306
|
+
After=network-online.target
|
|
307
|
+
|
|
308
|
+
[Service]
|
|
309
|
+
ExecStart=/usr/bin/python3 /opt/sshsentry/sshsentry.py -c /etc/sshsentry/sshsentry.yaml
|
|
310
|
+
EnvironmentFile=-/etc/sshsentry/env
|
|
311
|
+
Restart=on-failure
|
|
312
|
+
RestartSec=10
|
|
313
|
+
|
|
314
|
+
[Install]
|
|
315
|
+
WantedBy=multi-user.target
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
sudo systemctl daemon-reload && sudo systemctl enable --now sshsentry
|
|
320
|
+
journalctl -u sshsentry -f # alerts appear here (stdout)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Prefer a timer? Use `ExecStart=... --once` with `Type=oneshot` and a `sshsentry.timer` with `OnCalendar=minutely`; the saved log offset means no line is processed twice.
|
|
324
|
+
|
|
325
|
+
## Testing (dry run)
|
|
326
|
+
|
|
327
|
+
**Automated test suite:** `python3 tests/run_all.py` runs all the end-to-end tests (it needs PyYAML; add `--demo` to also check the demo page). See [`tests/README.md`](tests/README.md).
|
|
328
|
+
|
|
329
|
+
`--replay FILE` (alias `--dry-run FILE`) processes a log from the start, prints alerts, and never sends email. Use a throwaway state DB so your real baseline is untouched:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
python3 sshsentry.py -c sshsentry.yaml --db /tmp/sshsentry-test.db --replay sample-auth.log
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Expected: two brute-force alerts, one login-after-failures alert, and new-IP alerts. Replay never sends email, Slack/webhook alerts, or sharing reports.
|
|
336
|
+
|
|
337
|
+
**Test-only clock:** `SSHSENTRY_TEST_NOW=<epoch seconds>` makes digest scheduling act as if it were that time, so the test suite can simulate several days in a few seconds. It affects only digest scheduling (not log timestamps or alerts). Don't set it on a real server, because it can generate digests for the wrong dates. (Earlier builds called it `SSHSENTRY_NOW`; that name is no longer read.)
|
|
338
|
+
|
|
339
|
+
To test sharing without a real server, run a local endpoint that prints what it receives, and use `--once` (not `--replay`) on a copy of the sample log:
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
python3 -c '
|
|
343
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
344
|
+
class H(BaseHTTPRequestHandler):
|
|
345
|
+
def do_POST(self):
|
|
346
|
+
print(self.rfile.read(int(self.headers["Content-Length"])).decode(), flush=True)
|
|
347
|
+
self.send_response(200); self.end_headers()
|
|
348
|
+
HTTPServer(("127.0.0.1", 8765), H).serve_forever()' &
|
|
349
|
+
T=$(mktemp -d); : > $T/auth.log
|
|
350
|
+
printf "log_file: $T/auth.log\nlearning: {learn_existing_log: false}\nsharing: {enabled: true, endpoint: \"http://localhost:8765/report\"}\n" > $T/s.yaml
|
|
351
|
+
python3 sshsentry.py -c $T/s.yaml --db $T/s.db --once # start at end of (empty) log
|
|
352
|
+
cat sample-auth.log >> $T/auth.log
|
|
353
|
+
python3 sshsentry.py -c $T/s.yaml --db $T/s.db --once # alerts fire; 3 payloads printed
|
|
354
|
+
python3 sshsentry.py -c $T/s.yaml --db $T/s.db --show-shared
|
|
355
|
+
python3 sshsentry.py -c $T/s.yaml --db $T/s.db --forget-me
|
|
356
|
+
kill %1 # stop the test endpoint
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
To test the user/key rules, point the config at temp files, run once to baseline, change them, and run again:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
T=$(mktemp -d); mkdir -p $T/home/alice/.ssh
|
|
363
|
+
cp /etc/passwd $T/passwd; echo 'ssh-ed25519 AAAAexample alice@laptop' > $T/home/alice/.ssh/authorized_keys
|
|
364
|
+
cat > $T/test.yaml <<EOF2
|
|
365
|
+
rules:
|
|
366
|
+
new_user: {passwd_file: $T/passwd}
|
|
367
|
+
new_ssh_key: {paths: ["$T/home/*/.ssh/authorized_keys"]}
|
|
368
|
+
EOF2
|
|
369
|
+
: > $T/empty.log
|
|
370
|
+
python3 sshsentry.py -c $T/test.yaml --db $T/s.db --replay $T/empty.log # baseline, no alerts
|
|
371
|
+
echo 'backdoor:x:0:0::/tmp:/bin/sh' >> $T/passwd
|
|
372
|
+
echo 'ssh-rsa AAAAexample evil@attacker' >> $T/home/alice/.ssh/authorized_keys
|
|
373
|
+
python3 sshsentry.py -c $T/test.yaml --db $T/s.db --replay $T/empty.log # 2 HIGH alerts
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Community
|
|
377
|
+
|
|
378
|
+
Discussions happen on GitHub Discussions: <https://github.com/sshsentry/sshsentry/discussions>
|
|
379
|
+
- **Configs:** share your thresholds and learn-mode suggestions (form: `.github/DISCUSSION_TEMPLATE/share-config.yml`).
|
|
380
|
+
- **Attack patterns:** describe what you saw and what helped (form: `.github/DISCUSSION_TEMPLATE/attack-pattern.yml`).
|
|
381
|
+
|
|
382
|
+
**Privacy:** before you post, remove your server IPs, hostnames, usernames, email addresses, webhook URLs, passwords, and API tokens. Only share attacker IPs that are public and that you're comfortable publishing.
|
|
383
|
+
|
|
384
|
+
## Contact
|
|
385
|
+
|
|
386
|
+
Email: [sshsentry@gmail.com](mailto:sshsentry@gmail.com). Report security issues privately to sshsentry@gmail.com rather than in a public issue or Discussion.
|
|
387
|
+
|
|
388
|
+
## Limits
|
|
389
|
+
|
|
390
|
+
Detection only, SSH-focused. It won't see attacks that leave no login/account trace (e.g. memory-leak bugs, silent zero-days, kernel rootkits that tamper with logs). Ship logs off-box if tampering is a concern. See `sshsentry-design-v2.md`.
|
|
391
|
+
|
|
392
|
+
## Disclaimer
|
|
393
|
+
|
|
394
|
+
sshsentry helps you notice and respond to suspicious logins. It can't guarantee your server is secure or stop every break-in.
|
|
395
|
+
|
|
396
|
+
The responder runs real, destructive commands on your server: it locks accounts, blocks IPs, and rotates passwords and keys. You're responsible for every step you approve, so read each one before you say yes. When it says no unexplained changes were found, that doesn't prove your server is clean.
|
|
397
|
+
|
|
398
|
+
If an incident involves customer data, legal duties, or anything you can't explain, get a professional. If you have cyber insurance, call your insurer's hotline before you hire anyone.
|
|
399
|
+
|
|
400
|
+
sshsentry is provided as-is under the MIT license, with no warranty of any kind.
|
|
401
|
+
|
|
402
|
+
## License
|
|
403
|
+
|
|
404
|
+
MIT
|