infradrift 1.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serber1990
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,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: infradrift
3
+ Version: 1.1.0
4
+ Summary: Detect drift between a known-good baseline and the current state of a Linux server โ€” packages, ports, users, groups, crons and services.
5
+ Author-email: Serber1990 <serber1990@pm.me>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/serber1990/infradrift
8
+ Project-URL: Repository, https://github.com/serber1990/infradrift
9
+ Project-URL: Issues, https://github.com/serber1990/infradrift/issues
10
+ Project-URL: Changelog, https://github.com/serber1990/infradrift/blob/main/CHANGELOG.md
11
+ Keywords: infrastructure,drift,sysadmin,devops,security,audit,snapshot,linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Topic :: System :: Systems Administration
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: shellcolorize>=1.1.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff>=0.4; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # infradrift
29
+
30
+ [![CI](https://github.com/serber1990/infradrift/actions/workflows/ci.yml/badge.svg)](https://github.com/serber1990/infradrift/actions/workflows/ci.yml)
31
+ [![PyPI version](https://badge.fury.io/py/infradrift.svg)](https://badge.fury.io/py/infradrift)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
33
+
34
+ Detect **infrastructure drift** between a known-good baseline and the current state of any Linux server โ€”
35
+ packages, listening ports, user accounts, privileged groups, cron jobs and systemd services.
36
+
37
+ Take a snapshot when the server is in a good state. Run `check` any time โ€” after a deploy, from cron,
38
+ or during an incident โ€” to see exactly what changed. No agent, no daemon, no dependencies on the server
39
+ beyond Python.
40
+
41
+ ---
42
+
43
+ ## ๐Ÿ“„ Example
44
+
45
+ ```
46
+ โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
47
+ โ•‘ infradrift ยท drift report โ•‘
48
+ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
49
+
50
+ Baseline 2026-05-10T09:00:00+02:00 (prod-01) as root
51
+ Current 2026-05-12T14:22:18+02:00 (prod-01) as root
52
+
53
+
54
+ โ”€โ”€ Ports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
55
+ โ›” + 4444/tcp on all interfaces (nc) โ€” new listening port
56
+ โ›” ~ 6379/tcp on all interfaces (redis-server) โ€” now listening on a new address
57
+
58
+ โ”€โ”€ Users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
59
+ โ›” ~ backup โ€” account changed shell /usr/sbin/nologin โ†’ /bin/bash
60
+ โ›” + deploy โ€” new user uid=1005 shell=/bin/bash
61
+
62
+ โ”€โ”€ Privileged Groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
63
+ โ›” + deploy added to privileged group sudo
64
+
65
+ โ”€โ”€ Cron Jobs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
66
+ โš  + New cron job: @reboot /tmp/.cache/update.sh crontab:www-data
67
+
68
+ โ”€โ”€ Services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
69
+ โš  ~ nginx active โ†’ failed
70
+
71
+ โ”€โ”€ Packages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
72
+ ยท ~ [apt] openssl upgraded 3.0.13-0ubuntu3 โ†’ 3.0.13-0ubuntu3.1
73
+
74
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
75
+ 8 changes detected (5 critical, 2 warnings, 1 info)
76
+ ```
77
+
78
+ ---
79
+
80
+ ## โœจ What it detects
81
+
82
+ | Category | What's tracked |
83
+ |----------|----------------|
84
+ | **Packages** | dpkg (Debian/Ubuntu), rpm (RHEL/Fedora/SUSE), pacman (Arch), pip โ€” installed, removed, upgraded, downgraded |
85
+ | **Ports** | Listening TCP and bound UDP sockets **with their bind address** โ€” a service moving from `127.0.0.1` to `0.0.0.0` is flagged |
86
+ | **Users** | `/etc/passwd` accounts โ€” added, removed, uid/gid/shell/home changes |
87
+ | **Privileged groups** | Membership of `sudo`, `wheel`, `admin`, `docker`, `lxd`, `libvirt`, `adm`, `disk`, `shadow`, `root` |
88
+ | **Cron jobs** | User crontabs, `/etc/crontab`, `/etc/cron.d/`, `/etc/cron.{hourly,daily,weekly,monthly}/`, including `@reboot` entries |
89
+ | **Services** | systemd unit state (active / inactive / failed) and boot enablement (enabled / disabled / masked) |
90
+
91
+ ### Severity levels
92
+
93
+ | Severity | Examples |
94
+ |----------|----------|
95
+ | โ›” **CRITICAL** | New listening port, port exposed on a new address, new user, uid/shell change, user added to a privileged group |
96
+ | โš ๏ธ **WARNING** | Service state or enablement change, new enabled service, cron job added/removed, package removed or downgraded |
97
+ | โ„น๏ธ **INFO** | Package installed or upgraded, new high UDP port (ephemeral range โ€” usually a client socket) |
98
+
99
+ ---
100
+
101
+ ## ๐Ÿ“ฅ Installation
102
+
103
+ ```bash
104
+ pip install infradrift
105
+ ```
106
+
107
+ Run it as **root** for complete results: other users' crontabs and the process names of every socket are
108
+ only visible to root. infradrift warns you when it runs unprivileged.
109
+
110
+ ---
111
+
112
+ ## ๐Ÿ›  Usage
113
+
114
+ ### 1 โ€” Take a baseline snapshot
115
+
116
+ ```bash
117
+ sudo infradrift snapshot
118
+ ```
119
+
120
+ Saved to `/var/lib/infradrift/baseline.json` when run as root, or `~/.config/infradrift/baseline.json` otherwise.
121
+ The file is created with `0600` permissions: it lists users, ports and cron commands.
122
+
123
+ ```bash
124
+ sudo infradrift snapshot --output /opt/baselines/prod-$(date +%F).json
125
+ ```
126
+
127
+ ### 2 โ€” Check for drift
128
+
129
+ ```bash
130
+ sudo infradrift check # terminal report
131
+ sudo infradrift check --format json | jq .summary # JSON on stdout, progress on stderr
132
+ sudo infradrift check -f markdown -o drift-report.md # Markdown file
133
+ sudo infradrift check --baseline /opt/baselines/prod-2026-05-12.json --fail-on critical
134
+ ```
135
+
136
+ ### 3 โ€” Diff any two snapshots
137
+
138
+ ```bash
139
+ infradrift diff before-deploy.json after-deploy.json
140
+ infradrift diff web-01.json web-02.json --format markdown # compare two servers
141
+ ```
142
+
143
+ ---
144
+
145
+ ## ๐Ÿ” Use in cron / CI
146
+
147
+ | Exit code | Meaning |
148
+ |-----------|---------|
149
+ | `0` | No drift at or above `--fail-on` |
150
+ | `1` | Drift detected |
151
+ | `2` | Error (missing or unreadable baseline) |
152
+
153
+ ```cron
154
+ # Every hour: keep a Markdown report only when something critical changed
155
+ 0 * * * * root infradrift check --fail-on critical -o /var/log/infradrift/last.md || cp /var/log/infradrift/last.md /var/log/infradrift/alert-$(date +\%F-\%H).md
156
+ ```
157
+
158
+ ---
159
+
160
+ ## ๐Ÿ“‹ Options
161
+
162
+ ### `infradrift snapshot`
163
+ | Option | Description |
164
+ |--------|-------------|
165
+ | `-o`, `--output FILE` | Where to save the baseline |
166
+
167
+ ### `infradrift check` / `infradrift diff BASELINE CURRENT`
168
+ | Option | Description |
169
+ |--------|-------------|
170
+ | `-b`, `--baseline FILE` | *(check)* Baseline to compare against |
171
+ | `-f`, `--format` | `terminal` (default), `json` or `markdown` |
172
+ | `-o`, `--output FILE` | Write the report to a file (JSON or Markdown) |
173
+ | `--fail-on LEVEL` | `info` (default: any change), `warning`, `critical` or `never` |
174
+
175
+ ---
176
+
177
+ ## ๐Ÿงช Development
178
+
179
+ ```bash
180
+ pip install -e ".[dev]"
181
+ ruff check .
182
+ pytest
183
+ ```
184
+
185
+ See [CHANGELOG.md](CHANGELOG.md) for release notes.
186
+
187
+ ---
188
+
189
+ ## ๐Ÿ“ License
190
+
191
+ MIT โ€” see [LICENSE](LICENSE).
192
+
193
+ ## ๐ŸŒ Connect
194
+
195
+ [![GitHub](https://img.shields.io/badge/GitHub-@serber1990-181717?style=flat-square&logo=github)](https://github.com/serber1990)
@@ -0,0 +1,168 @@
1
+ # infradrift
2
+
3
+ [![CI](https://github.com/serber1990/infradrift/actions/workflows/ci.yml/badge.svg)](https://github.com/serber1990/infradrift/actions/workflows/ci.yml)
4
+ [![PyPI version](https://badge.fury.io/py/infradrift.svg)](https://badge.fury.io/py/infradrift)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
6
+
7
+ Detect **infrastructure drift** between a known-good baseline and the current state of any Linux server โ€”
8
+ packages, listening ports, user accounts, privileged groups, cron jobs and systemd services.
9
+
10
+ Take a snapshot when the server is in a good state. Run `check` any time โ€” after a deploy, from cron,
11
+ or during an incident โ€” to see exactly what changed. No agent, no daemon, no dependencies on the server
12
+ beyond Python.
13
+
14
+ ---
15
+
16
+ ## ๐Ÿ“„ Example
17
+
18
+ ```
19
+ โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
20
+ โ•‘ infradrift ยท drift report โ•‘
21
+ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
22
+
23
+ Baseline 2026-05-10T09:00:00+02:00 (prod-01) as root
24
+ Current 2026-05-12T14:22:18+02:00 (prod-01) as root
25
+
26
+
27
+ โ”€โ”€ Ports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
28
+ โ›” + 4444/tcp on all interfaces (nc) โ€” new listening port
29
+ โ›” ~ 6379/tcp on all interfaces (redis-server) โ€” now listening on a new address
30
+
31
+ โ”€โ”€ Users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
32
+ โ›” ~ backup โ€” account changed shell /usr/sbin/nologin โ†’ /bin/bash
33
+ โ›” + deploy โ€” new user uid=1005 shell=/bin/bash
34
+
35
+ โ”€โ”€ Privileged Groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+ โ›” + deploy added to privileged group sudo
37
+
38
+ โ”€โ”€ Cron Jobs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
39
+ โš  + New cron job: @reboot /tmp/.cache/update.sh crontab:www-data
40
+
41
+ โ”€โ”€ Services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
+ โš  ~ nginx active โ†’ failed
43
+
44
+ โ”€โ”€ Packages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
45
+ ยท ~ [apt] openssl upgraded 3.0.13-0ubuntu3 โ†’ 3.0.13-0ubuntu3.1
46
+
47
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48
+ 8 changes detected (5 critical, 2 warnings, 1 info)
49
+ ```
50
+
51
+ ---
52
+
53
+ ## โœจ What it detects
54
+
55
+ | Category | What's tracked |
56
+ |----------|----------------|
57
+ | **Packages** | dpkg (Debian/Ubuntu), rpm (RHEL/Fedora/SUSE), pacman (Arch), pip โ€” installed, removed, upgraded, downgraded |
58
+ | **Ports** | Listening TCP and bound UDP sockets **with their bind address** โ€” a service moving from `127.0.0.1` to `0.0.0.0` is flagged |
59
+ | **Users** | `/etc/passwd` accounts โ€” added, removed, uid/gid/shell/home changes |
60
+ | **Privileged groups** | Membership of `sudo`, `wheel`, `admin`, `docker`, `lxd`, `libvirt`, `adm`, `disk`, `shadow`, `root` |
61
+ | **Cron jobs** | User crontabs, `/etc/crontab`, `/etc/cron.d/`, `/etc/cron.{hourly,daily,weekly,monthly}/`, including `@reboot` entries |
62
+ | **Services** | systemd unit state (active / inactive / failed) and boot enablement (enabled / disabled / masked) |
63
+
64
+ ### Severity levels
65
+
66
+ | Severity | Examples |
67
+ |----------|----------|
68
+ | โ›” **CRITICAL** | New listening port, port exposed on a new address, new user, uid/shell change, user added to a privileged group |
69
+ | โš ๏ธ **WARNING** | Service state or enablement change, new enabled service, cron job added/removed, package removed or downgraded |
70
+ | โ„น๏ธ **INFO** | Package installed or upgraded, new high UDP port (ephemeral range โ€” usually a client socket) |
71
+
72
+ ---
73
+
74
+ ## ๐Ÿ“ฅ Installation
75
+
76
+ ```bash
77
+ pip install infradrift
78
+ ```
79
+
80
+ Run it as **root** for complete results: other users' crontabs and the process names of every socket are
81
+ only visible to root. infradrift warns you when it runs unprivileged.
82
+
83
+ ---
84
+
85
+ ## ๐Ÿ›  Usage
86
+
87
+ ### 1 โ€” Take a baseline snapshot
88
+
89
+ ```bash
90
+ sudo infradrift snapshot
91
+ ```
92
+
93
+ Saved to `/var/lib/infradrift/baseline.json` when run as root, or `~/.config/infradrift/baseline.json` otherwise.
94
+ The file is created with `0600` permissions: it lists users, ports and cron commands.
95
+
96
+ ```bash
97
+ sudo infradrift snapshot --output /opt/baselines/prod-$(date +%F).json
98
+ ```
99
+
100
+ ### 2 โ€” Check for drift
101
+
102
+ ```bash
103
+ sudo infradrift check # terminal report
104
+ sudo infradrift check --format json | jq .summary # JSON on stdout, progress on stderr
105
+ sudo infradrift check -f markdown -o drift-report.md # Markdown file
106
+ sudo infradrift check --baseline /opt/baselines/prod-2026-05-12.json --fail-on critical
107
+ ```
108
+
109
+ ### 3 โ€” Diff any two snapshots
110
+
111
+ ```bash
112
+ infradrift diff before-deploy.json after-deploy.json
113
+ infradrift diff web-01.json web-02.json --format markdown # compare two servers
114
+ ```
115
+
116
+ ---
117
+
118
+ ## ๐Ÿ” Use in cron / CI
119
+
120
+ | Exit code | Meaning |
121
+ |-----------|---------|
122
+ | `0` | No drift at or above `--fail-on` |
123
+ | `1` | Drift detected |
124
+ | `2` | Error (missing or unreadable baseline) |
125
+
126
+ ```cron
127
+ # Every hour: keep a Markdown report only when something critical changed
128
+ 0 * * * * root infradrift check --fail-on critical -o /var/log/infradrift/last.md || cp /var/log/infradrift/last.md /var/log/infradrift/alert-$(date +\%F-\%H).md
129
+ ```
130
+
131
+ ---
132
+
133
+ ## ๐Ÿ“‹ Options
134
+
135
+ ### `infradrift snapshot`
136
+ | Option | Description |
137
+ |--------|-------------|
138
+ | `-o`, `--output FILE` | Where to save the baseline |
139
+
140
+ ### `infradrift check` / `infradrift diff BASELINE CURRENT`
141
+ | Option | Description |
142
+ |--------|-------------|
143
+ | `-b`, `--baseline FILE` | *(check)* Baseline to compare against |
144
+ | `-f`, `--format` | `terminal` (default), `json` or `markdown` |
145
+ | `-o`, `--output FILE` | Write the report to a file (JSON or Markdown) |
146
+ | `--fail-on LEVEL` | `info` (default: any change), `warning`, `critical` or `never` |
147
+
148
+ ---
149
+
150
+ ## ๐Ÿงช Development
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ ruff check .
155
+ pytest
156
+ ```
157
+
158
+ See [CHANGELOG.md](CHANGELOG.md) for release notes.
159
+
160
+ ---
161
+
162
+ ## ๐Ÿ“ License
163
+
164
+ MIT โ€” see [LICENSE](LICENSE).
165
+
166
+ ## ๐ŸŒ Connect
167
+
168
+ [![GitHub](https://img.shields.io/badge/GitHub-@serber1990-181717?style=flat-square&logo=github)](https://github.com/serber1990)
@@ -0,0 +1,5 @@
1
+ __version__ = "1.1.0"
2
+
3
+ from .cli import main # noqa: E402 (after __version__: cli imports it)
4
+
5
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ infradrift โ€” snapshot a Linux server and detect drift: packages, ports, users,
4
+ privileged groups, cron jobs and systemd services.
5
+
6
+ Exit codes: 0 no drift (at or above --fail-on) 1 drift detected 2 usage or I/O error
7
+ """
8
+ import argparse
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from shellcolorize import Color
14
+
15
+ from . import __version__
16
+ from .diff import diff_snapshots
17
+ from .renderer import render_json, render_markdown, render_terminal
18
+ from .snapshot import StateSnapshot, default_path, take_snapshot
19
+
20
+ EXIT_OK, EXIT_DRIFT, EXIT_ERROR = 0, 1, 2
21
+ FAIL_LEVELS = {'info': 'INFO', 'warning': 'WARNING', 'critical': 'CRITICAL'}
22
+
23
+
24
+ # Progress, warnings and errors go to stderr so stdout only carries the report.
25
+ def _ok(msg: str) -> None: print(f" {Color.GREEN}โœ”{Color.RESET} {msg}", file=sys.stderr)
26
+ def _step(msg: str) -> None: print(f" {Color.CYAN}โ–ถ{Color.RESET} {msg}", file=sys.stderr)
27
+ def _err(msg: str) -> None: print(f" {Color.RED}โœ–{Color.RESET} {msg}", file=sys.stderr)
28
+ def _warn(msg: str) -> None: print(f" {Color.YELLOW}โš {Color.RESET} {msg}", file=sys.stderr)
29
+
30
+
31
+ def _root_hint() -> None:
32
+ if os.geteuid() != 0:
33
+ _warn('Not running as root: other users\' crontabs and some process names are not visible.')
34
+
35
+ # โ”€โ”€ snapshot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+
37
+ def cmd_snapshot(args: argparse.Namespace) -> int:
38
+ output = Path(args.output) if args.output else default_path()
39
+ print(f"\n {Color.CYAN}{Color.BOLD}infradrift ยท snapshot{Color.RESET}\n", file=sys.stderr)
40
+ _root_hint()
41
+ snap = take_snapshot(progress=lambda what: _step(f'Collecting {what}...'))
42
+
43
+ try:
44
+ snap.save(output)
45
+ except OSError as e:
46
+ _err(f'Cannot write {output}: {e.strerror}')
47
+ if os.geteuid() != 0:
48
+ _err('Use --output to choose another location, or run with sudo.')
49
+ return EXIT_ERROR
50
+
51
+ print()
52
+ _ok(f'Baseline saved: {output}')
53
+ managers = ', '.join(f'{m} {len(p)}' for m, p in snap.packages.items()) or 'none'
54
+ print(f' Packages : {sum(len(p) for p in snap.packages.values())} ({managers})')
55
+ print(f' Ports : {len(snap.ports)} listening')
56
+ print(f' Users : {len(snap.users)} accounts, {len(snap.groups)} privileged groups')
57
+ print(f' Crons : {len(snap.crons)} jobs')
58
+ print(f' Services : {len(snap.services)} units')
59
+ print()
60
+ return EXIT_OK
61
+
62
+ # โ”€โ”€ check / diff โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
63
+
64
+ def _load(path: Path):
65
+ try:
66
+ return StateSnapshot.load(path)
67
+ except FileNotFoundError:
68
+ _err(f'Snapshot not found: {path}')
69
+ except PermissionError:
70
+ _err(f'Permission denied: {path} (baselines are only readable by their owner)')
71
+ except (OSError, ValueError, TypeError) as e:
72
+ _err(f'Cannot read snapshot {path}: {e}')
73
+ return None
74
+
75
+
76
+ def _report(report, args: argparse.Namespace) -> int:
77
+ b_user, c_user = report.baseline_meta.get('user'), report.current_meta.get('user')
78
+ if b_user and c_user and b_user != c_user:
79
+ _warn(f'Baseline taken as "{b_user}", current state as "{c_user}": '
80
+ 'some differences may only be visibility differences.')
81
+
82
+ fmt = args.format
83
+ if fmt == 'terminal' and args.output:
84
+ fmt = 'markdown' if str(args.output).endswith('.md') else 'json'
85
+
86
+ if fmt == 'terminal':
87
+ render_terminal(report)
88
+ else:
89
+ text = render_json(report) if fmt == 'json' else render_markdown(report)
90
+ if args.output:
91
+ try:
92
+ Path(args.output).write_text(text + '\n', encoding='utf-8')
93
+ except OSError as e:
94
+ _err(f'Cannot write {args.output}: {e.strerror}')
95
+ return EXIT_ERROR
96
+ _ok(f'Report saved to {args.output}')
97
+ else:
98
+ print(text)
99
+
100
+ if args.fail_on == 'never':
101
+ return EXIT_OK
102
+ return EXIT_DRIFT if report.has_at_least(FAIL_LEVELS[args.fail_on]) else EXIT_OK
103
+
104
+
105
+ def cmd_check(args: argparse.Namespace) -> int:
106
+ baseline_path = Path(args.baseline) if args.baseline else default_path()
107
+ if not baseline_path.exists():
108
+ _err(f'Baseline not found: {baseline_path}')
109
+ _err("Run 'infradrift snapshot' first to create one.")
110
+ return EXIT_ERROR
111
+ baseline = _load(baseline_path)
112
+ if baseline is None:
113
+ return EXIT_ERROR
114
+ _step('Collecting current state...')
115
+ return _report(diff_snapshots(baseline, take_snapshot()), args)
116
+
117
+
118
+ def cmd_diff(args: argparse.Namespace) -> int:
119
+ snap_a, snap_b = _load(Path(args.snapshot_a)), _load(Path(args.snapshot_b))
120
+ if snap_a is None or snap_b is None:
121
+ return EXIT_ERROR
122
+ return _report(diff_snapshots(snap_a, snap_b), args)
123
+
124
+ # โ”€โ”€ main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
125
+
126
+ def _add_report_options(p: argparse.ArgumentParser) -> None:
127
+ p.add_argument('--format', '-f', choices=['terminal', 'json', 'markdown'], default='terminal',
128
+ help='Report format (default: terminal)')
129
+ p.add_argument('--output', '-o', metavar='FILE',
130
+ help='Write the report to FILE (json or markdown; inferred from .md/.json if needed)')
131
+ p.add_argument('--fail-on', choices=['info', 'warning', 'critical', 'never'], default='info',
132
+ help='Lowest severity that makes the exit code 1 (default: info = any change)')
133
+
134
+
135
+ def build_parser() -> argparse.ArgumentParser:
136
+ default = default_path()
137
+ parser = argparse.ArgumentParser(
138
+ prog='infradrift',
139
+ description='Detect drift between a known-good baseline and the current state of a Linux server.',
140
+ epilog='exit codes: 0 no drift ยท 1 drift detected ยท 2 error',
141
+ )
142
+ parser.add_argument('-v', '--version', action='version', version=f'infradrift {__version__}')
143
+ sub = parser.add_subparsers(dest='command', metavar='COMMAND')
144
+
145
+ p_snap = sub.add_parser('snapshot', help='Capture the current state as baseline')
146
+ p_snap.add_argument('--output', '-o', metavar='FILE', help=f'Baseline output path (default: {default})')
147
+
148
+ p_check = sub.add_parser('check', help='Compare the current state against the baseline')
149
+ p_check.add_argument('--baseline', '-b', metavar='FILE',
150
+ help=f'Baseline to compare against (default: {default})')
151
+ _add_report_options(p_check)
152
+
153
+ p_diff = sub.add_parser('diff', help='Compare two snapshot files')
154
+ p_diff.add_argument('snapshot_a', metavar='BASELINE')
155
+ p_diff.add_argument('snapshot_b', metavar='CURRENT')
156
+ _add_report_options(p_diff)
157
+ return parser
158
+
159
+
160
+ def main(argv=None) -> None:
161
+ parser = build_parser()
162
+ args = parser.parse_args(argv)
163
+ Color.auto()
164
+
165
+ if args.command is None:
166
+ parser.print_help()
167
+ sys.exit(EXIT_OK)
168
+
169
+ dispatch = {'snapshot': cmd_snapshot, 'check': cmd_check, 'diff': cmd_diff}
170
+ try:
171
+ sys.exit(dispatch[args.command](args))
172
+ except KeyboardInterrupt:
173
+ _err('Interrupted')
174
+ sys.exit(130)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ main()