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.
- fixplesk/__init__.py +5 -0
- fixplesk/__main__.py +3 -0
- fixplesk/assurance/__init__.py +1 -0
- fixplesk/assurance/breaches.py +149 -0
- fixplesk/assurance/cli.py +293 -0
- fixplesk/assurance/common.py +212 -0
- fixplesk/assurance/feeds.py +328 -0
- fixplesk/assurance/filesystem.py +233 -0
- fixplesk/assurance/indicators.py +55 -0
- fixplesk/assurance/observe.py +190 -0
- fixplesk/assurance/permissions.py +307 -0
- fixplesk/assurance/runtime.py +238 -0
- fixplesk/assurance/wpdata.py +73 -0
- fixplesk/backup.py +72 -0
- fixplesk/cli.py +547 -0
- fixplesk/collector.py +143 -0
- fixplesk/config.py +351 -0
- fixplesk/data/demo.json +50 -0
- fixplesk/data/rules.yml +543 -0
- fixplesk/data/runbooks.yml +1751 -0
- fixplesk/data/security_catalog.json +1409 -0
- fixplesk/data/sources.yml +301 -0
- fixplesk/deepseek.py +103 -0
- fixplesk/diagnostics.py +22 -0
- fixplesk/docroot.py +286 -0
- fixplesk/dsl.py +269 -0
- fixplesk/errors.py +28 -0
- fixplesk/executor.py +342 -0
- fixplesk/explain.py +134 -0
- fixplesk/graph.py +58 -0
- fixplesk/llm.py +194 -0
- fixplesk/local_analysis.py +135 -0
- fixplesk/models.py +212 -0
- fixplesk/normalizer.py +111 -0
- fixplesk/offline.py +263 -0
- fixplesk/operations.py +56 -0
- fixplesk/plugins.py +20 -0
- fixplesk/privacy.py +212 -0
- fixplesk/providers/__init__.py +0 -0
- fixplesk/providers/files.py +114 -0
- fixplesk/providers/plesk.py +220 -0
- fixplesk/providers/wordpress.py +134 -0
- fixplesk/py.typed +0 -0
- fixplesk/recovery.py +82 -0
- fixplesk/reports.py +95 -0
- fixplesk/secops/__init__.py +5 -0
- fixplesk/secops/cli.py +85 -0
- fixplesk/secops/demo.py +33 -0
- fixplesk/secops/engine.py +266 -0
- fixplesk/secops/importers.py +183 -0
- fixplesk/secops/models.py +181 -0
- fixplesk/security.py +180 -0
- fixplesk/sources.py +169 -0
- fixplesk/store.py +192 -0
- fixplesk/tools.py +307 -0
- fixplesk/transport.py +203 -0
- fixplesk/twin.py +76 -0
- fixplesk/workflow.py +77 -0
- fixplesk/zai.py +104 -0
- fixplesk-0.6.1.dist-info/METADATA +321 -0
- fixplesk-0.6.1.dist-info/RECORD +65 -0
- fixplesk-0.6.1.dist-info/WHEEL +5 -0
- fixplesk-0.6.1.dist-info/entry_points.txt +2 -0
- fixplesk-0.6.1.dist-info/licenses/LICENSE +25 -0
- fixplesk-0.6.1.dist-info/top_level.txt +1 -0
fixplesk/__init__.py
ADDED
fixplesk/__main__.py
ADDED
|
@@ -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()
|