Skip to content

Commit 417312e

Browse files
Add Rust NVD import cutover flow
1 parent beab81c commit 417312e

6 files changed

Lines changed: 504 additions & 96 deletions

File tree

apps/vulnerability_intelligence/services.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,11 @@ def upsert_cve(cve_id: str, *, assume_normalized: bool = False):
172172
if rust_only:
173173
if not RustBackendClient.is_configured():
174174
raise RuntimeError('VULN_INTEL_RUST_ONLY ist aktiv, aber RUST_BACKEND_URL ist nicht gesetzt.')
175-
cve, payload = NVDService.fetch_cve(normalized)
176-
persisted_cve_id = RustBackendClient.upsert_nvd_cve(cve=cve, raw_payload=payload, fallback_cve_id=normalized)
175+
persisted_cve_id = RustBackendClient.import_nvd_cve(normalized)
177176
return CVERecord.objects.get(cve_id=persisted_cve_id)
178177

179178
if RustBackendClient.is_configured():
180-
cve, payload = NVDService.fetch_cve(normalized)
181-
persisted_cve_id = RustBackendClient.upsert_nvd_cve(cve=cve, raw_payload=payload, fallback_cve_id=normalized)
179+
persisted_cve_id = RustBackendClient.import_nvd_cve(normalized)
182180
return CVERecord.objects.get(cve_id=persisted_cve_id)
183181

