Skip to content

Commit 1bb53f4

Browse files
author
Enrico Wilhelm
committed
Add vulnerability intelligence feeds and ISCY handbook
1 parent 1ab8289 commit 1bb53f4

16 files changed

Lines changed: 1316 additions & 20 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ COMPOSE_STAGE=docker compose -f docker-compose.yml -f docker-compose.stage.yml
33
COMPOSE_PROD=docker compose -f docker-compose.yml -f docker-compose.prod.yml
44
COMPOSE_PROD_LLM=docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.llm.yml
55

6-
.PHONY: dev-up dev-down stage-up stage-down prod-up prod-down prod-up-llm llm-download backup restore health
6+
.PHONY: dev-up dev-down stage-up stage-down prod-up prod-down prod-up-llm llm-download backup restore health handbook-pdf
77

88
dev-up:
99
$(COMPOSE_DEV) up --build
@@ -39,3 +39,6 @@ restore:
3939

4040
health:
4141
curl -fsS http://127.0.0.1/health/ready/
42+
43+
handbook-pdf:
44+
python3 scripts/export_iscy_handbook_pdf.py

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Django-basierte ISMS-/Cybersecurity-Plattform mit ISO 27001-, NIS2- und KRITIS-U
2222
- Product Security / CRA / AI Act / IEC 62443 / ISO-SAE 21434
2323
- CVE- und Vulnerability-Intelligence
2424
- lokales LLM-Enrichment mit Qwen3 GGUF
25+
- EPSS-/KEV-/NIS2-kontextualisierte CVE-Bewertung mit Git-/Repository-Vorbereitung
2526
- Reporting, Dashboard, Roadmap, Evidence, Risks, Requirements
2627

