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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -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.
@@ -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
+ [![CI](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml/badge.svg)](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml)
27
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-0e75b6)
28
+ ![No dependencies](https://img.shields.io/badge/dependencies-none-2ea44f)
29
+ [![MIT License](https://img.shields.io/badge/license-MIT-0e75b6)](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
+ [![CI](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml/badge.svg)](https://github.com/winxtxrgit/honeyfile/actions/workflows/ci.yml)
4
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-0e75b6)
5
+ ![No dependencies](https://img.shields.io/badge/dependencies-none-2ea44f)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-0e75b6)](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,3 @@
1
+ """Plant decoy files and get alerted when ransomware or an intruder touches them."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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())