184182
cve, payload = NVDService.fetch_cve(normalized)
@@ -312,6 +310,35 @@ def upsert_nvd_cve(*, cve: dict, raw_payload: dict, fallback_cve_id: str = '', t
312310
raise RuntimeError('Rust-Backend hat keine persistierte CVE-ID geliefert.')
313311
return cve_id
314312

313+
@staticmethod
314+
def import_nvd_cve(cve_id: str, timeout: int = 30) -> str:
315+
base = RustBackendClient._base_url()
316+
if not base:
317+
raise RuntimeError('RUST_BACKEND_URL ist nicht gesetzt.')
318+
request = Request(
319+
f'{base}/api/v1/nvd/import',
320+
data=json.dumps({'cve_id': cve_id}).encode('utf-8'),
321+
method='POST',
322+
)
323+
request.add_header('Content-Type', 'application/json')
324+
request.add_header('Accept', 'application/json')
325+
try:
326+
with urlopen(request, timeout=timeout) as response:
327+
payload = json.loads(response.read().decode('utf-8'))
328+
except HTTPError as exc:
329+
detail = ''
330+
try:
331+
error_payload = json.loads(exc.read().decode('utf-8'))
332+
detail = str(error_payload.get('error_code') or error_payload.get('message') or '').strip()
333+
except Exception:
334+
detail = str(exc.reason or '').strip()
335+
raise RuntimeError(f'Rust-Backend NVD-Import fehlgeschlagen ({exc.code}): {detail}') from exc
336+
337+
imported_cve_id = str(payload.get('cve_id') or '').strip().upper()
338+
if not imported_cve_id:
339+
raise RuntimeError('Rust-Backend hat keine importierte CVE-ID geliefert.')
340+
return imported_cve_id
341+
315342
@staticmethod
316343
def calculate_priority(*, score: float, exposure: str, criticality: str, epss_score=None,
317344
in_kev_catalog: bool = False, exploit_maturity: str = 'UNKNOWN',

apps/vulnerability_intelligence/tests.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,15 @@ def test_upsert_cve_requires_rust_when_rust_only_enabled(self):
8484

8585
@override_settings(RUST_BACKEND_URL='http://rust-backend:9000')
8686
def test_upsert_cve_uses_rust_persistence_when_rust_only_enabled(self):
87-
cve = {'id': 'CVE-2026-4242', 'descriptions': [{'lang': 'en', 'value': 'Rust CVE'}]}
88-
payload = {'vulnerabilities': [{'cve': cve}]}
89-
90-
def _rust_upsert(**_kwargs):
87+
def _rust_import(_cve_id):
9188
return CVERecord.objects.create(cve_id='CVE-2026-4242', description='Rust CVE').cve_id
9289

93-
with patch.object(NVDService, 'fetch_cve', return_value=(cve, payload)) as mocked_fetch, \
94-
patch.object(RustBackendClient, 'upsert_nvd_cve', side_effect=_rust_upsert) as mocked_rust_upsert, \
90+
with patch.object(RustBackendClient, 'import_nvd_cve', side_effect=_rust_import) as mocked_rust_import, \
9591
patch.object(NVDService, 'upsert_from_payload') as mocked_python_upsert:
9692
record = NVDService.upsert_cve('CVE-2026-4242', assume_normalized=True)
9793

9894
self.assertEqual(record.cve_id, 'CVE-2026-4242')
99-
mocked_fetch.assert_called_once_with('CVE-2026-4242')
100-
mocked_rust_upsert.assert_called_once_with(cve=cve, raw_payload=payload, fallback_cve_id='CVE-2026-4242')
95+
mocked_rust_import.assert_called_once_with('CVE-2026-4242')
10196
mocked_python_upsert.assert_not_called()
10297

10398

@@ -201,6 +196,47 @@ def test_upsert_nvd_cve_surfaces_rust_error_code(self):
201196
with self.assertRaisesRegex(RuntimeError, 'database_not_configured'):
202197
RustBackendClient.upsert_nvd_cve(cve={'id': 'CVE-2026-4242'}, raw_payload={})
203198

199+
@override_settings(RUST_BACKEND_URL='http://rust-backend:9000')
200+
def test_import_nvd_cve_uses_rust_response(self):
201+
response_mock = Mock()
202+
response_mock.read.return_value = json.dumps({
203+
'accepted': True,
204+
'api_version': 'v1',
205+
'cve_id': 'CVE-2026-4242',
206+
'persisted': True,
207+
}).encode('utf-8')
208+
response_ctx = Mock()
209+
response_ctx.__enter__ = Mock(return_value=response_mock)
210+
response_ctx.__exit__ = Mock(return_value=False)
211+
212+
with patch('apps.vulnerability_intelligence.services.urlopen', return_value=response_ctx) as mocked_urlopen:
213+
cve_id = RustBackendClient.import_nvd_cve(' cve-2026-4242 ')
214+
215+
self.assertEqual(cve_id, 'CVE-2026-4242')
216+
request = mocked_urlopen.call_args.args[0]
217+
self.assertEqual(request.full_url, 'http://rust-backend:9000/api/v1/nvd/import')
218+
payload = json.loads(request.data.decode('utf-8'))
219+
self.assertEqual(payload['cve_id'], ' cve-2026-4242 ')
220+
221+
@override_settings(RUST_BACKEND_URL='http://rust-backend:9000')
222+
def test_import_nvd_cve_surfaces_rust_error_code(self):
223+
error = HTTPError(
224+
url='http://rust-backend:9000/api/v1/nvd/import',
225+
code=404,
226+
msg='Not Found',
227+
hdrs=None,
228+
fp=io.BytesIO(json.dumps({
229+
'accepted': False,
230+
'api_version': 'v1',
231+
'error_code': 'cve_not_found',
232+
'message': 'nicht gefunden',
233+
}).encode('utf-8')),
234+
)
235+
236+
with patch('apps.vulnerability_intelligence.services.urlopen', side_effect=error):
237+
with self.assertRaisesRegex(RuntimeError, 'cve_not_found'):
238+
RustBackendClient.import_nvd_cve('CVE-2026-4242')
239+
204240
@override_settings(RUST_BACKEND_URL='http://rust-backend:9000')
205241
def test_calculate_priority_uses_rust_response(self):
206242
response_mock = Mock()

docs/RUST_CUTOVER_STATUS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ISCY Rust-Cutover-Status
22

3-
Stand: 2026-04-24
3+
Stand: 2026-04-25
44

55
## Kurzfassung
66

@@ -46,13 +46,13 @@ Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
4646

4747
## Bereits nach Rust verschoben
4848

49-
- CVE-/NVD-Normalisierung, Upsert- und Canary-Pfade.
49+
- CVE-/NVD-Normalisierung, Einzelimport-, Upsert- und Canary-Pfade.
5050
- Lokaler LLM-Endpunkt, Risiko-Priorisierung, Guidance-Auswertung und CVE-Report-Summary.
5151
- Dashboard-, Report-, Catalog-, Requirements-, Asset-, Process- und Assessment-Read-Flows.
5252
- Risk-Register Read-, Detail-, Create- und Update-Flows.
5353
- Evidence Read-/Detail-Flows, Evidence-Need-Sync und Evidence-Dateiuploads ueber Rust-Web/API.
5454
- Rust-Web-Shell mit Kontext-Formular sowie datengetriebenem Dashboard, Guidance Navigator, Catalog, Requirements, Assessments, Organizations, Risk-Register, Evidence-Ueberblick, Reports, Roadmap, Assets, Imports, Processes und Product Security.
55-
- Rust-CVE-Slice mit `GET /api/v1/cves`, `GET /api/v1/cves/{cve_id}`, `GET /api/v1/cve-assessments`, `POST /api/v1/cve-assessments`, `GET /api/v1/cve-assessments/{assessment_id}` sowie Webrouten `/cves/`, `/cves/assessments/{assessment_id}` und `/cves/llm-test/` fuer Feed, tenantgebundene Assessment-Reads, Assessment-Create-/Update-Upserts, automatische Risk-/Vulnerability-Verknuepfung und LLM-Runtime-Checks.
55+
- Rust-CVE-Slice mit `GET /api/v1/cves`, `GET /api/v1/cves/{cve_id}`, `GET /api/v1/cve-assessments`, `POST /api/v1/cve-assessments`, `GET /api/v1/cve-assessments/{assessment_id}`, `POST /api/v1/nvd/import` sowie Webrouten `/cves/`, `/cves/{assessment_id}/`, `/cves/assessments/{assessment_id}` und `/cves/llm-test/` fuer Feed, tenantgebundene Assessment-Reads, Assessment-Create-/Update-Upserts, echten Rust-NVD-Einzelimport, automatische Risk-/Vulnerability-Verknuepfung und LLM-Runtime-Checks.
5656
- Rust-DB-Admin-CLI mit `migrate`, `seed-demo` und `init-demo` fuer Rust-eigenen SQLite/PostgreSQL-Bootstrap der operativen Kern-Tabellen inklusive Product-Security sowie vollstaendigem Katalog-/Requirement-Seed.
5757
- Rust-Session-Schicht mit `iscy_auth_session`, `/api/v1/auth/sessions`, `/api/v1/auth/session`, Logout, Cookie/Bearer-Aufloesung, Django-kompatibler `pbkdf2_sha256`-Passwortpruefung und Web-Kontext ohne Query-Parameter.
5858
- Rust-RBAC-Grundlage mit `accounts_role`, `accounts_userrole`, Session-Rollencodes, Header-Rollen-Fallback und Schreibschutz fuer migrierte Write-Endpunkte.
@@ -71,14 +71,14 @@ Der alte Django-Runserver ist damit nicht mehr der lokale Standardpfad.
7171
1. **Weboberflaeche:** Rust liefert fuer `/dashboard/`, `/navigator/`, `/catalog/`, `/requirements/`, `/assessments/`, `/organizations/`, `/risks/`, `/evidence/`, `/reports/`, `/roadmap/`, `/assets/`, `/imports/`, `/processes/`, `/product-security/` und `/cves/` bereits echte serverseitige Seiten. Die restlichen Detail-/Form-Flows, Zwischenwizard-Schritte und spezialisierten Exporte liegen noch in Django-Templates und Django-Views.
7272
2. **Auth, Sessions und Admin:** Rust-Sessions, Passwort-Login, Rollen-/Schreibrechte sowie Account-Administration fuer User/Rollen-/Gruppen-/Direktrechtewechsel sind vorhanden. Die Django-kompatiblen Tabellen fuer Gruppen, Permissions und User-Zuordnung sind gebootstrapped; vollstaendige Django-Admin-Paritaet ist noch nicht komplett ersetzt.
7373
3. **Migrations und Seeds:** Ein Rust-eigener Bootstrap fuer operative Kern-Tabellen inklusive Product-Security, Catalog und Requirements ist vorhanden. Einzelne historische Django-Schema-Details ausserhalb dieser Cutover-Slices sind noch nicht vollstaendig abgeloest.
74-
4. **Formulare und Uploads:** Evidence-Dateiuploads sowie Import-Center Datei-Upload, CSV/XLSX/XLSM-Parsing und Mapping-Vorschau laufen direkt ueber Rust-Web/API. Weitere Django-Formreste ausserhalb dieser Cutover-Slices muessen noch ersetzt werden; im CVE-Bereich sind die tenantgebundenen Assessment-Upserts inklusive automatischer Risikoanreicherung jetzt in Rust, offen bleiben vor allem Restparitaet fuer NVD-Sync-/Edge-Flows.
74+
4. **Formulare und Uploads:** Evidence-Dateiuploads sowie Import-Center Datei-Upload, CSV/XLSX/XLSM-Parsing und Mapping-Vorschau laufen direkt ueber Rust-Web/API. Weitere Django-Formreste ausserhalb dieser Cutover-Slices muessen noch ersetzt werden; im CVE-Bereich sind tenantgebundene Assessment-Upserts inklusive automatischer Risikoanreicherung und der NVD-Einzelimport jetzt in Rust, offen bleiben vor allem Bulk-/Sync-Orchestrierung und letzte Edge-Paritaet.
7575
5. **Python-Dateien im Repo:** CI und lokaler Start sind Rust-first. Python/Django-Dateien bleiben noch als Legacy-Kompatibilitaet und muessen nach Abschluss von Auth/Web/Form-Flows gezielt entfernt werden.
7676

7777
## Naechster fachlich sinnvoller Cutover-Schritt
7878

7979
Der naechste Abschlussblock ist nicht mehr ein weiterer einzelner API-Endpunkt, sondern die Web- und Betriebsablösung:
8080

81-
1. Letzte Admin-Paritaet in Rust finalisieren.
81+
1. Letzte `vulnerability_intelligence`- und Admin-Reste fachlich auf Rust umhaengen.
8282
2. Django-Templates durch Rust-Web oder ein separates Frontend auf Rust-API ersetzen.
8383
3. Verbleibende Django-Template-/Form-Reste systematisch durch Rust-Web oder ein separates Frontend auf Rust-API ersetzen.
8484
4. Python-Dateien, Requirements, Django-Settings und Django-Startpfade entfernen.

docs/RUST_REWRITE_ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,14 @@ ISCY schrittweise von Django/Python auf Rust ueberfuehren, ohne Fachfunktionalit
118118
- `vulnerability_intelligence` hat nun einen Rust-Read-Slice fuer globalen CVE-Feed, tenantgebundene Assessments und LLM-Runtime-Checks:
119119
- `GET /api/v1/cves` liefert Kennzahlen und die aktuelle CVE-Liste aus `vulnerability_intelligence_cverecord`
120120
- `GET /api/v1/cves/{cve_id}` liefert Schwachstellen-Detaildaten inklusive Weaknesses, Referenzen, KEV-Flags und Raw-JSON
121+
- `POST /api/v1/nvd/import` importiert eine einzelne CVE direkt aus der NVD in Rust, persistiert sie im Rust-Store und ersetzt damit den Python-fetch-plus-upsert-Pfad fuer Einzelimporte
121122
- `GET /api/v1/cve-assessments` liefert tenantgebundene CVE-Assessments inklusive Summary, Prioritaet, Produkt- und Risiko-Bezug
122123
- `POST /api/v1/cve-assessments` legt tenantgebundene CVE-Assessments in Rust an oder aktualisiert sie per Natural-Key-Upsert, zieht Defaultwerte aus CVE/Tenant, berechnet Prioritaet/Faelligkeit, haengt den lokalen LLM-Stub an und verknuepft bei Bedarf automatisch Produkt-Security-Vulnerability und Risiko
123124
- `GET /api/v1/cve-assessments/{assessment_id}` liefert den tenantgebundenen Assessment-Detailbaum inklusive LLM-Ergebnis, empfohlenen Massnahmen und benoetigten Evidenzen
124125
- `/cves/` rendert CVE-Feed plus letzte tenantgebundene Assessments direkt aus dem Rust-Store und bietet den neuen Rust-Webflow fuer Assessment-Anlage/-Update
125-
- `/cves/assessments/{assessment_id}` rendert die Assessment-Detailansicht direkt aus dem Rust-Store
126+
- `/cves/{assessment_id}/` und `/cves/assessments/{assessment_id}` rendert die Assessment-Detailansicht direkt aus dem Rust-Store und haengt die alte Django-URL-Form an den Rust-Webslice
126127
- `/cves/llm-test/` prueft die lokale Rust-LLM-Runtime direkt ueber die Web-Shell
127-
- offen bleiben im CVE-Bereich vor allem Restparitaet fuer NVD-Sync-/Edge-Flows und das anschliessende Django-Abraumen
128+
- offen bleiben im CVE-Bereich vor allem Bulk-/Sync-Orchestrierung, letzte Edge-Paritaet und das anschliessende Django-Abraumen
128129

129130
## App-Migrationsreihenfolge
130131

0 commit comments

Comments
 (0)