2728
## Betriebsmodi
@@ -92,6 +93,31 @@ Danach kann weiter wie gewohnt gestartet werden:
9293
AUTO_YES=1 INSTALL_LOCAL_LLM=1 DOWNLOAD_LOCAL_LLM=1 VERIFY_LOCAL_LLM=1 ./start.sh
9394
```
9495

96+
## Lokale Vulnerability-Feeds
97+
98+
Für lokale EPSS-/KEV-Anreicherung und Git-/Scanner-Vorbereitung stehen Management-Commands bereit:
99+
100+
```bash
101+
python3 manage.py import_epss_feed /pfad/zu/epss_scores.csv
102+
python3 manage.py import_kev_catalog /pfad/zu/known_exploited_vulnerabilities.json --reset-missing
103+
python3 manage.py import_cve_context_csv <tenant-slug> /pfad/zu/cve_context.csv --user-id <id>
104+
```
105+
106+
`import_cve_context_csv` ist für externe Git-/SBOM-/Scanner-Pipelines gedacht. Wichtige CSV-Spalten sind z. B.:
107+
`cve_id`, `product`, `release`, `component`, `repository_name`, `repository_url`, `git_ref`, `source_package`, `source_package_version`, `exposure`, `asset_criticality`, `nis2_relevant`.
108+
109+
## Handbuch
110+
111+
Ein fachliches Handbuch fuer ISCY liegt in `docs/ISCY_Handbuch.md`.
112+
113+
PDF-Export:
114+
115+
```bash
116+
make handbook-pdf
117+
```
118+
119+
Das Skript erzeugt `docs/ISCY_Handbuch.pdf`.
120+
95121
## CI
96122

97123
GitHub Actions prüft:

apps/vulnerability_intelligence/admin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55

66
@admin.register(CVERecord)
77
class CVERecordAdmin(admin.ModelAdmin):
8-
list_display = ('cve_id', 'severity', 'cvss_score', 'published_at', 'modified_at')
8+
list_display = ('cve_id', 'severity', 'cvss_score', 'epss_score', 'in_kev_catalog', 'published_at', 'modified_at')
99
search_fields = ('cve_id', 'description')
10-
list_filter = ('severity', 'source')
10+
list_filter = ('severity', 'source', 'in_kev_catalog')
1111

1212

1313
@admin.register(CVEAssessment)
1414
class CVEAssessmentAdmin(admin.ModelAdmin):
15-
list_display = ('cve', 'tenant', 'product', 'deterministic_priority', 'llm_status', 'related_risk')
15+
list_display = ('cve', 'tenant', 'product', 'deterministic_priority', 'in_kev_catalog', 'nis2_relevant', 'llm_status', 'related_risk')
1616
search_fields = ('cve__cve_id', 'product__name', 'business_context')
17-
list_filter = ('deterministic_priority', 'llm_status', 'exposure', 'asset_criticality')
17+
list_filter = ('deterministic_priority', 'llm_status', 'exposure', 'asset_criticality', 'in_kev_catalog', 'nis2_relevant', 'exploit_maturity')

apps/vulnerability_intelligence/forms.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,44 @@
44
from .models import CVEAssessment
55

66

7+
AUTO_BOOL_CHOICES = (
8+
('', 'Automatisch / aus Feed oder Tenant ableiten'),
9+
('true', 'Ja'),
10+
('false', 'Nein'),
11+
)
12+
13+
714
class CVELookupForm(forms.Form):
815
cve_id = forms.CharField(label='CVE-ID', max_length=32, help_text='Beispiel: CVE-2025-12345')
916
product = forms.ModelChoiceField(queryset=Product.objects.none(), required=False, label='Produkt')
1017
release = forms.ModelChoiceField(queryset=ProductRelease.objects.none(), required=False, label='Release')
1118
component = forms.ModelChoiceField(queryset=Component.objects.none(), required=False, label='Komponente')
1219
exposure = forms.ChoiceField(choices=CVEAssessment.Exposure.choices, initial=CVEAssessment.Exposure.UNKNOWN, label='Exponierung')
1320
asset_criticality = forms.ChoiceField(choices=CVEAssessment.AssetCriticality.choices, initial=CVEAssessment.AssetCriticality.MEDIUM, label='Geschäftskritikalität')
21+
epss_score = forms.DecimalField(required=False, max_digits=5, decimal_places=4, min_value=0, max_value=1, label='EPSS-Score', help_text='Optionaler EPSS-Wert zwischen 0 und 1.')
22+
in_kev_catalog = forms.TypedChoiceField(
23+
required=False,
24+
choices=AUTO_BOOL_CHOICES,
25+
coerce=lambda value: {'true': True, 'false': False}.get(value, None),
26+
empty_value=None,
27+
label='In CISA KEV / Known Exploited gelistet',
28+
)
29+
exploit_maturity = forms.ChoiceField(choices=CVEAssessment.ExploitMaturity.choices, initial=CVEAssessment.ExploitMaturity.UNKNOWN, label='Exploit-Reife')
30+
affects_critical_service = forms.BooleanField(required=False, label='Beeinflusst kritischen Dienst / wesentliche Leistung')
31+
nis2_relevant = forms.TypedChoiceField(
32+
required=False,
33+
choices=AUTO_BOOL_CHOICES,
34+
coerce=lambda value: {'true': True, 'false': False}.get(value, None),
35+
empty_value=None,
36+
label='NIS2-relevanter Fall',
37+
)
38+
nis2_impact_summary = forms.CharField(widget=forms.Textarea(attrs={'rows': 2}), required=False, label='NIS2-Auswirkung')
39+
repository_name = forms.CharField(required=False, max_length=255, label='Repository')
40+
repository_url = forms.URLField(required=False, label='Repository-URL')
41+
git_ref = forms.CharField(required=False, max_length=128, label='Git-Ref / Branch / Tag / Commit')
42+
source_package = forms.CharField(required=False, max_length=255, label='Paket / Artefakt')
43+
source_package_version = forms.CharField(required=False, max_length=128, label='Paket-Version')
44+
regulatory_tags = forms.CharField(required=False, label='Regulatorische Tags', help_text='Kommagetrennt, z. B. NIS2, CRA, ISO27001')
1445
business_context = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), required=False, label='Geschäftskontext')
1546
existing_controls = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), required=False, label='Vorhandene Controls')
1647
auto_create_risk = forms.BooleanField(required=False, initial=True, label='Initiales Risiko automatisch erzeugen/aktualisieren')
@@ -23,6 +54,11 @@ def __init__(self, *args, **kwargs):
2354
self.fields['product'].queryset = Product.objects.for_tenant(tenant).order_by('name')
2455
self.fields['release'].queryset = ProductRelease.objects.for_tenant(tenant).select_related('product').order_by('product__name', 'version')
2556
self.fields['component'].queryset = Component.objects.for_tenant(tenant).select_related('product').order_by('product__name', 'name')
57+
self.fields['nis2_relevant'].initial = 'true' if getattr(tenant, 'nis2_relevant', False) else ''
58+
59+
def clean_regulatory_tags(self):
60+
raw = self.cleaned_data.get('regulatory_tags', '')
61+
return [tag.strip().upper() for tag in raw.split(',') if tag.strip()]
2662

2763

2864
class LLMRuntimeTestForm(forms.Form):
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import csv
2+
3+
from django.core.management.base import BaseCommand, CommandError
4+
5+
from apps.organizations.models import Tenant
6+
from apps.product_security.models import Component, Product, ProductRelease
7+
from apps.vulnerability_intelligence.models import CVEAssessment
8+
from apps.vulnerability_intelligence.services import CVERiskEnrichmentService
9+
10+
11+
def _parse_bool(value, default=False):
12+
if value is None or str(value).strip() == '':
13+
return default
14+
return str(value).strip().lower() in {'1', 'true', 'yes', 'ja', 'y'}
15+
16+
17+
class Command(BaseCommand):
18+
help = 'Imports tenant-specific CVE assessment context from CSV, suitable for Git/SBOM/scanner pipeline outputs.'
19+
20+
def add_arguments(self, parser):
21+
parser.add_argument('tenant_slug', help='Tenant slug')
22+
parser.add_argument('file', help='Path to the CSV file')
23+
parser.add_argument('--user-id', type=int, required=True, help='User ID for audit logging')
24+
25+
def handle(self, *args, **options):
26+
try:
27+
tenant = Tenant.objects.get(slug=options['tenant_slug'])
28+
except Tenant.DoesNotExist as exc:
29+
raise CommandError(f"Tenant nicht gefunden: {options['tenant_slug']}") from exc
30+
31+
user_model = Tenant._meta.apps.get_model('accounts', 'User')
32+
try:
33+
user = user_model.objects.get(pk=options['user_id'])
34+
except user_model.DoesNotExist as exc:
35+
raise CommandError(f"User nicht gefunden: {options['user_id']}") from exc
36+
37+
created = 0
38+
updated = 0
39+
try:
40+
with open(options['file'], 'r', encoding='utf-8-sig', newline='') as handle:
41+
reader = csv.DictReader(handle)
42+
for row in reader:
43+
cve_id = str(row.get('cve_id') or row.get('cve') or '').strip().upper()
44+
if not cve_id:
45+
continue
46+
product = self._resolve_product(tenant, row.get('product'))
47+
release = self._resolve_release(tenant, product, row.get('release'))
48+
component = self._resolve_component(tenant, product, row.get('component'))
49+
assessment, was_created = self._upsert_assessment(
50+
tenant=tenant,
51+
user=user,
52+
cve_id=cve_id,
53+
product=product,
54+
release=release,
55+
component=component,
56+
row=row,
57+
)
58+
created += int(was_created)
59+
updated += int(not was_created)
60+
except FileNotFoundError as exc:
61+
raise CommandError(f"Datei nicht gefunden: {options['file']}") from exc
62+
except Exception as exc:
63+
raise CommandError(f'CSV-Import fehlgeschlagen: {exc}') from exc
64+
65+
self.stdout.write(self.style.SUCCESS(
66+
f'CSV-Kontext importiert. Neu: {created} | aktualisiert: {updated}'
67+
))
68+
69+
def _upsert_assessment(self, *, tenant, user, cve_id, product, release, component, row):
70+
existing = CVEAssessment.objects.filter(
71+
tenant=tenant,
72+
cve__cve_id=cve_id,
73+
product=product,
74+
release=release,
75+
component=component,
76+
).first()
77+
assessment = CVERiskEnrichmentService.create_or_update_assessment(
78+
tenant=tenant,
79+
user=user,
80+
cve_id=cve_id,
81+
product=product,
82+
release=release,
83+
component=component,
84+
exposure=(row.get('exposure') or CVEAssessment.Exposure.UNKNOWN).strip().upper(),
85+
asset_criticality=(row.get('asset_criticality') or CVEAssessment.AssetCriticality.MEDIUM).strip().upper(),
86+
epss_score=(row.get('epss_score') or None),
87+
in_kev_catalog=self._optional_bool(row.get('in_kev_catalog')),
88+
exploit_maturity=(row.get('exploit_maturity') or CVEAssessment.ExploitMaturity.UNKNOWN).strip().upper(),
89+
affects_critical_service=_parse_bool(row.get('affects_critical_service')),
90+
nis2_relevant=self._optional_bool(row.get('nis2_relevant')),
91+
nis2_impact_summary=(row.get('nis2_impact_summary') or '').strip(),
92+
repository_name=(row.get('repository_name') or '').strip(),
93+
repository_url=(row.get('repository_url') or '').strip(),
94+
git_ref=(row.get('git_ref') or '').strip(),
95+
source_package=(row.get('source_package') or '').strip(),
96+
source_package_version=(row.get('source_package_version') or '').strip(),
97+
regulatory_tags=self._split_tags(row.get('regulatory_tags')),
98+
business_context=(row.get('business_context') or '').strip(),
99+
existing_controls=(row.get('existing_controls') or '').strip(),
100+
auto_create_risk=_parse_bool(row.get('auto_create_risk'), default=True),
101+
run_llm=_parse_bool(row.get('run_llm')),
102+
)
103+
return assessment, existing is None
104+
105+
def _resolve_product(self, tenant, name):
106+
value = (name or '').strip()
107+
if not value:
108+
return None
109+
return Product.objects.for_tenant(tenant).filter(name=value).first()
110+
111+
def _resolve_release(self, tenant, product, version):
112+
value = (version or '').strip()
113+
if not value:
114+
return None
115+
queryset = ProductRelease.objects.for_tenant(tenant)
116+
if product is not None:
117+
queryset = queryset.filter(product=product)
118+
return queryset.filter(version=value).first()
119+
120+
def _resolve_component(self, tenant, product, name):
121+
value = (name or '').strip()
122+
if not value:
123+
return None
124+
queryset = Component.objects.for_tenant(tenant)
125+
if product is not None:
126+
queryset = queryset.filter(product=product)
127+
return queryset.filter(name=value).first()
128+
129+
def _optional_bool(self, value):
130+
if value is None or str(value).strip() == '':
131+
return None
132+
return _parse_bool(value)
133+
134+
def _split_tags(self, value):
135+
if not value:
136+
return []
137+
return [tag.strip().upper() for tag in str(value).split(',') if tag.strip()]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.core.management.base import BaseCommand, CommandError
2+
3+
from apps.vulnerability_intelligence.services import LocalIntelligenceFeedService
4+
5+
6+
class Command(BaseCommand):
7+
help = 'Imports a local EPSS CSV feed and propagates EPSS-based rescoring to existing CVE assessments.'
8+
9+
def add_arguments(self, parser):
10+
parser.add_argument('file', help='Path to the EPSS CSV file')
11+
12+
def handle(self, *args, **options):
13+
file_path = options['file']
14+
try:
15+
result = LocalIntelligenceFeedService.import_epss(file_path)
16+
except FileNotFoundError as exc:
17+
raise CommandError(f'Datei nicht gefunden: {file_path}') from exc
18+
except Exception as exc:
19+
raise CommandError(f'EPSS-Import fehlgeschlagen: {exc}') from exc
20+
21+
self.stdout.write(self.style.SUCCESS(
22+
f"EPSS importiert. Aktualisierte CVEs: {result['updated_records']} | berührte CVE-IDs: {result['touched_cves']}"
23+
))
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.core.management.base import BaseCommand, CommandError
2+
3+
from apps.vulnerability_intelligence.services import LocalIntelligenceFeedService
4+
5+
6+
class Command(BaseCommand):
7+
help = 'Imports a local CISA KEV JSON catalog and propagates KEV-based rescoring to existing CVE assessments.'
8+
9+
def add_arguments(self, parser):
10+
parser.add_argument('file', help='Path to the KEV JSON file')
11+
parser.add_argument(
12+
'--reset-missing',
13+
action='store_true',
14+
help='Mark previously imported KEV CVEs as no longer in KEV when they are missing from the current file.',
15+
)
16+
17+
def handle(self, *args, **options):
18+
file_path = options['file']
19+
try:
20+
result = LocalIntelligenceFeedService.import_kev(
21+
file_path,
22+
reset_missing=options['reset_missing'],
23+
)
24+
except FileNotFoundError as exc:
25+
raise CommandError(f'Datei nicht gefunden: {file_path}') from exc
26+
except Exception as exc:
27+
raise CommandError(f'KEV-Import fehlgeschlagen: {exc}') from exc
28+
29+
self.stdout.write(self.style.SUCCESS(
30+
f"KEV importiert. Aktualisierte CVEs: {result['updated_records']} | berührte CVE-IDs: {result['touched_cves']}"
31+
))
32+
if result['reset_missing']:
33+
self.stdout.write('Nicht mehr enthaltene KEV-Einträge wurden zurückgesetzt.')

0 commit comments

Comments
 (0)