fixplesk 0.6.1__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 (65) hide show
  1. fixplesk/__init__.py +5 -0
  2. fixplesk/__main__.py +3 -0
  3. fixplesk/assurance/__init__.py +1 -0
  4. fixplesk/assurance/breaches.py +149 -0
  5. fixplesk/assurance/cli.py +293 -0
  6. fixplesk/assurance/common.py +212 -0
  7. fixplesk/assurance/feeds.py +328 -0
  8. fixplesk/assurance/filesystem.py +233 -0
  9. fixplesk/assurance/indicators.py +55 -0
  10. fixplesk/assurance/observe.py +190 -0
  11. fixplesk/assurance/permissions.py +307 -0
  12. fixplesk/assurance/runtime.py +238 -0
  13. fixplesk/assurance/wpdata.py +73 -0
  14. fixplesk/backup.py +72 -0
  15. fixplesk/cli.py +547 -0
  16. fixplesk/collector.py +143 -0
  17. fixplesk/config.py +351 -0
  18. fixplesk/data/demo.json +50 -0
  19. fixplesk/data/rules.yml +543 -0
  20. fixplesk/data/runbooks.yml +1751 -0
  21. fixplesk/data/security_catalog.json +1409 -0
  22. fixplesk/data/sources.yml +301 -0
  23. fixplesk/deepseek.py +103 -0
  24. fixplesk/diagnostics.py +22 -0
  25. fixplesk/docroot.py +286 -0
  26. fixplesk/dsl.py +269 -0
  27. fixplesk/errors.py +28 -0
  28. fixplesk/executor.py +342 -0
  29. fixplesk/explain.py +134 -0
  30. fixplesk/graph.py +58 -0
  31. fixplesk/llm.py +194 -0
  32. fixplesk/local_analysis.py +135 -0
  33. fixplesk/models.py +212 -0
  34. fixplesk/normalizer.py +111 -0
  35. fixplesk/offline.py +263 -0
  36. fixplesk/operations.py +56 -0
  37. fixplesk/plugins.py +20 -0
  38. fixplesk/privacy.py +212 -0
  39. fixplesk/providers/__init__.py +0 -0
  40. fixplesk/providers/files.py +114 -0
  41. fixplesk/providers/plesk.py +220 -0
  42. fixplesk/providers/wordpress.py +134 -0
  43. fixplesk/py.typed +0 -0
  44. fixplesk/recovery.py +82 -0
  45. fixplesk/reports.py +95 -0
  46. fixplesk/secops/__init__.py +5 -0
  47. fixplesk/secops/cli.py +85 -0
  48. fixplesk/secops/demo.py +33 -0
  49. fixplesk/secops/engine.py +266 -0
  50. fixplesk/secops/importers.py +183 -0
  51. fixplesk/secops/models.py +181 -0
  52. fixplesk/security.py +180 -0
  53. fixplesk/sources.py +169 -0
  54. fixplesk/store.py +192 -0
  55. fixplesk/tools.py +307 -0
  56. fixplesk/transport.py +203 -0
  57. fixplesk/twin.py +76 -0
  58. fixplesk/workflow.py +77 -0
  59. fixplesk/zai.py +104 -0
  60. fixplesk-0.6.1.dist-info/METADATA +321 -0
  61. fixplesk-0.6.1.dist-info/RECORD +65 -0
  62. fixplesk-0.6.1.dist-info/WHEEL +5 -0
  63. fixplesk-0.6.1.dist-info/entry_points.txt +2 -0
  64. fixplesk-0.6.1.dist-info/licenses/LICENSE +25 -0
  65. fixplesk-0.6.1.dist-info/top_level.txt +1 -0
