backuplint 1.0.0__py3-none-any.whl

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.
Files changed (106) hide show
  1. backuplint/__init__.py +3 -0
  2. backuplint/__main__.py +6 -0
  3. backuplint/audit.py +293 -0
  4. backuplint/classify.py +109 -0
  5. backuplint/cli.py +1444 -0
  6. backuplint/compose.py +215 -0
  7. backuplint/config.py +531 -0
  8. backuplint/coverage.py +386 -0
  9. backuplint/database.py +39 -0
  10. backuplint/engine/__init__.py +22 -0
  11. backuplint/engine/base.py +43 -0
  12. backuplint/engine/factory.py +56 -0
  13. backuplint/engine/restic_engine.py +139 -0
  14. backuplint/engine/types.py +43 -0
  15. backuplint/errors.py +11 -0
  16. backuplint/events.py +253 -0
  17. backuplint/fleet/__init__.py +19 -0
  18. backuplint/fleet/agent.py +515 -0
  19. backuplint/fleet/capabilities.py +266 -0
  20. backuplint/fleet/certs.py +346 -0
  21. backuplint/fleet/compat.py +104 -0
  22. backuplint/fleet/controller.py +784 -0
  23. backuplint/fleet/controller_store.py +1476 -0
  24. backuplint/fleet/dashboard/__init__.py +14 -0
  25. backuplint/fleet/dashboard/assurance.py +478 -0
  26. backuplint/fleet/dashboard/auth.py +215 -0
  27. backuplint/fleet/dashboard/config.py +31 -0
  28. backuplint/fleet/dashboard/format.py +173 -0
  29. backuplint/fleet/dashboard/http.py +562 -0
  30. backuplint/fleet/dashboard/query.py +1288 -0
  31. backuplint/fleet/dashboard/static/logo-mark-64.png +0 -0
  32. backuplint/fleet/dashboard/static/logo-mark-white-64.png +0 -0
  33. backuplint/fleet/dashboard/static/logo-mark.svg +4 -0
  34. backuplint/fleet/dashboard/ui.py +1583 -0
  35. backuplint/fleet/delivery.py +260 -0
  36. backuplint/fleet/policy_apply.py +346 -0
  37. backuplint/fleet/policy_store.py +1199 -0
  38. backuplint/fleet/presence.py +44 -0
  39. backuplint/fleet/protocol.py +178 -0
  40. backuplint/fleet/provisioning.py +114 -0
  41. backuplint/fleet/queue_policy.py +319 -0
  42. backuplint/fleet/telemetry.py +264 -0
  43. backuplint/fleet_config.py +44 -0
  44. backuplint/install/__init__.py +50 -0
  45. backuplint/install/deployment.py +29 -0
  46. backuplint/install/detect.py +162 -0
  47. backuplint/install/diagnostics.py +132 -0
  48. backuplint/install/distro.py +85 -0
  49. backuplint/install/executor.py +580 -0
  50. backuplint/install/features.py +231 -0
  51. backuplint/install/layout.py +296 -0
  52. backuplint/install/manifest.py +137 -0
  53. backuplint/install/packages.py +165 -0
  54. backuplint/install/plan.py +226 -0
  55. backuplint/install/profile.py +275 -0
  56. backuplint/install/roles.py +99 -0
  57. backuplint/install/support.py +86 -0
  58. backuplint/install/systemd_units.py +148 -0
  59. backuplint/install/wizard.py +143 -0
  60. backuplint/integrity_state.py +206 -0
  61. backuplint/models.py +45 -0
  62. backuplint/paths.py +74 -0
  63. backuplint/policy/__init__.py +27 -0
  64. backuplint/policy/cli.py +400 -0
  65. backuplint/policy/errors.py +7 -0
  66. backuplint/policy/gitops.py +89 -0
  67. backuplint/policy/resolve.py +79 -0
  68. backuplint/policy/rollout.py +87 -0
  69. backuplint/policy/schema.py +213 -0
  70. backuplint/policy/settings.py +220 -0
  71. backuplint/process.py +44 -0
  72. backuplint/reporting.py +387 -0
  73. backuplint/restic.py +494 -0
  74. backuplint/restic_errors.py +175 -0
  75. backuplint/restore_dest.py +256 -0
  76. backuplint/restore_verify.py +416 -0
  77. backuplint/schedule_config.py +181 -0
  78. backuplint/schedule_lock.py +96 -0
  79. backuplint/schedule_store.py +416 -0
  80. backuplint/scheduler.py +591 -0
  81. backuplint/secrets/__init__.py +65 -0
  82. backuplint/secrets/errors.py +35 -0
  83. backuplint/secrets/file_checks.py +193 -0
  84. backuplint/secrets/ref.py +190 -0
  85. backuplint/secrets/resolve.py +146 -0
  86. backuplint/secrets/value.py +53 -0
  87. backuplint/siem/__init__.py +35 -0
  88. backuplint/siem/config.py +375 -0
  89. backuplint/siem/event.py +571 -0
  90. backuplint/siem/exporter.py +224 -0
  91. backuplint/siem/queue.py +602 -0
  92. backuplint/siem/runtime.py +241 -0
  93. backuplint/siem/telemetry.py +129 -0
  94. backuplint/siem/transport/__init__.py +6 -0
  95. backuplint/siem/transport/base.py +78 -0
  96. backuplint/siem/transport/https_json.py +186 -0
  97. backuplint/sqlite_backup.py +530 -0
  98. backuplint/sqlite_util.py +51 -0
  99. backuplint/timeutil.py +100 -0
  100. backuplint/volumes.py +43 -0
  101. backuplint-1.0.0.dist-info/METADATA +318 -0
  102. backuplint-1.0.0.dist-info/RECORD +106 -0
  103. backuplint-1.0.0.dist-info/WHEEL +5 -0
  104. backuplint-1.0.0.dist-info/entry_points.txt +2 -0
  105. backuplint-1.0.0.dist-info/licenses/LICENSE +21 -0
  106. backuplint-1.0.0.dist-info/top_level.txt +1 -0
