diff --git a/README.md b/README.md index 00d146c..7c74879 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,13 @@ curl -X POST http://localhost:8000/api/check/asn-rpki \ - **CRITICAL:** Es wurde ein kritisches RPKI-Problem gefunden, zum Beispiel `invalid_asn` oder `invalid_length`. - **UNKNOWN:** Die Bewertung konnte nicht zuverlässig durchgeführt werden, zum Beispiel wegen fehlender Daten oder API-Fehlern. + +## Registry/IRR Check + +Der Prefix Check enthält zusätzlich einen read-only Registry/IRR-Plausibilitätscheck auf Basis verfügbarer RIPEstat-/Whois-/Registry-Daten. +Der Check bewertet Hinweise auf route/route6-Objekte und eine mögliche Origin-AS-Plausibilität, liefert bei unklarer Datenlage bewusst `UNKNOWN` oder `WARNING` und zeigt Rohdaten zur Nachvollziehbarkeit an. +Er ersetzt keine manuelle Registry-Prüfung. + ## Demo-Modus `ROUTEFORGE_DEMO_MODE=true` nutzt feste Beispieldaten und ist für Präsentationen, Tests und Offline-Demos gedacht. diff --git a/backend/app/services/prefix_checker.py b/backend/app/services/prefix_checker.py index 050a3a9..dfd6acf 100644 --- a/backend/app/services/prefix_checker.py +++ b/backend/app/services/prefix_checker.py @@ -2,6 +2,7 @@ from app.core.recommendations import evaluate_rpki_status from app.core.status import CheckStatus from app.config import settings +from app.services.registry_checker import RegistryChecker from app.services.ripe_db_client import RipeDbClient from app.services.ripe_stat_client import RipeStatClient from app.services.rpki_checker import RpkiChecker @@ -12,6 +13,7 @@ def __init__(self, client: RipeStatClient): self.client = client self.ripe_db = RipeDbClient(client) self.rpki = RpkiChecker(client) + self.registry = RegistryChecker() def check(self, prefix: str, origin_as: str | None) -> dict: normalized_prefix = validate_prefix(prefix) @@ -20,8 +22,8 @@ def check(self, prefix: str, origin_as: str | None) -> dict: whois = self.ripe_db.whois(normalized_prefix) routing_status = self.client.get("routing-status", {"resource": normalized_prefix}) rpki_check = self.rpki.check(normalized_prefix, normalized_origin) + registry_check = self.registry.check(normalized_prefix, normalized_origin, whois) - status = CheckStatus(rpki_check["status"]) warnings: list[str] = [] if whois.get("error") or routing_status.get("error"): warnings.append("Mindestens eine zusätzliche Datenquelle war nicht erreichbar.") @@ -47,7 +49,15 @@ def check(self, prefix: str, origin_as: str | None) -> dict: "risk": rpki_check["risk"], "recommendations": rpki_check["recommendations"], "raw": rpki_check.get("raw", {}), - } + }, + "registry": { + "status": registry_check["status"], + "summary": registry_check["summary"], + "explanation": registry_check["explanation"], + "risk": registry_check["risk"], + "recommendations": registry_check["recommendations"], + "raw": registry_check.get("raw", {}), + }, }, "details": { "whois": whois, @@ -56,6 +66,7 @@ def check(self, prefix: str, origin_as: str | None) -> dict: "whois": whois.get("error"), "routing_status": routing_status.get("error"), "rpki": rpki_check.get("raw", {}).get("error") if isinstance(rpki_check.get("raw"), dict) else None, + "registry": registry_check.get("raw", {}).get("error") if isinstance(registry_check.get("raw"), dict) else None, }, "warnings": warnings, "demo_mode": settings.demo_mode, diff --git a/backend/app/services/registry_checker.py b/backend/app/services/registry_checker.py new file mode 100644 index 0000000..bbe5bee --- /dev/null +++ b/backend/app/services/registry_checker.py @@ -0,0 +1,106 @@ +from app.core.status import CheckStatus + + +class RegistryChecker: + def check(self, prefix: str, origin_as: str | None, whois_payload: dict) -> dict: + if not isinstance(whois_payload, dict) or whois_payload.get("error"): + return { + "status": CheckStatus.UNKNOWN.value, + "summary": "Registry-/IRR-Daten konnten nicht bestimmt werden", + "explanation": "Die Whois-/Registry-Datenquelle war nicht erreichbar oder lieferte einen Fehler.", + "risk": "Die Bewertung ist unvollständig.", + "recommendations": [ + "Prüfe die Rohdaten der Registry-Quelle.", + "Wiederhole die Abfrage später.", + "Vergleiche das Ergebnis mit einer zweiten Registry-/IRR-Quelle.", + ], + "raw": whois_payload if isinstance(whois_payload, dict) else {}, + } + + route_origins = self._extract_route_origins(whois_payload) + has_registry_data = bool(whois_payload.get("data") or route_origins) + + if not has_registry_data: + return { + "status": CheckStatus.UNKNOWN.value, + "summary": "Keine verwertbaren Registry-/IRR-Daten gefunden", + "explanation": "Die Quelle lieferte keine eindeutig parsebaren Daten zum Prefix.", + "risk": "Es kann keine belastbare Plausibilitätsaussage getroffen werden.", + "recommendations": [ + "Prüfe das Prefix manuell in der zuständigen Registry.", + "Vergleiche die Daten mit einer alternativen Whois-/IRR-Quelle.", + ], + "raw": whois_payload, + } + + if not route_origins: + return { + "status": CheckStatus.WARNING.value, + "summary": "Registry-Daten vorhanden, aber kein route/route6-Hinweis gefunden", + "explanation": "Es wurden allgemeine Whois-/Registry-Daten gefunden, jedoch kein klares route/route6-Objekt.", + "risk": "Ohne route/route6-Hinweis bleibt die Origin-Plausibilität eingeschränkt.", + "recommendations": [ + "Prüfe, ob ein passendes route/route6-Objekt in der IRR gepflegt ist.", + "Validiere die Origin-Zuordnung zusätzlich manuell.", + ], + "raw": whois_payload, + } + + if not origin_as: + return { + "status": CheckStatus.OK.value, + "summary": "Route/route6-Hinweise gefunden", + "explanation": "Es wurden route/route6-Objekte bzw. Origin-Hinweise zum Prefix gefunden. Ohne angegebenes Origin-AS erfolgt keine AS-Konsistenzprüfung.", + "risk": "Grundsätzliche Registry-Plausibilität ist gegeben, AS-Abgleich ist offen.", + "recommendations": [ + "Für eine strengere Prüfung optional ein Origin-AS mitgeben.", + ], + "raw": whois_payload, + } + + normalized_origin = origin_as.upper() + if normalized_origin in route_origins: + return { + "status": CheckStatus.OK.value, + "summary": "Plausibles route/route6-Origin gefunden", + "explanation": f"Mindestens ein route/route6-Hinweis enthält das erwartete Origin {normalized_origin}.", + "risk": "Keine offensichtliche Registry-Inkonsistenz erkannt.", + "recommendations": [ + "Registry-Daten regelmäßig aktuell halten.", + ], + "raw": whois_payload, + } + + return { + "status": CheckStatus.CRITICAL.value, + "summary": "Route/route6-Origin widerspricht dem angegebenen Origin-AS", + "explanation": f"Gefundene Origins: {', '.join(sorted(route_origins))}. Erwartet wurde {normalized_origin}.", + "risk": "Möglicher Konfigurations- oder Registry-Fehler mit Hijack-Risiko.", + "recommendations": [ + "Origin-AS und route/route6-Objekte in der zuständigen Registry abgleichen.", + "Fehlerhafte Registry-Einträge korrigieren.", + ], + "raw": whois_payload, + } + + def _extract_route_origins(self, payload: dict) -> set[str]: + objects = payload.get("data", {}).get("irr_records") or payload.get("data", {}).get("records") or [] + origins: set[str] = set() + + for obj in objects: + route_seen = False + current_origin: str | None = None + + for field in obj if isinstance(obj, list) else []: + key = str(field.get("key", "")).lower() + value = str(field.get("value", "")).strip().upper() + + if key in {"route", "route6"} and value: + route_seen = True + if key == "origin" and value.startswith("AS"): + current_origin = value + + if route_seen and current_origin: + origins.add(current_origin) + + return origins diff --git a/backend/app/templates/report.html.j2 b/backend/app/templates/report.html.j2 index 839a843..2e29c91 100644 --- a/backend/app/templates/report.html.j2 +++ b/backend/app/templates/report.html.j2 @@ -27,12 +27,21 @@
  • Risiko: {{ report.checks.rpki.risk if report.checks and report.checks.rpki else '-' }}
  • +

    Registry/IRR

    + +

    Technische Details

    {{ report.details | tojson(indent=2) }}

    Rohdaten

    - RPKI API Rohdaten anzeigen -
    {{ report.checks.rpki.raw | tojson(indent=2) if report.checks and report.checks.rpki else '{}' }}
    + RPKI/Registry API Rohdaten anzeigen +
    {"rpki": {{ (report.checks.rpki.raw if report.checks and report.checks.rpki else {}) | tojson(indent=2) }},
    +"registry": {{ (report.checks.registry.raw if report.checks and report.checks.registry else {}) | tojson(indent=2) }}}
    diff --git a/backend/app/templates/report.md.j2 b/backend/app/templates/report.md.j2 index b42abdb..a782df7 100644 --- a/backend/app/templates/report.md.j2 +++ b/backend/app/templates/report.md.j2 @@ -30,6 +30,12 @@ - Erklärung: {{ report.checks.rpki.explanation if report.checks and report.checks.rpki else '-' }} - Risiko: {{ report.checks.rpki.risk if report.checks and report.checks.rpki else '-' }} +### Registry/IRR +- Status: **{{ report.checks.registry.status if report.checks and report.checks.registry else '-' }}** +- Summary: {{ report.checks.registry.summary if report.checks and report.checks.registry else '-' }} +- Erklärung: {{ report.checks.registry.explanation if report.checks and report.checks.registry else '-' }} +- Risiko: {{ report.checks.registry.risk if report.checks and report.checks.registry else '-' }} + ## Technische Details ```json {{ report.details | tojson(indent=2) }} @@ -37,7 +43,7 @@ ## Rohdaten ```json -{{ report.checks.rpki.raw | tojson(indent=2) if report.checks and report.checks.rpki else '{}' }} +{{ (report.checks.rpki.raw if report.checks and report.checks.rpki else {}) | tojson(indent=2) }}\n\nRegistry/IRR:\n{{ (report.checks.registry.raw if report.checks and report.checks.registry else {}) | tojson(indent=2) }} ``` diff --git a/backend/tests/test_registry_checker.py b/backend/tests/test_registry_checker.py new file mode 100644 index 0000000..f819f11 --- /dev/null +++ b/backend/tests/test_registry_checker.py @@ -0,0 +1,42 @@ +from app.services.registry_checker import RegistryChecker + + +def test_route_object_matching_origin_is_ok(): + payload = { + "data": { + "records": [ + [ + {"key": "route", "value": "193.0.6.0/24"}, + {"key": "origin", "value": "AS3333"}, + ] + ] + } + } + result = RegistryChecker().check("193.0.6.0/24", "AS3333", payload) + assert result["status"] == "OK" + + +def test_no_route_object_is_warning(): + payload = {"data": {"records": [[{"key": "descr", "value": "example"}]]}} + result = RegistryChecker().check("193.0.6.0/24", "AS3333", payload) + assert result["status"] == "WARNING" + + +def test_route_object_origin_mismatch_is_critical(): + payload = { + "data": { + "records": [ + [ + {"key": "route", "value": "193.0.6.0/24"}, + {"key": "origin", "value": "AS64500"}, + ] + ] + } + } + result = RegistryChecker().check("193.0.6.0/24", "AS3333", payload) + assert result["status"] == "CRITICAL" + + +def test_empty_data_is_unknown(): + result = RegistryChecker().check("193.0.6.0/24", "AS3333", {}) + assert result["status"] == "UNKNOWN" diff --git a/frontend/src/components/ReportView.tsx b/frontend/src/components/ReportView.tsx index 5efcc9c..ae6c02f 100644 --- a/frontend/src/components/ReportView.tsx +++ b/frontend/src/components/ReportView.tsx @@ -7,7 +7,8 @@ const order = { CRITICAL: 0, WARNING: 1, UNKNOWN: 2, OK: 3 } export function ReportView({ report }: { report: CheckResponse }) { const recs = report.recommendations ?? [] const rpki = report.checks?.rpki - const hasRpkiCheck = Boolean(rpki) + const registry = report.checks?.registry + const hasAnyChecks = Boolean(rpki || registry) const rpkiExplanation = report.details?.rpki_explanation const extractedPrefixes = Array.isArray(report.details?.extracted_prefixes) ? report.details.extracted_prefixes : [] const rpkiSummary = report.details?.rpki_summary @@ -30,7 +31,8 @@ export function ReportView({ report }: { report: CheckResponse }) { {recs.length > 0 ? :

    Keine Empfehlungen verfügbar.

    }

    Einzelprüfungen

    - {hasRpkiCheck ?
    + {hasAnyChecks ?
    + {rpki &&
    RPKI

    Summary: {rpki?.summary || '-'}

    Erklärung: {rpki?.explanation || '-'}

    @@ -39,6 +41,17 @@ export function ReportView({ report }: { report: CheckResponse }) { RPKI Rohdaten
    {JSON.stringify(rpki?.raw ?? {}, null, 2)}
    +
    } + {registry &&
    +
    Registry/IRR
    +

    Summary: {registry?.summary || '-'}

    +

    Erklärung: {registry?.explanation || '-'}

    +

    Risiko: {registry?.risk || '-'}

    +
    + Registry/IRR Rohdaten +
    {JSON.stringify(registry?.raw ?? {}, null, 2)}
    +
    +
    }
    :

    Für diesen Check-Typ sind keine Einzelprüfungen verfügbar.

    } {rpkiSummary &&

    ASN-RPKI Summary

    diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cb584a4..6015792 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -24,7 +24,7 @@ export type CheckResponse = { risk?: string recommendations: string[] input?: { prefix?: string; origin_as?: string | null; asn?: string; limit?: number } - checks?: { rpki?: CheckSection } | null + checks?: { rpki?: CheckSection; registry?: CheckSection } | null details?: { rpki_explanation?: string extracted_prefixes?: string[]