fixplesk/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Plesk and WordPress diagnostics. No I/O or provider imports on package import."""
2
+ from .models import Event, Plan, Snapshot
3
+
4
+ __all__ = ["Event", "Plan", "Snapshot", "__version__"]
5
+ __version__ = "0.6.1"
fixplesk/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Evidence-based local assurance. Importing this package performs no I/O."""
@@ -0,0 +1,149 @@
1
+ """Opt-in breach checks. Never crack hashes, authenticate to accounts, or expose passwords."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import re
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+
12
+ from .common import anchored_file, fresh, now
13
+ from .feeds import get_bytes
14
+
15
+
16
+ def _sha(value: str) -> str:
17
+ if not isinstance(value, str) or not 1 <= len(value) <= 4096:
18
+ raise ValueError('Nieprawidłowy rozmiar wartości; treść nie jest rejestrowana')
19
+ return hashlib.sha1(value.encode('utf-8'), usedforsecurity=False).hexdigest().upper()
20
+
21
+
22
+ def password_range_result(password: str, response: str) -> dict:
23
+ suffix = _sha(password)[5:]
24
+ matches = []
25
+ rows = response.splitlines()
26
+ if not rows or len(rows) > 100000:
27
+ raise ValueError('Nieprawidłowa odpowiedź Pwned Passwords; wynik nieznany')
28
+ seen = set()
29
+ for row in rows:
30
+ match = re.fullmatch(r'([A-Fa-f0-9]{35}):(\d{1,18})', row.strip())
31
+ if match is None or match[1].upper() in seen:
32
+ raise ValueError('Niejednoznaczna odpowiedź Pwned Passwords; wynik nieznany')
33
+ seen.add(match[1].upper())
34
+ if hmac.compare_digest(match[1].upper(), suffix):
35
+ matches.append(int(match[2]))
36
+ count = max(matches, default=0)
37
+ return {'kind': 'password_exposure', 'checked_at': now(),
38
+ 'status': 'found_in_password_corpus' if count else 'not_found_in_queried_corpus',
39
+ 'occurrences': count, 'account_password_verified': False, 'email_linkage_proven': False,
40
+ 'plaintext_stored': False, 'hash_stored': False,
41
+ 'notice': 'Sprawdzono podaną wartość, nie hasło konta. Trafienie nie wskazuje e-maila ani źródła wycieku; brak trafienia nie certyfikuje hasła.'}
42
+
43
+
44
+ def check_password_online(password: str, *, consent: bool = False, allow_network: bool = False,
45
+ client: httpx.Client | None = None) -> dict:
46
+ if not consent or not allow_network:
47
+ raise ValueError('Wymagana świadoma zgoda i --allow-network')
48
+ prefix = _sha(password)[:5]
49
+ data, _ = get_bytes('https://api.pwnedpasswords.com/range/' + prefix, limit=5_000_000,
50
+ headers={'Add-Padding': 'true'}, client=client)
51
+ result = password_range_result(password, data.decode('ascii'))
52
+ result.update({'source': 'HIBP-PwnedPasswords', 'mode': 'sha1_prefix_5_padding',
53
+ 'network_disclosure': '5 znaków prefiksu SHA-1; nie pełne hasło ani pełny hash'})
54
+ return result
55
+
56
+
57
+ def check_password_file(password: str, path: Path, *, corpus_date: str,
58
+ max_seconds: float = 60, max_bytes: int = 2_000_000_000) -> dict:
59
+ """Streaming SHA1:count input, not email:password. A partial scan never returns clean."""
60
+ if not fresh(corpus_date, 10 * 365 * 86400):
61
+ raise ValueError('Podaj prawdziwą datę pochodzenia korpusu; nie datę importu')
62
+ target = _sha(password)
63
+ path = path.absolute()
64
+ consumed = 0
65
+ count = 0
66
+ rows = 0
67
+ started = time.monotonic()
68
+ complete = True
69
+ with anchored_file(path.parent, path.name) as fd:
70
+ import os
71
+ before = os.fstat(fd)
72
+ with os.fdopen(os.dup(fd), 'rb') as stream:
73
+ while True:
74
+ if consumed >= max_bytes or time.monotonic() - started > max_seconds:
75
+ complete = False
76
+ break
77
+ line = stream.readline(128)
78
+ if not line:
79
+ break
80
+ consumed += len(line)
81
+ found = re.fullmatch(rb'([A-Fa-f0-9]{40}):(\d{1,18})\r?\n?', line)
82
+ if found is None:
83
+ raise ValueError('Oczekiwany korpus SHA1:count, bez adresów i haseł jawnych')
84
+ rows += 1
85
+ if hmac.compare_digest(found[1].decode().upper(), target):
86
+ count = max(count, int(found[2]))
87
+ after = os.fstat(fd)
88
+ if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
89
+ complete = False
90
+ if rows == 0:
91
+ raise ValueError('Pusty korpus; nie można wnioskować o braku hasła')
92
+ return {'kind': 'password_exposure', 'checked_at': now(), 'mode': 'offline_sha1_corpus',
93
+ 'status': 'found_in_password_corpus' if count else 'not_found_in_queried_corpus' if complete else 'unknown_incomplete_scan',
94
+ 'occurrences': count, 'complete_scan': complete, 'corpus_date': corpus_date,
95
+ 'corpus_fresh_24h': fresh(corpus_date), 'rows_scanned': rows,
96
+ 'account_password_verified': False, 'email_linkage_proven': False,
97
+ 'plaintext_stored': False, 'hash_stored': False,
98
+ 'notice': 'Wynik dotyczy tylko dostarczonego korpusu; autentyczność korpusu nie została potwierdzona.'}
99
+
100
+
101
+ def normalize_email(email: str) -> str:
102
+ email = email.strip().lower()
103
+ if (len(email) > 254 or not re.fullmatch(r'[^\s@\x00-\x1f]+@[^\s@\x00-\x1f]+\.[^\s@\x00-\x1f]+', email)):
104
+ raise ValueError('Nieprawidłowy adres; wartość nie jest rejestrowana')
105
+ return email
106
+
107
+
108
+ def email_range_result(email: str, records: list, *, account_ref: str) -> dict:
109
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}', account_ref):
110
+ raise ValueError('Użyj lokalnego identyfikatora konta, bez danych osobowych')
111
+ suffix = _sha(normalize_email(email))[6:]
112
+ if not isinstance(records, list) or len(records) > 100000:
113
+ raise ValueError('Nieprawidłowa odpowiedź zakresu HIBP')
114
+ sites = []
115
+ seen = set()
116
+ for record in records:
117
+ if not isinstance(record, dict) or set(record) != {'hashSuffix', 'websites'}:
118
+ raise ValueError('Nieznany format HIBP; bez przetwarzania dodatkowych pól')
119
+ value = record['hashSuffix']
120
+ websites = record['websites']
121
+ if (not isinstance(value, str) or not re.fullmatch(r'[A-Fa-f0-9]{34}', value)
122
+ or value.upper() in seen or not isinstance(websites, list) or len(websites) > 5000
123
+ or any(not isinstance(s, str) or len(s) > 256 or any(ord(c) < 32 for c in s) for s in websites)):
124
+ raise ValueError('Nieprawidłowy rekord zakresu HIBP')
125
+ seen.add(value.upper())
126
+ if hmac.compare_digest(value.upper(), suffix):
127
+ sites = websites
128
+ # Other identities and their breach names are deliberately not returned/persisted.
129
+ return {'kind': 'email_exposure', 'account_ref': account_ref, 'checked_at': now(),
130
+ 'status': 'address_reported_in_breaches' if sites else 'not_found_in_returned_breaches',
131
+ 'breach_names': sites, 'raw_email_stored': False, 'unrelated_accounts_stored': False,
132
+ 'current_password_exposed': 'unknown', 'account_taken_over': 'unknown',
133
+ 'notice': 'Adres w bazie wycieków nie dowodzi wycieku obecnego hasła ani włamania na tę skrzynkę. Zakres API nie obejmuje wszystkich wycieków.'}
134
+
135
+
136
+ def check_email_online(email: str, api_key: str, *, account_ref: str, consent: bool = False,
137
+ allow_network: bool = False, client: httpx.Client | None = None) -> dict:
138
+ if not consent or not allow_network:
139
+ raise ValueError('Wymagana zgoda na kontrolę własnego lub powierzonego konta i --allow-network')
140
+ if not re.fullmatch(r'[a-fA-F0-9]{32}', api_key):
141
+ raise ValueError('Brak prawidłowego HIBP_API_KEY')
142
+ import json
143
+ prefix = _sha(normalize_email(email))[:6]
144
+ data, _ = get_bytes('https://haveibeenpwned.com/api/v3/breachedaccount/range/' + prefix,
145
+ limit=8_000_000, headers={'hibp-api-key': api_key}, client=client)
146
+ result = email_range_result(email, json.loads(data), account_ref=account_ref)
147
+ result.update({'source': 'HIBP', 'mode': 'email_sha1_prefix_6',
148
+ 'network_disclosure': '6 znaków SHA-1 adresu; wymagana usługa obsługująca tę funkcję, bez fallback do jawnego adresu'})
149
+ return result
@@ -0,0 +1,293 @@
1
+ """Explicit guard commands: no background downloads or implicit model execution."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import getpass
6
+ import json
7
+ import os
8
+ import sys
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from fixplesk.config import load_config
13
+
14
+ from .common import digest, read_bytes, read_json, write_json
15
+ from .feeds import SOURCES, FeedCache, enrich_priorities, match_wordpress
16
+ from .filesystem import ScanLimits, compare, scan
17
+ from .observe import ObservationPolicy, observe
18
+ from .permissions import PermissionPlan, checked_target, dry_run, execute, make_plan, reconcile
19
+ from .runtime import ScriptSpec, Session
20
+
21
+
22
+ def register(commands) -> None:
23
+ root = commands.add_parser('guard', help='Wersja 0.5: pliki, bazy dowodów, wycieki, jawne skrypty i kontrola zmian')
24
+ groups = root.add_subparsers(dest='guard_group', required=True)
25
+ feeds = groups.add_parser('feeds').add_subparsers(dest='guard_action', required=True)
26
+ for action in ('list', 'sync', 'import', 'status'):
27
+ p = feeds.add_parser(action)
28
+ p.add_argument('--output', type=Path)
29
+ if action == 'list':
30
+ continue
31
+ p.add_argument('--source', required=True, choices=sorted(SOURCES))
32
+ p.add_argument('--cache', type=Path, default=Path.home() / '.cache/fixplesk/feeds')
33
+ p.add_argument('--version')
34
+ p.add_argument('--locale')
35
+ if action == 'sync':
36
+ p.add_argument('--allow-network', action='store_true')
37
+ if action == 'import':
38
+ p.add_argument('--input', required=True, type=Path)
39
+ p.add_argument('--obtained-at', required=True, help='Rzeczywista data pozyskania, ISO8601 ze strefą')
40
+ p = groups.add_parser('wp-match', help='Dopasuj zapisane komponenty do Wordfence; niczego nie aktualizuje')
41
+ p.add_argument('--inventory', type=Path, required=True)
42
+ p.add_argument('--cache', type=Path, default=Path.home() / '.cache/fixplesk/feeds')
43
+ p.add_argument('--output', type=Path)
44
+ f = groups.add_parser('files').add_subparsers(dest='guard_action', required=True)
45
+ p = f.add_parser('scan')
46
+ s = p.add_mutually_exclusive_group(required=True)
47
+ s.add_argument('--domain')
48
+ s.add_argument('--all', action='store_true', help='Tylko jawne widoczne cele inventory; nie całe /var/www')
49
+ p.add_argument('--limits', type=Path)
50
+ p.add_argument('--indicators', type=Path)
51
+ p.add_argument('--checksum-version')
52
+ p.add_argument('--checksum-locale')
53
+ p.add_argument('--cache', type=Path, default=Path.home() / '.cache/fixplesk/feeds')
54
+ p.add_argument('--output', type=Path)
55
+ p = f.add_parser('compare')
56
+ p.add_argument('--before', type=Path, required=True)
57
+ p.add_argument('--after', type=Path, required=True)
58
+ p.add_argument('--output', type=Path)
59
+ breach = groups.add_parser('breach').add_subparsers(dest='guard_action', required=True)
60
+ p = breach.add_parser('email', help='HIBP k-anon: ukryty prompt e-mail, bez jawnego adresu w argv')
61
+ p.add_argument('--account-ref', required=True)
62
+ p.add_argument('--response', type=Path, help='Opcjonalny lokalny JSON odpowiedzi range do kontroli offline')
63
+ p.add_argument('--consent', action='store_true')
64
+ p.add_argument('--allow-network', action='store_true')
65
+ p.add_argument('--output', type=Path)
66
+ p = breach.add_parser('password', help='Ukryty prompt; nigdy argument, log ani dane LLM')
67
+ modes = p.add_mutually_exclusive_group(required=True)
68
+ modes.add_argument('--online', action='store_true')
69
+ modes.add_argument('--offline-hashes', type=Path)
70
+ p.add_argument('--corpus-date')
71
+ p.add_argument('--assert-current', action='store_true', help='Deklaracja użytkownika, nie uwierzytelnienie hasła')
72
+ p.add_argument('--consent', action='store_true')
73
+ p.add_argument('--allow-network', action='store_true')
74
+ p.add_argument('--output', type=Path)
75
+ p = groups.add_parser('wp-data', help='Odczytowy audyt jawnego eksportu wybranych pól WP')
76
+ p.add_argument('--input', type=Path, required=True)
77
+ p.add_argument('--output', type=Path)
78
+ session = groups.add_parser('session').add_subparsers(dest='guard_action', required=True)
79
+ for action in ('create', 'list', 'stage', 'run', 'capture', 'export'):
80
+ p = session.add_parser(action)
81
+ p.add_argument('--domain', required=True)
82
+ p.add_argument('--output', type=Path, required=action == 'export')
83
+ if action != 'create':
84
+ p.add_argument('--session', type=Path, required=True)
85
+ if action == 'stage':
86
+ p.add_argument('--spec', type=Path, required=True)
87
+ if action == 'run':
88
+ p.add_argument('--script', required=True)
89
+ p.add_argument('--approve', required=True)
90
+ p.add_argument('--execute', action='store_true')
91
+ if action == 'capture':
92
+ p.add_argument('--policy', type=Path, required=True)
93
+ p.add_argument('--allow-network', action='store_true')
94
+ perms = groups.add_parser('permissions').add_subparsers(dest='guard_action', required=True)
95
+ p = perms.add_parser('plan')
96
+ p.add_argument('--domain', required=True)
97
+ p.add_argument('--path', required=True, help='Ścieżka względna w document_root')
98
+ p.add_argument('--mode', required=True, help='Węższe prawa, np. 0640')
99
+ p.add_argument('--policy', type=Path, required=True)
100
+ p.add_argument('--why', required=True)
101
+ p.add_argument('--allow-restore-previous-mode', action='store_true')
102
+ p.add_argument('--output', type=Path, required=True)
103
+ p = perms.add_parser('run')
104
+ p.add_argument('--plan', required=True, type=Path)
105
+ p.add_argument('--approve', default='')
106
+ p.add_argument('--execute', action='store_true')
107
+ p.add_argument('--allow-network', action='store_true')
108
+ p.add_argument('--output', type=Path)
109
+ p = perms.add_parser('reconcile')
110
+ p.add_argument('--digest', required=True)
111
+ p.add_argument('--acknowledge', action='store_true')
112
+ p.add_argument('--allow-network', action='store_true')
113
+ p.add_argument('--output', type=Path)
114
+ p = groups.add_parser('schema')
115
+ p.add_argument('--kind', choices=['script', 'policy', 'permission-plan', 'wp-data', 'hash-indicator', 'scan-limits'], required=True)
116
+ p.add_argument('--output', type=Path)
117
+
118
+
119
+ def _hidden(prompt: str) -> str:
120
+ if not sys.stdin.isatty():
121
+ raise ValueError('Wymagany lokalny TTY. Nie podawaj hasła/adresu w argv, potoku ani pliku')
122
+ return getpass.getpass(prompt)
123
+
124
+
125
+ def _safe_output(path: Path | None, targets: list) -> None:
126
+ if path is None:
127
+ return
128
+ # Reports/scripts contain local identities; do not publish them inside a web root.
129
+ candidate = path.absolute()
130
+ for target in targets:
131
+ if target.document_root:
132
+ try:
133
+ candidate.relative_to(target.document_root.absolute())
134
+ except ValueError:
135
+ continue
136
+ raise ValueError('Raport/plan/archiwum nie może być zapisywany pod document_root')
137
+
138
+
139
+ def run(args: argparse.Namespace, emit) -> int:
140
+ def emit(value, output=None):
141
+ if output is not None:
142
+ write_json(output, value)
143
+ else:
144
+ print(json.dumps(value, ensure_ascii=True, indent=2, allow_nan=False))
145
+ group, action = args.guard_group, getattr(args, 'guard_action', None)
146
+ out = getattr(args, 'output', None)
147
+ if group == 'schema':
148
+ from .indicators import HashIndicator
149
+ from .wpdata import WPDataExport
150
+ models = {'script': ScriptSpec, 'policy': ObservationPolicy, 'permission-plan': PermissionPlan,
151
+ 'wp-data': WPDataExport, 'hash-indicator': HashIndicator, 'scan-limits': ScanLimits}
152
+ emit(models[args.kind].model_json_schema(), out)
153
+ elif group == 'feeds':
154
+ if action == 'list':
155
+ emit(SOURCES, out)
156
+ else:
157
+ cache = FeedCache(args.cache)
158
+ params = {'version': args.version, 'locale': args.locale}
159
+ if action == 'sync':
160
+ value = cache.sync(args.source, allow_network=args.allow_network, **params)
161
+ elif action == 'import':
162
+ value = cache.save(args.source, read_bytes(args.input, SOURCES[args.source]['max_bytes']),
163
+ obtained_at=args.obtained_at, **params)
164
+ else:
165
+ _, value = cache.load(args.source, **params)
166
+ emit(value, out)
167
+ elif group == 'wp-match':
168
+ records, metadata = FeedCache(args.cache).load('wordfence')
169
+ report = match_wordpress(read_json(args.inventory), records, metadata)
170
+ emit(enrich_priorities(report, FeedCache(args.cache)), out)
171
+ elif group == 'wp-data':
172
+ from .wpdata import WPDataExport, inspect_wp_data
173
+ emit(inspect_wp_data(WPDataExport.model_validate(read_json(args.input))), out)
174
+ elif group == 'breach':
175
+ from .breaches import (
176
+ check_email_online,
177
+ check_password_file,
178
+ check_password_online,
179
+ email_range_result,
180
+ )
181
+ if not args.consent:
182
+ raise ValueError('Wymagana zgoda właściciela lub uprawnionego administratora (--consent)')
183
+ if action == 'email':
184
+ value = _hidden('Adres własnego/powierzonego konta (ukryty): ')
185
+ try:
186
+ if args.response:
187
+ result = email_range_result(value, read_json(args.response), account_ref=args.account_ref)
188
+ result['source'] = 'unattested_local_range_response'
189
+ else:
190
+ result = check_email_online(value, os.environ.get('HIBP_API_KEY', ''), account_ref=args.account_ref,
191
+ consent=True, allow_network=args.allow_network)
192
+ finally:
193
+ del value
194
+ else:
195
+ value = _hidden('Hasło do sprawdzenia (nie jest zapisywane): ')
196
+ try:
197
+ if args.online:
198
+ result = check_password_online(value, consent=True, allow_network=args.allow_network)
199
+ else:
200
+ if not args.corpus_date:
201
+ raise ValueError('Korpus offline wymaga --corpus-date')
202
+ result = check_password_file(value, args.offline_hashes, corpus_date=args.corpus_date)
203
+ result['current_password_declared_by_user'] = args.assert_current
204
+ finally:
205
+ del value # CPython cannot guarantee secure zeroisation of immutable str.
206
+ emit(result, out)
207
+ elif group == 'files' and action == 'compare':
208
+ emit(compare(read_json(args.before, 96_000_000), read_json(args.after, 96_000_000)), out)
209
+ elif group == 'permissions' and action == 'run' and not args.execute:
210
+ emit(dry_run(PermissionPlan.model_validate(read_json(args.plan))), out)
211
+ else:
212
+ config = load_config(args.config)
213
+ _safe_output(out, config.visible_targets())
214
+ if group == 'files':
215
+ targets = config.visible_targets() if args.all else [config.target(args.domain)]
216
+ if args.all and args.checksum_version:
217
+ raise ValueError('Nie przypisuj jednej wersji sum wszystkim domenom; wybierz pojedynczy cel')
218
+ limits = ScanLimits.model_validate(read_json(args.limits)) if args.limits else ScanLimits()
219
+ checksums, context = None, None
220
+ if args.checksum_version:
221
+ checksums, meta = FeedCache(args.cache).load('wp-checksums', version=args.checksum_version, locale=args.checksum_locale)
222
+ context = {'version': args.checksum_version, 'locale': args.checksum_locale,
223
+ 'source_sha256': meta['sha256'], 'inventory_version_verified': False,
224
+ 'feed_fresh': meta['fresh_for_policy'], 'notice': 'Wersję wybrał operator; porównaj z lokalnym inventory WP.'}
225
+ reports = []
226
+ indicators = None
227
+ if args.indicators:
228
+ from .indicators import load_indicators
229
+ indicators = load_indicators(args.indicators)
230
+ for target in targets:
231
+ checked_target(config, target.domain)
232
+ report = scan(target.document_root, domain=target.domain, tenant=target.tenant_id,
233
+ limits=limits, checksums=checksums, checksum_context=context)
234
+ if indicators:
235
+ from .indicators import apply_indicators
236
+ report = apply_indicators(report, indicators)
237
+ reports.append(report)
238
+ emit({'kind': 'scoped_multi_site_audit', 'reports': reports,
239
+ 'notice': 'Wyłącznie widoczne skonfigurowane document_root; nie odkryto wszystkich domen serwera.'} if args.all else reports[0], out)
240
+ elif group == 'session':
241
+ target = checked_target(config, args.domain)
242
+ session = Session.create(config, target) if action == 'create' else Session(args.session, config, target)
243
+ if action == 'create':
244
+ emit({'session': str(session.path), 'manifest': session.manifest}, out)
245
+ elif action == 'list':
246
+ emit(read_json(session.path / 'session.json'), out)
247
+ elif action == 'stage':
248
+ emit(session.stage(ScriptSpec.model_validate(read_json(args.spec))), out)
249
+ elif action == 'run':
250
+ emit(session.run(args.script, approved_digest=args.approve, execute=args.execute), out)
251
+ elif action == 'capture':
252
+ policy = ObservationPolicy.model_validate(read_json(args.policy))
253
+ value = observe(config, target, policy, allow_network=args.allow_network)
254
+ name = digest(value) + '.json'
255
+ write_json(session.path / 'observations' / name, value)
256
+ write_json(session.archive / 'observations' / name, value)
257
+ session.event('observation', {'file': name, 'checks_passed': value['checks_passed']})
258
+ emit(value, out)
259
+ elif action == 'export':
260
+ # Create exclusively and read no symlinks. Export contains local sensitive metadata.
261
+ if out.absolute().is_relative_to(session.path):
262
+ raise ValueError('Archiwum musi być poza katalogiem sesji')
263
+ from .common import open_dir
264
+ export_parent = open_dir(out.absolute().parent)
265
+ try:
266
+ output_fd = os.open(out.name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600, dir_fd=export_parent)
267
+ finally:
268
+ os.close(export_parent)
269
+ with os.fdopen(output_fd, 'wb') as stream, zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as archive:
270
+ for path in sorted(session.path.rglob('*')):
271
+ if path.is_symlink():
272
+ raise ValueError('Dowiązanie w sesji; odmowa eksportu')
273
+ if path.is_file() and path.name != '.lock':
274
+ archive.writestr(str(path.relative_to(session.path)), read_bytes(path, 96_000_000))
275
+ session.event('session_exported', {'export_created': True})
276
+ emit({'export': str(out), 'private_local_metadata': True}, None)
277
+ elif group == 'permissions':
278
+ if action == 'plan':
279
+ value = make_plan(config, args.domain, args.path, args.mode,
280
+ ObservationPolicy.model_validate(read_json(args.policy)), args.why,
281
+ allow_restore_previous_mode=args.allow_restore_previous_mode)
282
+ emit(value.model_dump(mode='json'), out)
283
+ elif action == 'run':
284
+ value = execute(config, PermissionPlan.model_validate(read_json(args.plan)), approved_digest=args.approve,
285
+ execute_changes=True, allow_network=args.allow_network)
286
+ emit(value, out)
287
+ if value['status'] not in ('succeeded_within_observed_scope', 'rolled_back'):
288
+ return 4
289
+ elif action == 'reconcile':
290
+ emit(reconcile(config, args.digest, acknowledge=args.acknowledge, allow_network=args.allow_network), out)
291
+ else:
292
+ raise ValueError('Nieznana komenda guard')
293
+ return 0
@@ -0,0 +1,212 @@
1
+ """Bounded, no-follow I/O used by the new guard commands (Linux/POSIX)."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import stat
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Iterator
12
+
13
+
14
+ def now() -> str:
15
+ return datetime.now(timezone.utc).isoformat()
16
+
17
+
18
+ def age(value: str, *, clock: datetime | None = None) -> float:
19
+ stamp = datetime.fromisoformat(value.replace('Z', '+00:00'))
20
+ if stamp.tzinfo is None:
21
+ raise ValueError('Czas musi zawierać strefę')
22
+ return ((clock or datetime.now(timezone.utc)) - stamp).total_seconds()
23
+
24
+
25
+ def fresh(value: str | None, ttl: int = 86400) -> bool:
26
+ if value is None:
27
+ return False
28
+ try:
29
+ return -60 <= age(value) <= ttl
30
+ except (ValueError, TypeError, OverflowError):
31
+ return False
32
+
33
+
34
+ def digest(value: Any) -> str:
35
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'),
36
+ ensure_ascii=True, allow_nan=False).encode()).hexdigest()
37
+
38
+
39
+ def relative(value: str) -> str:
40
+ if (not value or len(value) > 4096 or value.startswith('/') or '\\' in value
41
+ or any(ord(c) < 32 or ord(c) == 127 for c in value)
42
+ or any(p in ('', '.', '..') for p in value.split('/'))):
43
+ raise ValueError('Wymagana jednoznaczna ścieżka względna, bez dowiązań i ..')
44
+ return value
45
+
46
+
47
+ def open_dir(path: Path) -> int:
48
+ """Anchor every absolute component with openat/O_NOFOLLOW; no resolve() race."""
49
+ path = path.absolute()
50
+ if '..' in path.parts:
51
+ raise ValueError('Niedozwolona ścieżka katalogu')
52
+ fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
53
+ try:
54
+ for part in path.parts[1:]:
55
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
56
+ os.close(fd)
57
+ fd = child
58
+ return fd
59
+ except BaseException:
60
+ os.close(fd)
61
+ raise
62
+
63
+
64
+ @contextlib.contextmanager
65
+ def anchored_file(root: Path, name: str, *, write: bool = False) -> Iterator[int]:
66
+ """File descriptor remains pinned during checks/mutation. Reject all symlinks."""
67
+ parts = relative(name).split('/')
68
+ directory = open_dir(root)
69
+ fd = -1
70
+ try:
71
+ for part in parts[:-1]:
72
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
73
+ os.close(directory)
74
+ directory = child
75
+ flags = (os.O_RDWR if write else os.O_RDONLY) | os.O_NOFOLLOW | os.O_NONBLOCK
76
+ fd = os.open(parts[-1], flags, dir_fd=directory)
77
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
78
+ raise ValueError('Operacja wymaga zwykłego pliku')
79
+ yield fd
80
+ finally:
81
+ if fd >= 0:
82
+ os.close(fd)
83
+ os.close(directory)
84
+
85
+
86
+ def read_bytes(path: Path, limit: int = 12_000_000) -> bytes:
87
+ path = path.absolute()
88
+ with anchored_file(path.parent, path.name) as fd:
89
+ out = bytearray()
90
+ while len(out) <= limit:
91
+ part = os.read(fd, min(65536, limit + 1 - len(out)))
92
+ if not part:
93
+ return bytes(out)
94
+ out.extend(part)
95
+ raise ValueError('Przekroczono limit danych wejściowych')
96
+
97
+
98
+ def read_json(path: Path, limit: int = 12_000_000) -> Any:
99
+ def duplicate_check(pairs):
100
+ result = {}
101
+ for key, value in pairs:
102
+ if key in result:
103
+ raise ValueError('Powtórzony klucz JSON')
104
+ result[key] = value
105
+ return result
106
+ def invalid_number(_):
107
+ raise ValueError('JSON zawiera niestandardową liczbę')
108
+ return json.loads(read_bytes(path, limit), object_pairs_hook=duplicate_check,
109
+ parse_constant=invalid_number)
110
+
111
+
112
+ def write_json(path: Path, value: Any, *, overwrite: bool = True) -> None:
113
+ """Create private output using pinned parent FDs, without following any symlink."""
114
+ import secrets
115
+ path = path.absolute()
116
+ if '..' in path.parts:
117
+ raise ValueError('Nieprawidłowa ścieżka wyniku')
118
+ parent = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
119
+ temp = '.fixplesk-' + secrets.token_hex(12)
120
+ created = False
121
+ try:
122
+ for part in path.parts[1:-1]:
123
+ try:
124
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
125
+ except FileNotFoundError:
126
+ try:
127
+ os.mkdir(part, mode=0o700, dir_fd=parent)
128
+ except FileExistsError:
129
+ pass
130
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
131
+ os.close(parent)
132
+ parent = child
133
+ try:
134
+ dst = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
135
+ if not stat.S_ISREG(dst.st_mode):
136
+ raise ValueError('Wynik nie może zastępować dowiązania ani pliku specjalnego')
137
+ except FileNotFoundError:
138
+ pass
139
+ data = (json.dumps(value, ensure_ascii=True, indent=2, allow_nan=False) + '\n').encode()
140
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
141
+ created = True
142
+ try:
143
+ position = 0
144
+ while position < len(data):
145
+ written = os.write(fd, data[position:position+65536])
146
+ if written <= 0:
147
+ raise OSError('Niepełny zapis wyniku')
148
+ position += written
149
+ os.fsync(fd)
150
+ finally:
151
+ os.close(fd)
152
+ if overwrite:
153
+ os.replace(temp, path.name, src_dir_fd=parent, dst_dir_fd=parent)
154
+ created = False
155
+ else:
156
+ os.link(temp, path.name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
157
+ os.fsync(parent)
158
+ finally:
159
+ if created:
160
+ os.unlink(temp, dir_fd=parent)
161
+ os.close(parent)
162
+
163
+
164
+ def private_directory(path: Path) -> Path:
165
+ path = path.expanduser().absolute()
166
+ if '..' in path.parts:
167
+ raise ValueError('Nieprawidłowa ścieżka katalogu prywatnego')
168
+ fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
169
+ try:
170
+ for part in path.parts[1:]:
171
+ try:
172
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
173
+ except FileNotFoundError:
174
+ try:
175
+ os.mkdir(part, mode=0o700, dir_fd=fd)
176
+ except FileExistsError:
177
+ pass
178
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
179
+ os.close(fd)
180
+ fd = child
181
+ st = os.fstat(fd)
182
+ if st.st_uid != os.geteuid() or st.st_mode & 0o077:
183
+ raise ValueError('Katalog prywatny musi należeć do bieżącego UID i mieć prawa 0700')
184
+ finally:
185
+ os.close(fd)
186
+ return path
187
+
188
+
189
+ def identity(st: os.stat_result) -> dict[str, int]:
190
+ return {'device': st.st_dev, 'inode': st.st_ino, 'uid': st.st_uid, 'gid': st.st_gid,
191
+ 'mode': stat.S_IMODE(st.st_mode), 'size': st.st_size, 'mtime_ns': st.st_mtime_ns,
192
+ 'ctime_ns': st.st_ctime_ns, 'nlink': st.st_nlink}
193
+
194
+
195
+ def file_hash(fd: int, maximum: int = 16_000_000) -> str:
196
+ before = identity(os.fstat(fd))
197
+ if before['size'] > maximum or before['nlink'] != 1:
198
+ raise ValueError('Plik zbyt duży albo współdzielony hardlink')
199
+ os.lseek(fd, 0, os.SEEK_SET)
200
+ total = 0
201
+ sha = hashlib.sha256()
202
+ while True:
203
+ chunk = os.read(fd, min(65536, maximum + 1 - total))
204
+ if not chunk:
205
+ break
206
+ total += len(chunk)
207
+ if total > maximum:
208
+ raise ValueError('Plik rośnie podczas odczytu')
209
+ sha.update(chunk)
210
+ if identity(os.fstat(fd)) != before:
211
+ raise ValueError('Plik zmienił się podczas odczytu')
212
+ return sha.hexdigest()