backuplint/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """BackupLint: detect unprotected Docker Compose persistent data."""
2
+
3
+ __version__ = "1.0.0"
backuplint/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m backuplint``."""
2
+
3
+ from backuplint.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
backuplint/audit.py ADDED
@@ -0,0 +1,293 @@
1
+ """Application orchestration for a BackupLint scan (CLI-independent)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from backuplint.compose import discover_mounts
9
+ from backuplint.config import (
10
+ BackupLintConfig,
11
+ ConfigError,
12
+ IntegrityConfig,
13
+ IntegrityMode,
14
+ RestoreVerificationMode,
15
+ default_config_path,
16
+ load_config,
17
+ parse_integrity_mode,
18
+ parse_restore_verification_mode,
19
+ )
20
+ from backuplint.coverage import (
21
+ CoverageFinding,
22
+ CoverageStatus,
23
+ evaluate_coverage,
24
+ evaluate_coverage_restic,
25
+ )
26
+ from backuplint.engine import BackupEngineError, get_backup_engine
27
+ from backuplint.integrity_state import (
28
+ IntegrityStateError,
29
+ is_integrity_success_stale,
30
+ last_success_for_repository,
31
+ load_integrity_state,
32
+ record_integrity_success,
33
+ )
34
+ from backuplint.models import ServiceMounts
35
+ from backuplint.reporting import AuditSummary, summarize_findings
36
+ from backuplint.restic import (
37
+ IntegrityCheckResult,
38
+ IntegrityStatus,
39
+ ResticError,
40
+ )
41
+ from backuplint.restore_verify import (
42
+ RestoreVerificationResult,
43
+ RestoreVerificationStatus,
44
+ verify_restore,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class AuditOutcome:
50
+ """Result of a full audit orchestration pass."""
51
+
52
+ findings: list[CoverageFinding]
53
+ summary: AuditSummary
54
+ integrity: IntegrityCheckResult | None
55
+ restore: RestoreVerificationResult | None
56
+
57
+
58
+ def resolve_integrity_mode(
59
+ *,
60
+ configured: IntegrityMode,
61
+ cli_override: str | None,
62
+ restic_configured: bool,
63
+ ) -> IntegrityMode:
64
+ """CLI option overrides config; config overrides the safe default (off)."""
65
+ if cli_override is not None:
66
+ mode = parse_integrity_mode(cli_override)
67
+ else:
68
+ mode = configured
69
+ if mode is not IntegrityMode.OFF and not restic_configured:
70
+ raise ConfigError(
71
+ "Integrity checking requires a 'restic' configuration section."
72
+ )
73
+ return mode
74
+
75
+
76
+ def resolve_restore_mode(
77
+ *,
78
+ configured: RestoreVerificationMode,
79
+ cli_override: str | None,
80
+ restic_configured: bool,
81
+ ) -> RestoreVerificationMode:
82
+ """CLI option overrides config; config overrides the safe default (off)."""
83
+ if cli_override is not None:
84
+ mode = parse_restore_verification_mode(cli_override)
85
+ else:
86
+ mode = configured
87
+ if mode is not RestoreVerificationMode.OFF and not restic_configured:
88
+ raise ConfigError(
89
+ "Restore verification requires a 'restic' configuration section."
90
+ )
91
+ return mode
92
+
93
+
94
+ def relevant_restore_paths(findings: list[CoverageFinding]) -> tuple[str, ...]:
95
+ """Host paths eligible for restore verification (historical selection rules)."""
96
+ paths: list[str] = []
97
+ seen: set[str] = set()
98
+ for finding in findings:
99
+ if finding.status not in {CoverageStatus.PROTECTED, CoverageStatus.STALE}:
100
+ continue
101
+ if not finding.host_path:
102
+ continue
103
+ if finding.host_path in seen:
104
+ continue
105
+ seen.add(finding.host_path)
106
+ paths.append(finding.host_path)
107
+ return tuple(paths)
108
+
109
+
110
+ def stale_integrity_from_state(
111
+ *,
112
+ repository: str,
113
+ integrity_cfg: IntegrityConfig,
114
+ ) -> IntegrityCheckResult | None:
115
+ """When live checks are off, optionally WARN from remembered success age."""
116
+ if integrity_cfg.max_age is None or integrity_cfg.state_file is None:
117
+ return None
118
+ try:
119
+ records = load_integrity_state(integrity_cfg.state_file)
120
+ except IntegrityStateError as exc:
121
+ raise ResticError(exc.message) from exc
122
+ record = last_success_for_repository(records, repository)
123
+ if is_integrity_success_stale(record, max_age=integrity_cfg.max_age):
124
+ return IntegrityCheckResult(
125
+ mode=IntegrityMode.OFF,
126
+ status=IntegrityStatus.STALE,
127
+ duration_seconds=0.0,
128
+ message=(
129
+ "last successful integrity verification is older than configured max_age"
130
+ ),
131
+ requested=False,
132
+ )
133
+ return IntegrityCheckResult(
134
+ mode=record.mode if record is not None else IntegrityMode.OFF,
135
+ status=IntegrityStatus.NOT_REQUESTED,
136
+ duration_seconds=0.0,
137
+ message="Integrity verification was not requested.",
138
+ requested=False,
139
+ )
140
+
141
+
142
+ def run_audit(
143
+ compose_file: Path,
144
+ *,
145
+ config_path: Path | None = None,
146
+ integrity_cli: str | None = None,
147
+ restore_verify_cli: str | None = None,
148
+ ) -> AuditOutcome:
149
+ """Discover mounts, evaluate coverage/integrity/restore, return summary."""
150
+ resolved_config = (
151
+ config_path if config_path is not None else default_config_path(compose_file)
152
+ )
153
+ backup_config = load_config(resolved_config)
154
+ services = discover_mounts(
155
+ compose_file, extra_files=backup_config.compose_files
156
+ )
157
+ return run_audit_with_config(
158
+ services=services,
159
+ backup_config=backup_config,
160
+ integrity_cli=integrity_cli,
161
+ restore_verify_cli=restore_verify_cli,
162
+ )
163
+
164
+
165
+ def run_audit_with_config(
166
+ *,
167
+ services: list[ServiceMounts],
168
+ backup_config: BackupLintConfig,
169
+ integrity_cli: str | None = None,
170
+ restore_verify_cli: str | None = None,
171
+ ) -> AuditOutcome:
172
+ configured_mode = (
173
+ backup_config.restic.integrity.mode
174
+ if backup_config.restic is not None
175
+ else IntegrityMode.OFF
176
+ )
177
+ integrity_mode = resolve_integrity_mode(
178
+ configured=configured_mode,
179
+ cli_override=integrity_cli,
180
+ restic_configured=backup_config.restic is not None,
181
+ )
182
+ configured_restore = (
183
+ backup_config.restic.restore_verification.mode
184
+ if backup_config.restic is not None
185
+ else RestoreVerificationMode.OFF
186
+ )
187
+ restore_mode = resolve_restore_mode(
188
+ configured=configured_restore,
189
+ cli_override=restore_verify_cli,
190
+ restic_configured=backup_config.restic is not None,
191
+ )
192
+
193
+ integrity_result: IntegrityCheckResult | None = None
194
+ restore_result: RestoreVerificationResult | None = None
195
+
196
+ if backup_config.restic is not None:
197
+ engine = get_backup_engine(backup_config.restic)
198
+ try:
199
+ snapshots = engine.list_snapshots()
200
+ except BackupEngineError as exc:
201
+ # Preserve historical CLI/scheduler raise semantics (ResticError).
202
+ raise ResticError(exc.message) from exc
203
+ findings = evaluate_coverage_restic(
204
+ services,
205
+ snapshots,
206
+ max_backup_age=backup_config.max_backup_age,
207
+ )
208
+ integrity_cfg = backup_config.restic.integrity
209
+ if integrity_mode is not IntegrityMode.OFF:
210
+ # Explicit live check — never silently downgrade deep to standard.
211
+ try:
212
+ integrity_result = engine.integrity_check(integrity_mode)
213
+ except BackupEngineError as exc:
214
+ raise ResticError(exc.message) from exc
215
+ if integrity_result.status is IntegrityStatus.ERROR:
216
+ raise ResticError(integrity_result.message)
217
+ if (
218
+ integrity_result.status is IntegrityStatus.PASSED
219
+ and integrity_cfg.state_file is not None
220
+ ):
221
+ try:
222
+ record_integrity_success(
223
+ integrity_cfg.state_file,
224
+ repository=backup_config.restic.repository,
225
+ mode=integrity_result.mode,
226
+ checked_at=integrity_result.checked_at,
227
+ restic_version=integrity_result.restic_version,
228
+ )
229
+ except IntegrityStateError as exc:
230
+ raise ResticError(exc.message) from exc
231
+ else:
232
+ integrity_result = stale_integrity_from_state(
233
+ repository=backup_config.restic.repository,
234
+ integrity_cfg=integrity_cfg,
235
+ )
236
+ if integrity_result is None:
237
+ integrity_result = IntegrityCheckResult(
238
+ mode=IntegrityMode.OFF,
239
+ status=IntegrityStatus.NOT_REQUESTED,
240
+ duration_seconds=0.0,
241
+ message="Integrity verification was not requested.",
242
+ requested=False,
243
+ )
244
+
245
+ restore_cfg = backup_config.restic.restore_verification
246
+ if restore_mode is not RestoreVerificationMode.OFF:
247
+ paths = relevant_restore_paths(findings)
248
+ live_binds = tuple(Path(p) for p in paths)
249
+ timeout_seconds = (
250
+ restore_cfg.timeout.total_seconds()
251
+ if restore_cfg.timeout is not None
252
+ else None
253
+ )
254
+ restore_result = verify_restore(
255
+ mode=restore_mode,
256
+ repository=backup_config.restic.repository,
257
+ snapshots=snapshots,
258
+ relevant_paths=paths,
259
+ password_file=backup_config.restic.password_file,
260
+ password_ref=backup_config.restic.password,
261
+ timeout=timeout_seconds,
262
+ expected_paths=restore_cfg.expected_paths,
263
+ live_bind_paths=live_binds,
264
+ )
265
+ if restore_result.status is RestoreVerificationStatus.ERROR:
266
+ raise ResticError(restore_result.message)
267
+ else:
268
+ restore_result = RestoreVerificationResult(
269
+ mode=RestoreVerificationMode.OFF,
270
+ status=RestoreVerificationStatus.NOT_REQUESTED,
271
+ duration_seconds=0.0,
272
+ message="Restore verification was not requested.",
273
+ requested=False,
274
+ )
275
+ else:
276
+ findings = evaluate_coverage(services, backup_config.backup_paths)
277
+ restore_result = RestoreVerificationResult(
278
+ mode=RestoreVerificationMode.OFF,
279
+ status=RestoreVerificationStatus.NOT_REQUESTED,
280
+ duration_seconds=0.0,
281
+ message="Restore verification was not requested.",
282
+ requested=False,
283
+ )
284
+
285
+ summary = summarize_findings(
286
+ findings, integrity=integrity_result, restore=restore_result
287
+ )
288
+ return AuditOutcome(
289
+ findings=findings,
290
+ summary=summary,
291
+ integrity=integrity_result,
292
+ restore=restore_result,
293
+ )
backuplint/classify.py ADDED
@@ -0,0 +1,109 @@
1
+ """Classify discovered mounts as persistent, cache, temporary, or unknown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from backuplint.models import Mount, MountType
8
+
9
+
10
+ class StorageClass(StrEnum):
11
+ PERSISTENT = "persistent"
12
+ CACHE = "cache"
13
+ TEMPORARY = "temporary"
14
+ INFRASTRUCTURE = "infrastructure"
15
+ UNKNOWN = "unknown"
16
+
17
+
18
+ # Conservative, explicit rules. Prefer unknown over silent ignore.
19
+ # These are container mount target patterns, not host temp-file usage.
20
+ _CACHE_TARGET_HINTS = (
21
+ "/cache",
22
+ "/.cache",
23
+ "/var/cache",
24
+ "/tmp/cache", # nosec B108
25
+ )
26
+ _TEMPORARY_TARGET_HINTS = (
27
+ "/tmp", # nosec B108
28
+ "/var/tmp", # nosec B108
29
+ "/dev/shm", # nosec B108
30
+ "/run",
31
+ )
32
+
33
+ # Obvious non-data OS/runtime binds. Not a blanket /etc/* exclusion.
34
+ _INFRA_EXACT = frozenset(
35
+ {
36
+ "/etc/localtime",
37
+ "/etc/timezone",
38
+ "/var/run/docker.sock",
39
+ "/run/docker.sock",
40
+ }
41
+ )
42
+ _INFRA_RO_SOURCE_PREFIXES = (
43
+ "/etc/ssl/certs",
44
+ "/etc/pki",
45
+ "/usr/share/ca-certificates",
46
+ "/usr/share/zoneinfo",
47
+ )
48
+ _INFRA_RO_TARGET_PREFIXES = (
49
+ "/etc/ssl/certs",
50
+ "/etc/pki",
51
+ "/usr/share/ca-certificates",
52
+ "/usr/share/zoneinfo",
53
+ )
54
+
55
+
56
+ def _norm(path: str) -> str:
57
+ text = (path or "").strip()
58
+ if not text:
59
+ return ""
60
+ return text.rstrip("/") or "/"
61
+
62
+
63
+ def _is_infrastructure_bind(mount: Mount) -> bool:
64
+ """True only for explicit OS/runtime binds, never named volumes."""
65
+ if mount.type is not MountType.BIND:
66
+ return False
67
+ source = _norm(mount.source).lower()
68
+ target = _norm(mount.target).lower()
69
+ if source in _INFRA_EXACT or target in _INFRA_EXACT:
70
+ return True
71
+ if not mount.read_only:
72
+ return False
73
+ source_hit = any(
74
+ source == prefix or source.startswith(prefix + "/")
75
+ for prefix in _INFRA_RO_SOURCE_PREFIXES
76
+ )
77
+ target_hit = any(
78
+ target == prefix or target.startswith(prefix + "/")
79
+ for prefix in _INFRA_RO_TARGET_PREFIXES
80
+ )
81
+ return source_hit and target_hit
82
+
83
+
84
+ def classify_mount(mount: Mount) -> StorageClass:
85
+ """Classify a single mount using explicit, understandable rules."""
86
+ if mount.type is MountType.TMPFS:
87
+ return StorageClass.TEMPORARY
88
+
89
+ if _is_infrastructure_bind(mount):
90
+ return StorageClass.INFRASTRUCTURE
91
+
92
+ target = mount.target.rstrip("/") or "/"
93
+ target_lower = target.lower()
94
+
95
+ for hint in _CACHE_TARGET_HINTS:
96
+ if target_lower == hint or target_lower.endswith(hint):
97
+ return StorageClass.CACHE
98
+
99
+ for hint in _TEMPORARY_TARGET_HINTS:
100
+ if target_lower == hint or target_lower.startswith(hint + "/"):
101
+ # /tmp/cache already handled above as cache.
102
+ return StorageClass.TEMPORARY
103
+
104
+ if mount.type in {MountType.BIND, MountType.VOLUME}:
105
+ # Config/data-style targets and generic bind/volume mounts are treated
106
+ # as persistent unless matched by a temporary/cache/infrastructure rule.
107
+ return StorageClass.PERSISTENT
108
+
109
+ return StorageClass.UNKNOWN