Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende (sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“. - **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit. Tokens eines Operators gehen bis zum Bereich „Ausrollen“. - **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`). - **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM, Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte `notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus. - **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen). - **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle `rule_hits` (Migration `0012`). - Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
95 lines
6.6 KiB
Python
95 lines
6.6 KiB
Python
import asyncio, copy, json, ssl, uuid
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
from httpx import AsyncClient, ASGITransport
|
|
from app.main import app
|
|
from app.database import get_db
|
|
from app.routers import auth, users, hosts
|
|
from app.services import auth_service, totp_service, host_status_service, opnsense_client, rule_deploy_service, ipsec_service
|
|
from app.security_middleware import _origin_allowed
|
|
from app.schemas.host import HostCreate
|
|
|
|
results=[]
|
|
def record(name, **details):
|
|
results.append({'probe':name, **details})
|
|
|
|
class FakeDB:
|
|
def __init__(self, user=None): self.user=user; self.executed=[]; self.commits=0
|
|
async def get(self,*args,**kwargs): return self.user
|
|
async def commit(self): self.commits+=1
|
|
def add(self,obj): pass
|
|
async def scalar(self,q): return None
|
|
async def execute(self,q): self.executed.append(str(q)); return SimpleNamespace(scalar_one_or_none=lambda: self.user)
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self,*args): pass
|
|
|
|
async def main():
|
|
user=SimpleNamespace(id=uuid.uuid4(),username='synthetic-reader',role='read_only')
|
|
fake=FakeDB(user)
|
|
async def dep(): yield fake
|
|
app.dependency_overrides[get_db]=dep
|
|
host_id=uuid.uuid4()
|
|
with patch('app.auth_middleware.SessionLocal',return_value=fake),patch.object(auth_service,'resolve_session',AsyncMock(return_value=user)),patch.object(hosts,'_get_host_or_404',AsyncMock(return_value=SimpleNamespace(id=host_id))),patch.object(hosts.backup_store,'read_snapshot',return_value=b'<opnsense><synthetic-secret>MARKER</synthetic-secret></opnsense>'):
|
|
async with AsyncClient(transport=ASGITransport(app=app),base_url='https://synthetic.test',cookies={auth.COOKIE_NAME:'synthetic-token'}) as c:
|
|
r=await c.get(f'/api/hosts/{host_id}/backups/synthetic.xml')
|
|
record('read_only_backup_download',status=r.status_code,synthetic_marker_returned='MARKER' in r.text)
|
|
w=await c.post('/api/hosts',json={})
|
|
record('read_only_write_control',status=w.status_code)
|
|
user.role='admin'
|
|
with patch('app.services.audit_service.persist',AsyncMock()):
|
|
r=await c.post('/api/certificates/managed',json={'action':'internal','private_key':'SYNTHETIC-PRIVATE-KEY-MARKER'})
|
|
record('validation_error_secret_echo',status=r.status_code,synthetic_marker_returned='SYNTHETIC-PRIVATE-KEY-MARKER' in r.text)
|
|
app.dependency_overrides.clear()
|
|
# No real user, session, database or secret is consulted below.
|
|
target=SimpleNamespace(id=uuid.uuid4(),password_hash='synthetic-hash')
|
|
db=FakeDB(target)
|
|
with patch.object(auth_service,'hash_password',return_value='new-synthetic-hash'):
|
|
await users.reset_password(target.id,users.UserPasswordReset(new_password='synthetic-password-123'),db)
|
|
record('password_reset_revoke',sql_statements=db.executed,commits=db.commits)
|
|
secret=totp_service.generate_secret(); code=totp_service.code_at(secret)
|
|
barrier=asyncio.Event(); entered=0
|
|
class ConcurrentDB(FakeDB):
|
|
async def commit(self):
|
|
nonlocal entered
|
|
entered+=1
|
|
if entered==2: barrier.set()
|
|
await barrier.wait()
|
|
u1=SimpleNamespace(id=uuid.uuid4(),username='synthetic-user',totp_last_step=0,totp_recovery_hashes=[])
|
|
u2=copy.copy(u1)
|
|
with patch.object(totp_service,'read_secret',return_value=secret):
|
|
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
|
|
record('totp_parallel_replay',accepted=accepted)
|
|
code='synthetic-recovery'; recovery=totp_service.hash_recovery(code)
|
|
u1.totp_recovery_hashes=[recovery];u2.totp_recovery_hashes=[recovery];entered=0;barrier=asyncio.Event()
|
|
with patch.object(totp_service,'read_secret',return_value=None):
|
|
accepted=await asyncio.gather(auth._accept_second_factor(ConcurrentDB(),u1,code),auth._accept_second_factor(ConcurrentDB(),u2,code))
|
|
record('recovery_parallel_replay',accepted=accepted)
|
|
with patch.object(host_status_service,'get_vip_status',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('401 synthetic upstream secret MARKER'))):
|
|
status=await host_status_service._status_for(SimpleNamespace(enabled=True))
|
|
record('status_error_disclosure',synthetic_marker_returned='MARKER' in status['status_detail'])
|
|
ctx=ssl._create_stdlib_context()
|
|
record('smtp_default_tls_context',verify_mode=int(ctx.verify_mode),check_hostname=ctx.check_hostname)
|
|
record('cross_scheme_origin',accepted=_origin_allowed('http://synthetic.test','synthetic.test'))
|
|
h=HostCreate(name='synthetic',base_url='http://synthetic.test',api_key='synthetic',api_secret='synthetic')
|
|
record('plaintext_firewall_url',accepted=h.base_url.startswith('http://'),verify_tls=h.verify_tls)
|
|
throttle=auth_service._LoginThrottle()
|
|
for i in range(100): throttle.record_failure(f'synthetic-{i}',f'user-{i}')
|
|
before=len(throttle._records)
|
|
with patch('app.services.auth_service.time.monotonic',return_value=10**20):
|
|
for i in range(100): throttle.retry_after(f'synthetic-{i}',f'user-{i}')
|
|
record('expired_throttle_records',before=before,after=len(throttle._records),username_counters=len(throttle._failures_since_success))
|
|
rule=SimpleNamespace(id=uuid.uuid4(),enabled=True,action='pass',interface='wan',direction='in',ipprotocol='inet',protocol='any',source_net='any',source_port=None,destination_net='any',destination_port=None,description='synthetic rule')
|
|
with patch.object(opnsense_client,'add_rule',AsyncMock(return_value={'uuid':'synthetic-remote-uuid'})),patch.object(opnsense_client,'apply',AsyncMock(side_effect=opnsense_client.OpnsenseAPIError('synthetic timeout after apply'))):
|
|
d=await rule_deploy_service._deploy_to_host(FakeDB(),rule,SimpleNamespace(id=uuid.uuid4()))
|
|
record('ambiguous_apply_rollback',status=d.status,revert_deadline=d.revert_deadline,previous_payload=d.previous_payload,remote_uuid_known=bool(d.opnsense_rule_uuid))
|
|
tunnel=SimpleNamespace(id=uuid.uuid4(),description='synthetic',local_address='192.0.2.1',remote_address='192.0.2.2',ike_version='ikev2',proposal='aes256-sha256-modp2048',local_identity='synthetic-local',remote_identity='synthetic-remote',local_networks='192.0.2.0/25',remote_networks='198.51.100.0/25',esp_proposal='aes256-sha256',enabled=True,pre_shared_key_set=True,targets=[],groups=[],deployments=[])
|
|
tunnel.targets=[SimpleNamespace(id=uuid.uuid4(),name='synthetic-host',enabled=True,base_url='https://synthetic.test',verify_tls=True)]
|
|
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-old-secret'):
|
|
a=ipsec_service.preview(tunnel)
|
|
with patch('app.services.ipsec_psk_store.read_secret',return_value='synthetic-new-secret'):
|
|
b=ipsec_service.preview(tunnel)
|
|
record('vpn_preview_secret_binding',same_token_after_secret_change=a['token']==b['token'])
|
|
print(json.dumps(results,indent=2))
|
|
|
|
asyncio.run(main())
|