honeyfile 0.3.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.
- honeyfile-0.3.0/.gitignore +7 -0
- honeyfile-0.3.0/LICENSE +21 -0
- honeyfile-0.3.0/PKG-INFO +132 -0
- honeyfile-0.3.0/README.md +109 -0
- honeyfile-0.3.0/pyproject.toml +54 -0
- honeyfile-0.3.0/src/honeyfile/__init__.py +3 -0
- honeyfile-0.3.0/src/honeyfile/__main__.py +3 -0
- honeyfile-0.3.0/src/honeyfile/alerts.py +96 -0
- honeyfile-0.3.0/src/honeyfile/cli.py +221 -0
- honeyfile-0.3.0/src/honeyfile/decoys.py +88 -0
- honeyfile-0.3.0/src/honeyfile/inotify.py +213 -0
- honeyfile-0.3.0/src/honeyfile/manifest.py +108 -0
- honeyfile-0.3.0/src/honeyfile/plant.py +109 -0
- honeyfile-0.3.0/src/honeyfile/watch.py +119 -0
- honeyfile-0.3.0/tests/test_alerts.py +95 -0
- honeyfile-0.3.0/tests/test_cli.py +87 -0
- honeyfile-0.3.0/tests/test_inotify.py +80 -0
- honeyfile-0.3.0/tests/test_manifest.py +40 -0
- honeyfile-0.3.0/tests/test_plant.py +80 -0
- honeyfile-0.3.0/tests/test_unplant.py +54 -0
- honeyfile-0.3.0/tests/test_watch.py +87 -0
honeyfile-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thanut Jongteerathanachote
|
|
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.
|
honeyfile-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: honeyfile
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Plant decoy files and get alerted the moment ransomware or an intruder touches them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/winxtxrgit/honeyfile
|
|
6
|
+
Project-URL: Issues, https://github.com/winxtxrgit/honeyfile/issues
|
|
7
|
+
Author: Thanut Jongteerathanachote
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: blue-team,canary,decoy,detection,honeypot,incident-response,ransomware,security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: System :: Monitoring
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# honeyfile
|
|
25
|
+
|
|
26
|
+
[](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml)
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+
[](https://github.com/winxtxrgit/honeyfile/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
**Plant decoy files and get alerted the moment ransomware or an intruder touches them.**
|
|
32
|
+
|
|
33
|
+
honeyfile drops tempting-looking bait — `Payroll_2026_Q4.csv`, `passwords.txt`, `.env`, `backup.sql` — into a directory and records exactly what each one looks like. Then it watches. The instant something encrypts, modifies or deletes a decoy, you get an alert with the file and what happened. It's a tripwire: a normal user never touches these files, so any change is a signal.
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="https://raw.githubusercontent.com/winxtxrgit/honeyfile/main/docs/terminal.png" alt="honeyfile planting three office decoys, reporting them intact, then detecting two encrypted files, one deleted file and a dropped ransom note" width="820">
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Ransomware encrypts files fast, and by the time you notice, it's done. But it has to touch files to encrypt them, and a decoy that no human ever opens is the perfect early warning: one honeyfile changing means something is walking through your directory encrypting everything. The same tripwire catches an intruder browsing a file share for `passwords.txt`.
|
|
42
|
+
|
|
43
|
+
This is a detection tool, the opposite of malware. It never encrypts anything; it watches for something else that does.
|
|
44
|
+
|
|
45
|
+
## Try it in 30 seconds
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pipx install git+https://github.com/winxtxrgit/honeyfile
|
|
49
|
+
|
|
50
|
+
mkdir demo
|
|
51
|
+
honeyfile plant demo --profile office
|
|
52
|
+
honeyfile status demo # OK: all 3 decoys are intact
|
|
53
|
+
|
|
54
|
+
echo "encrypted" > demo/passwords.txt # pretend to be ransomware
|
|
55
|
+
honeyfile watch demo --once # [modified] passwords.txt — contents changed
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
honeyfile plant /srv/share --profile office # plant decoys and record a baseline
|
|
62
|
+
honeyfile plant ./repo --profile dev # .env, id_rsa, deploy_keys.json
|
|
63
|
+
honeyfile status /srv/share # one-shot check, exits 1 if anything changed
|
|
64
|
+
|
|
65
|
+
honeyfile watch /srv/share # keep watching, alert on every change
|
|
66
|
+
honeyfile watch /srv/share --json alerts.jsonl
|
|
67
|
+
honeyfile watch /srv/share --webhook https://hooks.example.com/honeyfile
|
|
68
|
+
honeyfile watch /srv/share --exec "/usr/local/bin/isolate-host.sh"
|
|
69
|
+
honeyfile watch /srv/share --once # for cron; exits 1 if anything changed
|
|
70
|
+
|
|
71
|
+
honeyfile unplant /srv/share # remove the decoys and the baseline
|
|
72
|
+
|
|
73
|
+
# Keep the baseline out of the watched directory (recommended for shares an
|
|
74
|
+
# intruder can browse). Use the same --manifest path for plant, status and watch:
|
|
75
|
+
honeyfile plant /srv/share --manifest /var/lib/honeyfile/share.json
|
|
76
|
+
honeyfile status /srv/share --manifest /var/lib/honeyfile/share.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On Linux, `watch` uses **inotify** by default: it reacts the instant a decoy is touched and — unlike polling — catches a decoy merely being *opened or read*, before anything is changed. Elsewhere it falls back to polling. Force either with `--backend poll` or `--backend inotify`.
|
|
80
|
+
|
|
81
|
+
**Profiles** decide which decoys get planted: `office` (payroll, passwords, a confidential doc), `dev` (`.env`, `id_rsa`, deploy keys), `db` (SQL dumps, a customer export). Every decoy contains only inert placeholder data — the one AWS-shaped key is AWS's own documented example — plus a unique `HONEYFILE-CANARY-…` token you can also search for in logs, email or outbound traffic.
|
|
82
|
+
|
|
83
|
+
## What it detects
|
|
84
|
+
|
|
85
|
+
| Event | Severity | Means |
|
|
86
|
+
| :--- | :---: | :--- |
|
|
87
|
+
| `modified` | high | A decoy's contents changed — ransomware encrypting, or someone editing it |
|
|
88
|
+
| `deleted` | high | A decoy is gone — wiped or moved |
|
|
89
|
+
| `manifest-error` | high | The baseline itself was altered or deleted — someone covering their tracks |
|
|
90
|
+
| `opened` | high | A decoy was opened or read — someone browsing the bait (Linux inotify backend only) |
|
|
91
|
+
| `new-file` | medium | A file that wasn't planted appeared next to the decoys — a ransom note, a dropped tool |
|
|
92
|
+
| `touched` | low | A decoy's timestamp changed but its contents are intact — re-saved or restored |
|
|
93
|
+
|
|
94
|
+
Each decoy is fingerprinted by **sha256 + size**, so a change that keeps the same byte count is still caught. The baseline lives in `.honeyfile.json`, which carries a checksum of its own contents: editing it to hide a change fails the check, and deleting it is reported too. Pass `--manifest PATH` to keep that baseline **outside** the watched directory, so an intruder among the decoys can't read it to learn which files are bait. Planted decoys also get **varied, backdated timestamps** so a directory of them doesn't betray itself by sharing one creation time.
|
|
95
|
+
|
|
96
|
+
## Alerting
|
|
97
|
+
|
|
98
|
+
`watch` prints every alert to stderr (coloured by severity on a terminal) and fans each one out to whatever you configure:
|
|
99
|
+
|
|
100
|
+
- **`--json FILE`** — one JSON object per line: `{"time", "event", "severity", "path", "directory", "detail"}`. Feed it to your log pipeline.
|
|
101
|
+
- **`--webhook URL`** — POST that JSON to Slack, Discord, or your SOAR. A dead endpoint is logged and never stops the watch.
|
|
102
|
+
- **`--exec CMD`** — run a command on each alert, with the event passed as `HONEYFILE_EVENT`, `HONEYFILE_PATH`, `HONEYFILE_SEVERITY` and so on. Use it to isolate a host, kill a session, or page someone. No shell is involved, so a filename from disk is never interpreted.
|
|
103
|
+
|
|
104
|
+
## Good places to plant decoys
|
|
105
|
+
|
|
106
|
+
- **File shares** people rarely open — the first thing ransomware and intruders sweep.
|
|
107
|
+
- **A user's Documents or Desktop** — many ransomware strains start there.
|
|
108
|
+
- **Backup and database directories** — high-value targets.
|
|
109
|
+
- **Web roots and repos** — where a `.env` or `id_rsa` is what an attacker is hunting for.
|
|
110
|
+
|
|
111
|
+
Put decoys where a legitimate user has no reason to go, so every alert is real. honeyfile tells you *something touched the bait*; pair it with `--exec` or your EDR to respond.
|
|
112
|
+
|
|
113
|
+
## Limits, honestly
|
|
114
|
+
|
|
115
|
+
- **Read detection is Linux-only.** On Linux the inotify backend reports a decoy being opened or read (`opened`), the earliest possible signal. On macOS and Windows `watch` polls, which can only compare snapshots: a plain read changes nothing it can see, so there it detects **tampering**, not reads. For OS-level read-auditing everywhere, use `auditd` (Linux) or Windows file-access auditing; honeyfile is the lightweight, portable layer.
|
|
116
|
+
- **Polling is near-real-time, not instant.** Where inotify isn't used, `watch` scans every few seconds, so fast ransomware may encrypt several files between scans — you'll still catch it, within one interval. The inotify backend reacts immediately.
|
|
117
|
+
- Decoys protect themselves and signal an attack; they don't stop it. The response is yours, via `--exec` or your tooling.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python -m venv .venv && . .venv/bin/activate
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest
|
|
125
|
+
ruff check . && ruff format --check .
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
No dependencies outside the Python standard library. Tested on Python 3.10–3.13, Linux/macOS/Windows.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
[MIT](https://github.com/winxtxrgit/honeyfile/blob/main/LICENSE) © Thanut Jongteerathanachote
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# honeyfile
|
|
2
|
+
|
|
3
|
+
[](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
[](https://github.com/winxtxrgit/honeyfile/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
**Plant decoy files and get alerted the moment ransomware or an intruder touches them.**
|
|
9
|
+
|
|
10
|
+
honeyfile drops tempting-looking bait — `Payroll_2026_Q4.csv`, `passwords.txt`, `.env`, `backup.sql` — into a directory and records exactly what each one looks like. Then it watches. The instant something encrypts, modifies or deletes a decoy, you get an alert with the file and what happened. It's a tripwire: a normal user never touches these files, so any change is a signal.
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="https://raw.githubusercontent.com/winxtxrgit/honeyfile/main/docs/terminal.png" alt="honeyfile planting three office decoys, reporting them intact, then detecting two encrypted files, one deleted file and a dropped ransom note" width="820">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
Ransomware encrypts files fast, and by the time you notice, it's done. But it has to touch files to encrypt them, and a decoy that no human ever opens is the perfect early warning: one honeyfile changing means something is walking through your directory encrypting everything. The same tripwire catches an intruder browsing a file share for `passwords.txt`.
|
|
19
|
+
|
|
20
|
+
This is a detection tool, the opposite of malware. It never encrypts anything; it watches for something else that does.
|
|
21
|
+
|
|
22
|
+
## Try it in 30 seconds
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install git+https://github.com/winxtxrgit/honeyfile
|
|
26
|
+
|
|
27
|
+
mkdir demo
|
|
28
|
+
honeyfile plant demo --profile office
|
|
29
|
+
honeyfile status demo # OK: all 3 decoys are intact
|
|
30
|
+
|
|
31
|
+
echo "encrypted" > demo/passwords.txt # pretend to be ransomware
|
|
32
|
+
honeyfile watch demo --once # [modified] passwords.txt — contents changed
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
honeyfile plant /srv/share --profile office # plant decoys and record a baseline
|
|
39
|
+
honeyfile plant ./repo --profile dev # .env, id_rsa, deploy_keys.json
|
|
40
|
+
honeyfile status /srv/share # one-shot check, exits 1 if anything changed
|
|
41
|
+
|
|
42
|
+
honeyfile watch /srv/share # keep watching, alert on every change
|
|
43
|
+
honeyfile watch /srv/share --json alerts.jsonl
|
|
44
|
+
honeyfile watch /srv/share --webhook https://hooks.example.com/honeyfile
|
|
45
|
+
honeyfile watch /srv/share --exec "/usr/local/bin/isolate-host.sh"
|
|
46
|
+
honeyfile watch /srv/share --once # for cron; exits 1 if anything changed
|
|
47
|
+
|
|
48
|
+
honeyfile unplant /srv/share # remove the decoys and the baseline
|
|
49
|
+
|
|
50
|
+
# Keep the baseline out of the watched directory (recommended for shares an
|
|
51
|
+
# intruder can browse). Use the same --manifest path for plant, status and watch:
|
|
52
|
+
honeyfile plant /srv/share --manifest /var/lib/honeyfile/share.json
|
|
53
|
+
honeyfile status /srv/share --manifest /var/lib/honeyfile/share.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
On Linux, `watch` uses **inotify** by default: it reacts the instant a decoy is touched and — unlike polling — catches a decoy merely being *opened or read*, before anything is changed. Elsewhere it falls back to polling. Force either with `--backend poll` or `--backend inotify`.
|
|
57
|
+
|
|
58
|
+
**Profiles** decide which decoys get planted: `office` (payroll, passwords, a confidential doc), `dev` (`.env`, `id_rsa`, deploy keys), `db` (SQL dumps, a customer export). Every decoy contains only inert placeholder data — the one AWS-shaped key is AWS's own documented example — plus a unique `HONEYFILE-CANARY-…` token you can also search for in logs, email or outbound traffic.
|
|
59
|
+
|
|
60
|
+
## What it detects
|
|
61
|
+
|
|
62
|
+
| Event | Severity | Means |
|
|
63
|
+
| :--- | :---: | :--- |
|
|
64
|
+
| `modified` | high | A decoy's contents changed — ransomware encrypting, or someone editing it |
|
|
65
|
+
| `deleted` | high | A decoy is gone — wiped or moved |
|
|
66
|
+
| `manifest-error` | high | The baseline itself was altered or deleted — someone covering their tracks |
|
|
67
|
+
| `opened` | high | A decoy was opened or read — someone browsing the bait (Linux inotify backend only) |
|
|
68
|
+
| `new-file` | medium | A file that wasn't planted appeared next to the decoys — a ransom note, a dropped tool |
|
|
69
|
+
| `touched` | low | A decoy's timestamp changed but its contents are intact — re-saved or restored |
|
|
70
|
+
|
|
71
|
+
Each decoy is fingerprinted by **sha256 + size**, so a change that keeps the same byte count is still caught. The baseline lives in `.honeyfile.json`, which carries a checksum of its own contents: editing it to hide a change fails the check, and deleting it is reported too. Pass `--manifest PATH` to keep that baseline **outside** the watched directory, so an intruder among the decoys can't read it to learn which files are bait. Planted decoys also get **varied, backdated timestamps** so a directory of them doesn't betray itself by sharing one creation time.
|
|
72
|
+
|
|
73
|
+
## Alerting
|
|
74
|
+
|
|
75
|
+
`watch` prints every alert to stderr (coloured by severity on a terminal) and fans each one out to whatever you configure:
|
|
76
|
+
|
|
77
|
+
- **`--json FILE`** — one JSON object per line: `{"time", "event", "severity", "path", "directory", "detail"}`. Feed it to your log pipeline.
|
|
78
|
+
- **`--webhook URL`** — POST that JSON to Slack, Discord, or your SOAR. A dead endpoint is logged and never stops the watch.
|
|
79
|
+
- **`--exec CMD`** — run a command on each alert, with the event passed as `HONEYFILE_EVENT`, `HONEYFILE_PATH`, `HONEYFILE_SEVERITY` and so on. Use it to isolate a host, kill a session, or page someone. No shell is involved, so a filename from disk is never interpreted.
|
|
80
|
+
|
|
81
|
+
## Good places to plant decoys
|
|
82
|
+
|
|
83
|
+
- **File shares** people rarely open — the first thing ransomware and intruders sweep.
|
|
84
|
+
- **A user's Documents or Desktop** — many ransomware strains start there.
|
|
85
|
+
- **Backup and database directories** — high-value targets.
|
|
86
|
+
- **Web roots and repos** — where a `.env` or `id_rsa` is what an attacker is hunting for.
|
|
87
|
+
|
|
88
|
+
Put decoys where a legitimate user has no reason to go, so every alert is real. honeyfile tells you *something touched the bait*; pair it with `--exec` or your EDR to respond.
|
|
89
|
+
|
|
90
|
+
## Limits, honestly
|
|
91
|
+
|
|
92
|
+
- **Read detection is Linux-only.** On Linux the inotify backend reports a decoy being opened or read (`opened`), the earliest possible signal. On macOS and Windows `watch` polls, which can only compare snapshots: a plain read changes nothing it can see, so there it detects **tampering**, not reads. For OS-level read-auditing everywhere, use `auditd` (Linux) or Windows file-access auditing; honeyfile is the lightweight, portable layer.
|
|
93
|
+
- **Polling is near-real-time, not instant.** Where inotify isn't used, `watch` scans every few seconds, so fast ransomware may encrypt several files between scans — you'll still catch it, within one interval. The inotify backend reacts immediately.
|
|
94
|
+
- Decoys protect themselves and signal an attack; they don't stop it. The response is yours, via `--exec` or your tooling.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m venv .venv && . .venv/bin/activate
|
|
100
|
+
pip install -e ".[dev]"
|
|
101
|
+
pytest
|
|
102
|
+
ruff check . && ruff format --check .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
No dependencies outside the Python standard library. Tested on Python 3.10–3.13, Linux/macOS/Windows.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
[MIT](https://github.com/winxtxrgit/honeyfile/blob/main/LICENSE) © Thanut Jongteerathanachote
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# Cap hatchling below 1.28: 1.28+ emits Metadata-Version 2.5, which no released
|
|
3
|
+
# twine yet understands, so `twine check --strict` in release.yml rejects the
|
|
4
|
+
# build. 1.27 emits 2.4 (same PEP 639 License-Expression) and passes. Lift the
|
|
5
|
+
# ceiling once twine supports metadata 2.5.
|
|
6
|
+
requires = ["hatchling>=1.27,<1.28"]
|
|
7
|
+
build-backend = "hatchling.build"
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
name = "honeyfile"
|
|
11
|
+
dynamic = ["version"]
|
|
12
|
+
description = "Plant decoy files and get alerted the moment ransomware or an intruder touches them."
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
authors = [{ name = "Thanut Jongteerathanachote" }]
|
|
18
|
+
keywords = ["security", "ransomware", "honeypot", "canary", "decoy", "blue-team", "detection", "incident-response"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: System :: Monitoring",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
honeyfile = "honeyfile.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/winxtxrgit/honeyfile"
|
|
38
|
+
Issues = "https://github.com/winxtxrgit/honeyfile/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/honeyfile/__init__.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Where an alert goes once it fires: the terminal, a JSON-lines file, a webhook,
|
|
2
|
+
or a command of your own.
|
|
3
|
+
|
|
4
|
+
The webhook and command sinks reach outside the process, so both fail soft: a
|
|
5
|
+
dead endpoint or a broken command must never stop the watch from reporting the
|
|
6
|
+
next event.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from .watch import Alert
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now() -> str:
|
|
24
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_record(alert: Alert, directory: str) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"time": _now(),
|
|
30
|
+
"event": alert.event,
|
|
31
|
+
"severity": alert.severity,
|
|
32
|
+
"path": alert.path,
|
|
33
|
+
"directory": directory,
|
|
34
|
+
"detail": alert.detail,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_COLORS = {True: {90: "1;31", 95: "1;31", 50: "33", 40: "2"}, False: {}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _use_color(stream) -> bool:
|
|
42
|
+
return stream.isatty() and "NO_COLOR" not in os.environ
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_terminal(alert: Alert, color: bool) -> str:
|
|
46
|
+
tag = f"[{alert.event}]"
|
|
47
|
+
line = f"{_now()} {tag:<16} {alert.path} — {alert.detail}"
|
|
48
|
+
code = _COLORS[color].get(alert.severity) if color else None
|
|
49
|
+
return f"\033[{code}m{line}\033[0m" if code else line
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Sinks:
|
|
54
|
+
"""Fan one alert out to every configured destination."""
|
|
55
|
+
|
|
56
|
+
directory: str
|
|
57
|
+
json_path: str | None = None
|
|
58
|
+
webhook: str | None = None
|
|
59
|
+
exec_cmd: list[str] | None = None
|
|
60
|
+
color: bool = False
|
|
61
|
+
|
|
62
|
+
def emit(self, alert: Alert) -> None:
|
|
63
|
+
print(format_terminal(alert, self.color), file=sys.stderr, flush=True)
|
|
64
|
+
record = to_record(alert, self.directory)
|
|
65
|
+
if self.json_path:
|
|
66
|
+
self._write_json(record)
|
|
67
|
+
if self.webhook:
|
|
68
|
+
self._post(record)
|
|
69
|
+
if self.exec_cmd:
|
|
70
|
+
self._run(record)
|
|
71
|
+
|
|
72
|
+
def _write_json(self, record: dict) -> None:
|
|
73
|
+
try:
|
|
74
|
+
with open(self.json_path, "a", encoding="utf-8") as handle:
|
|
75
|
+
handle.write(json.dumps(record) + "\n")
|
|
76
|
+
except OSError as error:
|
|
77
|
+
print(f"honeyfile: cannot write {self.json_path}: {error}", file=sys.stderr)
|
|
78
|
+
|
|
79
|
+
def _post(self, record: dict) -> None:
|
|
80
|
+
body = json.dumps(record).encode()
|
|
81
|
+
request = urllib.request.Request(
|
|
82
|
+
self.webhook, data=body, headers={"Content-Type": "application/json"}
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
urllib.request.urlopen(request, timeout=10).close()
|
|
86
|
+
except (urllib.error.URLError, OSError) as error:
|
|
87
|
+
print(f"honeyfile: webhook failed: {error}", file=sys.stderr)
|
|
88
|
+
|
|
89
|
+
def _run(self, record: dict) -> None:
|
|
90
|
+
# The command gets the event as HONEYFILE_* env vars; no shell, so a path
|
|
91
|
+
# from the filesystem is never interpreted.
|
|
92
|
+
env = {**os.environ, **{f"HONEYFILE_{k.upper()}": str(v) for k, v in record.items()}}
|
|
93
|
+
try:
|
|
94
|
+
subprocess.run(self.exec_cmd, env=env, timeout=30, check=False)
|
|
95
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
96
|
+
print(f"honeyfile: --exec failed: {error}", file=sys.stderr)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Command line: ``honeyfile plant``, ``honeyfile watch``, ``honeyfile status``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, inotify
|
|
11
|
+
from .alerts import Sinks, format_terminal
|
|
12
|
+
from .decoys import PROFILES
|
|
13
|
+
from .manifest import ManifestError, manifest_path, read
|
|
14
|
+
from .plant import PlantError, plant, unplant
|
|
15
|
+
from .watch import monitor, scan
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _add_manifest_arg(p: argparse.ArgumentParser, *, writing: bool) -> None:
|
|
19
|
+
verb = "store" if writing else "read"
|
|
20
|
+
p.add_argument(
|
|
21
|
+
"--manifest",
|
|
22
|
+
type=Path,
|
|
23
|
+
default=None,
|
|
24
|
+
metavar="PATH",
|
|
25
|
+
help=f"{verb} the baseline at PATH instead of .honeyfile.json inside the "
|
|
26
|
+
"directory, so an intruder among the decoys cannot find it",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="honeyfile",
|
|
33
|
+
description="Plant decoy files and get alerted the moment something modifies, "
|
|
34
|
+
"encrypts or deletes them — an early warning for ransomware and intruders.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
37
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
p = sub.add_parser("plant", help="write decoy files and record their baseline")
|
|
40
|
+
p.add_argument("directory", type=Path, help="directory to plant decoys in")
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--profile",
|
|
43
|
+
choices=sorted(PROFILES),
|
|
44
|
+
default="office",
|
|
45
|
+
help="which set of decoys to plant (default: %(default)s)",
|
|
46
|
+
)
|
|
47
|
+
p.add_argument("--force", action="store_true", help="overwrite existing files of the same name")
|
|
48
|
+
_add_manifest_arg(p, writing=True)
|
|
49
|
+
|
|
50
|
+
w = sub.add_parser("watch", help="monitor planted decoys and alert on any change")
|
|
51
|
+
w.add_argument("directory", type=Path)
|
|
52
|
+
w.add_argument(
|
|
53
|
+
"--interval",
|
|
54
|
+
type=_positive,
|
|
55
|
+
default=5.0,
|
|
56
|
+
metavar="SECONDS",
|
|
57
|
+
help="seconds between scans (default: %(default)s)",
|
|
58
|
+
)
|
|
59
|
+
w.add_argument("--once", action="store_true", help="scan once and exit instead of looping")
|
|
60
|
+
w.add_argument(
|
|
61
|
+
"--backend",
|
|
62
|
+
choices=("auto", "poll", "inotify"),
|
|
63
|
+
default="auto",
|
|
64
|
+
help="auto (default) uses inotify on Linux for instant, read-level detection "
|
|
65
|
+
"and falls back to polling elsewhere; poll forces the portable scanner",
|
|
66
|
+
)
|
|
67
|
+
w.add_argument(
|
|
68
|
+
"--no-new-files",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="do not alert on unplanted files appearing in the directory",
|
|
71
|
+
)
|
|
72
|
+
w.add_argument("--json", metavar="FILE", help="append each alert to FILE as JSON lines")
|
|
73
|
+
w.add_argument("--webhook", metavar="URL", help="POST each alert as JSON to URL")
|
|
74
|
+
w.add_argument(
|
|
75
|
+
"--exec",
|
|
76
|
+
dest="exec_cmd",
|
|
77
|
+
metavar="CMD",
|
|
78
|
+
help="run CMD on each alert (shell-style quoting; event passed as HONEYFILE_* env)",
|
|
79
|
+
)
|
|
80
|
+
_add_manifest_arg(w, writing=False)
|
|
81
|
+
|
|
82
|
+
s = sub.add_parser("status", help="show the current state of the decoys and exit")
|
|
83
|
+
s.add_argument("directory", type=Path)
|
|
84
|
+
s.add_argument("--no-new-files", action="store_true")
|
|
85
|
+
_add_manifest_arg(s, writing=False)
|
|
86
|
+
|
|
87
|
+
u = sub.add_parser("unplant", help="remove the planted decoys and their baseline")
|
|
88
|
+
u.add_argument("directory", type=Path, help="directory the decoys were planted in")
|
|
89
|
+
_add_manifest_arg(u, writing=False)
|
|
90
|
+
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _positive(text: str) -> float:
|
|
95
|
+
value = float(text)
|
|
96
|
+
if value <= 0:
|
|
97
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cmd_plant(args) -> int:
|
|
102
|
+
try:
|
|
103
|
+
manifest = plant(args.directory, args.profile, args.force, manifest_path=args.manifest)
|
|
104
|
+
except (PlantError, ValueError, OSError) as error:
|
|
105
|
+
print(f"honeyfile: {error}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
print(f"Planted {len(manifest.entries)} {args.profile} decoys in {args.directory}:")
|
|
108
|
+
for entry in manifest.entries:
|
|
109
|
+
print(f" {entry.name}")
|
|
110
|
+
print(f"Baseline: {manifest_path(args.directory, args.manifest)}")
|
|
111
|
+
watch_cmd = f"honeyfile watch {args.directory}"
|
|
112
|
+
if args.manifest:
|
|
113
|
+
watch_cmd += f" --manifest {args.manifest}"
|
|
114
|
+
print(f"\nNow watch them:\n {watch_cmd}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_status(args) -> int:
|
|
119
|
+
try:
|
|
120
|
+
manifest = read(args.directory, args.manifest)
|
|
121
|
+
except ManifestError as error:
|
|
122
|
+
print(f"honeyfile: {error}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
alerts = scan(args.directory, manifest, detect_new=not args.no_new_files)
|
|
125
|
+
color = sys.stdout.isatty()
|
|
126
|
+
if not alerts:
|
|
127
|
+
print(f"OK: all {len(manifest.entries)} decoys in {args.directory} are intact.")
|
|
128
|
+
return 0
|
|
129
|
+
print(f"{len(alerts)} alert(s) in {args.directory}:")
|
|
130
|
+
for alert in alerts:
|
|
131
|
+
print(" " + format_terminal(alert, color))
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_watch(args) -> int:
|
|
136
|
+
sinks = Sinks(
|
|
137
|
+
directory=str(args.directory),
|
|
138
|
+
json_path=args.json,
|
|
139
|
+
webhook=args.webhook,
|
|
140
|
+
exec_cmd=shlex.split(args.exec_cmd) if args.exec_cmd else None,
|
|
141
|
+
color=sys.stderr.isatty(),
|
|
142
|
+
)
|
|
143
|
+
if args.once:
|
|
144
|
+
try:
|
|
145
|
+
manifest = read(args.directory, args.manifest)
|
|
146
|
+
except ManifestError as error:
|
|
147
|
+
print(f"honeyfile: {error}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
alerts = scan(args.directory, manifest, detect_new=not args.no_new_files)
|
|
150
|
+
for alert in alerts:
|
|
151
|
+
sinks.emit(alert)
|
|
152
|
+
return 1 if alerts else 0
|
|
153
|
+
|
|
154
|
+
use_inotify = args.backend == "inotify" or (args.backend == "auto" and inotify.available())
|
|
155
|
+
if args.backend == "inotify" and not inotify.available():
|
|
156
|
+
print(
|
|
157
|
+
"honeyfile: the inotify backend needs Linux; use --backend poll here",
|
|
158
|
+
file=sys.stderr,
|
|
159
|
+
)
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
if use_inotify:
|
|
163
|
+
print(
|
|
164
|
+
f"Watching {args.directory} with inotify (real-time). Ctrl+C to stop.",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
events = inotify.monitor(
|
|
168
|
+
args.directory,
|
|
169
|
+
detect_new=not args.no_new_files,
|
|
170
|
+
manifest_path=args.manifest,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
print(
|
|
174
|
+
f"Watching {args.directory} every {args.interval:g}s. Ctrl+C to stop.", file=sys.stderr
|
|
175
|
+
)
|
|
176
|
+
events = monitor(
|
|
177
|
+
args.directory,
|
|
178
|
+
args.interval,
|
|
179
|
+
detect_new=not args.no_new_files,
|
|
180
|
+
manifest_path=args.manifest,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
for event in events:
|
|
185
|
+
if isinstance(event, ManifestError):
|
|
186
|
+
print(f"honeyfile: baseline problem: {event}", file=sys.stderr, flush=True)
|
|
187
|
+
else:
|
|
188
|
+
sinks.emit(event)
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
return 130
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _cmd_unplant(args) -> int:
|
|
195
|
+
try:
|
|
196
|
+
removed = unplant(args.directory, args.manifest)
|
|
197
|
+
except (ManifestError, OSError) as error:
|
|
198
|
+
print(f"honeyfile: {error}", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
print(f"Removed {len(removed.deleted)} decoy(s) from {args.directory}:")
|
|
201
|
+
for name in removed.deleted:
|
|
202
|
+
print(f" {name}")
|
|
203
|
+
for name in removed.missing:
|
|
204
|
+
print(f" {name} (already gone)")
|
|
205
|
+
print(f"Removed baseline: {removed.manifest}")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main(argv: list[str] | None = None) -> int:
|
|
210
|
+
args = build_parser().parse_args(argv)
|
|
211
|
+
handlers = {
|
|
212
|
+
"plant": _cmd_plant,
|
|
213
|
+
"watch": _cmd_watch,
|
|
214
|
+
"status": _cmd_status,
|
|
215
|
+
"unplant": _cmd_unplant,
|
|
216
|
+
}
|
|
217
|
+
return handlers[args.command](args)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
raise SystemExit(main())
|