Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 623d80b6c8 | |||
| 8609b696c9 | |||
| 207fc9cb56 | |||
| fdaf547b06 | |||
| fa536f9714 | |||
| cba066f49b | |||
| 75f7bd8de4 | |||
| f85fff4398 | |||
| 3bcffaf52c | |||
| 3a19affb67 | |||
| 2b985ad526 | |||
| 4e761c1363 | |||
| 9aef5ecf6c | |||
| f6c5f4e0a9 | |||
| c72fd3eb5a | |||
| b0435f9885 | |||
| 2341bda621 | |||
| 4634cc09d0 | |||
| d4df1e01df | |||
| ed31fdc0df | |||
| 5412bf0ba3 | |||
| 8a9d5e7c4d | |||
| 01956ee690 | |||
| e46e74ddbb | |||
| 63d65af41b | |||
| 8937f105ea | |||
| 1584b8fb2f | |||
| 2301fb2122 | |||
| 4aa6aa9812 | |||
| a53d67a35a | |||
| 3259984d1c | |||
| 5e3ed4071b | |||
| c090617afd | |||
| c5ecfa8f6c | |||
| 417bcda68c | |||
| 86d1473a6a | |||
| 9e0a9ccef4 | |||
| 7e1c3668bf | |||
| ab3cb86b1c | |||
| 0db0e9a129 | |||
| 53ea388ea0 | |||
| e5cce9caff | |||
| 2f3c98fbe0 | |||
| d987e4fde6 | |||
| 67dba5f641 | |||
| a3053c3c86 | |||
| db2fd9d8e9 | |||
| d21e1247c9 | |||
| e1b270c36e | |||
| 48e39423e6 | |||
| 188bb787d2 | |||
| 2645b5b043 | |||
| 6b7950f428 | |||
| ba6f1bd1f6 | |||
| c1ea9458a7 | |||
| 0631a98bdd | |||
| c3542f7dfe | |||
| 7ec29999a2 | |||
| 402a42d30d |
@@ -33,6 +33,14 @@ COPY migrations/ ./migrations/
|
|||||||
# Copy policy files (YAML rules)
|
# Copy policy files (YAML rules)
|
||||||
COPY policies/ ./policies/
|
COPY policies/ ./policies/
|
||||||
|
|
||||||
|
# Copy Compliance Execution Graph data (file-backed: Registry join-key copy + accepted control
|
||||||
|
# mappings + evidence requirements) consumed by GET /sdk/v1/compliance/obligation-status.
|
||||||
|
# data/obligations/obligation_join_keys.json is a synced copy of the repo-root Registry contract
|
||||||
|
# (the Obligation Registry owns the canonical file) — re-sync it when the Registry grows.
|
||||||
|
COPY data/control_mappings/ ./data/control_mappings/
|
||||||
|
COPY data/evidence_requirements/ ./data/evidence_requirements/
|
||||||
|
COPY data/obligations/ ./data/obligations/
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN adduser -D -u 1000 appuser
|
RUN adduser -D -u 1000 appuser
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Control-Mapping: CRA Annex I -> NIST SP 800-53 Rev. 5. Eine Zeile = ein Mapping (Schema: ControlMapping).
|
||||||
|
// Reviewt 2026-06-25 (benjamin): 3 accepted, mapping_type=primary_implementation (kanonische Primaer-Control je Anforderung).
|
||||||
|
// Heimat der OWASP-Rejects (2)(e)/(2)(l)/(2)(i): dort war OWASP nicht der Zielstandard ("Mapping ueber NIST/BSI erforderlich").
|
||||||
|
// related-Controls (SC-3(3), RA-5, AC-6, SI-16, ...) folgen separat als mapping_type=supports — hier nur der kanonische Einstieg.
|
||||||
|
// obligation_id (Registry-Handoff #4 adoptiert, #6 auf CORE re-pointet 2026-06-26): SI-7->software_integrity_protection (CORE (2)(f)), SI-2->provide_security_updates, CM-7->attack_surface_minimization (CORE (2)(j)). Join exakt. Die domaenen-scoped IDs (signed_update_integrity, remote_access_attack_surface_min) bleiben gueltige Obligations und zeigen per specializes->CORE auf diese Ziele.
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-7 = Software, Firmware, and Information Integrity — kanonische Integritaetskontrolle (Signaturpruefung, Manipulationserkennung).", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Integritaetsanforderung; OWASP war hier kein passender Treffer. Related (spaeter, supports): SA-10, CM-14.", "version": "2026-06-25", "obligation_id": "software_integrity_protection"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-2", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-2 = Flaw Remediation — kanonische Update-/Patch-Kontrolle.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Update-Anforderung. Related (spaeter, supports): RA-5, CM-3, SA-11.", "version": "2026-06-25", "obligation_id": "provide_security_updates"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "CM-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST CM-7 = Least Functionality — Deaktivierung nicht benoetigter Ports/Dienste/Funktionen.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "CM-7 als Primaer-Control fuer Angriffsflaeche (nicht SC-3(3)). Related (spaeter, supports): SC-3(3), AC-6, SI-16.", "version": "2026-06-25", "obligation_id": "attack_surface_minimization"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Control-Mapping: CRA Annex I -> OWASP ASVS 5.0. Eine Zeile = ein Mapping (Schema: ControlMapping).
|
||||||
|
// Reviewt 2026-06-25 (benjamin): 7 accepted, 13 rejected. accepted = Audit-Wahrheit (Advisor nutzt acceptedOnly).
|
||||||
|
// rejected bleiben als Audit-Spur ("warum verworfen"). KEIN confidence — kuratiert = fachliche Feststellung.
|
||||||
|
// Architekturbeweis: CRA -> OWASP fuer AppSec/Auth/Crypto/Logging; Ops/Update/Attack-Surface/Integritaet -> NIST/BSI.
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.3.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.2.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11 = Cryptography.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11 = Cryptography, richtiger Bereich fuer Verschluesselung.", "version": "2026-06-25", "obligation_id": "credential_confidentiality_protection"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.7.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11.7 = Key Management.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11.7 = Key Management fuer Verschluesselung/Schluesselverwaltung.", "version": "2026-06-25", "obligation_id": "auth_key_management"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.3", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.4", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, kein Auth — verworfen.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.3.2", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V1.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V2.4.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V15.3.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
|
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V8.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Evidence-Requirements je NIST-SP-800-53-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
|
||||||
|
// WICHTIG: evidence_type ist FRAMEWORK-AGNOSTISCH (geteilter Katalog config_export/test_report/repo_scan/sbom/...) —
|
||||||
|
// dieselben Typen tragen CRA, NIST, ISO 27001, IEC 62443, BSI. (framework, control) ist nur der Verweis, nicht der Typ.
|
||||||
|
// Stand 2026-06-25, Basis: die 3 accepted CRA->NIST primary_implementation-Mappings (SI-7 Integritaet, SI-2 Updates, CM-7 Angriffsflaeche).
|
||||||
|
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die Integritaet/Herkunft der Software-Bestandteile nach (bekannte, unmanipulierte Komponenten).", "version": "2026-06-25"}
|
||||||
|
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Secure-Boot-/Code-Signing-Konfiguration als Nachweis der Integritaetspruefung.", "version": "2026-06-25"}
|
||||||
|
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration des sicheren Update-/Patch-Mechanismus (signierte/automatische Updates) als technischer Nachweis.", "version": "2026-06-25"}
|
||||||
|
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Update-/Patch-Verifikationstest (CI) belegt, dass Sicherheitsupdates greifen.", "version": "2026-06-25"}
|
||||||
|
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration deaktivierter Ports/Dienste/Funktionen als Nachweis minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
||||||
|
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "repo_scan", "evidence_source": "scanner", "freshness_requirement": "per_release", "required": true, "rationale": "Angriffsflaechen-Scan (offene Ports/Dienste) als Nachweis tatsaechlich minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Evidence-Requirements je OWASP-ASVS-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
|
||||||
|
// Autoriert/kuratiert (nicht Retriever). Der Advisor kann eine CRA-Anforderung erst dann als erfuellt melden,
|
||||||
|
// wenn die required Evidenzen der gemappten, accepted Controls vorliegen + frisch genug sind.
|
||||||
|
// Stand 2026-06-25, Basis: die 7 accepted CRA->OWASP-Mappings (Auth V6, Crypto V11, Logging V16).
|
||||||
|
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "IAM-/Zugriffskonfiguration als Nachweis der Authentisierungs-Anforderung.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Automatisierter Zugriffstest (CI) belegt funktionierende Zugriffskontrolle.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "pentest", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": false, "rationale": "Jaehrlicher PenTest der Authentisierung — vertieft, aber nicht Pflicht je Release.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V6.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Rollenmodell/Auth-Architektur als Nachweis.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Krypto-Konfiguration (zugelassene Algorithmen) als Nachweis der Verschluesselung.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die eingesetzten Krypto-Bibliotheken/-Versionen nach.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "policy", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": true, "rationale": "Key-Management-Policy (Rotation, Aufbewahrung) als organisatorischer Nachweis.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration der Schluesselverwaltung als technischer Nachweis.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs belegen, dass sicherheitsrelevante Ereignisse protokolliert werden.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Konfiguration als Nachweis der erfassten Ereignisarten.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V16.3.4", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs.", "version": "2026-06-25"}
|
||||||
|
{"framework": "OWASP ASVS", "control": "V16.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Architektur-Konfiguration als Nachweis.", "version": "2026-06-25"}
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "obligation_join_keys_v1",
|
||||||
|
"contract": "obligation_id ist der stabile Join-Key. Legal Knowledge Graph haengt citation_spans an obligation_id; Compliance Execution Graph mappt control_mapping.source_norm -> obligation_id. Interim-Bruecke = citation_units. obligation_id NIE neu vergeben (re-link).",
|
||||||
|
"count": 95,
|
||||||
|
"obligation_ids": [
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_creation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_dependency_coverage",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 3(36) i.V.m. Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_format_standard",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_maintenance_update",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_completeness_verification",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_tooling_automation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_access_provision",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_authority_provision",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31 / Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_confidentiality",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31(4)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_supply_chain_contracts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_technical_documentation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31 i.V.m. Annex VII"
|
||||||
|
],
|
||||||
|
"source_role": "EVIDENCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_identification_inventory",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_assessment_prioritization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_remediation_patching",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (2) & (8)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_handling_process",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Article 13(8) & Annex VII"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "coordinated_vulnerability_disclosure",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (5)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "exploited_vuln_reporting_authorities",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Article 14 & Article 16"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_info_dissemination_users",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (4) & (6)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "attack_surface_minimization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(j)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "software_integrity_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(f)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "user_authentication_required",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "authentication_policy_documented",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "auth_exceptions_documented",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "mfa_required",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "step_up_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "privileged_op_reauth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "strong_crypto_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "credential_lifecycle_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "credential_confidentiality_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "password_policy",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "no_default_credentials",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(a)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "account_lockout_failed_attempts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "server_side_validation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "session_binding_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "reauth_after_inactivity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "token_validation_lifecycle",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "mutual_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "revocation_check",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "encrypted_auth_channel",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "tls_certificate_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "service_to_service_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "auth_key_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "biometric_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "federated_auth_assertions",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "separate_authn_authz",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "supplier_access_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "personal_admin_accounts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "firmware_software_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "event_logging_security_events",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "access_control_event_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "audit_trail_admin_actions",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_integrity_immutability",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_access_control_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_retention_archival",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "centralized_log_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_monitoring_alerting",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_data_minimization_privacy",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_format_standardization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_timestamp_synchronization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_availability_resilience",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_thread_safety_correctness",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_library_supply_chain",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_config_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_governance_roles",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "incident_response_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_transmission_security",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "network_traffic_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_control_least_privilege",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_confidentiality_integrity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(b)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_session_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_mfa",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_encryption",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "reject_insecure_remote_protocols",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_logging_audit",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(g)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_user_validation_ot",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_training",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_architecture_design",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_attack_surface_min",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(a)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_vuln_patch_mgmt",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_threat_detection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_maintenance_governance",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "temporary_remote_access_mgmt",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_data_export_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "component_remote_interface_security",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_fallback_concept",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "provide_security_updates",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)",
|
||||||
|
"Art. 13"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "support_period_maintenance",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 13(8)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "signed_update_integrity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(3)(f)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "trusted_update_source",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(3)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_testing_validation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_rollback",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "automatic_updates_optout",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_risk_assessment",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "secure_modification_control",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComplianceGraphHandlers serves the read-only Compliance Execution Graph
|
||||||
|
// (Regulation -> Obligation -> Control -> Evidence) over the file-backed bridge artifacts.
|
||||||
|
// It is intentionally SEPARATE from the DB-backed ObligationsHandlers: this is the curated
|
||||||
|
// cross-session graph (Registry join keys + accepted control mappings + evidence requirements),
|
||||||
|
// loaded once at startup. Fail-closed: if the graph could not load, every request answers 503.
|
||||||
|
type ComplianceGraphHandlers struct {
|
||||||
|
joins *ucca.ObligationJoinKeys
|
||||||
|
mappings *ucca.ControlMappingSet
|
||||||
|
evidence *ucca.EvidenceRequirementSet
|
||||||
|
loadErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewComplianceGraphHandlers loads the graph once. Construction never fails; a load error is
|
||||||
|
// retained and surfaced as 503 per request (matches the codebase's load-warn-continue startup).
|
||||||
|
func NewComplianceGraphHandlers() *ComplianceGraphHandlers {
|
||||||
|
joins, mappings, evidence, err := ucca.LoadComplianceGraph()
|
||||||
|
return &ComplianceGraphHandlers{joins: joins, mappings: mappings, evidence: evidence, loadErr: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadError exposes a startup load failure so the wiring can log a warning.
|
||||||
|
func (h *ComplianceGraphHandlers) LoadError() error { return h.loadErr }
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the compliance-graph routes under /compliance.
|
||||||
|
func (h *ComplianceGraphHandlers) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
|
g := r.Group("/compliance")
|
||||||
|
g.GET("/obligation-status", h.ObligationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cgControlDTO struct {
|
||||||
|
Framework string `json:"framework"`
|
||||||
|
Control string `json:"control"`
|
||||||
|
MappingType string `json:"mapping_type"`
|
||||||
|
EvidenceRequired []string `json:"evidence_required"`
|
||||||
|
EvidenceStatus string `json:"evidence_status"` // missing | partial | present | none_required
|
||||||
|
}
|
||||||
|
|
||||||
|
type cgStatusResponse struct {
|
||||||
|
ObligationID string `json:"obligation_id"`
|
||||||
|
OverallStatus string `json:"overall_status"` // unknown_obligation | unmapped | not_assessed | open | met
|
||||||
|
LegalBasis []string `json:"legal_basis,omitempty"`
|
||||||
|
CitationSpans string `json:"citation_spans"` // "pending" until the Legal-KG attaches spans
|
||||||
|
Controls []cgControlDTO `json:"controls"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationStatus answers GET /sdk/v1/compliance/obligation-status?obligation_id=...
|
||||||
|
//
|
||||||
|
// It NEVER asserts fulfillment automatically. With no evidence collection wired (MVP), a mapped
|
||||||
|
// obligation is "not_assessed" and every required evidence is "missing" — the honest picture is
|
||||||
|
// "required vs present evidence", not "a document exists". Fail-closed otherwise:
|
||||||
|
// - no obligation_id -> 400
|
||||||
|
// - graph not loaded -> 503
|
||||||
|
// - id not in the Registry -> 200 overall_status=unknown_obligation
|
||||||
|
// - mapped but no control yet -> 200 overall_status=unmapped
|
||||||
|
func (h *ComplianceGraphHandlers) ObligationStatus(c *gin.Context) {
|
||||||
|
if h.loadErr != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compliance graph unavailable", "detail": h.loadErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
obID := strings.TrimSpace(c.Query("obligation_id"))
|
||||||
|
if obID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "obligation_id query parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := cgStatusResponse{ObligationID: obID, CitationSpans: "pending", Controls: []cgControlDTO{}}
|
||||||
|
|
||||||
|
if h.joins.FindObligation(obID) == nil {
|
||||||
|
resp.OverallStatus = "unknown_obligation"
|
||||||
|
resp.Note = "obligation_id not in the Registry join-key contract"
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVP: hasEvidence=nil -> no collection wired -> all required evidence counts as missing.
|
||||||
|
st := ucca.AssessObligationStatus(h.joins, h.mappings, h.evidence, obID, nil)
|
||||||
|
resp.LegalBasis = st.LegalBasis
|
||||||
|
|
||||||
|
if len(st.Controls) == 0 {
|
||||||
|
resp.OverallStatus = "unmapped"
|
||||||
|
resp.Note = "no accepted control maps to this obligation yet"
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cs := range st.Controls {
|
||||||
|
types := make([]string, 0, len(cs.RequiredEvidence))
|
||||||
|
for _, e := range cs.RequiredEvidence {
|
||||||
|
types = append(types, e.EvidenceType)
|
||||||
|
}
|
||||||
|
resp.Controls = append(resp.Controls, cgControlDTO{
|
||||||
|
Framework: cs.Framework,
|
||||||
|
Control: cs.Control,
|
||||||
|
MappingType: cs.MappingType,
|
||||||
|
EvidenceRequired: types,
|
||||||
|
EvidenceStatus: cgEvidenceStatus(len(cs.RequiredEvidence), len(cs.MissingEvidence)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// No fulfillment claim without real evidence collection.
|
||||||
|
resp.OverallStatus = "not_assessed"
|
||||||
|
resp.Note = "evidence collection not wired (MVP) — fulfillment not asserted"
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cgEvidenceStatus(required, missing int) string {
|
||||||
|
switch {
|
||||||
|
case required == 0:
|
||||||
|
return "none_required"
|
||||||
|
case missing == 0:
|
||||||
|
return "present"
|
||||||
|
case missing == required:
|
||||||
|
return "missing"
|
||||||
|
default:
|
||||||
|
return "partial"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newComplianceGraphTestRouter(t *testing.T) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
h := NewComplianceGraphHandlers()
|
||||||
|
if err := h.LoadError(); err != nil {
|
||||||
|
t.Fatalf("compliance graph failed to load (candidate paths): %v", err)
|
||||||
|
}
|
||||||
|
r := gin.New()
|
||||||
|
h.RegisterRoutes(r.Group("/sdk/v1"))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func getObligationStatus(t *testing.T, r *gin.Engine, query string) (int, cgStatusResponse) {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/sdk/v1/compliance/obligation-status"+query, nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
var resp cgStatusResponse
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Code, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObligationStatus(t *testing.T) {
|
||||||
|
r := newComplianceGraphTestRouter(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantHTTP int
|
||||||
|
wantOverall string
|
||||||
|
wantControls bool // expect >=1 control
|
||||||
|
}{
|
||||||
|
{"missing param -> 400", "", http.StatusBadRequest, "", false},
|
||||||
|
{"unknown id -> unknown_obligation", "?obligation_id=does_not_exist", http.StatusOK, "unknown_obligation", false},
|
||||||
|
{"mapped (OWASP V6) -> not_assessed", "?obligation_id=user_authentication_required", http.StatusOK, "not_assessed", true},
|
||||||
|
{"NIST adopted (SI-2) -> not_assessed", "?obligation_id=provide_security_updates", http.StatusOK, "not_assessed", true},
|
||||||
|
{"CORE attack_surface_minimization -> CM-7", "?obligation_id=attack_surface_minimization", http.StatusOK, "not_assessed", true},
|
||||||
|
{"CORE software_integrity_protection -> SI-7", "?obligation_id=software_integrity_protection", http.StatusOK, "not_assessed", true},
|
||||||
|
{"in registry, no control -> unmapped", "?obligation_id=sbom_creation", http.StatusOK, "unmapped", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
code, resp := getObligationStatus(t, r, tt.query)
|
||||||
|
if code != tt.wantHTTP {
|
||||||
|
t.Fatalf("http %d, want %d", code, tt.wantHTTP)
|
||||||
|
}
|
||||||
|
if tt.wantHTTP != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.OverallStatus != tt.wantOverall {
|
||||||
|
t.Errorf("overall_status=%q, want %q", resp.OverallStatus, tt.wantOverall)
|
||||||
|
}
|
||||||
|
if tt.wantControls && len(resp.Controls) == 0 {
|
||||||
|
t.Error("expected >=1 control")
|
||||||
|
}
|
||||||
|
if !tt.wantControls && len(resp.Controls) != 0 {
|
||||||
|
t.Errorf("expected 0 controls, got %d", len(resp.Controls))
|
||||||
|
}
|
||||||
|
if resp.CitationSpans != "pending" {
|
||||||
|
t.Errorf("citation_spans=%q, want pending", resp.CitationSpans)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The MVP must NEVER auto-assert fulfillment: with no evidence collection wired, every required
|
||||||
|
// evidence is "missing" and the overall status stays "not_assessed".
|
||||||
|
func TestObligationStatus_NoFulfillmentClaim(t *testing.T) {
|
||||||
|
r := newComplianceGraphTestRouter(t)
|
||||||
|
code, resp := getObligationStatus(t, r, "?obligation_id=user_authentication_required")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("http %d", code)
|
||||||
|
}
|
||||||
|
if resp.OverallStatus == "met" || resp.OverallStatus == "erfuellt" {
|
||||||
|
t.Fatalf("MVP must not assert fulfillment, got overall_status=%q", resp.OverallStatus)
|
||||||
|
}
|
||||||
|
for _, ctl := range resp.Controls {
|
||||||
|
if len(ctl.EvidenceRequired) > 0 && ctl.EvidenceStatus != "missing" {
|
||||||
|
t.Errorf("control %s/%s evidence_status=%q, want missing (no collection wired)", ctl.Framework, ctl.Control, ctl.EvidenceStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin the curated evidence_required set per NIST obligation. A required:false row silently
|
||||||
|
// drops from evidence_required, which the table test above (control-count only) would miss.
|
||||||
|
func TestObligationStatus_NISTEvidenceTypes(t *testing.T) {
|
||||||
|
r := newComplianceGraphTestRouter(t)
|
||||||
|
want := map[string][]string{
|
||||||
|
"attack_surface_minimization": {"config_export", "repo_scan"},
|
||||||
|
"software_integrity_protection": {"sbom", "config_export"},
|
||||||
|
"provide_security_updates": {"config_export", "test_report"},
|
||||||
|
}
|
||||||
|
for ob, exp := range want {
|
||||||
|
_, resp := getObligationStatus(t, r, "?obligation_id="+ob)
|
||||||
|
if len(resp.Controls) != 1 {
|
||||||
|
t.Fatalf("%s: want 1 control, got %d", ob, len(resp.Controls))
|
||||||
|
}
|
||||||
|
if got := resp.Controls[0].EvidenceRequired; !sameStringSet(got, exp) {
|
||||||
|
t.Errorf("%s evidence_required = %v, want %v", ob, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameStringSet(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m := make(map[string]bool, len(a))
|
||||||
|
for _, x := range a {
|
||||||
|
m[x] = true
|
||||||
|
}
|
||||||
|
for _, x := range b {
|
||||||
|
if !m[x] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -153,6 +153,12 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||||
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
||||||
|
|
||||||
|
// Compliance Execution Graph (file-backed: Registry join keys + accepted control mappings + evidence)
|
||||||
|
complianceGraphHandlers := handlers.NewComplianceGraphHandlers()
|
||||||
|
if err := complianceGraphHandlers.LoadError(); err != nil {
|
||||||
|
log.Printf("WARNING: compliance graph not loaded (obligation-status -> 503): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Regulatory News
|
// Regulatory News
|
||||||
allV2Regs, err := ucca.LoadAllV2Regulations()
|
allV2Regs, err := ucca.LoadAllV2Regulations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,7 +207,8 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
|
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler,
|
||||||
|
complianceGraphHandlers)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func registerRoutes(
|
|||||||
maximizerHandlers *handlers.MaximizerHandlers,
|
maximizerHandlers *handlers.MaximizerHandlers,
|
||||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||||
useCaseHandler *handlers.UseCaseHandler,
|
useCaseHandler *handlers.UseCaseHandler,
|
||||||
|
complianceGraphHandlers *handlers.ComplianceGraphHandlers,
|
||||||
) {
|
) {
|
||||||
v1 := router.Group("/sdk/v1")
|
v1 := router.Group("/sdk/v1")
|
||||||
{
|
{
|
||||||
@@ -54,6 +55,7 @@ func registerRoutes(
|
|||||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||||
registerUseCaseRoutes(v1, useCaseHandler)
|
registerUseCaseRoutes(v1, useCaseHandler)
|
||||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||||
|
complianceGraphHandlers.RegisterRoutes(v1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ var domains = []domainDef{
|
|||||||
{"data_protection",
|
{"data_protection",
|
||||||
[]string{"DSGVO", "GDPR", "BDSG", "EDPB", "DSK", "BfDI", "BayLfD", "DPF"},
|
[]string{"DSGVO", "GDPR", "BDSG", "EDPB", "DSK", "BfDI", "BayLfD", "DPF"},
|
||||||
[]string{"personenbezogen", "betroffene", "datenschutz", "datenschutzbeauftrag", "dsb",
|
[]string{"personenbezogen", "betroffene", "datenschutz", "datenschutzbeauftrag", "dsb",
|
||||||
"datenpanne", "auskunft", "loesch", "lösch", "einwilligung", "besondere kategorien", "auftragsverarbeiter"}},
|
"datenpanne", "auskunft", "loesch", "lösch", "einwilligung", "besondere kategorien", "auftragsverarbeit"}},
|
||||||
{"cyber",
|
{"cyber",
|
||||||
[]string{"CRA", "NIS2", "NIS-2", "ENISA", "DORA", "EUCC"},
|
[]string{"CRA", "NIS2", "NIS-2", "ENISA", "DORA", "EUCC"},
|
||||||
[]string{"security update", "sicherheitsupdate", "sicherheitsaktualisierung", "schwachstelle", "sbom",
|
[]string{"security update", "sicherheitsupdate", "sicherheitsaktualisierung", "schwachstelle", "sbom",
|
||||||
@@ -126,6 +126,16 @@ var domains = []domainDef{
|
|||||||
nil},
|
nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// euPrimaryDomains are domains whose PRIMARY binding act is an EU regulation/directive
|
||||||
|
// (DSGVO, CRA/NIS2, AI Act, MaschinenVO). In these domains a NATIONAL implementing law
|
||||||
|
// (e.g. BDSG) is subsidiary for general questions — see nationalSubsidiarityPenalty.
|
||||||
|
var euPrimaryDomains = map[string]bool{
|
||||||
|
"data_protection": true,
|
||||||
|
"cyber": true,
|
||||||
|
"ai": true,
|
||||||
|
"product_safety": true,
|
||||||
|
}
|
||||||
|
|
||||||
func queryDomain(query string) string {
|
func queryDomain(query string) string {
|
||||||
ql := strings.ToLower(query)
|
ql := strings.ToLower(query)
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
@@ -135,6 +145,16 @@ func queryDomain(query string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: an explicit regulation mention (e.g. "DSGVO", "BDSG", "CRA") also signals the
|
||||||
|
// domain — so a question phrased around the act ("... gilt die DSGVO ...") is scoped even
|
||||||
|
// without a topical keyword. Keyword match wins first (more specific).
|
||||||
|
for _, d := range domains {
|
||||||
|
for _, reg := range d.regs {
|
||||||
|
if strings.Contains(ql, strings.ToLower(reg)) {
|
||||||
|
return d.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
domainMatchGain = 0.15
|
domainMatchGain = 0.15
|
||||||
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
|
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
|
||||||
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
|
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
|
||||||
|
subsidiarityPen = 0.18 // national implementing law (BDSG) on a general EU-primary question: SOFT demote, not exclusion
|
||||||
topicGain = 0.18 // amplifier only
|
topicGain = 0.18 // amplifier only
|
||||||
supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt
|
supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt
|
||||||
intentLiftGain = 0.10 // epsilon a qualifying interpretative source is lifted ABOVE the best binding
|
intentLiftGain = 0.10 // epsilon a qualifying interpretative source is lifted ABOVE the best binding
|
||||||
@@ -102,6 +103,15 @@ func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign
|
|||||||
if qDomain == "data_protection" && scopeClass(r) == "law_enforcement" {
|
if qDomain == "data_protection" && scopeClass(r) == "law_enforcement" {
|
||||||
score -= scopePenalty
|
score -= scopePenalty
|
||||||
}
|
}
|
||||||
|
// Subsidiarity: a national implementing law (DE binding, e.g. BDSG) is subsidiary to the
|
||||||
|
// primary EU act for GENERAL questions in an EU-primary domain — UNLESS the query hits a
|
||||||
|
// topic where the national norm is co-primary (DSB §38, special categories §22, ...). The
|
||||||
|
// topic boost below lifts those; here we only SOFT-demote the non-topic national norm, so
|
||||||
|
// it stays visible and can still win on a strongly matching topic. No hard exclusion.
|
||||||
|
if euPrimaryDomains[qDomain] && info.sourceClass == "binding_law" &&
|
||||||
|
info.jurisdiction == "DE" && !resultMatchesTopic(query, r) {
|
||||||
|
score -= subsidiarityPen
|
||||||
|
}
|
||||||
if resultMatchesTopic(query, r) {
|
if resultMatchesTopic(query, r) {
|
||||||
score += topicGain // Verstaerker, kein Override
|
score += topicGain // Verstaerker, kein Override
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,73 @@ func TestRerankByAuthority_Acceptance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Subsidiarity (KB-2026.1 BDSG-pilot regression): a national implementing § that is NOT a
|
||||||
|
// co-primary topic norm must not outrank the primary DSGVO article on a general question.
|
||||||
|
t.Run("subsidiarity dp_05: BDSG §23 below DSGVO Art.6 (Rechtsgrundlage)", func(t *testing.T) {
|
||||||
|
in := []LegalSearchResult{
|
||||||
|
bindingRes("§ 23 BDSG", "BDSG", "DE", 0.70),
|
||||||
|
bindingRes("Art. 6 DSGVO", "DSGVO", "EU", 0.66),
|
||||||
|
}
|
||||||
|
out := rerankByAuthority("Welche Rechtsgrundlagen erlauben eine Verarbeitung personenbezogener Daten?", in)
|
||||||
|
if out[0].RegulationShort != "DSGVO" {
|
||||||
|
t.Fatalf("DSGVO Art.6 must beat general BDSG §, got %q", out[0].ArticleLabel)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("BDSG must stay visible (soft demote), got len=%d", len(out))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subsidiarity dp_08: BDSG §70 below DSGVO Art.28 (Auftragsverarbeitung)", func(t *testing.T) {
|
||||||
|
in := []LegalSearchResult{
|
||||||
|
bindingRes("§ 70 BDSG", "BDSG", "DE", 0.70), // Teil 3 → scope + subsidiarity
|
||||||
|
bindingRes("Art. 28 DSGVO", "DSGVO", "EU", 0.66),
|
||||||
|
}
|
||||||
|
out := rerankByAuthority("Was muss ein Auftragsverarbeitungsvertrag enthalten?", in)
|
||||||
|
if out[0].RegulationShort != "DSGVO" {
|
||||||
|
t.Fatalf("DSGVO Art.28 must beat BDSG §70, got %q", out[0].ArticleLabel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subsidiarity dp_11: BDSG §22 below DSGVO Art.32 on a TOM question", func(t *testing.T) {
|
||||||
|
in := []LegalSearchResult{
|
||||||
|
bindingRes("§ 22 BDSG", "BDSG", "DE", 0.70),
|
||||||
|
bindingRes("Art. 32 DSGVO", "DSGVO", "EU", 0.66),
|
||||||
|
}
|
||||||
|
out := rerankByAuthority("Welche technischen und organisatorischen Massnahmen verlangt das Datenschutzrecht?", in)
|
||||||
|
if out[0].RegulationShort != "DSGVO" {
|
||||||
|
t.Fatalf("DSGVO Art.32 must beat BDSG §22 on a non-topic TOM question, got %q", out[0].ArticleLabel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cr_07: a 'DSGVO' mention scopes the domain so BDSG Teil-3 §64 is demoted", func(t *testing.T) {
|
||||||
|
in := []LegalSearchResult{
|
||||||
|
bindingRes("§ 64 BDSG", "BDSG", "DE", 0.70), // Teil 3 (law enforcement)
|
||||||
|
bindingRes("Art. 32 DSGVO", "DSGVO", "EU", 0.66),
|
||||||
|
}
|
||||||
|
// Query has no DP keyword but names the DSGVO → domain fallback scopes it data_protection,
|
||||||
|
// so scope+subsidiarity demote the law-enforcement § below the primary norm.
|
||||||
|
out := rerankByAuthority("Welche rechtliche Grundlage gilt fuer technische und organisatorische Massnahmen - DSGVO oder ein Standard?", in)
|
||||||
|
if out[0].RegulationShort != "DSGVO" {
|
||||||
|
t.Fatalf("DSGVO must win on a DSGVO-mention question, got %q", out[0].ArticleLabel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("co-primary dp_01: BDSG §38 stays top on a DSB question (national special rule)", func(t *testing.T) {
|
||||||
|
in := []LegalSearchResult{
|
||||||
|
bindingRes("§ 38 BDSG", "BDSG", "DE", 0.66),
|
||||||
|
bindingRes("Art. 37 DSGVO", "DSGVO", "EU", 0.64),
|
||||||
|
}
|
||||||
|
out := rerankByAuthority("Ab wann muss ein Datenschutzbeauftragter benannt werden?", in)
|
||||||
|
// DSB topic → §38 is co-primary (topic-matched, NOT subsidiarity-demoted) and keeps its
|
||||||
|
// semantic lead; Art. 37 stays a close second. Both remain top-2.
|
||||||
|
if out[0].RegulationShort != "BDSG" {
|
||||||
|
t.Fatalf("BDSG §38 (DSB co-primary) must stay top, got %q", out[0].ArticleLabel)
|
||||||
|
}
|
||||||
|
if out[1].RegulationShort != "DSGVO" {
|
||||||
|
t.Fatalf("Art. 37 DSGVO must stay co-primary second, got %q", out[1].ArticleLabel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("nothing is dropped and topic amplifies", func(t *testing.T) {
|
t.Run("nothing is dropped and topic amplifies", func(t *testing.T) {
|
||||||
in := []LegalSearchResult{
|
in := []LegalSearchResult{
|
||||||
guidanceRes("ENISA", "ENISA", 0.72),
|
guidanceRes("ENISA", "ENISA", 0.72),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphCallerRel resolves a path relative to THIS source file (build-time location), so the
|
||||||
|
// graph data is findable under `go test` (cwd = package dir) regardless of working directory.
|
||||||
|
// In a built container the source is gone, so cwd-relative candidates carry the load instead.
|
||||||
|
func graphCallerRel(rel string) string {
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(file), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstExisting returns the first candidate path that exists with the requested kind (dir vs
|
||||||
|
// file). Empty candidates (e.g. unset env overrides) are skipped.
|
||||||
|
func firstExisting(candidates []string, wantDir bool) string {
|
||||||
|
for _, p := range candidates {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil || info.IsDir() != wantDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadComplianceGraph loads the file-backed Compliance Execution Graph: the Registry join-key
|
||||||
|
// contract (obligations/obligation_join_keys.json — owned by the Obligation session) + our
|
||||||
|
// curated, accepted control mappings + evidence requirements. Locations are resolved across
|
||||||
|
// three layouts: dev (cwd = ai-compliance-sdk/, canonical contract at ../obligations), container
|
||||||
|
// (WORKDIR /app, data/ copied in incl. a synced data/obligations/ copy) and `go test`
|
||||||
|
// (cwd = package dir, via graphCallerRel). Fail-closed: a missing/invalid source returns an
|
||||||
|
// error so the handler serves 503 — never a half-built graph.
|
||||||
|
//
|
||||||
|
// NOTE: data/obligations/obligation_join_keys.json is a SYNCED COPY of the repo-root contract
|
||||||
|
// (the canonical owner is the Obligation session). Re-sync it when the Registry grows; dev/test
|
||||||
|
// prefer the canonical repo-root path, only the container falls back to the copy.
|
||||||
|
func LoadComplianceGraph() (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet, error) {
|
||||||
|
joinPath := firstExisting([]string{
|
||||||
|
os.Getenv("BP_OBLIGATION_JOIN_KEYS"),
|
||||||
|
"../obligations/obligation_join_keys.json",
|
||||||
|
graphCallerRel("../../../obligations/obligation_join_keys.json"),
|
||||||
|
"data/obligations/obligation_join_keys.json",
|
||||||
|
graphCallerRel("../../data/obligations/obligation_join_keys.json"),
|
||||||
|
}, false)
|
||||||
|
if joinPath == "" {
|
||||||
|
return nil, nil, nil, fmt.Errorf("obligation_join_keys.json not found in any candidate path")
|
||||||
|
}
|
||||||
|
mapDir := firstExisting([]string{
|
||||||
|
os.Getenv("BP_CONTROL_MAPPINGS_DIR"),
|
||||||
|
"data/control_mappings",
|
||||||
|
graphCallerRel("../../data/control_mappings"),
|
||||||
|
}, true)
|
||||||
|
if mapDir == "" {
|
||||||
|
return nil, nil, nil, fmt.Errorf("control_mappings dir not found in any candidate path")
|
||||||
|
}
|
||||||
|
evDir := firstExisting([]string{
|
||||||
|
os.Getenv("BP_EVIDENCE_DIR"),
|
||||||
|
"data/evidence_requirements",
|
||||||
|
graphCallerRel("../../data/evidence_requirements"),
|
||||||
|
}, true)
|
||||||
|
if evDir == "" {
|
||||||
|
return nil, nil, nil, fmt.Errorf("evidence_requirements dir not found in any candidate path")
|
||||||
|
}
|
||||||
|
|
||||||
|
joins, err := LoadObligationJoinKeys(joinPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("load join keys (%s): %w", joinPath, err)
|
||||||
|
}
|
||||||
|
mappings, err := LoadControlMappings(mapDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("load control mappings (%s): %w", mapDir, err)
|
||||||
|
}
|
||||||
|
evidence, err := LoadEvidenceRequirements(evDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("load evidence (%s): %w", evDir, err)
|
||||||
|
}
|
||||||
|
return joins, mappings, evidence, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
// ObligationStatus is the Advisor's vertical slice over the compliance graph for ONE legal
|
||||||
|
// obligation: which accepted controls satisfy it, what evidence they require, what's missing,
|
||||||
|
// and the resulting status. The point is "the required evidence is (not) present", not "a
|
||||||
|
// document exists". citation_spans is pending until the Legal-Knowledge-Graph session attaches
|
||||||
|
// them to the obligation (the upper half of the bridge).
|
||||||
|
type ObligationStatus struct {
|
||||||
|
ObligationID string `json:"obligation_id"`
|
||||||
|
LegalBasis []string `json:"legal_basis"` // the obligation's citation_units
|
||||||
|
Status string `json:"status"` // erfuellt | offen | unklar
|
||||||
|
Controls []ObligationControlStatus `json:"controls"`
|
||||||
|
CitationSpans string `json:"citation_spans"` // "pending" until the registry fills them
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationControlStatus is one control under an obligation with its evidence picture.
|
||||||
|
type ObligationControlStatus struct {
|
||||||
|
Framework string `json:"framework"`
|
||||||
|
Control string `json:"control"`
|
||||||
|
MappingType string `json:"mapping_type"`
|
||||||
|
RequiredEvidence []EvidenceRequirement `json:"required_evidence"`
|
||||||
|
MissingEvidence []EvidenceRequirement `json:"missing_evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssessObligationStatus traverses obligation_id -> (citation_unit) -> accepted Controls ->
|
||||||
|
// required Evidence -> Status. hasEvidence reports whether a given (framework, control,
|
||||||
|
// evidence_type) is already collected; pass nil in the MVP (no collection yet) -> everything
|
||||||
|
// required is missing and the status is "offen". Unknown or unmapped obligation -> "unklar".
|
||||||
|
func AssessObligationStatus(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet, obligationID string, hasEvidence func(framework, control, evidenceType string) bool) ObligationStatus {
|
||||||
|
ob := joins.FindObligation(obligationID)
|
||||||
|
if ob == nil {
|
||||||
|
return ObligationStatus{ObligationID: obligationID, Status: "unklar", CitationSpans: "pending"}
|
||||||
|
}
|
||||||
|
st := ObligationStatus{
|
||||||
|
ObligationID: obligationID,
|
||||||
|
LegalBasis: ob.CitationUnits,
|
||||||
|
CitationSpans: "pending",
|
||||||
|
Controls: []ObligationControlStatus{},
|
||||||
|
}
|
||||||
|
ctrls := AcceptedControlsForObligation(*ob, mappings)
|
||||||
|
if len(ctrls) == 0 {
|
||||||
|
st.Status = "unklar" // no accepted control reaches it — we cannot assess
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
anyMissing := false
|
||||||
|
for _, m := range ctrls {
|
||||||
|
req := evidence.RequiredFor(m.TargetFramework, m.TargetControl)
|
||||||
|
missing := make([]EvidenceRequirement, 0, len(req))
|
||||||
|
for _, e := range req {
|
||||||
|
if hasEvidence == nil || !hasEvidence(e.Framework, e.Control, e.EvidenceType) {
|
||||||
|
missing = append(missing, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
anyMissing = true
|
||||||
|
}
|
||||||
|
st.Controls = append(st.Controls, ObligationControlStatus{
|
||||||
|
Framework: m.TargetFramework,
|
||||||
|
Control: m.TargetControl,
|
||||||
|
MappingType: m.MappingType,
|
||||||
|
RequiredEvidence: req,
|
||||||
|
MissingEvidence: missing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if anyMissing {
|
||||||
|
st.Status = "offen"
|
||||||
|
} else {
|
||||||
|
st.Status = "erfuellt"
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func loadGraph(t *testing.T) (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet) {
|
||||||
|
t.Helper()
|
||||||
|
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join keys: %v", err)
|
||||||
|
}
|
||||||
|
maps, err := LoadControlMappings("../../data/control_mappings")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mappings: %v", err)
|
||||||
|
}
|
||||||
|
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evidence: %v", err)
|
||||||
|
}
|
||||||
|
return joins, maps, ev
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssessObligationStatus(t *testing.T) {
|
||||||
|
joins, maps, ev := loadGraph(t)
|
||||||
|
|
||||||
|
// covered obligation, no evidence collected yet (MVP) -> offen
|
||||||
|
st := AssessObligationStatus(joins, maps, ev, "user_authentication_required", nil)
|
||||||
|
if st.Status != "offen" {
|
||||||
|
t.Errorf("want offen, got %q", st.Status)
|
||||||
|
}
|
||||||
|
if len(st.Controls) == 0 {
|
||||||
|
t.Fatal("expected controls for a covered obligation")
|
||||||
|
}
|
||||||
|
for _, c := range st.Controls {
|
||||||
|
if len(c.MissingEvidence) != len(c.RequiredEvidence) {
|
||||||
|
t.Error("MVP: all required evidence should be missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("DURCHSTICH user_authentication_required: status=%s legal_basis=%v citation_spans=%s",
|
||||||
|
st.Status, st.LegalBasis, st.CitationSpans)
|
||||||
|
for _, c := range st.Controls {
|
||||||
|
t.Logf(" %s %s (%s): %d required evidence, %d missing", c.Framework, c.Control, c.MappingType, len(c.RequiredEvidence), len(c.MissingEvidence))
|
||||||
|
}
|
||||||
|
|
||||||
|
// all evidence present -> erfuellt
|
||||||
|
st2 := AssessObligationStatus(joins, maps, ev, "user_authentication_required", func(f, c, et string) bool { return true })
|
||||||
|
if st2.Status != "erfuellt" {
|
||||||
|
t.Errorf("want erfuellt with all evidence present, got %q", st2.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uncovered obligation (no accepted control reaches it) -> unklar
|
||||||
|
if st3 := AssessObligationStatus(joins, maps, ev, "sbom_creation", nil); st3.Status != "unklar" {
|
||||||
|
t.Errorf("uncovered sbom_creation: want unklar, got %q", st3.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown obligation_id -> unklar
|
||||||
|
if st4 := AssessObligationStatus(joins, maps, ev, "does_not_exist", nil); st4.Status != "unklar" {
|
||||||
|
t.Errorf("unknown obligation: want unklar, got %q", st4.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControlMapping is one persisted, versioned, REVIEWABLE link from a legal
|
||||||
|
// obligation/requirement to a concrete framework control — a node in the curated
|
||||||
|
// compliance graph (Regulation -> Obligation -> Control -> Evidence). The retriever only
|
||||||
|
// PROPOSES candidates (mapping_status=candidate); a human/rule decision turns the good ones
|
||||||
|
// into mapping_status=accepted, which is the audited truth the Advisor uses at runtime.
|
||||||
|
//
|
||||||
|
// There is intentionally NO probabilistic "confidence" field: once curated, a mapping is a
|
||||||
|
// professional statement, not an AI guess. The retriever's score lives only in the rationale
|
||||||
|
// of a candidate, never as structured truth.
|
||||||
|
type ControlMapping struct {
|
||||||
|
SourceNorm string `json:"source_norm"` // e.g. "CRA Annex I Part I (2)(c)"
|
||||||
|
SourceRole string `json:"source_role"` // source_role of the norm (operational_requirement, ...)
|
||||||
|
TargetFramework string `json:"target_framework"` // e.g. "OWASP ASVS"
|
||||||
|
TargetControl string `json:"target_control"` // e.g. "V6.3.1"
|
||||||
|
MappingType string `json:"mapping_type"` // primary_implementation | implements | supports | partially_supports | related | contradicts
|
||||||
|
MappingStatus string `json:"mapping_status"` // candidate | accepted | rejected | superseded
|
||||||
|
Provenance string `json:"provenance"` // retriever_candidate | human_curated | rule_based
|
||||||
|
ObligationID string `json:"obligation_id,omitempty"` // stable cross-session join key (Obligation Registry); empty until adopted, citation_unit is the interim bridge
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
ReviewedBy string `json:"reviewed_by,omitempty"` // who decided (human or rule id)
|
||||||
|
ReviewDate string `json:"review_date,omitempty"` // YYYY-MM-DD
|
||||||
|
ReviewReason string `json:"review_reason,omitempty"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed enum values — the deterministic "rule" layer that keeps the curated store clean.
|
||||||
|
var (
|
||||||
|
mappingTypeValues = map[string]bool{"primary_implementation": true, "implements": true, "supports": true, "partially_supports": true, "related": true, "contradicts": true}
|
||||||
|
mappingStatusValues = map[string]bool{"candidate": true, "accepted": true, "rejected": true, "superseded": true}
|
||||||
|
provenanceValues = map[string]bool{"retriever_candidate": true, "human_curated": true, "rule_based": true}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate checks required fields + enum membership, and enforces the audit trail: any
|
||||||
|
// human/rule DECISION (accepted/rejected) must carry who/when/why. Fail-closed at load.
|
||||||
|
func (m ControlMapping) Validate() error {
|
||||||
|
switch {
|
||||||
|
case m.SourceNorm == "":
|
||||||
|
return fmt.Errorf("control mapping: source_norm required")
|
||||||
|
case m.TargetFramework == "":
|
||||||
|
return fmt.Errorf("control mapping: target_framework required")
|
||||||
|
case m.TargetControl == "":
|
||||||
|
return fmt.Errorf("control mapping: target_control required")
|
||||||
|
case !mappingTypeValues[m.MappingType]:
|
||||||
|
return fmt.Errorf("control mapping: invalid mapping_type %q", m.MappingType)
|
||||||
|
case !mappingStatusValues[m.MappingStatus]:
|
||||||
|
return fmt.Errorf("control mapping: invalid mapping_status %q", m.MappingStatus)
|
||||||
|
case !provenanceValues[m.Provenance]:
|
||||||
|
return fmt.Errorf("control mapping: invalid provenance %q", m.Provenance)
|
||||||
|
}
|
||||||
|
if m.MappingStatus == "accepted" || m.MappingStatus == "rejected" {
|
||||||
|
if m.ReviewedBy == "" || m.ReviewDate == "" || m.ReviewReason == "" {
|
||||||
|
return fmt.Errorf("control mapping %s->%s: status %q requires reviewed_by + review_date + review_reason (audit trail)",
|
||||||
|
m.SourceNorm, m.TargetControl, m.MappingStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAccepted reports whether this mapping is the active audited truth.
|
||||||
|
func (m ControlMapping) IsAccepted() bool { return m.MappingStatus == "accepted" }
|
||||||
|
|
||||||
|
// ControlMappingSet is the loaded, indexed mapping store (forward + reverse lookup).
|
||||||
|
type ControlMappingSet struct {
|
||||||
|
All []ControlMapping
|
||||||
|
bySourceNorm map[string][]ControlMapping
|
||||||
|
byControl map[string][]ControlMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlKey(framework, control string) string { return framework + ":" + control }
|
||||||
|
|
||||||
|
// ControlsFor returns the controls mapped to a source norm. acceptedOnly restricts to the
|
||||||
|
// audited truth (what the Advisor may treat as fact).
|
||||||
|
func (s *ControlMappingSet) ControlsFor(sourceNorm string, acceptedOnly bool) []ControlMapping {
|
||||||
|
return filterAccepted(s.bySourceNorm[sourceNorm], acceptedOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationsFor returns the norms mapped to a framework control (reverse lookup).
|
||||||
|
func (s *ControlMappingSet) ObligationsFor(framework, control string, acceptedOnly bool) []ControlMapping {
|
||||||
|
return filterAccepted(s.byControl[controlKey(framework, control)], acceptedOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterAccepted(in []ControlMapping, acceptedOnly bool) []ControlMapping {
|
||||||
|
if !acceptedOnly {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
out := make([]ControlMapping, 0, len(in))
|
||||||
|
for _, m := range in {
|
||||||
|
if m.IsAccepted() {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadControlMappings reads every *.jsonl file under dir (one mapping per line; blank and
|
||||||
|
// //-prefixed lines ignored), validates each row, and builds the index. An invalid row
|
||||||
|
// aborts the whole load — fail-closed, because this is the audit truth, not best-effort.
|
||||||
|
func LoadControlMappings(dir string) (*ControlMappingSet, error) {
|
||||||
|
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set := &ControlMappingSet{
|
||||||
|
bySourceNorm: map[string][]ControlMapping{},
|
||||||
|
byControl: map[string][]ControlMapping{},
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
fh, err := os.Open(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(fh)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
line := 0
|
||||||
|
for sc.Scan() {
|
||||||
|
line++
|
||||||
|
raw := strings.TrimSpace(sc.Text())
|
||||||
|
if raw == "" || strings.HasPrefix(raw, "//") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var m ControlMapping
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
fh.Close()
|
||||||
|
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
||||||
|
}
|
||||||
|
if err := m.Validate(); err != nil {
|
||||||
|
fh.Close()
|
||||||
|
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
||||||
|
}
|
||||||
|
set.All = append(set.All, m)
|
||||||
|
set.bySourceNorm[m.SourceNorm] = append(set.bySourceNorm[m.SourceNorm], m)
|
||||||
|
k := controlKey(m.TargetFramework, m.TargetControl)
|
||||||
|
set.byControl[k] = append(set.byControl[k], m)
|
||||||
|
}
|
||||||
|
fh.Close()
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControlMapping_Validate(t *testing.T) {
|
||||||
|
candidate := ControlMapping{SourceNorm: "CRA Annex I", TargetFramework: "OWASP ASVS", TargetControl: "V6.3.1", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}
|
||||||
|
if err := candidate.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid candidate rejected: %v", err)
|
||||||
|
}
|
||||||
|
accepted := ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "implements", MappingStatus: "accepted", Provenance: "human_curated", ReviewedBy: "benjamin", ReviewDate: "2026-06-25", ReviewReason: "passt"}
|
||||||
|
if err := accepted.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid accepted rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
m ControlMapping
|
||||||
|
}{
|
||||||
|
{"no source_norm", ControlMapping{TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
|
||||||
|
{"bad mapping_type", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "nope", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
|
||||||
|
{"bad mapping_status", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "maybe", Provenance: "retriever_candidate"}},
|
||||||
|
{"bad provenance", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "guessed"}},
|
||||||
|
{"accepted without audit trail", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "accepted", Provenance: "human_curated"}},
|
||||||
|
{"rejected without reason", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "rejected", Provenance: "human_curated", ReviewedBy: "b", ReviewDate: "2026-06-25"}},
|
||||||
|
}
|
||||||
|
for _, tt := range bad {
|
||||||
|
if err := tt.m.Validate(); err == nil {
|
||||||
|
t.Errorf("%s: expected rejection", tt.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadControlMappings(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
content := `// header comment, ignored
|
||||||
|
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V6.3.1","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","reviewed_by":"benjamin","review_date":"2026-06-25","review_reason":"V6=Auth passt","rationale":"r","version":"2026-06-25"}
|
||||||
|
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V14.2.4","mapping_type":"related","mapping_status":"candidate","provenance":"retriever_candidate","rationale":"r","version":"2026-06-25"}
|
||||||
|
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "m.jsonl"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
set, err := LoadControlMappings(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(set.All) != 2 {
|
||||||
|
t.Fatalf("want 2 mappings, got %d", len(set.All))
|
||||||
|
}
|
||||||
|
if got := set.ControlsFor("CRA Annex I", false); len(got) != 2 {
|
||||||
|
t.Errorf("ControlsFor(all): want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := set.ControlsFor("CRA Annex I", true); len(got) != 1 {
|
||||||
|
t.Errorf("ControlsFor(acceptedOnly): want 1 (only accepted), got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := set.ObligationsFor("OWASP ASVS", "V6.3.1", true); len(got) != 1 {
|
||||||
|
t.Errorf("ObligationsFor accepted reverse lookup: want 1, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadControlMappings_RejectsInvalid(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// accepted without the who/when/why audit trail must fail-closed.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "bad.jsonl"), []byte(`{"source_norm":"A","target_framework":"X","target_control":"Y","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","rationale":"r","version":"v"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadControlMappings(dir); err == nil {
|
||||||
|
t.Error("accepted mapping without audit trail must fail the load (fail-closed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlMappings_SeedFileValid(t *testing.T) {
|
||||||
|
// The committed seed store must always load + validate.
|
||||||
|
set, err := LoadControlMappings("../../data/control_mappings")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed control_mappings failed to load: %v", err)
|
||||||
|
}
|
||||||
|
if len(set.All) == 0 {
|
||||||
|
t.Fatal("seed control_mappings is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvidenceRequirement is the last edge of the compliance graph: it says WHAT concrete
|
||||||
|
// evidence proves a framework control is met, and how fresh that evidence must be. This is
|
||||||
|
// what lets the Advisor eventually state "the CRA requirement is fulfilled" — not because a
|
||||||
|
// document exists, but because the required, current evidence is present. Authored/curated,
|
||||||
|
// not retriever-generated.
|
||||||
|
type EvidenceRequirement struct {
|
||||||
|
Framework string `json:"framework"` // e.g. "OWASP ASVS"
|
||||||
|
Control string `json:"control"` // e.g. "V6.3.1"
|
||||||
|
EvidenceType string `json:"evidence_type"` // sbom|test_report|config_export|repo_scan|policy|ticket|audit_log|pentest
|
||||||
|
EvidenceSource string `json:"evidence_source"` // github|ci|scanner|manual_upload
|
||||||
|
FreshnessRequirement string `json:"freshness_requirement"` // per_release|quarterly|annually|continuous
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Rationale string `json:"rationale"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed enum values — the rule layer that keeps the evidence catalog clean.
|
||||||
|
var (
|
||||||
|
evidenceTypeValues = map[string]bool{"sbom": true, "test_report": true, "config_export": true, "repo_scan": true, "policy": true, "ticket": true, "audit_log": true, "pentest": true}
|
||||||
|
evidenceSourceValues = map[string]bool{"github": true, "ci": true, "scanner": true, "manual_upload": true}
|
||||||
|
freshnessValues = map[string]bool{"per_release": true, "quarterly": true, "annually": true, "continuous": true}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate checks required fields + enum membership. Fail-closed at load.
|
||||||
|
func (e EvidenceRequirement) Validate() error {
|
||||||
|
switch {
|
||||||
|
case e.Framework == "":
|
||||||
|
return fmt.Errorf("evidence requirement: framework required")
|
||||||
|
case e.Control == "":
|
||||||
|
return fmt.Errorf("evidence requirement: control required")
|
||||||
|
case !evidenceTypeValues[e.EvidenceType]:
|
||||||
|
return fmt.Errorf("evidence requirement: invalid evidence_type %q", e.EvidenceType)
|
||||||
|
case !evidenceSourceValues[e.EvidenceSource]:
|
||||||
|
return fmt.Errorf("evidence requirement: invalid evidence_source %q", e.EvidenceSource)
|
||||||
|
case !freshnessValues[e.FreshnessRequirement]:
|
||||||
|
return fmt.Errorf("evidence requirement: invalid freshness_requirement %q", e.FreshnessRequirement)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidenceRequirementSet is the loaded, indexed evidence catalog.
|
||||||
|
type EvidenceRequirementSet struct {
|
||||||
|
All []EvidenceRequirement
|
||||||
|
byControl map[string][]EvidenceRequirement
|
||||||
|
}
|
||||||
|
|
||||||
|
// For returns all evidence requirements declared for a framework control.
|
||||||
|
func (s *EvidenceRequirementSet) For(framework, control string) []EvidenceRequirement {
|
||||||
|
return s.byControl[controlKey(framework, control)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiredFor returns only the required evidence for a control — the minimum that must be
|
||||||
|
// present before the control may be treated as met.
|
||||||
|
func (s *EvidenceRequirementSet) RequiredFor(framework, control string) []EvidenceRequirement {
|
||||||
|
out := make([]EvidenceRequirement, 0)
|
||||||
|
for _, e := range s.byControl[controlKey(framework, control)] {
|
||||||
|
if e.Required {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEvidenceRequirements reads every *.jsonl file under dir (one requirement per line;
|
||||||
|
// blank and //-prefixed lines ignored), validates each, and builds the per-control index.
|
||||||
|
// An invalid row aborts the load — fail-closed.
|
||||||
|
func LoadEvidenceRequirements(dir string) (*EvidenceRequirementSet, error) {
|
||||||
|
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set := &EvidenceRequirementSet{byControl: map[string][]EvidenceRequirement{}}
|
||||||
|
for _, f := range files {
|
||||||
|
fh, err := os.Open(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(fh)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
line := 0
|
||||||
|
for sc.Scan() {
|
||||||
|
line++
|
||||||
|
raw := strings.TrimSpace(sc.Text())
|
||||||
|
if raw == "" || strings.HasPrefix(raw, "//") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e EvidenceRequirement
|
||||||
|
if err := json.Unmarshal([]byte(raw), &e); err != nil {
|
||||||
|
fh.Close()
|
||||||
|
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
||||||
|
}
|
||||||
|
if err := e.Validate(); err != nil {
|
||||||
|
fh.Close()
|
||||||
|
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
||||||
|
}
|
||||||
|
set.All = append(set.All, e)
|
||||||
|
k := controlKey(e.Framework, e.Control)
|
||||||
|
set.byControl[k] = append(set.byControl[k], e)
|
||||||
|
}
|
||||||
|
fh.Close()
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvidenceRequirement_Validate(t *testing.T) {
|
||||||
|
valid := EvidenceRequirement{Framework: "OWASP ASVS", Control: "V6.3.1", EvidenceType: "config_export", EvidenceSource: "github", FreshnessRequirement: "per_release", Required: true}
|
||||||
|
if err := valid.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid rejected: %v", err)
|
||||||
|
}
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
e EvidenceRequirement
|
||||||
|
}{
|
||||||
|
{"no control", EvidenceRequirement{Framework: "X", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
|
||||||
|
{"bad evidence_type", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "screenshot", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
|
||||||
|
{"bad evidence_source", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "email", FreshnessRequirement: "per_release"}},
|
||||||
|
{"bad freshness", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "weekly"}},
|
||||||
|
}
|
||||||
|
for _, tt := range bad {
|
||||||
|
if err := tt.e.Validate(); err == nil {
|
||||||
|
t.Errorf("%s: expected rejection", tt.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEvidenceRequirements(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
content := `// header
|
||||||
|
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"config_export","evidence_source":"github","freshness_requirement":"per_release","required":true,"version":"2026-06-25"}
|
||||||
|
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"pentest","evidence_source":"manual_upload","freshness_requirement":"annually","required":false,"version":"2026-06-25"}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "e.jsonl"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
set, err := LoadEvidenceRequirements(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(set.All) != 2 {
|
||||||
|
t.Fatalf("want 2, got %d", len(set.All))
|
||||||
|
}
|
||||||
|
if got := set.For("OWASP ASVS", "V6.3.1"); len(got) != 2 {
|
||||||
|
t.Errorf("For: want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := set.RequiredFor("OWASP ASVS", "V6.3.1"); len(got) != 1 {
|
||||||
|
t.Errorf("RequiredFor: want 1 (pentest is optional), got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvidenceRequirements_SeedFileValid(t *testing.T) {
|
||||||
|
set, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed evidence_requirements failed to load: %v", err)
|
||||||
|
}
|
||||||
|
if len(set.All) == 0 {
|
||||||
|
t.Fatal("seed evidence_requirements is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGraph_AcceptedControlsHaveEvidence wires the two layers: every control an accepted
|
||||||
|
// CRA->OWASP mapping points to must have >=1 required evidence — the Obligation -> Control ->
|
||||||
|
// Evidence chain must be connected, no dangling control nodes.
|
||||||
|
func TestGraph_AcceptedControlsHaveEvidence(t *testing.T) {
|
||||||
|
maps, err := LoadControlMappings("../../data/control_mappings")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range maps.All {
|
||||||
|
if !m.IsAccepted() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ev.RequiredFor(m.TargetFramework, m.TargetControl)) == 0 {
|
||||||
|
t.Errorf("accepted control %s %s has no required evidence (dangling graph node)", m.TargetFramework, m.TargetControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObligationKey is one entry of the Obligation Registry's cross-session contract
|
||||||
|
// (obligations/obligation_join_keys.json). obligation_id is the STABLE join key — assigned
|
||||||
|
// only by the Registry, never minted here. citation_units are the interim bridge until our
|
||||||
|
// ControlMapping adopts obligation_id directly.
|
||||||
|
type ObligationKey struct {
|
||||||
|
ObligationID string `json:"obligation_id"`
|
||||||
|
Regulation string `json:"regulation"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
CitationUnits []string `json:"citation_units"`
|
||||||
|
SourceRole string `json:"source_role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationJoinKeys is the loaded contract + a citation-unit index for the interim join.
|
||||||
|
type ObligationJoinKeys struct {
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
ObligationIDs []ObligationKey `json:"obligation_ids"`
|
||||||
|
byCitationKey map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var citationRefRe = regexp.MustCompile(`\(([0-9a-zA-Z]+)\)`)
|
||||||
|
|
||||||
|
// citationUnitKey normalizes a CRA Annex I reference for the INTERIM citation_unit join, so
|
||||||
|
// our "CRA Annex I Part I (2)(c)" and the Registry's "Annex I (2)(c)" collapse to the same
|
||||||
|
// key ("i:2.c"). Interim only — superseded by the stable obligation_id once adopted.
|
||||||
|
func citationUnitKey(cu string) string {
|
||||||
|
low := strings.ToLower(cu)
|
||||||
|
part := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "part ii"):
|
||||||
|
part = "ii"
|
||||||
|
case strings.Contains(low, "part i"), strings.Contains(low, "(2)"):
|
||||||
|
part = "i" // CRA Annex I Part I = the (2)(x) essential requirements
|
||||||
|
}
|
||||||
|
var refs []string
|
||||||
|
for _, m := range citationRefRe.FindAllStringSubmatch(cu, -1) {
|
||||||
|
refs = append(refs, strings.ToLower(m[1]))
|
||||||
|
}
|
||||||
|
return part + ":" + strings.Join(refs, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadObligationJoinKeys reads the Registry contract and indexes it by citation-unit key.
|
||||||
|
func LoadObligationJoinKeys(path string) (*ObligationJoinKeys, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var o ObligationJoinKeys
|
||||||
|
if err := json.Unmarshal(raw, &o); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.byCitationKey = map[string][]string{}
|
||||||
|
for _, ob := range o.ObligationIDs {
|
||||||
|
for _, cu := range ob.CitationUnits {
|
||||||
|
k := citationUnitKey(cu)
|
||||||
|
o.byCitationKey[k] = append(o.byCitationKey[k], ob.ObligationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationsForCitation returns the obligation_ids that join (interim) to a citation
|
||||||
|
// reference such as a control_mapping.source_norm.
|
||||||
|
func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string {
|
||||||
|
return o.byCitationKey[citationUnitKey(citationRef)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
|
||||||
|
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
|
||||||
|
for i := range o.ObligationIDs {
|
||||||
|
if o.ObligationIDs[i].ObligationID == obligationID {
|
||||||
|
return &o.ObligationIDs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingReaches reports whether a control mapping reaches an obligation — EXACT via the
|
||||||
|
// adopted obligation_id (semantic, preferred), else via the interim citation_unit join (for
|
||||||
|
// not-yet-adopted rows). Once obligation_id is set, the coarse citation_unit match is ignored:
|
||||||
|
// that is how the semantic join replaces the structural one (e.g. V11.2.1 crypto no longer
|
||||||
|
// rides (2)(d) into user_authentication_required — it goes to credential_confidentiality_protection).
|
||||||
|
func mappingReaches(m ControlMapping, ob ObligationKey, citationKeys map[string]bool) bool {
|
||||||
|
if m.ObligationID != "" {
|
||||||
|
return m.ObligationID == ob.ObligationID
|
||||||
|
}
|
||||||
|
return citationKeys[citationUnitKey(m.SourceNorm)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptedControlsForObligation returns our accepted control mappings that reach an obligation
|
||||||
|
// (deduped by target control), obligation_id-exact where adopted, citation_unit otherwise.
|
||||||
|
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
|
||||||
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
||||||
|
for _, cu := range ob.CitationUnits {
|
||||||
|
keys[citationUnitKey(cu)] = true
|
||||||
|
}
|
||||||
|
out := []ControlMapping{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range mappings.All {
|
||||||
|
if !m.IsAccepted() || !mappingReaches(m, ob, keys) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ck := m.TargetFramework + ":" + m.TargetControl
|
||||||
|
if seen[ck] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ck] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObligationCoverage is one row of the cross-session coverage report.
|
||||||
|
type ObligationCoverage struct {
|
||||||
|
ObligationID string `json:"obligation_id"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Status string `json:"status"` // covered | mapped_rejected | uncovered
|
||||||
|
AcceptedControls []string `json:"accepted_controls"`
|
||||||
|
EvidenceCount int `json:"evidence_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeObligationCoverage joins the Registry obligations to our control mappings — exact via
|
||||||
|
// obligation_id where adopted, else via the interim citation_unit join — and reports per
|
||||||
|
// obligation: covered (>=1 accepted control reaches it), mapped_rejected (only rejected
|
||||||
|
// mappings reach it), or uncovered. The signal back to the Obligation session.
|
||||||
|
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
|
||||||
|
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
|
||||||
|
for _, ob := range joins.ObligationIDs {
|
||||||
|
keys := make(map[string]bool, len(ob.CitationUnits))
|
||||||
|
for _, cu := range ob.CitationUnits {
|
||||||
|
keys[citationUnitKey(cu)] = true
|
||||||
|
}
|
||||||
|
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
rejected := false
|
||||||
|
for _, m := range mappings.All {
|
||||||
|
if !mappingReaches(m, ob, keys) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.IsAccepted() {
|
||||||
|
ck := m.TargetFramework + ":" + m.TargetControl
|
||||||
|
if !seen[ck] {
|
||||||
|
seen[ck] = true
|
||||||
|
cov.AcceptedControls = append(cov.AcceptedControls, ck)
|
||||||
|
cov.EvidenceCount += len(evidence.RequiredFor(m.TargetFramework, m.TargetControl))
|
||||||
|
}
|
||||||
|
} else if m.MappingStatus == "rejected" {
|
||||||
|
rejected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(cov.AcceptedControls) > 0:
|
||||||
|
cov.Status = "covered"
|
||||||
|
case rejected:
|
||||||
|
cov.Status = "mapped_rejected"
|
||||||
|
default:
|
||||||
|
cov.Status = "uncovered"
|
||||||
|
}
|
||||||
|
out = append(out, cov)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCitationUnitKey_Join(t *testing.T) {
|
||||||
|
// our source_norm and the registry citation_unit must collapse to the SAME key.
|
||||||
|
if citationUnitKey("CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff") != citationUnitKey("Annex I (2)(c)") {
|
||||||
|
t.Errorf("interim join broken: %q vs %q",
|
||||||
|
citationUnitKey("CRA Annex I Part I (2)(c)"), citationUnitKey("Annex I (2)(c)"))
|
||||||
|
}
|
||||||
|
// Part II must NOT collide with Part I.
|
||||||
|
if citationUnitKey("Annex I Part II (1)") == citationUnitKey("CRA Annex I Part I (2)(c)") {
|
||||||
|
t.Error("Part II must not join to Part I")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadObligationJoinKeys(t *testing.T) {
|
||||||
|
o, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if o.Count != len(o.ObligationIDs) {
|
||||||
|
t.Errorf("count %d != len %d", o.Count, len(o.ObligationIDs))
|
||||||
|
}
|
||||||
|
if len(o.ObligationIDs) == 0 {
|
||||||
|
t.Fatal("empty contract")
|
||||||
|
}
|
||||||
|
if got := o.ObligationsForCitation("CRA Annex I Part I (2)(c)"); len(got) == 0 {
|
||||||
|
t.Error("expected an obligation joined to (2)(c)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObligationCoverage_Report(t *testing.T) {
|
||||||
|
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join keys: %v", err)
|
||||||
|
}
|
||||||
|
maps, err := LoadControlMappings("../../data/control_mappings")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mappings: %v", err)
|
||||||
|
}
|
||||||
|
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evidence: %v", err)
|
||||||
|
}
|
||||||
|
cov := ComputeObligationCoverage(joins, maps, ev)
|
||||||
|
if len(cov) == 0 {
|
||||||
|
t.Fatal("no coverage computed")
|
||||||
|
}
|
||||||
|
byStatus := map[string]int{}
|
||||||
|
for _, c := range cov {
|
||||||
|
byStatus[c.Status]++
|
||||||
|
}
|
||||||
|
t.Logf("COVERAGE: %d Obligations | covered=%d mapped_rejected=%d uncovered=%d",
|
||||||
|
len(cov), byStatus["covered"], byStatus["mapped_rejected"], byStatus["uncovered"])
|
||||||
|
for _, c := range cov {
|
||||||
|
if c.Status != "uncovered" {
|
||||||
|
t.Logf(" %-15s %-36s controls=%v evidence=%d", c.Status, c.ObligationID, c.AcceptedControls, c.EvidenceCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Obligation Aggregation Engine — Ausführung des Legal Obligation Layer v1.
|
||||||
|
|
||||||
|
Aggregiert Bewertungen auf KRITERIUM-Ebene (pro Control) zu Ergebnissen auf
|
||||||
|
OBLIGATION-Ebene. Das ist die erstmalige Ausführung des Modells
|
||||||
|
|
||||||
|
Regulation → Legal Obligation → Control → Criterion
|
||||||
|
|
||||||
|
— das Finding entsteht auf der OBLIGATION, nicht pro Control. Damit kollabiert
|
||||||
|
die im Katalog gemessene Redundanz (portability 11×, recipients 14×): N Controls,
|
||||||
|
die dieselbe Pflicht prüfen, ergeben EIN Obligation-Finding statt N Control-Findings.
|
||||||
|
|
||||||
|
Regulierungs-agnostisch: kennt nur obligation_id, tier, met, legal_basis,
|
||||||
|
conditional. DSGVO/CRA/NIS2/DORA/MaschVO/AI-Act speisen dieselbe Funktion.
|
||||||
|
|
||||||
|
Fail-safe (docs-src/development/legal_obligation_layer_v1.md, §Aggregation):
|
||||||
|
LEGAL_MINIMUM-Obligation:
|
||||||
|
applicable=false → NA (kein Finding)
|
||||||
|
keine LM-Anforderung erfüllt → FAILED (Pflicht-Lücke)
|
||||||
|
alle LM-Anforderungen erfüllt → MET
|
||||||
|
nur ein Teil erfüllt → PARTIAL
|
||||||
|
LM nicht bewertbar (Prüfer down) → UNDETERMINED (Aufrufer behält Legacy)
|
||||||
|
BEST_PRACTICE/OPTIONAL-Obligation (kein LM):
|
||||||
|
mind. ein Kriterium erfüllt → MET (abgedeckt)
|
||||||
|
keines → OPEN (nur Empfehlung, NIE FAILED)
|
||||||
|
|
||||||
|
Redundanz-Kollaps: LM-Kriterien EINER Obligation werden zu „Anforderungen" nach
|
||||||
|
`legal_basis` gruppiert; eine Anforderung gilt als erfüllt, sobald IRGENDEIN Control
|
||||||
|
sie bestätigt (OR). 9× recipients_disclosed (alle Art 13(1)(e)) = eine Anforderung.
|
||||||
|
PARTIAL entsteht nur bei mehreren DISTINKTEN LM-Anforderungen (verschiedene
|
||||||
|
legal_basis) innerhalb einer Obligation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
LM, BP, OPT = "LEGAL_MINIMUM", "BEST_PRACTICE", "OPTIONAL"
|
||||||
|
MET, PARTIAL, FAILED = "MET", "PARTIAL", "FAILED"
|
||||||
|
NA, UNDETERMINED, OPEN = "NA", "UNDETERMINED", "OPEN"
|
||||||
|
PFLICHT, EMPFEHLUNG, NICHT_ANWENDBAR = "PFLICHT", "EMPFEHLUNG", "NICHT_ANWENDBAR"
|
||||||
|
|
||||||
|
# Predikat-Hook: (conditional, doc_text) → True (anwendbar) / False (→ NA) / None (unbekannt → anwendbar)
|
||||||
|
ApplicableFn = Callable[[str, str], Optional[bool]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CriterionEval:
|
||||||
|
"""Eine Kriteriums-Bewertung eines Controls, einer Obligation zugeordnet."""
|
||||||
|
obligation_id: str
|
||||||
|
tier: str # LEGAL_MINIMUM / BEST_PRACTICE / OPTIONAL
|
||||||
|
met: Optional[bool] # True erfüllt · False fehlt · None unbestimmt
|
||||||
|
control_id: str
|
||||||
|
legal_basis: str = ""
|
||||||
|
criterion: str = ""
|
||||||
|
conditional: Optional[str] = None # Applicability-Prädikat der Obligation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObligationResult:
|
||||||
|
obligation_id: str
|
||||||
|
status: str # MET / PARTIAL / FAILED / NA / UNDETERMINED / OPEN
|
||||||
|
bucket: str # PFLICHT / EMPFEHLUNG / NICHT_ANWENDBAR
|
||||||
|
tier: str # bestimmende Tier der Obligation
|
||||||
|
applicable: bool
|
||||||
|
evidence: list[str] # beitragende control_ids
|
||||||
|
lm_met: int # erfüllte LM-Anforderungen
|
||||||
|
lm_total: int # distinkte LM-Anforderungen (bewertbar)
|
||||||
|
recommendations: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _governing_tier(evals: list[CriterionEval]) -> str:
|
||||||
|
tiers = {e.tier for e in evals}
|
||||||
|
if LM in tiers:
|
||||||
|
return LM
|
||||||
|
return BP if BP in tiers else OPT
|
||||||
|
|
||||||
|
|
||||||
|
def _requirement_state(evals: list[CriterionEval]) -> Optional[bool]:
|
||||||
|
"""Zustand EINER LM-Anforderung über alle prüfenden Controls (OR/Redundanz):
|
||||||
|
True (irgendwer bestätigt) · None (alle unbestimmt) · False (bewertet, fehlt)."""
|
||||||
|
if any(e.met is True for e in evals):
|
||||||
|
return True
|
||||||
|
if all(e.met is None for e in evals):
|
||||||
|
return None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _recommendations(evals: list[CriterionEval]) -> list[dict]:
|
||||||
|
"""Nicht erfüllte BEST_PRACTICE/OPTIONAL-Kriterien → Empfehlungen."""
|
||||||
|
return [{"criterion": e.criterion, "tier": e.tier, "legal_basis": e.legal_basis,
|
||||||
|
"control_id": e.control_id}
|
||||||
|
for e in evals if e.tier in (BP, OPT) and e.met is False]
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_obligation(obligation_id: str, evals: list[CriterionEval], *,
|
||||||
|
applicable_fn: Optional[ApplicableFn] = None,
|
||||||
|
doc_text: str = "") -> ObligationResult:
|
||||||
|
evidence = sorted({e.control_id for e in evals if e.control_id})
|
||||||
|
conditional = next((e.conditional for e in evals if e.conditional), None)
|
||||||
|
tier = _governing_tier(evals)
|
||||||
|
recs = _recommendations(evals)
|
||||||
|
|
||||||
|
applicable = True
|
||||||
|
if applicable_fn is not None and conditional:
|
||||||
|
verdict = applicable_fn(conditional, doc_text)
|
||||||
|
applicable = True if verdict is None else bool(verdict)
|
||||||
|
if not applicable:
|
||||||
|
return ObligationResult(obligation_id, NA, NICHT_ANWENDBAR, tier, False,
|
||||||
|
evidence, 0, 0, recs)
|
||||||
|
|
||||||
|
lm_evals = [e for e in evals if e.tier == LM]
|
||||||
|
if lm_evals:
|
||||||
|
reqs: dict[str, list[CriterionEval]] = defaultdict(list)
|
||||||
|
for e in lm_evals:
|
||||||
|
reqs[e.legal_basis or obligation_id].append(e)
|
||||||
|
states = [_requirement_state(v) for v in reqs.values()]
|
||||||
|
determinable = [s for s in states if s is not None]
|
||||||
|
if not determinable:
|
||||||
|
return ObligationResult(obligation_id, UNDETERMINED, PFLICHT, LM, True,
|
||||||
|
evidence, 0, len(states), recs)
|
||||||
|
met = sum(1 for s in determinable if s)
|
||||||
|
total = len(determinable)
|
||||||
|
status = MET if met == total else (FAILED if met == 0 else PARTIAL)
|
||||||
|
return ObligationResult(obligation_id, status, PFLICHT, LM, True,
|
||||||
|
evidence, met, total, recs)
|
||||||
|
|
||||||
|
# Reine BEST_PRACTICE/OPTIONAL-Obligation: nie Pflicht, nie FAILED.
|
||||||
|
covered = any(e.met is True for e in evals)
|
||||||
|
return ObligationResult(obligation_id, MET if covered else OPEN, EMPFEHLUNG,
|
||||||
|
tier, True, evidence, 0, 0, recs)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_obligations(evals: list[CriterionEval], *,
|
||||||
|
applicable_fn: Optional[ApplicableFn] = None,
|
||||||
|
doc_text: str = "") -> list[ObligationResult]:
|
||||||
|
"""Flache Kriteriums-Liste → ein ObligationResult je obligation_id."""
|
||||||
|
groups: dict[str, list[CriterionEval]] = defaultdict(list)
|
||||||
|
for e in evals:
|
||||||
|
if e.obligation_id:
|
||||||
|
groups[e.obligation_id].append(e)
|
||||||
|
return [aggregate_obligation(oid, g, applicable_fn=applicable_fn, doc_text=doc_text)
|
||||||
|
for oid, g in groups.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def evals_from_tiered(control_id: str, tiered_criteria: list[dict],
|
||||||
|
detail: list[dict], conditional: Optional[str] = None
|
||||||
|
) -> list[CriterionEval]:
|
||||||
|
"""Adapter: tiered_criteria (obligation_id/tier/legal_basis) + das
|
||||||
|
evaluate_tiered-`detail` (met pro Index, gleiche Reihenfolge) → CriterionEvals.
|
||||||
|
`conditional` kommt aus der Control-`applicability` (gilt für die Obligation)."""
|
||||||
|
out: list[CriterionEval] = []
|
||||||
|
for i, c in enumerate(tiered_criteria or []):
|
||||||
|
oid = c.get("obligation_id")
|
||||||
|
if not oid:
|
||||||
|
continue
|
||||||
|
d = detail[i] if i < len(detail) else {}
|
||||||
|
out.append(CriterionEval(
|
||||||
|
obligation_id=oid,
|
||||||
|
tier=(c.get("compliance_tier") or "").upper(),
|
||||||
|
met=d.get("met"),
|
||||||
|
control_id=control_id,
|
||||||
|
legal_basis=c.get("legal_basis") or "",
|
||||||
|
criterion=c.get("criterion") or "",
|
||||||
|
conditional=conditional,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(results: list[ObligationResult]) -> dict:
|
||||||
|
"""Phase-C-Kennzahlen: Obligation-Anzahl + Verteilung nach Bucket/Status."""
|
||||||
|
return {
|
||||||
|
"obligations": len(results),
|
||||||
|
"buckets": dict(Counter(r.bucket for r in results)),
|
||||||
|
"statuses": dict(Counter(r.status for r in results)),
|
||||||
|
"pflicht_failed": sum(1 for r in results if r.bucket == PFLICHT and r.status == FAILED),
|
||||||
|
"pflicht_partial": sum(1 for r in results if r.bucket == PFLICHT and r.status == PARTIAL),
|
||||||
|
"recommendations": sum(len(r.recommendations) for r in results),
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Applicability-Prädikate (minimal) für die Obligation Aggregation Engine.
|
||||||
|
|
||||||
|
Jedes Prädikat entscheidet aus dem Dokumenttext, ob eine BEDINGTE Obligation
|
||||||
|
anwendbar ist:
|
||||||
|
True → anwendbar (normal bewerten)
|
||||||
|
False → NICHT anwendbar (→ NA statt FEHLT)
|
||||||
|
None → Prädikat unbekannt → Aufrufer behält Default=anwendbar (fail-safe,
|
||||||
|
KEINE stille NA)
|
||||||
|
|
||||||
|
Bewusst KLEIN gehalten: nur die bereits modellierten Bedingungen
|
||||||
|
has_third_country_transfer · uses_legitimate_interest · direct_marketing
|
||||||
|
(+ legitimate_interest_or_public_task, weil objection_general_art21_1 dieselbe
|
||||||
|
Rechtsgrundlage als Anknüpfung nutzt). profiling/employment/telecom/health/
|
||||||
|
data_act folgen in der nächsten Charge — bis dahin → None → anwendbar.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
_THIRD_COUNTRY = (
|
||||||
|
"drittland", "drittstaat", "drittländ", "third countr", "außerhalb der eu",
|
||||||
|
"ausserhalb der eu", "außerhalb des ewr", "ausserhalb des ewr",
|
||||||
|
"angemessenheitsbeschluss", "standardvertragsklausel", "standarddatenschutzklausel",
|
||||||
|
"binding corporate rules", "verbindliche interne datenschutzvorschriften",
|
||||||
|
"data privacy framework", "privacy shield", "in die usa", "in den usa",
|
||||||
|
"vereinigte staaten", "international transfer", "internationale übermittlung",
|
||||||
|
"art. 44", "art. 46",
|
||||||
|
)
|
||||||
|
_LEGIT = (
|
||||||
|
"berechtigtes interesse", "berechtigten interesse", "berechtigte interesse",
|
||||||
|
"legitimate interest", "art. 6 abs. 1 lit. f", "art. 6 abs. 1 f",
|
||||||
|
"art. 6 (1) (f)", "abs. 1 buchstabe f", "interessenabwägung",
|
||||||
|
)
|
||||||
|
_PUBLIC_TASK = (
|
||||||
|
"öffentliche aufgabe", "öffentlichen aufgabe", "im öffentlichen interesse",
|
||||||
|
"art. 6 abs. 1 lit. e", "ausübung öffentlicher gewalt", "official authority",
|
||||||
|
)
|
||||||
|
_DIRECT_MKT = (
|
||||||
|
"direktwerbung", "direktmarketing", "direkt-werbung", "werbe-e-mail", "werbe-mail",
|
||||||
|
"newsletter", "werbliche", "marketingzweck", "marketing-zweck", "zwecke der werbung",
|
||||||
|
"zu werbezwecken", "e-mail-marketing", "postwerbung", "telefonwerbung",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has(text: str, kws: tuple[str, ...]) -> bool:
|
||||||
|
return any(k in text for k in kws)
|
||||||
|
|
||||||
|
|
||||||
|
def has_third_country_transfer(text: str) -> bool:
|
||||||
|
return _has(text, _THIRD_COUNTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def uses_legitimate_interest(text: str) -> bool:
|
||||||
|
return _has(text, _LEGIT)
|
||||||
|
|
||||||
|
|
||||||
|
def direct_marketing(text: str) -> bool:
|
||||||
|
return _has(text, _DIRECT_MKT)
|
||||||
|
|
||||||
|
|
||||||
|
_PREDICATES = {
|
||||||
|
"has_third_country_transfer": has_third_country_transfer,
|
||||||
|
"uses_legitimate_interest": uses_legitimate_interest,
|
||||||
|
"legitimate_interest_or_public_task":
|
||||||
|
lambda t: _has(t, _LEGIT) or _has(t, _PUBLIC_TASK),
|
||||||
|
"direct_marketing": direct_marketing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def applicable(conditional: str, doc_text: str) -> Optional[bool]:
|
||||||
|
"""applicable_fn-Hook für `aggregate_obligations`. Unbekanntes Prädikat → None
|
||||||
|
(Aufrufer behält Default=anwendbar; NIE stille NA)."""
|
||||||
|
fn = _PREDICATES.get(conditional)
|
||||||
|
if fn is None:
|
||||||
|
return None
|
||||||
|
return fn((doc_text or "").lower())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Obligation-Taxonomie-Registry — versioniertes Artefakt bis zur DB-Owner-Tabelle
|
||||||
|
(Legal Obligation Layer v1, docs-src/development/legal_obligation_layer_v1.md).
|
||||||
|
|
||||||
|
Hält Metadaten auf OBLIGATION-Ebene, die (noch) keine eigene DB-Tabelle haben.
|
||||||
|
|
||||||
|
`decision_method_required`: Obligations, deren Erkennung Keyword/Embedding
|
||||||
|
NACHWEISLICH nicht zuverlässig leistet (kompakte/synonymreiche Offenlegung) und
|
||||||
|
die CONTENT/LLM brauchen. Empirisch belegt am TeamViewer-Recall-Defekt: 0/22
|
||||||
|
recipients+international_transfer Controls trafen, obwohl die Pflicht erfüllt war
|
||||||
|
(„…außerhalb EU/EWR … Standardvertragsklauseln/Schutzmaßnahmen"); Embedding cos
|
||||||
|
0.49–0.57 < 0.62, teils falscher Chunk → kein Schwellen-Fix, sondern LLM-Klasse.
|
||||||
|
|
||||||
|
Wirkung: der Shadow zählt ein FAILED solcher Obligations NICHT als „echte Lücke",
|
||||||
|
sondern als RECALL_LIMITED (Prüfer kann sie mit aktueller Methode nicht verifizieren).
|
||||||
|
"""
|
||||||
|
OBLIGATION_META: dict[str, dict] = {
|
||||||
|
"recipients_disclosed": {"decision_method_required": "LLM"},
|
||||||
|
"third_country_transfer_disclosed": {"decision_method_required": "LLM"},
|
||||||
|
"safeguards_disclosed": {"decision_method_required": "LLM"},
|
||||||
|
"safeguards_accessible": {"decision_method_required": "LLM"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def requires_llm(obligation_id: str) -> bool:
|
||||||
|
"""True, wenn diese Obligation CONTENT/LLM braucht (Keyword/Embedding-Recall belegt unzureichend)."""
|
||||||
|
return OBLIGATION_META.get(obligation_id, {}).get("decision_method_required") == "LLM"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""DSE Shadow-Verdrahtung der Obligation Aggregation Engine.
|
||||||
|
|
||||||
|
Erzeugt aus den v3-`results` zusätzlich Obligation-Ergebnisse — AUSSCHLIESSLICH
|
||||||
|
für die Telemetrie (Shadow Mode). Ändert KEINE nutzer-sichtbaren Findings.
|
||||||
|
|
||||||
|
Mapping control-level über generation_metadata.legal_obligations +
|
||||||
|
applicability.conditional; das `met`-Signal ist das Legacy-`passed` des Controls
|
||||||
|
(kein zusätzlicher Prüfer-Call, kein Key). Liefert die Vergleichszahlen, mit denen
|
||||||
|
sich der Umschalt-Entscheid später absichern lässt:
|
||||||
|
legacy_control_findings · obligation_shadow_results · collapse_factor ·
|
||||||
|
na_count · met_failed_delta · top_collapsed_obligations
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_obligation_markers(cids: list[str], db_url: str = "") -> dict[str, dict]:
|
||||||
|
"""legal_obligations + applicability.conditional der Controls laden.
|
||||||
|
Leeres Dict bei Fehler/keiner DB (Shadow fällt still aus)."""
|
||||||
|
cids = [c for c in cids if c]
|
||||||
|
if not cids:
|
||||||
|
return {}
|
||||||
|
import json
|
||||||
|
dsn = db_url or os.getenv("DATABASE_URL") or os.getenv("COMPLIANCE_DATABASE_URL")
|
||||||
|
if not dsn:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
import asyncpg
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"select control_id, generation_metadata->'legal_obligations' obl, "
|
||||||
|
"generation_metadata->'applicability'->>'conditional' cond "
|
||||||
|
"from compliance.canonical_controls "
|
||||||
|
"where control_id = any($1::text[]) "
|
||||||
|
"and generation_metadata ? 'legal_obligations'", cids)
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("fetch_obligation_markers failed: %s", e)
|
||||||
|
return {}
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
obl = r["obl"]
|
||||||
|
obl = json.loads(obl) if isinstance(obl, str) else obl
|
||||||
|
if obl:
|
||||||
|
out[r["control_id"]] = {"obl": obl, "cond": r["cond"]}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_obligation_shadow(results: list[dict], text: str,
|
||||||
|
markers: dict[str, dict]) -> dict[str, Any]:
|
||||||
|
"""Reiner Shadow-Vergleich (keine DB, keine Seiteneffekte). `markers`:
|
||||||
|
{control_id: {obl:[...], cond:str|None}}. `met` = Legacy-`passed`."""
|
||||||
|
from compliance.services.obligation_aggregation import (
|
||||||
|
FAILED, LM, MET, NA, PARTIAL, CriterionEval, aggregate_obligations,
|
||||||
|
)
|
||||||
|
from compliance.services.obligation_applicability import applicable
|
||||||
|
from compliance.services.obligation_taxonomy import requires_llm
|
||||||
|
|
||||||
|
legacy = 0
|
||||||
|
evals: list[Any] = []
|
||||||
|
contrib: dict[str, list] = {}
|
||||||
|
for r in results:
|
||||||
|
cid = r.get("control_id")
|
||||||
|
m = markers.get(cid)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
passed = bool(r.get("passed"))
|
||||||
|
if not passed:
|
||||||
|
legacy += 1
|
||||||
|
for ob in m["obl"]:
|
||||||
|
evals.append(CriterionEval(ob, LM, passed, cid, "", "", m.get("cond")))
|
||||||
|
contrib.setdefault(ob, []).append((cid, passed))
|
||||||
|
if not evals:
|
||||||
|
return {"status": "no obligation markers on result controls"}
|
||||||
|
|
||||||
|
obls = aggregate_obligations(evals, applicable_fn=applicable, doc_text=text)
|
||||||
|
# FAILED/PARTIAL ehrlich trennen: echte Lücke (failed_by_current_checker) vs
|
||||||
|
# RECALL_LIMITED (Obligation braucht LLM, aktueller Prüfer kann sie nicht verifizieren).
|
||||||
|
findings = failed_current = recall_limited = na = 0
|
||||||
|
for o in obls:
|
||||||
|
if o.status == NA:
|
||||||
|
na += 1
|
||||||
|
elif o.status in (FAILED, PARTIAL):
|
||||||
|
findings += 1
|
||||||
|
if requires_llm(o.obligation_id):
|
||||||
|
recall_limited += 1
|
||||||
|
else:
|
||||||
|
failed_current += 1
|
||||||
|
top = []
|
||||||
|
for o in obls:
|
||||||
|
cs = contrib.get(o.obligation_id, [])
|
||||||
|
fehlt = sum(1 for _, p in cs if not p)
|
||||||
|
if fehlt >= 2:
|
||||||
|
top.append({"obligation": o.obligation_id, "fehlt": fehlt,
|
||||||
|
"total": len(cs), "status": o.status,
|
||||||
|
"recall_limited": bool(requires_llm(o.obligation_id)
|
||||||
|
and o.status in (FAILED, PARTIAL))})
|
||||||
|
top.sort(key=lambda x: -x["fehlt"])
|
||||||
|
met_count = sum(1 for o in obls if o.status == MET)
|
||||||
|
recall_limited_obls = sorted({o.obligation_id for o in obls
|
||||||
|
if o.status in (FAILED, PARTIAL)
|
||||||
|
and requires_llm(o.obligation_id)})
|
||||||
|
return {
|
||||||
|
"legacy_control_findings": legacy,
|
||||||
|
"obligation_shadow_results": len(obls),
|
||||||
|
"obligation_findings": findings,
|
||||||
|
"failed_by_current_checker": failed_current,
|
||||||
|
"recall_limited": recall_limited,
|
||||||
|
"met_count": met_count,
|
||||||
|
"collapse_factor": round(legacy / findings, 2) if findings else None,
|
||||||
|
"na_count": na,
|
||||||
|
"met_failed_delta": legacy - findings,
|
||||||
|
"top_collapsed_obligations": top[:10],
|
||||||
|
"recall_limited_obligations": recall_limited_obls,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def build_obligation_shadow(results: list[dict], text: str,
|
||||||
|
db_url: str = "") -> dict[str, Any]:
|
||||||
|
"""Async-Wrapper: Marker laden, dann Shadow rechnen. NIE in `results` schreiben."""
|
||||||
|
cids = [r.get("control_id") for r in results if r.get("control_id")]
|
||||||
|
markers = await fetch_obligation_markers(cids, db_url)
|
||||||
|
if not markers:
|
||||||
|
return {"status": "no markers"}
|
||||||
|
return compute_obligation_shadow(results, text, markers)
|
||||||
@@ -158,6 +158,17 @@ async def run_v3_pipeline(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("dse tiered eval skipped: %s", e)
|
logger.warning("dse tiered eval skipped: %s", e)
|
||||||
|
|
||||||
|
# Layer 4 (SHADOW): Obligation-Aggregation NUR in die Telemetrie. Greift NICHT
|
||||||
|
# in `results` ein — nutzer-sichtbare Findings bleiben unverändert. Liefert die
|
||||||
|
# Vergleichszahlen für den späteren Umschalt-Entscheid (collapse_factor etc.).
|
||||||
|
obligation_shadow: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
from ._obligation_shadow import build_obligation_shadow
|
||||||
|
obligation_shadow = await build_obligation_shadow(results, text, db_url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("dse obligation shadow skipped: %s", e)
|
||||||
|
obligation_shadow = {"error": str(e)}
|
||||||
|
|
||||||
telemetry = {
|
telemetry = {
|
||||||
"layer_0_field_hits": len(boost_field_ids),
|
"layer_0_field_hits": len(boost_field_ids),
|
||||||
"layer_0_field_ids": boost_field_ids,
|
"layer_0_field_ids": boost_field_ids,
|
||||||
@@ -169,6 +180,7 @@ async def run_v3_pipeline(
|
|||||||
"offtopic_dropped": drop_stats.get("offtopic_dropped", 0),
|
"offtopic_dropped": drop_stats.get("offtopic_dropped", 0),
|
||||||
"gate_excluded": len(organizational),
|
"gate_excluded": len(organizational),
|
||||||
"organizational_checklist": organizational,
|
"organizational_checklist": organizational,
|
||||||
|
"obligation_shadow": obligation_shadow,
|
||||||
}
|
}
|
||||||
logger.info("dse v3 telemetry: %s", telemetry)
|
logger.info("dse v3 telemetry: %s", telemetry)
|
||||||
return results, telemetry
|
return results, telemetry
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Unit-Tests Obligation Aggregation Engine (Legal Obligation Layer v1).
|
||||||
|
|
||||||
|
Deckt die fail-safe Regeln + den Redundanz-Kollaps ab (echte DSE-Szenarien:
|
||||||
|
recipients 9×, objection LM+BP, portability OPTIONAL-Format)."""
|
||||||
|
from compliance.services.obligation_aggregation import (
|
||||||
|
BP, LM, OPT, CriterionEval, aggregate_obligation, aggregate_obligations,
|
||||||
|
evals_from_tiered, summarize,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ce(oid, tier, met, cid, basis="", crit="", cond=None):
|
||||||
|
return CriterionEval(oid, tier, met, cid, basis, crit, cond)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedundancyCollapse:
|
||||||
|
def test_nine_controls_one_confirms_collapses_to_one_met(self):
|
||||||
|
# recipients_disclosed: 9 Controls, gleiche Anforderung (Art 13(1)(e))
|
||||||
|
evals = [_ce("recipients_disclosed", LM, i == 4, f"DATA-{i}", "Art. 13(1)(e)")
|
||||||
|
for i in range(9)]
|
||||||
|
res = aggregate_obligation("recipients_disclosed", evals)
|
||||||
|
assert res.status == "MET"
|
||||||
|
assert res.lm_met == 1 and res.lm_total == 1 # 9 → 1 Anforderung
|
||||||
|
assert len(res.evidence) == 9
|
||||||
|
|
||||||
|
def test_all_nine_absent_fails_once(self):
|
||||||
|
evals = [_ce("recipients_disclosed", LM, False, f"DATA-{i}", "Art. 13(1)(e)")
|
||||||
|
for i in range(9)]
|
||||||
|
res = aggregate_obligation("recipients_disclosed", evals)
|
||||||
|
assert res.status == "FAILED"
|
||||||
|
assert res.bucket == "PFLICHT"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialMultiFacet:
|
||||||
|
def test_two_distinct_lm_requirements_one_met_is_partial(self):
|
||||||
|
evals = [
|
||||||
|
_ce("transfer", LM, True, "C1", "Art. 13(1)(f)"), # erfüllt
|
||||||
|
_ce("transfer", LM, False, "C2", "Art. 46"), # fehlt → distinkt
|
||||||
|
]
|
||||||
|
res = aggregate_obligation("transfer", evals)
|
||||||
|
assert res.status == "PARTIAL"
|
||||||
|
assert res.lm_met == 1 and res.lm_total == 2
|
||||||
|
|
||||||
|
def test_both_distinct_requirements_met(self):
|
||||||
|
evals = [
|
||||||
|
_ce("transfer", LM, True, "C1", "Art. 13(1)(f)"),
|
||||||
|
_ce("transfer", LM, True, "C2", "Art. 46"),
|
||||||
|
]
|
||||||
|
assert aggregate_obligation("transfer", evals).status == "MET"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplicability:
|
||||||
|
def test_conditional_false_is_na(self):
|
||||||
|
evals = [_ce("transfer", LM, False, "C1", "Art. 44", cond="has_third_country_transfer")]
|
||||||
|
res = aggregate_obligation("transfer", evals, applicable_fn=lambda c, t: False)
|
||||||
|
assert res.status == "NA"
|
||||||
|
assert res.bucket == "NICHT_ANWENDBAR"
|
||||||
|
assert res.applicable is False
|
||||||
|
|
||||||
|
def test_conditional_true_evaluates_normally(self):
|
||||||
|
evals = [_ce("transfer", LM, False, "C1", "Art. 44", cond="has_third_country_transfer")]
|
||||||
|
res = aggregate_obligation("transfer", evals, applicable_fn=lambda c, t: True)
|
||||||
|
assert res.status == "FAILED"
|
||||||
|
|
||||||
|
def test_conditional_unknown_defaults_applicable(self):
|
||||||
|
evals = [_ce("transfer", LM, True, "C1", "Art. 44", cond="x")]
|
||||||
|
res = aggregate_obligation("transfer", evals, applicable_fn=lambda c, t: None)
|
||||||
|
assert res.applicable is True and res.status == "MET"
|
||||||
|
|
||||||
|
def test_no_predicate_means_applicable(self):
|
||||||
|
evals = [_ce("transfer", LM, True, "C1", cond="x")]
|
||||||
|
assert aggregate_obligation("transfer", evals).applicable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestUndetermined:
|
||||||
|
def test_all_lm_none_is_undetermined(self):
|
||||||
|
evals = [_ce("ob", LM, None, "C1", "b"), _ce("ob", LM, None, "C2", "b")]
|
||||||
|
res = aggregate_obligation("ob", evals)
|
||||||
|
assert res.status == "UNDETERMINED"
|
||||||
|
assert res.bucket == "PFLICHT"
|
||||||
|
|
||||||
|
def test_one_determinable_requirement_decides(self):
|
||||||
|
# eine Anforderung unbestimmt, die andere klar erfüllt → MET über die bewertbare
|
||||||
|
evals = [_ce("ob", LM, None, "C1", "b1"), _ce("ob", LM, True, "C2", "b2")]
|
||||||
|
res = aggregate_obligation("ob", evals)
|
||||||
|
assert res.status == "MET"
|
||||||
|
assert res.lm_total == 1 # nur die bewertbare Anforderung zählt
|
||||||
|
|
||||||
|
|
||||||
|
class TestBestPracticeOnly:
|
||||||
|
def test_pure_bp_covered_is_met_recommendation_bucket(self):
|
||||||
|
evals = [_ce("art20_format", OPT, True, "C1")]
|
||||||
|
res = aggregate_obligation("art20_format", evals)
|
||||||
|
assert res.status == "MET"
|
||||||
|
assert res.bucket == "EMPFEHLUNG"
|
||||||
|
|
||||||
|
def test_pure_bp_not_covered_is_open_never_failed(self):
|
||||||
|
evals = [_ce("art20_format", OPT, False, "C1", crit="JSON/CSV")]
|
||||||
|
res = aggregate_obligation("art20_format", evals)
|
||||||
|
assert res.status == "OPEN"
|
||||||
|
assert res.bucket == "EMPFEHLUNG"
|
||||||
|
assert len(res.recommendations) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecommendationsWithinLm:
|
||||||
|
def test_unmet_bp_in_lm_obligation_becomes_recommendation(self):
|
||||||
|
# objection_direct_marketing: LM erfüllt + 3 BP teils offen
|
||||||
|
evals = [
|
||||||
|
_ce("obj_dm", LM, True, "SEC-8410", "Art. 21(2)", "Recht"),
|
||||||
|
_ce("obj_dm", BP, False, "SEC-8410", "", "Kontaktweg"),
|
||||||
|
_ce("obj_dm", BP, True, "SEC-8410", "", "kostenlos"),
|
||||||
|
]
|
||||||
|
res = aggregate_obligation("obj_dm", evals)
|
||||||
|
assert res.status == "MET" and res.bucket == "PFLICHT"
|
||||||
|
assert len(res.recommendations) == 1
|
||||||
|
assert res.recommendations[0]["criterion"] == "Kontaktweg"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdapterAndSummary:
|
||||||
|
def test_evals_from_tiered_zips_and_skips_no_obligation(self):
|
||||||
|
tc = [
|
||||||
|
{"criterion": "Recht", "compliance_tier": "LEGAL_MINIMUM",
|
||||||
|
"legal_basis": "Art. 21(1)", "obligation_id": "obj_gen"},
|
||||||
|
{"criterion": "Weg", "compliance_tier": "BEST_PRACTICE",
|
||||||
|
"legal_basis": "", "obligation_id": "obj_gen"},
|
||||||
|
{"criterion": "ohne", "compliance_tier": "OPTIONAL"}, # kein obligation_id → skip
|
||||||
|
]
|
||||||
|
detail = [{"met": True}, {"met": False}, {"met": True}]
|
||||||
|
evals = evals_from_tiered("AUTH-2051", tc, detail, conditional="x")
|
||||||
|
assert len(evals) == 2
|
||||||
|
assert evals[0].met is True and evals[0].conditional == "x"
|
||||||
|
assert evals[1].tier == BP and evals[1].met is False
|
||||||
|
|
||||||
|
def test_aggregate_obligations_groups_by_id(self):
|
||||||
|
evals = [
|
||||||
|
_ce("a", LM, True, "C1", "b"),
|
||||||
|
_ce("a", LM, True, "C2", "b"),
|
||||||
|
_ce("b", LM, False, "C3", "b"),
|
||||||
|
]
|
||||||
|
results = {r.obligation_id: r for r in aggregate_obligations(evals)}
|
||||||
|
assert set(results) == {"a", "b"}
|
||||||
|
assert results["a"].status == "MET"
|
||||||
|
assert results["b"].status == "FAILED"
|
||||||
|
|
||||||
|
def test_summarize_counts_buckets_and_failures(self):
|
||||||
|
evals = [
|
||||||
|
_ce("a", LM, False, "C1", "b"), # FAILED Pflicht
|
||||||
|
_ce("c", OPT, False, "C3", crit="x"), # OPEN Empfehlung
|
||||||
|
]
|
||||||
|
s = summarize(aggregate_obligations(evals))
|
||||||
|
assert s["obligations"] == 2
|
||||||
|
assert s["pflicht_failed"] == 1
|
||||||
|
assert s["buckets"]["PFLICHT"] == 1
|
||||||
|
assert s["buckets"]["EMPFEHLUNG"] == 1
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Unit-Tests für die minimalen Applicability-Prädikate."""
|
||||||
|
from compliance.services.obligation_applicability import (
|
||||||
|
applicable, direct_marketing, has_third_country_transfer,
|
||||||
|
uses_legitimate_interest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestThirdCountry:
|
||||||
|
def test_drittland_present(self):
|
||||||
|
assert has_third_country_transfer("übermittlung in ein drittland erfolgt") is True
|
||||||
|
|
||||||
|
def test_scc_present(self):
|
||||||
|
assert has_third_country_transfer("auf basis der standardvertragsklauseln") is True
|
||||||
|
|
||||||
|
def test_absent(self):
|
||||||
|
assert has_third_country_transfer("verarbeitung nur innerhalb deutschlands") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegitimateInterest:
|
||||||
|
def test_present(self):
|
||||||
|
assert uses_legitimate_interest("auf grundlage unseres berechtigten interesses") is True
|
||||||
|
|
||||||
|
def test_absent(self):
|
||||||
|
assert uses_legitimate_interest("nur auf grundlage ihrer einwilligung") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectMarketing:
|
||||||
|
def test_newsletter(self):
|
||||||
|
assert direct_marketing("anmeldung zum newsletter möglich") is True
|
||||||
|
|
||||||
|
def test_direktwerbung(self):
|
||||||
|
assert direct_marketing("daten für direktwerbung genutzt") is True
|
||||||
|
|
||||||
|
def test_absent(self):
|
||||||
|
assert direct_marketing("wir versenden keine werblichen inhalte ohne basis") is True # 'werbliche' trifft
|
||||||
|
|
||||||
|
def test_truly_absent(self):
|
||||||
|
assert direct_marketing("reine vertragsabwicklung") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplicableHook:
|
||||||
|
def test_known_predicate_true(self):
|
||||||
|
assert applicable("has_third_country_transfer", "Transfer in die USA") is True
|
||||||
|
|
||||||
|
def test_known_predicate_false_triggers_na(self):
|
||||||
|
assert applicable("has_third_country_transfer", "nur in der EU") is False
|
||||||
|
|
||||||
|
def test_public_task_alias(self):
|
||||||
|
assert applicable("legitimate_interest_or_public_task",
|
||||||
|
"zur ausübung öffentlicher gewalt") is True
|
||||||
|
|
||||||
|
def test_unknown_predicate_returns_none(self):
|
||||||
|
# profiling noch nicht modelliert → None → Aufrufer behält anwendbar
|
||||||
|
assert applicable("profiling", "irgendein text") is None
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
assert applicable("uses_legitimate_interest", "BERECHTIGTES INTERESSE") is True
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Unit-Tests für die reinen Helfer der Obligation Discovery Pipeline (scripts/obligation_discovery/_core.py)."""
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "scripts" / "obligation_discovery"))
|
||||||
|
|
||||||
|
from _core import ( # noqa: E402
|
||||||
|
centroid, cosine, greedy_cluster, merge_edges, parse_req, validate_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseReq:
|
||||||
|
def test_list_passthrough(self):
|
||||||
|
assert parse_req(["a", "b"]) == ["a", "b"]
|
||||||
|
|
||||||
|
def test_python_repr_string(self):
|
||||||
|
assert parse_req("['x', 'y']") == ["x", "y"]
|
||||||
|
|
||||||
|
def test_json_string(self):
|
||||||
|
assert parse_req('["x", "y"]') == ["x", "y"]
|
||||||
|
|
||||||
|
def test_plain_string(self):
|
||||||
|
assert parse_req("just text") == ["just text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCosine:
|
||||||
|
def test_identical(self):
|
||||||
|
assert cosine([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]) > 0.999
|
||||||
|
|
||||||
|
def test_orthogonal(self):
|
||||||
|
assert abs(cosine([1.0, 0.0], [0.0, 1.0])) < 1e-9
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert cosine([], [1.0]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreedyCluster:
|
||||||
|
def test_near_vectors_cluster_far_separate(self):
|
||||||
|
vecs = [[1.0, 0.0], [0.99, 0.01], [0.0, 1.0]]
|
||||||
|
clusters = greedy_cluster(vecs, 0.9)
|
||||||
|
assert len(clusters) == 2
|
||||||
|
assert clusters[0]["members"] == [0, 1]
|
||||||
|
assert clusters[1]["members"] == [2]
|
||||||
|
|
||||||
|
def test_deterministic(self):
|
||||||
|
vecs = [[1.0, 0.0], [0.5, 0.5], [0.99, 0.0]]
|
||||||
|
assert greedy_cluster(vecs, 0.8) == greedy_cluster(vecs, 0.8)
|
||||||
|
|
||||||
|
def test_none_vector_isolated(self):
|
||||||
|
clusters = greedy_cluster([[1.0, 0.0], None], 0.5)
|
||||||
|
assert clusters[1]["members"] == [1] and clusters[1]["seed"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCentroid:
|
||||||
|
def test_mean(self):
|
||||||
|
assert centroid([0, 1], [[0.0, 2.0], [2.0, 4.0]]) == [1.0, 3.0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateRegistry:
|
||||||
|
def _reg(self, obls, rels=None):
|
||||||
|
return {"obligations": obls, "relationships": rels or []}
|
||||||
|
|
||||||
|
def test_lm_without_legal_basis_fails(self):
|
||||||
|
r = self._reg([{"id": "x", "tier": "LEGAL_MINIMUM", "legal_basis": [], "member_controls": ["C1"]}])
|
||||||
|
v = validate_registry(r)
|
||||||
|
assert v["lm_without_legal_basis"] == ["x"] and v["passed"] is False
|
||||||
|
|
||||||
|
def test_clean_passes(self):
|
||||||
|
r = self._reg([{"id": "x", "tier": "LEGAL_MINIMUM", "legal_basis": [{"source": "CRA"}],
|
||||||
|
"member_controls": ["C1"], "provenance": {"source_meta_cluster": "M0"}}])
|
||||||
|
assert validate_registry(r)["passed"] is True
|
||||||
|
|
||||||
|
def test_over8_per_review_unit_flagged(self):
|
||||||
|
obls = [{"id": f"o{i}", "tier": "BEST_PRACTICE", "member_controls": ["C"],
|
||||||
|
"provenance": {"source_meta_cluster": "M0"}} for i in range(9)]
|
||||||
|
v = validate_registry(self._reg(obls))
|
||||||
|
assert v["over8_per_review_unit"] == {"M0": 9} and v["passed"] is False
|
||||||
|
|
||||||
|
def test_empty_member_controls_flagged(self):
|
||||||
|
v = validate_registry(self._reg([{"id": "x", "tier": "BEST_PRACTICE", "member_controls": []}]))
|
||||||
|
assert v["empty_member_controls"] == ["x"] and v["passed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeEdges:
|
||||||
|
def test_dedup_and_semantic_only(self):
|
||||||
|
existing = [{"type": "supports", "from": "a", "to": "b"}]
|
||||||
|
proposed = [{"type": "supports", "from": "a", "to": "b"}, # dup
|
||||||
|
{"type": "depends_on", "from": "c", "to": "d"}, # new
|
||||||
|
{"type": "out_of_scope", "clusters": [1]}] # not semantic
|
||||||
|
merged, added = merge_edges(existing, proposed)
|
||||||
|
assert added == 1
|
||||||
|
assert {"type": "depends_on", "from": "c", "to": "d"} in merged
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Unit-Tests für die DSE Shadow-Verdrahtung (compute_obligation_shadow, pure)."""
|
||||||
|
from compliance.services.specialist_agents.dse._obligation_shadow import (
|
||||||
|
compute_obligation_shadow,
|
||||||
|
)
|
||||||
|
|
||||||
|
NON_LLM = "art20_right_exists_core" # nicht in der LLM_REQUIRED-Registry
|
||||||
|
LLM_REQ = "third_country_transfer_disclosed" # in der LLM_REQUIRED-Registry
|
||||||
|
|
||||||
|
|
||||||
|
def _markers(n, ob, cond=None):
|
||||||
|
return {f"C{i}": {"obl": [ob], "cond": cond} for i in range(n)}
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeShadow:
|
||||||
|
def test_collapse_and_delta(self):
|
||||||
|
results = [{"control_id": f"C{i}", "passed": False} for i in range(5)]
|
||||||
|
s = compute_obligation_shadow(results, "x", _markers(5, NON_LLM))
|
||||||
|
assert s["legacy_control_findings"] == 5
|
||||||
|
assert s["obligation_findings"] == 1 # 5 → 1
|
||||||
|
assert s["failed_by_current_checker"] == 1
|
||||||
|
assert s["recall_limited"] == 0
|
||||||
|
assert s["collapse_factor"] == 5.0
|
||||||
|
assert s["met_failed_delta"] == 4
|
||||||
|
assert s["met_count"] == 0
|
||||||
|
top = s["top_collapsed_obligations"][0]
|
||||||
|
assert top["obligation"] == NON_LLM and top["fehlt"] == 5
|
||||||
|
assert top["recall_limited"] is False
|
||||||
|
|
||||||
|
def test_fp_correction_one_passed_collapses_to_met(self):
|
||||||
|
results = [{"control_id": f"C{i}", "passed": i == 0} for i in range(5)]
|
||||||
|
s = compute_obligation_shadow(results, "x", _markers(5, NON_LLM))
|
||||||
|
assert s["legacy_control_findings"] == 4
|
||||||
|
assert s["obligation_findings"] == 0 # MET (anderswo erfüllt)
|
||||||
|
assert s["met_failed_delta"] == 4
|
||||||
|
|
||||||
|
def test_na_when_predicate_false(self):
|
||||||
|
results = [{"control_id": "C0", "passed": False}]
|
||||||
|
m = {"C0": {"obl": [LLM_REQ], "cond": "has_third_country_transfer"}}
|
||||||
|
s = compute_obligation_shadow(results, "nur innerhalb der eu", m)
|
||||||
|
assert s["na_count"] == 1
|
||||||
|
assert s["obligation_findings"] == 0 # NA statt FEHLT
|
||||||
|
|
||||||
|
def test_no_markers_returns_status(self):
|
||||||
|
s = compute_obligation_shadow([{"control_id": "C0", "passed": False}], "x", {})
|
||||||
|
assert "no obligation" in s["status"]
|
||||||
|
|
||||||
|
def test_does_not_mutate_results(self):
|
||||||
|
results = [{"control_id": "C0", "passed": False}]
|
||||||
|
compute_obligation_shadow(results, "x", _markers(1, NON_LLM))
|
||||||
|
assert set(results[0].keys()) == {"control_id", "passed"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecallSegregation:
|
||||||
|
def test_llm_required_failed_is_recall_limited_not_real_gap(self):
|
||||||
|
# 5 verfehlte third_country-Controls, Transfer-Text vorhanden → FAILED,
|
||||||
|
# aber LLM_REQUIRED → RECALL_LIMITED, NICHT failed_by_current_checker.
|
||||||
|
results = [{"control_id": f"C{i}", "passed": False} for i in range(5)]
|
||||||
|
m = {f"C{i}": {"obl": [LLM_REQ], "cond": "has_third_country_transfer"}
|
||||||
|
for i in range(5)}
|
||||||
|
s = compute_obligation_shadow(results, "übermittlung in ein drittland", m)
|
||||||
|
assert s["obligation_findings"] == 1
|
||||||
|
assert s["recall_limited"] == 1
|
||||||
|
assert s["failed_by_current_checker"] == 0
|
||||||
|
assert s["recall_limited_obligations"] == [LLM_REQ]
|
||||||
|
assert s["top_collapsed_obligations"][0]["recall_limited"] is True
|
||||||
|
|
||||||
|
def test_mixed_real_gap_and_recall_limited(self):
|
||||||
|
results = [{"control_id": "A", "passed": False}, {"control_id": "B", "passed": False}]
|
||||||
|
m = {"A": {"obl": [NON_LLM], "cond": None},
|
||||||
|
"B": {"obl": [LLM_REQ], "cond": "has_third_country_transfer"}}
|
||||||
|
s = compute_obligation_shadow(results, "übermittlung in ein drittland", m)
|
||||||
|
assert s["obligation_findings"] == 2
|
||||||
|
assert s["failed_by_current_checker"] == 1
|
||||||
|
assert s["recall_limited"] == 1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Unit-Tests für die Obligation-Taxonomie-Registry (decision_method_required)."""
|
||||||
|
from compliance.services.obligation_taxonomy import OBLIGATION_META, requires_llm
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequiresLlm:
|
||||||
|
def test_marked_obligations_require_llm(self):
|
||||||
|
for ob in ("recipients_disclosed", "third_country_transfer_disclosed",
|
||||||
|
"safeguards_disclosed", "safeguards_accessible"):
|
||||||
|
assert requires_llm(ob) is True
|
||||||
|
|
||||||
|
def test_unmarked_obligation_does_not(self):
|
||||||
|
assert requires_llm("art20_right_exists_core") is False
|
||||||
|
assert requires_llm("objection_general_art21_1") is False
|
||||||
|
|
||||||
|
def test_unknown_obligation_is_false(self):
|
||||||
|
assert requires_llm("does_not_exist") is False
|
||||||
|
|
||||||
|
def test_registry_values_are_llm(self):
|
||||||
|
assert all(v.get("decision_method_required") == "LLM"
|
||||||
|
for v in OBLIGATION_META.values())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 01 — Retrieval-Pipeline
|
||||||
|
|
||||||
|
**Zweck:** Einen Kandidaten-Pool bauen, der die *richtigen* Quellen enthält (Pflichtquelle **und** Controls) — auch dann, wenn reine Semantik sie verfehlen würde. Re-Ranking (02) kann nur ordnen, was im Pool liegt; deshalb ist der Pool-Aufbau die erste Verteidigungslinie gegen Recall-Lücken.
|
||||||
|
|
||||||
|
## Mechanik
|
||||||
|
|
||||||
|
`searchInternal()` (`legal_rag_client.go`) orchestriert den Pool in fester Reihenfolge — jede Stufe **augmentiert** (ersetzt nie), Fehler degradieren still:
|
||||||
|
|
||||||
|
1. **Embedding** — `bge-m3` (1024-dim) über Ollama, Query auf 2000 Zeichen gekappt.
|
||||||
|
2. **Hybrid (RRF)** — `searchHybrid()`: dense + Volltext via Qdrant Query-API, RRF-Fusion. Fällt bei Fehler auf `searchDense()` (reine Vektorsuche) zurück.
|
||||||
|
3. **Binding-Augmentation** — `searchBinding()`: zieht die Top-`source_class=binding_law`-Treffer dazu, **damit die Pflichtquelle immer Kandidat ist**, auch wenn Guidance semantisch dominiert.
|
||||||
|
4. **Control-Augmentation** — `searchControls()`: nur bei Control-Intent (siehe [05](05-control-intent.md)); tiefer dense-Pull, gefiltert auf Control-Pool-Rollen.
|
||||||
|
5. **Graph-Augmentation** — `expandViaGraph()`: **opt-in**; zieht verbundene Normen über Zitations-Kanten.
|
||||||
|
6. **Merge** — `mergeDedupHits()`: konkateniert, behält die erste Vorkommnis je Punkt-ID, Reihenfolge erhalten.
|
||||||
|
|
||||||
|
Danach: Map auf `LegalSearchResult` → Authority-Rerank (02) → Control-Diversity (05) → Truncate auf `topK`.
|
||||||
|
|
||||||
|
## Konstanten + Warum
|
||||||
|
|
||||||
|
| Konstante | Wert | Warum |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `prefetchLimit` (hybrid) | `20`, bzw. `topK*4` bei topK>20 | Fusion-Fenster: genug dense-Kontext für RRF, ohne den Volltext-Anteil zu verwässern |
|
||||||
|
| `controlPoolDepth` | `60` | **Gemessen:** für EU-Cyber-Control-Queries liegen die relevanten Control-Quellen (NIST, CRA-Anhang) bei dense-Rang ~8–9 — weit unter dem kleinen top-K. Auf dem größeren (95k) synced Korpus reicht ein fixer Tiefen-Pull von 60, um sie zum Kandidaten zu machen |
|
||||||
|
| `graphSeedCount` | `5` | nur die Top-Hits als Graph-Saat (Begrenzung der Expansion) |
|
||||||
|
| `graphMaxExpand` | `15` | Obergrenze der über Kanten gezogenen Normen |
|
||||||
|
| `graphHopPenalty` | `0.05` | leichte Distanz-Strafe pro Kante (Pool-Expansion, kein Ranking-Hebel) |
|
||||||
|
| `RAG_GRAPH_EXPANSION` | env, default **aus** | **Opt-in:** kein gemessener Rang-Nutzen ggü. der Binding-Augmentation, +1 Qdrant-Call/Suche, Flutungsrisiko über Reverse-Kanten. Bleibt als Recall-Sicherheitsnetz |
|
||||||
|
|
||||||
|
> Forward-Kanten (`references_out`) treiben die Graph-Expansion; Reverse-Kanten (`references_in`) werden **nur als Metadaten** geführt (sonst flutet ein populärer Anhang den Pool).
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `legal_rag_client.go` → `searchInternal()`, `mergeDedupHits()`
|
||||||
|
- `legal_rag_http.go` → `searchHybrid()`, `searchDense()`, `searchBinding()`, `searchControls()`
|
||||||
|
- `legal_rag_graph.go` → `expandViaGraph()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Pflichtquelle nicht im Pool"** → Binding-Augmentation (Stufe 3) garantiert die `binding_law`-Quelle als Kandidat.
|
||||||
|
- **„Control-Quelle unter top-K"** → Control-Augmentation + `controlPoolDepth` (Stufe 4) holt tiefliegende NIST/CRA-Anhang-Treffer.
|
||||||
|
- **„Recall-Lücke bei Synonymen"** → Hybrid (RRF) deckt lexikalische Treffer ab, die rein semantisch fehlen.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 02 — Authority-Re-Ranking
|
||||||
|
|
||||||
|
**Zweck:** Bindendes Recht der passenden Jurisdiktion/Domäne nach oben, Guidance/Fremdrecht/Off-Domain nach unten — **Reihenfolge only, nichts wird gelöscht**. Der `Score` trägt nach dem Rerank den Authority-Score, damit nachgelagerte Multi-Collection-Merges (Advisor) die Ordnung bewahren.
|
||||||
|
|
||||||
|
## Mechanik
|
||||||
|
|
||||||
|
`authorityScore()` (`authority_rerank.go`) berechnet pro Treffer einen normativen Relevanz-Score aus dem rohen Semantik-Score + gewichteter Autorität + Kontext-Bonus/Penalty:
|
||||||
|
|
||||||
|
```
|
||||||
|
score = rawSemantic
|
||||||
|
+ authorityCoef · weight/100 (Autorität, siehe 03)
|
||||||
|
+ jurisdictionGain (DE/EU-Match)
|
||||||
|
− foreignPenalty (CH bei DE/EU-Frage)
|
||||||
|
− unknownPenalty (unbekannte Klasse)
|
||||||
|
+ domainMatchGain (Chunk-Domäne == Query-Domäne)
|
||||||
|
− offDomainPenalty (bindend, aber off-domain)
|
||||||
|
− scopePenalty (BDSG Teil 3 bei allgemeiner DS-Frage)
|
||||||
|
+ topicGain (bevorzugte kanonische Norm)
|
||||||
|
− supersededPenalty (status="superseded")
|
||||||
|
```
|
||||||
|
|
||||||
|
`rerankByAuthority()` sortiert stabil nach diesem Score und schreibt ihn zurück. `liftAboveBinding()` hebt bei **Auslegungs-Intent** eine semantisch konkurrenzfähige Guidance knapp über das bindende Recht — mit Margin-Guard, damit off-topic-Guidance das Gesetz nicht überholt.
|
||||||
|
|
||||||
|
## Konstanten + Warum
|
||||||
|
|
||||||
|
| Konstante | Wert | Warum |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `authorityCoef` | `0.40` | Gewicht→Score-Multiplikator. Konservativ kalibriert gegen die Offline-Golden-Harness (Phase A): hoch genug, dass bindendes Recht gewinnt, niedrig genug, dass starke Semantik nicht erschlagen wird |
|
||||||
|
| `jurisdictionGain` | `0.05` | leichter Vorzug für DE/EU-Quellen bei DE/EU-Frage |
|
||||||
|
| `foreignPenalty` | `0.60` | Fremdrecht (CH) bei DE/EU-Frage klar demoten — aber **nicht** entfernen (Vergleichsfälle bleiben auffindbar) |
|
||||||
|
| `unknownPenalty` | `0.08` | unklassifizierte Quellen leicht zurückstufen |
|
||||||
|
| `domainMatchGain` | `0.15` | Domänen-Treffer (data_protection / cyber / ai / product_safety) belohnen |
|
||||||
|
| `offDomainPenalty` | `0.10` | bindende, aber fachfremde Norm demoten (z.B. DSGVO bei reiner Cyber-Frage) |
|
||||||
|
| `scopePenalty` | `0.25` | BDSG §45–84 (Justiz/Strafverfolgung) bei allgemeiner DS-Frage zurückstufen — häufige Scope-Verwechslung |
|
||||||
|
| `topicGain` | `0.18` | Verstärker für bevorzugte kanonische Normen (z.B. Art. 37 DSGVO bei DSB-Fragen) |
|
||||||
|
| `supersededPenalty` | `0.50` | abgelöste Alt-Quelle demoten, „damit Default-Fragen die eu-v1-Norm sehen, History aber auffindbar bleibt" |
|
||||||
|
| `intentLiftGain` | `0.10` | Epsilon-Lift einer Guidance über das beste bindende Recht bei Auslegungs-Intent |
|
||||||
|
| `intentLiftMargin` | `0.05` | Guard: Lift nur, wenn die Semantik innerhalb von 0.05 zum besten bindenden Treffer liegt |
|
||||||
|
|
||||||
|
**Auslegungs-Intent-Signale** (`guidanceIntentSignals`): `edpb`, `dsk`, `enisa`, `bsi`, `leitlinie`, `guideline`, `orientierungshilfe`, `auslegung`, `empfiehlt`, `empfehlung`, `sagt`, `laut`, …
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `authority_rerank.go` → `authorityScore()`, `rerankByAuthority()`, `bestBindingSemantic()`, `liftAboveBinding()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Guidance verdrängt Gesetz"** → `authorityCoef`·weight hebt bindendes Recht; `liftAboveBinding` nur mit Margin-Guard.
|
||||||
|
- **„Fremdrecht Top-1"** → `foreignPenalty`.
|
||||||
|
- **„Off-Domain-Gesetz dominiert"** → `domainMatchGain` / `offDomainPenalty` / `scopePenalty`.
|
||||||
|
- **„Veraltete Norm gewinnt"** → `supersededPenalty` (siehe [08](08-explainability.md)).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 03 — `source_class` (Rechtsnatur / Autorität)
|
||||||
|
|
||||||
|
**Zweck:** Die Autoritäts-Achse, die den **Rang** bestimmt (siehe [02](02-authority.md)). Deterministisch abgeleitet — der noch nicht re-ingestierte (ungetaggte) Korpus wird trotzdem klassifiziert, ohne Re-Tagging des Bestands.
|
||||||
|
|
||||||
|
## Mechanik
|
||||||
|
|
||||||
|
`classifyAuthority()` (`authority.go`) entscheidet in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. **Standard-NAME-Override** — erkannter Standard-Name (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) erzwingt `technical_standard` (Gewicht 80), **auch wenn die Payload `supervisory_guidance` sagt**. Grund: der Korpus taggt viele Standards mit generischem guidance-`source_class`; der Name ist autoritativer. `binding_law` bleibt unangetastet.
|
||||||
|
2. **Explizite Payload-Werte** — gesetztes `source_class` / `authority_weight` gewinnen.
|
||||||
|
3. **Marker-Fallback** — foreign → standard → guidance → regulation → unknown.
|
||||||
|
|
||||||
|
`inferJurisdiction()`: Fremd-Marker → `CH`; enthält `§` oder DE-Marker → `DE`; sonst → `EU`.
|
||||||
|
|
||||||
|
## Konstanten + Warum
|
||||||
|
|
||||||
|
**Gewichte je Klasse** (`sourceClassFromWeight()`):
|
||||||
|
|
||||||
|
| `source_class` | Gewicht | Schwelle | Bedeutung |
|
||||||
|
|----------------|---------|----------|-----------|
|
||||||
|
| `binding_law` | `100` | w ≥ 100 | bindendes Recht (Gesetz/VO) |
|
||||||
|
| `technical_standard` | `80` | 80 ≤ w < 100 | Best-Practice-Control-Katalog (NIST/OWASP/ISO) |
|
||||||
|
| `supervisory_guidance` | `70` | 70 ≤ w < 80 | Aufsichts-/Auslegungs-Guidance (ENISA/BSI/EDPB) |
|
||||||
|
| `unknown` | `50` | default | unklassifiziert |
|
||||||
|
| `foreign_law` | `0` | w ≤ 0 | Fremdrecht (CH) |
|
||||||
|
|
||||||
|
**Marker-Listen** (Substring-Match):
|
||||||
|
|
||||||
|
| Liste | Einträge (Auszug) | Wirkung |
|
||||||
|
|-------|-------------------|---------|
|
||||||
|
| `standardMarkers` *(vor guidance geprüft)* | NIST, OWASP, Grundschutz, ISO 27001, ISO/IEC 27001, CSA CCM, Cloud Controls Matrix, CIS Benchmark, CIS Control | → `technical_standard` (80) |
|
||||||
|
| `guidanceMarkers` | DSK, EDPB, BfDI, ENISA, BSI, EUCC, Standards Mapping, Orientierungshilfe, Handreichung, Leitlinie, Empfehlung, OECD, CISA, Blue Guide, … | → `supervisory_guidance` (70) |
|
||||||
|
| `foreignMarkers` | RevDSG, fedlex, (CH) | → `foreign_law` (0) |
|
||||||
|
| `deMarkers` | BDSG, DSK, BfDI, BayLfD, BSI | Signal **DE**-Jurisdiktion |
|
||||||
|
|
||||||
|
## Der Standard-Name-Override (Fix 2026-06-25)
|
||||||
|
|
||||||
|
**Problem:** Der CE-Korpus taggt z.B. `NIST SP 800-82r3` als `source_class=supervisory_guidance` (Gewicht 70), **nicht** technical_standard. `classifyAuthority` vertraute dem Payload-Tag → NIST landete als guidance, **kein `control_standard`** im Pool → die Diversity-Regel ([05](05-control-intent.md)) konnte nichts injizieren.
|
||||||
|
|
||||||
|
**Fix:** Erkannter Standard-Name überschreibt ein fehl-getaggtes guidance/unknown-`source_class` → `technical_standard`. Code-Fix, **kein Re-Ingest** nötig. Bindendes Recht bleibt unangetastet (Sanity geprüft: Rechtsfrage liefert weiterhin binding Top-1).
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `authority.go` → `classifyAuthority()`, `sourceClassFromWeight()`, `inferJurisdiction()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Standard als guidance mistagged → kein control_standard"** → Standard-Name-Override.
|
||||||
|
- **„Fremdrecht falsch eingeordnet"** → `foreignMarkers` + `foreign_law`-Gewicht 0.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 04 — `source_role` (Funktionale Rolle)
|
||||||
|
|
||||||
|
**Zweck:** Die zu `source_class` **orthogonale** Achse: *Was tut die Quelle im Dokument?* Sie bestimmt die **Control-Pool-Zugehörigkeit** bei Umsetzungsfragen — unabhängig von der Rechtsnatur. Deterministisch aus Markern abgeleitet, kein Re-Tagging des Bestands.
|
||||||
|
|
||||||
|
## Die 7 Rollen
|
||||||
|
|
||||||
|
| Konstante | Wert | Definition |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| `roleObligation` | `obligation` | die abstrakte Pflicht (das WAS) |
|
||||||
|
| `roleOperationalReq` | `operational_requirement` | konkrete bindende Anforderung (z.B. CRA Anhang I) |
|
||||||
|
| `roleProceduralReq` | `procedural_requirement` | Prozess: Meldung/Registrierung/DSFA/Incident |
|
||||||
|
| `roleControlStandard` | `control_standard` | Best-Practice-Katalog (NIST/OWASP/ISO/CIS) |
|
||||||
|
| `roleImplGuidance` | `implementation_guidance` | Umsetzungs-How-to (ENISA Good Practices, BSI) |
|
||||||
|
| `roleInterpretation` | `interpretation` | interpretiert die *Bedeutung* der Norm (EDPB-Leitlinie) |
|
||||||
|
| `roleDefinition` | `definition` | Definitionen / Scope / Recitals |
|
||||||
|
|
||||||
|
**Control-Pool** = `{operational_requirement, procedural_requirement, control_standard, implementation_guidance}` (die vier „wie umsetzen"-Rollen, `isControlPoolRole()`).
|
||||||
|
|
||||||
|
## Mechanik
|
||||||
|
|
||||||
|
`classifyRole()` (`control_role.go`) — Entscheidungsreihenfolge:
|
||||||
|
|
||||||
|
1. `IsRecital` → `definition`
|
||||||
|
2. `source_class == technical_standard` → `control_standard`
|
||||||
|
3. `source_class == supervisory_guidance`:
|
||||||
|
- enthält `implMarker` → `implementation_guidance`
|
||||||
|
- sonst → `interpretation`
|
||||||
|
4. `source_class == binding_law`:
|
||||||
|
- `definitionMarker` → `definition`
|
||||||
|
- `proceduralMarker` → `procedural_requirement`
|
||||||
|
- `annexMarker` **oder** `operationalMarker` → `operational_requirement`
|
||||||
|
- sonst → `obligation`
|
||||||
|
5. default → `obligation`
|
||||||
|
|
||||||
|
`controlRoleOf(payload)` klassifiziert die rohe Qdrant-Payload **vor** dem Mapping — so kann `searchControls` ([01](01-retrieval.md)) seinen tiefen dense-Pull filtern, ohne jeden Treffer voll zu materialisieren.
|
||||||
|
|
||||||
|
## Marker-Listen
|
||||||
|
|
||||||
|
| Liste | Einträge (Auszug) | → Rolle |
|
||||||
|
|-------|-------------------|---------|
|
||||||
|
| `proceduralMarkers` | Meldung, Meldepflicht, Notification, Registrierung, Konformitätserklärung, Incident, Reporting, Folgenabschätzung, DSFA, DPIA, Anzeigepflicht | `procedural_requirement` |
|
||||||
|
| `annexMarkers` | Anhang, Annex, Appendix, Anlage | `operational_requirement` |
|
||||||
|
| `operationalMarkers` | Anforderung, Requirement, essential, wesentliche | `operational_requirement` |
|
||||||
|
| `implMarkers` | Good Practice, Best Practice, Standards Mapping, Umsetzung, Implementation, Handreichung, Maßnahmenkatalog, ICS, SCADA, Technical Guideline, TIG | `implementation_guidance` |
|
||||||
|
| `definitionMarkers` | Begriffsbestimmung, Definition | `definition` |
|
||||||
|
|
||||||
|
## Warum orthogonal zu `source_class`
|
||||||
|
|
||||||
|
`source_class` (Rechtsnatur) und `source_role` (Funktion) sind **zwei Achsen**, nicht eine. ENISA bleibt `supervisory_guidance` (Rechtsnatur) **und** `implementation_guidance` (Funktion) — sie wird **nicht** umgetaggt (fachlich falsch), darf aber bei Umsetzungsfragen in den Control-Pool. So muss der Bestand nicht angefasst werden: `source_role` ist wie `source_class` aus Markern ableitbar.
|
||||||
|
|
||||||
|
`source_role` ist die **Wirbelsäule der Langzeit-Architektur** Regulation → Obligation → Operational Requirement → Control → Evidence ([09](09-framework-layer.md), Prio 4).
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `control_role.go` → `classifyRole()`, `controlRoleOf()`, `isControlPoolRole()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Controls = nur technical_standard"** → vier Control-Pool-Rollen statt einer.
|
||||||
|
- **„abstrakte Pflicht dominiert Umsetzungsfrage"** → `obligation` ist *nicht* im Control-Pool (siehe [05](05-control-intent.md)).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 05 — Control-Intent + Diversity
|
||||||
|
|
||||||
|
**Zweck:** Bei einer **Umsetzungsfrage** („Welche Controls/Maßnahmen passen?") den Control-Pool ([04](04-source-role.md)) über die abstrakte Pflicht heben — und sicherstellen, dass die Ergebnisliste **verschiedene Quellenarten** zeigt, statt dass eine Rolle sie flutet. Bei einer **Rechtsfrage** bleibt alles beim Authority-Rerank ([02](02-authority.md)).
|
||||||
|
|
||||||
|
## Intent-Erkennung
|
||||||
|
|
||||||
|
`queryWantsControls()` (`authority_rerank.go`) — Keyword-Match (`controlIntentSignals`):
|
||||||
|
|
||||||
|
> control, controls, maßnahme, schutzmaßnahme, best practice, umsetzen, implementier, absicher, härt, hardening, nist, owasp, grundschutz, ccm, iso 27001, isms
|
||||||
|
|
||||||
|
Nur wenn dieser Gate `true` ist, feuern `applyControlRoles()` und `ensureControlDiversity()`.
|
||||||
|
|
||||||
|
## Rollen-Boost (`applyControlRoles`)
|
||||||
|
|
||||||
|
Jeder Control-Pool-Treffer bekommt `controlPoolGain + controlRoleBonus[role]` auf den Score:
|
||||||
|
|
||||||
|
| Größe | Wert | Warum |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `controlPoolGain` | `0.15` | hebt **jede** Control-Pool-Rolle über die Nicht-Control-Rollen (obligation/interpretation/definition) — sonst gewinnt die bindende abstrakte `obligation` per Autorität allein |
|
||||||
|
| `controlRoleBonus[operational_requirement]` | `0.100` | weicher Intra-Pool-Vorrang (User 2026-06-24): op_req zuerst |
|
||||||
|
| `controlRoleBonus[procedural_requirement]` | `0.075` | … dann Prozess-Pflichten |
|
||||||
|
| `controlRoleBonus[control_standard]` | `0.050` | … dann Standard-Kataloge |
|
||||||
|
| `controlRoleBonus[implementation_guidance]` | `0.000` | guidance als Basis, kein Bonus |
|
||||||
|
|
||||||
|
> **Bewusst weich, keine harte Hierarchie:** Eine semantisch dominante `implementation_guidance` (z.B. ENISA bei einer EU-Cyber-Umsetzungsfrage) **darf Top-1 bleiben** — das ist fachlich korrekt. Der Boost demoted nur die abstrakte Pflicht, er erzwingt keine Reihenfolge.
|
||||||
|
|
||||||
|
## Control-Diversity-Regel (`ensureControlDiversity`)
|
||||||
|
|
||||||
|
**Problem:** Selbst mit Boost kann eine dichte Wolke gleicher Rolle (viele ENISA-Chunks) `operational_requirement` und `control_standard` aus der Top-K verdrängen — die Quellenarten werden unsichtbar.
|
||||||
|
|
||||||
|
**Lösung (statt harter `+0.30`-Rollenkeule):** Wenn die Top-K nur `implementation_guidance` enthält, **injiziere** den besten `operational_requirement` + besten `control_standard` aus dem Pool, indem der niedrigst-platzierte redundante guidance-Slot verdrängt wird. Algorithmus:
|
||||||
|
|
||||||
|
1. Rolle jedes Treffers bestimmen (`roleAt`).
|
||||||
|
2. Prüfen, welche Rollen in der Top-K vertreten sind.
|
||||||
|
3. Für jede fehlende Wunsch-Rolle (`operational_requirement`, `control_standard`): besten Treffer dieser Rolle unterhalb der Top-K finden, niedrigste `implementation_guidance` in der Top-K überschreiben.
|
||||||
|
4. Truncate auf `topK` (das ursprüngliche Duplikat fällt im Tail weg).
|
||||||
|
|
||||||
|
**Ergebnis live:** Umsetzungsfrage → `1.–4. ENISA · 5. NIST SP 800-82r3 (control_standard) · 6. MaschinenVO Anhang-III (op_req)`. ENISA behält Top-1, die anderen Quellenarten sind sichtbar.
|
||||||
|
|
||||||
|
> **Prinzip:** Nicht raten, nicht erzwingen, sondern relevante Quellenarten sichtbar machen.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `authority_rerank.go` → `queryWantsControls()`
|
||||||
|
- `control_role.go` → `applyControlRoles()`, `ensureControlDiversity()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„abstrakte Pflicht dominiert Umsetzungsfrage"** → `controlPoolGain`.
|
||||||
|
- **„eine Rolle flutet die Top-K, Quellenarten unsichtbar"** → `ensureControlDiversity`.
|
||||||
|
- **„harte Tier-Ordnung overfittet auf eine Frage"** → weicher Boost statt Keule.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 06 — Assessment
|
||||||
|
|
||||||
|
**Zweck:** Eine **auditierbare Begründungsschicht** über die gerankten Ergebnisse. Sie macht aus einer Trefferliste eine prüfbare Aussage: *Welche Norm ist primär, welche hängen daran, wie eindeutig ist das, braucht es einen Menschen?*
|
||||||
|
|
||||||
|
## Mechanik
|
||||||
|
|
||||||
|
`Assess()` (`legal_rag_assess.go`) nimmt die bereits gerankten `results []LegalSearchResult` und baut ein `LegalAssessment`:
|
||||||
|
|
||||||
|
| Feld | Inhalt |
|
||||||
|
|------|--------|
|
||||||
|
| `PrimaryNorm` | `CitationUnit` bzw. `ArticleLabel` des Top-Treffers |
|
||||||
|
| `PrimaryRegulation` | `RegulationShort` des Top-Treffers |
|
||||||
|
| `ConnectedNorms` | verbundene Normen (`references_out` + `references_in`), gekappt + dedupliziert |
|
||||||
|
| `CrossRegime` | ob mehrere Regulierungen in den Top-N liegen |
|
||||||
|
| `WinnerMargin` | Score-Abstand Top-1 ↔ Top-2 (Proxy für Eindeutigkeit) |
|
||||||
|
| `HumanReviewFlag` | true bei niedriger Eindeutigkeit |
|
||||||
|
| `ScoreReasoning` | kurze deutsche Begründung |
|
||||||
|
|
||||||
|
## Konstanten + Warum
|
||||||
|
|
||||||
|
| Konstante | Wert | Warum |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `assessConnectedCap` | `12` | Obergrenze der in der Assessment gezeigten verbundenen Normen — verhindert, dass ein stark vernetzter Artikel die Begründung flutet |
|
||||||
|
| `assessCrossRegimeTopN` | `5` | Fenster, über das „Cross-Regime" (mehrere Regulierungen) beurteilt wird |
|
||||||
|
| `assessReviewMargin` | `0.05` | enger Winner-Abstand → Human-Review-Flag (siehe [07](07-confidence.md)) |
|
||||||
|
|
||||||
|
## Human-Review-Logik
|
||||||
|
|
||||||
|
`HumanReviewFlag` wird `true`, wenn **eine** der Bedingungen gilt:
|
||||||
|
|
||||||
|
- `WinnerMargin < 0.05` — Top-1 und Top-2 liegen zu dicht beieinander (uneindeutig),
|
||||||
|
- `CrossRegime == true` — mehrere Regimes betroffen (z.B. DSGVO + CRA),
|
||||||
|
- der Primär-Treffer ist **nicht** `binding_law` — eine Rechtsaussage ohne bindende Primärquelle.
|
||||||
|
|
||||||
|
> Das ist die deterministische Eskalations-Schwelle: das System sagt von sich aus „hier sollte ein Mensch drauf schauen", statt scheinbare Sicherheit vorzutäuschen.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `legal_rag_assess.go` → `Assess()`, `primaryLabel()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„uneindeutige Antwort wird als sicher verkauft"** → `WinnerMargin` + `HumanReviewFlag`.
|
||||||
|
- **„Cross-Regime übersehen"** → `CrossRegime` über `assessCrossRegimeTopN`.
|
||||||
|
- **„Rechtsaussage ohne bindende Quelle"** → Flag bei nicht-bindendem Primär-Treffer.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 07 — Confidence
|
||||||
|
|
||||||
|
**Zweck:** Eine ehrliche Aussage über die Verlässlichkeit eines Ergebnisses — ohne einen erfundenen „Confidence: 87 %"-Wert, der Scheinsicherheit suggeriert.
|
||||||
|
|
||||||
|
## Bewusste Entscheidung: kein eigenes Confidence-Feld
|
||||||
|
|
||||||
|
Es gibt **kein** explizites `confidence`-Feld in der Engine. Stattdessen wird Verlässlichkeit aus zwei real berechneten, prüfbaren Größen abgeleitet:
|
||||||
|
|
||||||
|
| Größe | Quelle | Bedeutung |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| `WinnerMargin` | `LegalAssessment` ([06](06-assessment.md)) | Score-Abstand Top-1 ↔ Top-2 — wie klar „gewinnt" die Primärnorm? |
|
||||||
|
| `HumanReviewFlag` | `LegalAssessment` | deterministische Eskalation: ist die Antwort uneindeutig/grenzwertig? |
|
||||||
|
|
||||||
|
**Warum so?** Ein kalibrierter Wahrscheinlichkeitswert würde eine Genauigkeit vortäuschen, die ein regelbasierter Retriever nicht hat. Der **Abstand** zwischen Top-1 und Top-2 ist dagegen eine *gemessene*, erklärbare Größe: ein großer Margin = eindeutige Norm, ein kleiner Margin = mehrere plausible Quellen → Mensch entscheiden lassen.
|
||||||
|
|
||||||
|
## Schwelle
|
||||||
|
|
||||||
|
| Konstante | Wert | Wirkung |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| `assessReviewMargin` | `0.05` | `WinnerMargin < 0.05` ⇒ `HumanReviewFlag = true` |
|
||||||
|
|
||||||
|
`HumanReviewFlag` feuert zusätzlich bei Cross-Regime und bei nicht-bindender Primärquelle ([06](06-assessment.md)).
|
||||||
|
|
||||||
|
## Verhältnis zur Authority-Schicht
|
||||||
|
|
||||||
|
Der `Score`, auf dem der Margin beruht, ist **nicht** der rohe Semantik-Score, sondern der Authority-Score nach dem Rerank ([02](02-authority.md)). Damit misst der Margin die *normative* Eindeutigkeit (Rechtsnatur + Domäne berücksichtigt), nicht nur die semantische Ähnlichkeit.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `legal_rag_types.go` → `LegalSearchResult.Score`, `LegalAssessment.WinnerMargin`, `LegalAssessment.HumanReviewFlag`
|
||||||
|
- `legal_rag_assess.go` → Berechnung in `Assess()`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Scheinsicherheit"** → kein erfundener Prozentwert; Margin + Flag statt Pseudo-Confidence.
|
||||||
|
- **„knappe Entscheidung wird automatisch durchgewinkt"** → `assessReviewMargin`-Eskalation.
|
||||||
|
|
||||||
|
> **Ausbaustufe:** Echte Citation-Gating-Confidence (Finding nur bei Quelle ∧ Scope ∧ Stichtag) gehört in die Authority-/Freshness-Schicht und an Control → Evidence ([09](09-framework-layer.md)), nicht in einen Modell-Score.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 08 — Explainability, Zitate + Supersede
|
||||||
|
|
||||||
|
**Zweck:** Jedes Ergebnis muss sich **belegen** lassen — woher es kommt, womit es verbunden ist, und ob es noch gilt. Das ist die Grundlage für Zitierfähigkeit und für die spätere Citation-Gating-Logik.
|
||||||
|
|
||||||
|
## Zitate + Graph-Kanten
|
||||||
|
|
||||||
|
Aus der Qdrant-Payload geladen (Phase-2-Graph-Metadaten):
|
||||||
|
|
||||||
|
| Feld | Inhalt | Verwendung |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `CitationUnit` | kanonischer Artikel-/Anhang-Identifier | Dedup, Primärnorm-Label |
|
||||||
|
| `article_label` | menschenlesbare Fundstelle (z.B. „Art. 13 CRA") | Anzeige, Begründung |
|
||||||
|
| `citation_style` | Zitierformat-Marker | Anzeige |
|
||||||
|
| `references_out` | Normen, die dieser Chunk **zitiert** (Forward-Kanten) | Graph-Expansion ([01](01-retrieval.md)) + `ConnectedNorms` |
|
||||||
|
| `references_in` | Normen, die **diesen** Chunk zitieren (Reverse-Kanten) | **nur** Metadaten — nicht expandiert (Flutungsschutz) |
|
||||||
|
|
||||||
|
`Assess()` ([06](06-assessment.md)) verdichtet die Kanten zu `ConnectedNorms` — so wird sichtbar, dass z.B. Art. 13 CRA auf Anhang I verweist (die eigentliche Pflichtquelle).
|
||||||
|
|
||||||
|
## Supersede-Handling
|
||||||
|
|
||||||
|
Recht ändert sich; ein veralteter Stand darf den aktuellen nicht schlagen — aber Übergangs-/History-Fragen müssen ihn noch finden.
|
||||||
|
|
||||||
|
| Mechanik | Wert / Feld | Verhalten |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| **Erkennung** | Payload `status == "superseded"` → `Superseded`-Flag | markiert die abgelöste Alt-Quelle |
|
||||||
|
| **Demotion** | `supersededPenalty = 0.50` (`authorityScore`, [02](02-authority.md)) | konsequente Zurückstufung |
|
||||||
|
| **Philosophie** | — | „Alt-Quelle demoted (nicht versteckt) — Default-Fragen sehen die eu-v1-Norm, History bleibt auffindbar" |
|
||||||
|
|
||||||
|
> **Nicht entfernt, nur bestraft:** Eine abgelöste Norm kann bei einer expliziten History-Frage trotzdem hoch ranken — sie wird nur konsistent demoted, nicht ausgeblendet. Das ist dieselbe „Reihenfolge, nichts löschen"-Linie wie beim Authority-Rerank.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- `legal_rag_client.go` → Payload-Mapping (`references_out/in`, `status`)
|
||||||
|
- `legal_rag_graph.go` → Forward-Kanten-Expansion, Reverse-Kanten als Metadaten
|
||||||
|
- `legal_rag_assess.go` → `ConnectedNorms`
|
||||||
|
- `authority_rerank.go` → `supersededPenalty`
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„Aussage ohne Fundstelle"** → `CitationUnit` / `article_label`.
|
||||||
|
- **„Pflichtquelle hinter Verweis versteckt"** → Forward-Kanten-Expansion (Art. 13 → Anhang I).
|
||||||
|
- **„veralteter Rechtsstand gewinnt"** → `supersededPenalty`, aber auffindbar.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 09 — `framework_*`-Layer (Control-Mapping-Brücke)
|
||||||
|
|
||||||
|
**Zweck:** Einen **konkreten Control adressierbar** machen (z.B. `V14.2.4`), damit das System vom „welches Dokument passt?" zum „welcher konkrete Control erfüllt CRA Annex I?" übergeht. Das ist die Brücke zur nächsten Stufe — **Control → Evidence** — und der eigentliche Burggraben.
|
||||||
|
|
||||||
|
> **Ehrlicher Status:** Dieser Layer lebt **heute in der Qdrant-Payload**, nicht im Retrieval-Code. Die `ucca`-Engine liest/routet `framework_*` (noch) nicht — sie ist die **Datengrundlage**, auf der Prio 4 aufsetzt. `framework_control` reist aktuell im Feld `article` mit und ist daher bereits in den Antworten sichtbar.
|
||||||
|
|
||||||
|
## Schema (pro Chunk)
|
||||||
|
|
||||||
|
| Feld | Beispiel (OWASP) | Bedeutung |
|
||||||
|
|------|------------------|-----------|
|
||||||
|
| `framework` | `OWASP ASVS` | Rahmenwerk |
|
||||||
|
| `framework_version` | `5.0` | Version (mit `superseded`-Mechanik historisierbar, [08](08-explainability.md)) |
|
||||||
|
| `framework_section` | `V6` | Kapitel/Sektion |
|
||||||
|
| `framework_control` | `V6.2.4` | konkrete Requirement-ID — der adressierbare Control |
|
||||||
|
| `framework_section_name` | `Password Security` | menschenlesbarer Kontext |
|
||||||
|
| `asvs_level` | `L1`/`L2`/`L3` | (OWASP-spezifisch) Stufe |
|
||||||
|
|
||||||
|
Analog für NIST geplant: `framework="NIST SP 800-53"`, `framework_family="SI"`, `framework_control="SI-2"`, `framework_revision="5"`.
|
||||||
|
|
||||||
|
## OWASP ASVS 5.0 — die erste Referenz (Parser-4-Muster)
|
||||||
|
|
||||||
|
- **Quelle:** `OWASP/ASVS` GitHub, `5.0/docs_en/...flat.json` (345 Requirements). Lizenz **CC-BY-SA-4.0** (zulässig; nur CC-BY-NC ist geblockt), Attribution `OWASP`.
|
||||||
|
- **Ingestion = per-Requirement Direct-Upsert** (nicht der RAG-Chunker, der `framework_control` zerschneiden würde): 1 Qdrant-Punkt pro Requirement, `id = uuid5("owasp_asvs_5.0_"+req_id)` (idempotent), `source_class=technical_standard` / `authority_weight=80`, bge-m3-Vektor.
|
||||||
|
- **Stand:** 345 Punkte auf macmini-qdrant **und** qdrant-dev, live verifiziert (`„OWASP … Authentifizierung"` → Top-OWASP mit `V`-Codes).
|
||||||
|
- **Lehre:** Künftige Standards (NIST-Re-Tag, BSI Grundschutz) **immer** mit `source_class=technical_standard` + `framework_*` direkt setzen — das NIST-Altskript ließ `source_class` leer, daher der guidance-Mistag ([03](03-source-class.md)).
|
||||||
|
|
||||||
|
## Brücke zu Prio 4 — Control → Evidence
|
||||||
|
|
||||||
|
```
|
||||||
|
Regulation
|
||||||
|
↓ (legal obligation layer)
|
||||||
|
Obligation
|
||||||
|
↓ (source_role: operational_requirement)
|
||||||
|
Operational Requirement ── CRA Annex I
|
||||||
|
↓ (Control-Mapping über framework_control)
|
||||||
|
Control ── OWASP V6.x · NIST SI-2 · BSI OPS.1.1
|
||||||
|
↓
|
||||||
|
Evidence ── der Nachweis, den ein Auditor sehen will
|
||||||
|
```
|
||||||
|
|
||||||
|
Der nächste Schritt verdrahtet `framework_control` in eine **Control-Mapping-Tabelle** (welcher konkrete Control erfüllt welche Obligation) und darunter die **Evidence-Schicht**. NIST + BSI ziehen im selben `framework_*`-Muster nach.
|
||||||
|
|
||||||
|
## Code / Daten
|
||||||
|
|
||||||
|
- Daten: Qdrant `bp_compliance_ce` (Payload-Felder oben), Ingestion-Skripte (`ingest_owasp.py` u.a.)
|
||||||
|
- Retrieval-Verdrahtung: **offen** (Prio 4)
|
||||||
|
|
||||||
|
## Adressierte Fehlerklassen
|
||||||
|
|
||||||
|
- **„nur Dokument-Treffer, kein adressierbarer Control"** → `framework_control` pro Chunk.
|
||||||
|
- **„Control-Katalog ohne Stand"** → `framework_version` + Supersede.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# RAG-Retrieval-Engine — Architektur
|
||||||
|
|
||||||
|
Diese Sektion dokumentiert die **deterministische, regelbasierte Retrieval-Engine** des Compliance-SDK (`ai-compliance-sdk/internal/ucca/`). Sie beantwortet für jede Nutzerfrage: *Welche Norm/Quelle ist relevant — und warum?*
|
||||||
|
|
||||||
|
> **Warum diese Doku existiert:** Die Engine trifft viele bewusste `+0.05 / +0.10`-Entscheidungen. Jede Konstante kodiert eine **gemessene** Entscheidung (Golden-Harness, Fehlerklasse) — nicht eine willkürliche Stellschraube. Ohne das *Warum* sind sie in sechs Monaten nicht mehr nachvollziehbar; diese Doku ist die Referenz für Wartung, Onboarding und Audit-/Investoren-Nachweis.
|
||||||
|
|
||||||
|
## Leitprinzip
|
||||||
|
|
||||||
|
> **Nicht raten, nicht erzwingen, sondern relevante Quellenarten sichtbar machen.**
|
||||||
|
|
||||||
|
Der LLM entscheidet **nicht**, was Recht ist — nur, wie eine bereits versionierte, zitierte Norm auf einen Sachverhalt gemappt wird. Wo möglich ist die Engine deterministisch (Marker, Gewichte, Schwellen), nicht modellbasiert. Nichts wird *gelöscht* — Re-Ranking ist reine Reihenfolge, alles bleibt auffindbar.
|
||||||
|
|
||||||
|
## Zwei orthogonale Achsen
|
||||||
|
|
||||||
|
Der Kern des Modells: zwei unabhängige Achsen, die in der Literatur meist vermischt werden.
|
||||||
|
|
||||||
|
| Achse | Frage | Wirkung | Doku |
|
||||||
|
|------|-------|---------|------|
|
||||||
|
| **`source_class`** (Rechtsnatur) | Wie bindend ist die Quelle? | bestimmt den **Rang** | [03](03-source-class.md) |
|
||||||
|
| **`source_role`** (Funktion) | Was tut die Quelle im Dokument? | bestimmt die **Control-Pool-Zugehörigkeit** | [04](04-source-role.md) |
|
||||||
|
|
||||||
|
Beispiel: NIST ist `technical_standard` (source_class) **und** `control_standard` (source_role). ENISA-Good-Practices sind `supervisory_guidance` **und** `implementation_guidance` — sie bleiben guidance, dürfen aber bei Umsetzungsfragen in den Control-Pool.
|
||||||
|
|
||||||
|
## Pipeline (Überblick)
|
||||||
|
|
||||||
|
```
|
||||||
|
Query
|
||||||
|
│ bge-m3 Embedding
|
||||||
|
▼
|
||||||
|
Retrieval-Pool ── hybrid (RRF) + binding-Augmentation + control-Augmentation + (graph) → 01
|
||||||
|
▼
|
||||||
|
Authority-Rerank ── source_class → Rang (bindendes Recht der passenden Jurisdiktion oben) → 02, 03
|
||||||
|
▼
|
||||||
|
Control-Intent ── source_role → Control-Pool + Diversity (Quellenarten sichtbar machen) → 04, 05
|
||||||
|
▼
|
||||||
|
Assessment ── PrimaryNorm · ConnectedNorms · WinnerMargin · CrossRegime → 06
|
||||||
|
▼
|
||||||
|
Confidence/Explainability ── HumanReviewFlag · Zitate · Graph-Kanten · Supersede → 07, 08
|
||||||
|
```
|
||||||
|
|
||||||
|
`framework_*` ([09](09-framework-layer.md)) ist die **Daten-Brücke** zur nächsten Stufe (Control → Evidence) — heute in der Qdrant-Payload, noch nicht im Retrieval-Code verdrahtet.
|
||||||
|
|
||||||
|
## Dokumente
|
||||||
|
|
||||||
|
| # | Dokument | Inhalt |
|
||||||
|
|---|----------|--------|
|
||||||
|
| 01 | [Retrieval-Pipeline](01-retrieval.md) | Pool-Aufbau: hybrid + binding + control + graph |
|
||||||
|
| 02 | [Authority-Re-Ranking](02-authority.md) | source_class → Rang, Bonus/Penalty-System |
|
||||||
|
| 03 | [source_class](03-source-class.md) | Rechtsnatur, Gewichte, Marker, Standard-Name-Override |
|
||||||
|
| 04 | [source_role](04-source-role.md) | 7 Rollen, Control-Pool, Klassifikation |
|
||||||
|
| 05 | [Control-Intent + Diversity](05-control-intent.md) | Intent-Erkennung, Rollen-Bonus, Diversity-Regel |
|
||||||
|
| 06 | [Assessment](06-assessment.md) | Auditierbare Begründungsschicht |
|
||||||
|
| 07 | [Confidence](07-confidence.md) | WinnerMargin, HumanReviewFlag |
|
||||||
|
| 08 | [Explainability + Supersede](08-explainability.md) | Zitate, Graph-Kanten, Supersede |
|
||||||
|
| 09 | [framework_*-Layer](09-framework-layer.md) | Control-Mapping-Brücke (CRA Annex → OWASP V6.x) |
|
||||||
|
|
||||||
|
> **Fehlerklassen-These:** Modell und Korpus sind austauschbar; die *Fehlerklassen + Hebel* sind das IP. Jede Konstante unten adressiert eine benannte Fehlerklasse (z.B. „Guidance verdrängt Gesetz", „Standard als guidance mistagged"). Die Kalibrierung ist sublinear: wenige Klassen, viele Module.
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Capability Model v1 — Objektarten & Beziehungstypen (Schema-Papier, NICHT materialisiert)
|
||||||
|
|
||||||
|
Status: **OFFEN / Entscheidung erforderlich (2026-06-26).** Dies ist Schritt **#5a** (Papier).
|
||||||
|
Schritt **#5b** (Materialisierung: `capabilities.json`, Migration, Obligation→Capability-Links,
|
||||||
|
Guidance-Mapping, Runtime) ist **GEGATED** auf die Annahme dieses Papiers. **Es wurde noch keine
|
||||||
|
Zeile Daten verschoben.**
|
||||||
|
|
||||||
|
Baut auf [legal_obligation_layer_v1.md](legal_obligation_layer_v1.md),
|
||||||
|
[obligation_registry_v1.md](obligation_registry_v1.md) und dem Cross-Domain-Review
|
||||||
|
(`obligations/cross_domain_relationships.json`, Commit `ed31fdc0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Warum ein Papier statt `capabilities.json`
|
||||||
|
|
||||||
|
Die Plattform hat drei empirische Architektur-Sprünge gemacht:
|
||||||
|
1. **Control ≠ Wissensobjekt** → Legal Obligation (sofort implementiert, datenbestätigt).
|
||||||
|
2. **Procedure ist eigenständig** (implementiert: `cra_procedures.json`).
|
||||||
|
3. **Capabilities tauchen domänenübergreifend wieder auf** (Cross-Domain-Review).
|
||||||
|
|
||||||
|
(1) und (2) waren breit datenbelegt → sofort umgesetzt. Bei (3) ist die **Objektart selbst noch
|
||||||
|
nicht definiert.** Wir wissen NICHT genau, was eine Capability ist. Materialisieren wir jetzt,
|
||||||
|
riskieren wir, in drei Wochen festzustellen: „Attack Surface war gar keine Capability" → Umbau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Der Auslöser: die 8 „Capabilities" sind NICHT eine Objektart
|
||||||
|
|
||||||
|
Der Cross-Domain-Review fand 16 `SHARED_CAPABILITY`-Paare → 8 Cluster. Bei Inspektion zerfallen
|
||||||
|
sie in **zwei verschiedene Objektarten**:
|
||||||
|
|
||||||
|
| Cluster (Opus-Benennung) | Art | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| `mfa` | **Capability** | implementierbar als Funktion |
|
||||||
|
| `session_management` | **Capability** | implementierbar |
|
||||||
|
| `transport_encryption` (tls/mutual_tls/cert) | **Capability** | implementierbar (vom Klassifikator fein gesplittet → 1 Capability) |
|
||||||
|
| `code_signing` | **Capability** | implementierbar |
|
||||||
|
| `anomaly_detection` | **Capability** | implementierbar |
|
||||||
|
| `access_control` | **Ziel** (schwach) | abstraktes Ziel, kein Baustein — eher OVERLAP (siehe Konsolidierung) |
|
||||||
|
|
||||||
|
Dazu die **zwei Gap-„Obligations" aus Handoff #4** (NIST SI-7/CM-7 waren breiter als jeder
|
||||||
|
einzelne Treffer):
|
||||||
|
|
||||||
|
| Kandidat | Art | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| `software_integrity_protection` (SI-7) | **Sicherheitsziel** | wird NICHT direkt gebaut; erreicht durch code_signing + hash_verification + secure_boot |
|
||||||
|
| `attack_surface_minimization` (CM-7) | **Sicherheitsziel** | erreicht durch least_functionality + Port-Deaktivierung + Interface-Reduktion |
|
||||||
|
|
||||||
|
**Kernbeobachtung (User):** Es gibt **Typ 1 — technische Fähigkeiten** (implementierbar) und
|
||||||
|
**Typ 2 — Sicherheitsziele** (nicht direkt implementierbar, durch mehrere Capabilities erreicht).
|
||||||
|
Sie in eine `capabilities.json` zu werfen wäre der Fehler.
|
||||||
|
|
||||||
|
```
|
||||||
|
Integrity Protection (Ziel) Access Protection (Ziel)
|
||||||
|
↑ erreicht durch ↑ erreicht durch
|
||||||
|
code_signing · hash_verification · mfa · session_management ·
|
||||||
|
secure_boot (Capabilities) credential_storage (Capabilities)
|
||||||
|
```
|
||||||
|
|
||||||
|
Das erklärt rückwirkend auch das **systematische Synth-Über-Tiering** (Auth 14→6, Remote 14→5):
|
||||||
|
das LLM mischte ziel-nahe Obligations mit fähigkeits-nahen Mechanismen, weil die Modellsprache
|
||||||
|
die Ebenen nicht trennte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Kandidat-Objektarten
|
||||||
|
|
||||||
|
| Objektart | Definition | Diskriminator-Test |
|
||||||
|
|---|---|---|
|
||||||
|
| **Regulation** | Rechtsakt (CRA, NIS2, DSGVO, MaschVO) | „Ist es ein Gesetz/VO?" |
|
||||||
|
| **Legal Obligation** | rechtlich verankerte Pflicht. **CORE** (abstrakt, oft = Sicherheitsziel) ⊇ **DOMAIN** (spezialisiert) — die CORE/DOMAIN-Achse existiert bereits (portability). | „Steht das so (sinngemäß) im Recht? Kann ein Prüfer FEHLT/ERFÜLLT sagen?" |
|
||||||
|
| **Capability** *(NEU)* | implementierbare, **regulierungs-agnostische** technische Funktion, als Einheit baubar & testbar | „Kann ein Hersteller GENAU DAS bauen/konfigurieren?" → ja |
|
||||||
|
| **Procedure** | wiederholbarer operativer Prozess, der eine Capability ausbringt/erhält (bereits modelliert) | „Ist es eine Tätigkeit/ein Ablauf?" |
|
||||||
|
| **Control** | testbare Prüfanweisung | „Kann man es prüfen (pass/fail)?" |
|
||||||
|
| **Evidence** | Nachweis-Artefakt (Audit-Log, SBOM, Release Notes) | „Ist es ein Beleg-Dokument/Datum?" |
|
||||||
|
| **Guidance** *(quer)* | externe Empfehlung WIE (NIST/OWASP/ENISA/BSI). **Nicht-bindend.** | „Beschreibt es eine empfohlene Umsetzung, kein Primärrecht?" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DER ZENTRALE KNACKPUNKT: Ist „Security Objective" eine eigene Klasse?
|
||||||
|
|
||||||
|
### Modell A — flach (Objektive = Obligations)
|
||||||
|
```
|
||||||
|
Regulation → Legal Obligation → Capability → Procedure → Control → Evidence
|
||||||
|
```
|
||||||
|
Sicherheitsziele sind einfach **CORE Legal Obligations**; domänen-scoped Pflichten sind DOMAIN-
|
||||||
|
Obligations, die per `specializes` an die CORE hängen.
|
||||||
|
|
||||||
|
### Modell B — mit eigener Security-Objective-Klasse
|
||||||
|
```
|
||||||
|
Regulation → Legal Obligation → Security Objective → Capability → Procedure → Control → Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modell C — hybrid (Capability als einzige neue Klasse) ← **EMPFEHLUNG**
|
||||||
|
```
|
||||||
|
Regulation → Legal Obligation (CORE ⊇ DOMAIN) --realized_by--> Capability → Procedure → Control → Evidence
|
||||||
|
▲ ▲
|
||||||
|
└── specializes (DOMAIN→CORE) └── described_by ── Guidance (NIST/OWASP/…)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empfehlung: Modell C.** Begründung aus den Daten:
|
||||||
|
- Die „Sicherheitsziele" (`software_integrity_protection`, `attack_surface_minimization`, CIA,
|
||||||
|
access-protection) **SIND im CRA bindende Pflichten** (Annex I (2)(a–m) ist Primärrecht). Ein
|
||||||
|
Sicherheitsziel ist also eine **CORE Legal Obligation**, kein neuer Objekttyp.
|
||||||
|
- Die **CORE/DOMAIN-Achse existiert schon** (portability_core ⊇ health/data_act). `attack_surface_
|
||||||
|
minimization` (CORE) ⊇ `remote_access_attack_surface_min` (DOMAIN) ist exakt dasselbe Muster.
|
||||||
|
→ keine neue Klasse, nur konsequente Nutzung des Vorhandenen.
|
||||||
|
- **Genau EINE** wirklich neue Klasse (**Capability**) ist sparsam und niedrig-risiko.
|
||||||
|
- Modell B verdoppelt die normative Ebene (Obligation vs Objective), die im CRA 1:1 zusammenfällt
|
||||||
|
→ Klasse, die niemand sauber befüllt.
|
||||||
|
|
||||||
|
**Konsequenz für die #4-Gap:** `software_integrity_protection` + `attack_surface_minimization`
|
||||||
|
werden als **CORE Legal Obligations** angelegt (nicht als Capabilities), und die domänen-scoped
|
||||||
|
Treffer (`signed_update_integrity`, `remote_access_attack_surface_min`) `specializes` → CORE.
|
||||||
|
NIST SI-7/CM-7 mappen dann `primary_implementation` auf die CORE.
|
||||||
|
|
||||||
|
**Offen für den User:** Modell C akzeptieren? Oder ist die regulierungs-AGNOSTISCHE Vereinheitlichung
|
||||||
|
(eine „confidentiality" über CRA+NIS2+ISO) so wertvoll, dass „Security Objective" doch eine eigene
|
||||||
|
Klasse verdient (Modell B)? Das ist die einzige wirklich offene Architekturentscheidung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Beziehungstypen — das Modell ist ein GRAPH, keine flache Ebene
|
||||||
|
|
||||||
|
Der Review fand **vier distinkte Cross-Domain-Strukturrelationen** (nicht eine):
|
||||||
|
`SUPPORTED_BY` 23 · `SHARED_CAPABILITY` 16 · `SHARED_EVIDENCE` 7 · `SHARED_PROCEDURE` 5 (+ 1 Merge).
|
||||||
|
Das ist kein Baum. Vorgeschlagenes gerichtetes Kanten-Vokabular:
|
||||||
|
|
||||||
|
| Kante | von → nach | aus Review-Relation |
|
||||||
|
|---|---|---|
|
||||||
|
| `specializes` | DOMAIN-Obligation → CORE-Obligation | (SUPPORTED_BY, Spezialfall) |
|
||||||
|
| `contributes_to` | Obligation → Obligation | (SUPPORTED_BY, Beitrag) |
|
||||||
|
| `realized_by` | Obligation → Capability | (SHARED_CAPABILITY ⇒ 2 Obl. teilen 1 Capability) |
|
||||||
|
| `deployed_via` | Capability → Procedure | (SHARED_PROCEDURE) |
|
||||||
|
| `verified_by` | Procedure/Capability → Control | — |
|
||||||
|
| `produces` | Procedure → Evidence | (SHARED_EVIDENCE ⇒ 2 Obl. teilen 1 Nachweis) |
|
||||||
|
| `described_by` | Capability → Guidance | (guidance_basis) |
|
||||||
|
| `same_as` | Obligation ↔ Obligation | (SAME_OBLIGATION, Merge) |
|
||||||
|
|
||||||
|
`SHARED_CAPABILITY`/`SHARED_EVIDENCE`/`SHARED_PROCEDURE` sind also **keine Obligation-Obligation-
|
||||||
|
Kanten**, sondern Belege, dass zwei Obligations **denselben Knoten einer tieferen Ebene** teilen
|
||||||
|
(Capability / Evidence / Procedure). Genau das ist der Mehrwert gegenüber „sieht ähnlich aus".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Die 8 offenen Fragen (Antwort + Tradeoff)
|
||||||
|
|
||||||
|
1. **Was ist eine Capability?** Eine implementierbare, regulierungs-agnostische technische Funktion,
|
||||||
|
als Einheit baubar/konfigurierbar/testbar (MFA, TLS, Code Signing, Session-Mgmt, Anomaly-Detection).
|
||||||
|
2. **Unterschied zur Obligation?** Obligation = rechtliche Pflicht (WAS das Recht verlangt, regulierungs-
|
||||||
|
verankert, normativ). Capability = technisches Mittel (WIE man sie erfüllt, agnostisch). n:m.
|
||||||
|
3. **Unterschied zum Security Objective?** Ziel = erwünschter Sicherheitszustand (CIA, attack-surface-min);
|
||||||
|
Capability = Mittel dorthin. **Empfehlung (Modell C):** das Ziel ist eine CORE Obligation, kein
|
||||||
|
eigener Typ → Unterschied reduziert sich auf Obligation(abstrakt) vs Capability(Mittel).
|
||||||
|
4. **Wann Guidance?** Wenn es eine **nicht-bindende externe Empfehlung zur Umsetzung** ist (NIST AC-12,
|
||||||
|
OWASP ASVS V6). Hängt an der **Capability** (meist) bzw. Procedure — NIE als `legal_basis` einer
|
||||||
|
LEGAL_MINIMUM-Obligation (Primärrecht-Regel bleibt).
|
||||||
|
5. **Wann Procedure?** Wenn es ein **wiederholbarer operativer Ablauf** ist, der eine Capability
|
||||||
|
ausbringt/erhält (MFA konfigurieren, Schlüssel rotieren, Patch-Zyklus fahren).
|
||||||
|
6. **Capability → mehrere Obligations?** **JA, belegt:** `mfa` erfüllt 6 Obligations (auth+remote),
|
||||||
|
`code_signing` 2 (auth+updates). n:m.
|
||||||
|
7. **Obligation → mehrere Capabilities?** **JA, belegt:** access-protection ← mfa + session_management
|
||||||
|
+ credential_storage. n:m.
|
||||||
|
8. **Wo hängen NIST/OWASP/ENISA/BSI?** Primär an der **Capability** (sie beschreiben deren Umsetzung),
|
||||||
|
teils an Procedure. **Das erklärt, warum die über-getierten BP-Obligations `guidance_basis` trugen:
|
||||||
|
sie waren in Wahrheit Capabilities.** Sauberer Sitz von `guidance_basis` = Capability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Worked Examples (4 Domänen, echte IDs)
|
||||||
|
|
||||||
|
**Authentication** — `user_authentication_required` (Obl, CORE: access-protection)
|
||||||
|
`--realized_by-->` { `mfa`, `session_management`, `credential_storage` } (Capabilities)
|
||||||
|
`--described_by-->` NIST IA-2 / OWASP ASVS V6 (Guidance).
|
||||||
|
|
||||||
|
**Updates** — `provide_security_updates` (Obl, LEGAL_MINIMUM) `--realized_by-->`
|
||||||
|
{ `code_signing` (= signed_update_integrity-Capability), `automatic_update_delivery`, `rollback` }
|
||||||
|
— exakt die `capability_candidate`-Marker aus `cra_updates.json`.
|
||||||
|
|
||||||
|
**Remote Access** — CORE `attack_surface_minimization` (NEU, = CM-7-Ziel) `⊇ specializes ⊇`
|
||||||
|
`remote_access_attack_surface_min` (DOMAIN) `--realized_by-->` { `least_functionality`, `port_disabling` }.
|
||||||
|
|
||||||
|
**SBOM** — Sonderfall: die SBOM-Familie ist im Cross-Review der **Evidence-/Procedure-Input** für
|
||||||
|
`vuln_identification_inventory` (5× SUPPORTED_BY-Hub), weniger Capability. → bestätigt, dass nicht
|
||||||
|
jede Domäne primär Capabilities beisteuert; manche liefern **Evidence**. Stützt den Graph-Charakter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Entscheidung, die ich vom User brauche (vor #5b)
|
||||||
|
|
||||||
|
1. **Modell C** (Capability = einzige neue Klasse; Sicherheitsziele = CORE-Obligations) — akzeptiert?
|
||||||
|
Oder Modell B (Security Objective als eigene Klasse für regulierungs-agnostische Vereinheitlichung)?
|
||||||
|
2. **Kanten-Vokabular** aus §4 — so einfrieren?
|
||||||
|
3. **`guidance_basis` wandert konzeptionell an die Capability** — einverstanden? (Bricht nichts sofort;
|
||||||
|
die Obligations behalten den Verweis bis #5b.)
|
||||||
|
4. Erst danach **#5b**: `capabilities.json` (capability_id, fulfills_obligations[] via `realized_by`,
|
||||||
|
guidance_basis hochgezogen), die 2 CORE-Gap-Obligations, der Merge (`vuln_remediation_patching` ≈
|
||||||
|
`provide_security_updates`), und die 2 Remote-Grenzfälle final tiern.
|
||||||
|
|
||||||
|
## 8. Bewusst NICHT in #5a (gegated)
|
||||||
|
|
||||||
|
Keine `capabilities.json`, keine Migration, kein Obligation-Rewrite, kein Guidance-Move, kein Runtime.
|
||||||
|
Erst Modell-Annahme, dann Daten. „Erst das Schema, dann verschieben."
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Compliance Operating System — Meta Model v1.0 (FROZEN)
|
||||||
|
|
||||||
|
> **STATUS: EINGEFROREN (2026-06-26). ARCHITEKTUR-FREEZE IN KRAFT.**
|
||||||
|
> Ab v1.0 dürfen neue Regulierungen das Modell **nicht mehr verändern** — sie müssen sich
|
||||||
|
> **einfügen**. Das Modell wird nur wieder geöffnet, wenn eine Regulierung **nachweislich
|
||||||
|
> scheitert** (eine Anforderung lässt sich ohne neue Objektklasse nicht abbilden).
|
||||||
|
> Validiert gegen 5 Regulierungsarten: DSGVO · CRA · MaschVO · Data Act · NIS2.
|
||||||
|
|
||||||
|
Konsolidiert + friert ein: [legal_obligation_layer_v1.md](legal_obligation_layer_v1.md),
|
||||||
|
[capability_model_v1.md](capability_model_v1.md) (Modell C), [meta_model_validation_v1.md](meta_model_validation_v1.md).
|
||||||
|
Was hier eingefroren wird, ist **ausschließlich die Meta-Semantik** — NICHT die Registry, NICHT die
|
||||||
|
Capabilities-Liste, NICHT die Procedures (diese wachsen als Daten weiter).
|
||||||
|
|
||||||
|
## 1. Objektklassen (6 + Guidance) — eingefroren
|
||||||
|
|
||||||
|
| Klasse | Was | Regulierungs-Bindung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Regulation** | Rechtsakt | — |
|
||||||
|
| **Legal Obligation** | rechtlich verankerte Pflicht; **CORE ⊇ DOMAIN** | regulierungs-anchored |
|
||||||
|
| **Capability** | implementierbare technische Faehigkeit (OPTIONAL für eine Obligation) | **agnostisch** (n:m über Regulierungen) |
|
||||||
|
| **Procedure** | wiederholbarer operativer Prozess | agnostisch |
|
||||||
|
| **Control** | testbare Prüfanweisung | agnostisch |
|
||||||
|
| **Evidence** | Nachweis-Artefakt | agnostisch |
|
||||||
|
| **Guidance** *(quer)* | externe nicht-bindende Empfehlung (NIST/OWASP/ISO/BSI) — hängt an der **Capability** | agnostisch |
|
||||||
|
|
||||||
|
## 2. Die Kette + kanonisches Kanten-Vokabular — eingefroren
|
||||||
|
|
||||||
|
```
|
||||||
|
Regulation
|
||||||
|
↓ definiert
|
||||||
|
Legal Obligation (CORE ⊇ DOMAIN)
|
||||||
|
↓ realized_by (OPTIONAL — rein prozessuale/dokumentarische Obligations überspringen Capability)
|
||||||
|
Capability
|
||||||
|
↓ deployed_via (alias: operationalized_by)
|
||||||
|
Procedure
|
||||||
|
↓ verified_by
|
||||||
|
Control
|
||||||
|
↓ produces (alias: produces_evidence_for)
|
||||||
|
Evidence
|
||||||
|
→ Produktstatus
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanten (gerichtet, eingefroren):
|
||||||
|
`specializes` (DOMAIN→CORE) · `realized_by` (Obligation→Capability) · `deployed_via` (Capability→Procedure) ·
|
||||||
|
`verified_by` (Procedure/Capability→Control) · `produces` (Procedure→Evidence) · `described_by` (Capability→Guidance) ·
|
||||||
|
`supports` / `depends_on` / `contributes_to` (Obligation↔Obligation) · `same_as` (Merge/Alias).
|
||||||
|
**Das Modell ist ein GRAPH, kein Baum** (n:m an realized_by, supports, produces).
|
||||||
|
|
||||||
|
## 3. Attribute (KEINE Klassen) — eingefroren
|
||||||
|
|
||||||
|
`applicability` · `tier` (LEGAL_MINIMUM/BEST_PRACTICE) · `legal_basis` (Primärrecht) ·
|
||||||
|
`guidance_basis` (NIST/OWASP/…, kanonisch an der Capability) · `objective_tags`
|
||||||
|
(integrity/confidentiality/attack_surface/… — Vorwärts-Kompat zu einer späteren Security-Objective-
|
||||||
|
Klasse) · `risk_level` · `deadline` · **`hazard` (Attribut, KEINE Klasse)**.
|
||||||
|
|
||||||
|
**Watch-Point (bewusste Nicht-Klasse):** `Hazard/Threat` bleibt ein Risiko-Treiber-Attribut. Es wird
|
||||||
|
*erst dann* eine eigene Klasse, wenn quantitatives Risiko (FMEA: Hazard→Risiko→Maßnahme) als
|
||||||
|
First-Class-Graph-Knoten modelliert werden soll — das ist die einzige bekannte künftige Öffnungs-Ursache.
|
||||||
|
|
||||||
|
## 4. Architektur-Freeze-Policy
|
||||||
|
|
||||||
|
1. **Neue Regulierung = Daten, nicht Architektur.** Sie läuft durch `Parser → Discovery-Pipeline →
|
||||||
|
Review → Registry` und fügt Obligations/Capabilities/Procedures/Evidence hinzu.
|
||||||
|
2. **Eine neue Objektklasse ist eine Architektur-Änderung** und erfordert explizite Wieder-Öffnung +
|
||||||
|
Begründung (nachgewiesenes Scheitern der Abbildung). Default-Erwartung: **0 neue Klassen.**
|
||||||
|
3. Verfeinerungen an Attributen (neues `*_tag`, neues risk-Attribut) sind erlaubt, solange keine
|
||||||
|
neue Klasse entsteht.
|
||||||
|
|
||||||
|
## 5. Reuse-Metrik (KPI je neuer Regulierung) — der Wissens-Akkumulations-Beweis
|
||||||
|
|
||||||
|
Für jede neue Regulierung gemessen (Baseline = der jeweils vorhandene Bestand):
|
||||||
|
|
||||||
|
| Kennzahl | Soll/Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| **Neue Objektklassen** | **= 0** (Invariante; sonst Freeze gebrochen) |
|
||||||
|
| Neue Capabilities | additiv (z.B. +8) |
|
||||||
|
| **Wiederverwendete Capabilities %** | Kern-KPI (z.B. NIS2 ~70–80 % erwartet) |
|
||||||
|
| Wiederverwendete Procedures % | (z.B. 58 %) |
|
||||||
|
| Wiederverwendete Evidence % | (z.B. 81 %) |
|
||||||
|
| Neue Obligations | additiv (z.B. +42) |
|
||||||
|
|
||||||
|
Zielaussage: *„Beim AI Act: 0 neue Objektklassen, 12 neue Capabilities, 41 neue Obligations,
|
||||||
|
78 % der vorhandenen Capabilities wiederverwendet."* → belegt, dass das System **Wissen akkumuliert**,
|
||||||
|
statt je Regulierung neu gebaut zu werden. (Tool zur Berechnung folgt mit dem ersten Live-Durchlauf.)
|
||||||
|
|
||||||
|
## 6. Der Burggraben (warum das mehr ist als ein Advisor / RAG)
|
||||||
|
|
||||||
|
Der Kunde denkt nicht in Artikeln, sondern: *„Wir haben Remote-Updates / signierte Firmware / einen
|
||||||
|
Vuln-Prozess."* Über die Capability-Schicht bildet das System diese Aussagen auf **alle betroffenen
|
||||||
|
Obligations mehrerer Regulierungen** ab und beantwortet die eigentliche Frage aus dem Kundengespräch:
|
||||||
|
> **„Habe ich das Gesetz richtig verstanden, und reicht das, was wir umgesetzt haben?"**
|
||||||
|
|
||||||
|
Das ist regel-/wissensgestütztes Reasoning über ein gemeinsames Modell — keine RAG-Aufgabe.
|
||||||
|
(Die Reasoning-Session hält dabei die Welt-Grenze: `ClaimCoverage` „potenziell relevant" ⊥
|
||||||
|
`ComplianceStatus` „erfüllt aus Nachweisen".)
|
||||||
|
|
||||||
|
## 7. Was NICHT eingefroren ist (wächst weiter als Daten)
|
||||||
|
|
||||||
|
Registry-Inhalte (Obligations je Regulierung), die Capabilities-Liste, Procedures, Evidence-Typen,
|
||||||
|
Applicability-Prädikate, Citation-Spans. Diese Schicht ist **Wissensaufbau** — explizit erwünschtes
|
||||||
|
Wachstum gegen das eingefrorene Modell.
|
||||||
|
|
||||||
|
## 8. Erster Live-Durchlauf (User-Priorität nach Informationswert)
|
||||||
|
|
||||||
|
1. **MaschVO** ⭐⭐⭐⭐⭐ — beweist „Compliance-OS ≠ Cybersecurity" (physische Safety, CE, Restgefahren).
|
||||||
|
2. **NIS2** ⭐⭐⭐⭐ — misst maximalen Capability-Reuse (erwartet 70–80 %).
|
||||||
|
3. **AI Act** ⭐⭐⭐⭐ — Risikoklassifizierung/Governance, vermutlich 0 neue Klassen.
|
||||||
|
4. **Data Act** ⭐⭐⭐ — bestätigt „Capability optional".
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Meta-Model Validation v1 — Ist das Modell regulierungsunabhängig?
|
||||||
|
|
||||||
|
Status: **Phase 6 — Meta-Validierung (2026-06-26). KEIN neues Coding, KEINE Regulierung ingestiert.**
|
||||||
|
Dieses Dokument ist der Stresstest VOR der nächsten Regulierung. Baut auf
|
||||||
|
[capability_model_v1.md](capability_model_v1.md) (Modell C, #5b materialisiert) +
|
||||||
|
[legal_obligation_layer_v1.md](legal_obligation_layer_v1.md).
|
||||||
|
|
||||||
|
## Die eigentliche Frage
|
||||||
|
|
||||||
|
Nicht „welche Regulierung kommt als nächstes?", sondern:
|
||||||
|
|
||||||
|
> **Kann eine völlig neue Regulierung in dieses Modell eingeordnet werden, OHNE eine neue
|
||||||
|
> Objektklasse einzuführen?**
|
||||||
|
|
||||||
|
Wenn ja für MaschVO + Data Act + AI Act + NIS2 → das ist kein CRA-Graph mehr, sondern ein
|
||||||
|
**Compliance Meta Model**. Ab dann bringt jede Regulierung primär *Daten*, nicht *Architektur*.
|
||||||
|
|
||||||
|
## Das zu testende Modell (6 Klassen + Attribute, KEINE weitere Klasse erlaubt)
|
||||||
|
|
||||||
|
```
|
||||||
|
Regulation
|
||||||
|
↓ definiert
|
||||||
|
Legal Obligation (CORE ⊇ DOMAIN; tier=LEGAL_MINIMUM/BEST_PRACTICE; objective_tags[]; applicability)
|
||||||
|
↓ realized_by (OPTIONAL)
|
||||||
|
Capability (regulierungs-agnostische technische Faehigkeit; guidance_basis hier)
|
||||||
|
↓ deployed_via
|
||||||
|
Procedure
|
||||||
|
↓ verified_by
|
||||||
|
Control
|
||||||
|
↓ produces
|
||||||
|
Evidence
|
||||||
|
```
|
||||||
|
Quer: **Guidance** (NIST/OWASP/ISO/BSI) hängt an der Capability. **Attribute** (keine Klassen):
|
||||||
|
`tier`, `objective_tags`, `applicability`, später `deadline`/`risk_level`/`severity`.
|
||||||
|
Kanten: realized_by · specializes · contributes_to · deployed_via · verified_by · produces · described_by · same_as.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 1 — Maschinenverordnung (EU) 2023/1230
|
||||||
|
|
||||||
|
| Modell-Klasse | MaschVO-Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Legal Obligation (CORE) | `hazard_minimization` (Sicherheits-Analogon zu attack_surface_minimization), `safe_control_systems`, `machine_risk_assessment`, `ce_conformity`, `instructions_for_use` — exakt die `machine_*`-Obligations, die die Reasoning-Session bereits unabhängig geprägt hat. |
|
||||||
|
| Capability | **physische Sicherheitsfunktionen**: `emergency_stop`, `safety_interlock`, `two_hand_control`, `guarding`, `safe_torque_off`. → die **Capability-Klasse generalisiert von Cyber auf physische Safety** (gleiche Klasse, andere Domäne). |
|
||||||
|
| Procedure | Risikobeurteilung (ISO 12100), CE-Konformitätsbewertung. |
|
||||||
|
| Evidence | Technische Unterlagen, Risikobeurteilungsbericht, Konformitätserklärung. |
|
||||||
|
|
||||||
|
**Stress-Punkt:** „**Hazard**" (mechanisch/elektrisch/thermisch) = Schadensquelle — weder Obligation
|
||||||
|
noch Capability. Kandidat für eine neue Klasse? → **Nein, für die Repräsentation:** ein Hazard ist
|
||||||
|
ein *Risiko-Treiber* (Attribut/Applicability der Risikobeurteilungs-Procedure); eine Capability
|
||||||
|
*mitigiert* einen Hazard, genau wie eine Cyber-Capability eine (implizite) Bedrohung kontert. `PL/SIL`
|
||||||
|
= Attribut (wie `tier`). **Hazard wird erst dann eine Klasse, wenn ihr quantitatives FMEA-Risiko als
|
||||||
|
First-Class-Graph-Knoten wollt** (vgl. [[project-fmea-safety-direction]]) — nicht für Compliance-Abbildung.
|
||||||
|
|
||||||
|
**Verdikt: KEINE neue Klasse.** (Stärkstes Ergebnis: die Capability-Klasse trägt von Cyber zu Safety.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 2 — Data Act (EU) 2023/2854
|
||||||
|
|
||||||
|
| Modell-Klasse | Data-Act-Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Legal Obligation | `data_act_data_access_by_design`, `data_act_user_data_access`, `data_portability_switching`, `fair_contract_terms` (FRAND), `interoperability` — deckt sich mit den `data_act_*`-Obligations der Reasoning-Session. |
|
||||||
|
| Capability | `data_export_api`, `interoperability_interface`, `access_control` (**Reuse**). ABER: `fair_contract_terms` hat **KEINE technische Capability**. |
|
||||||
|
| Procedure | FRAND-Klauseln entwerfen; Switching-Prozess. |
|
||||||
|
| Evidence | Vertrag/Klauselwerk, API-Doku. |
|
||||||
|
|
||||||
|
**Stress-Punkt:** **vertraglich-rechtliche Pflichten** (FRAND, Verbot unfairer Klauseln) haben kein
|
||||||
|
technisches Mittel. → Beleg, dass **`realized_by Capability` OPTIONAL ist**: manche Obligations werden
|
||||||
|
rein über **Procedure (Entwurf) + Evidence (Vertrag)** erfüllt. Das ist KEINE neue Klasse — wir haben
|
||||||
|
es schon gesehen (SBOM-Familie war Evidence-/Procedure-lastig, kaum Capability).
|
||||||
|
|
||||||
|
**Verdikt: KEINE neue Klasse.** Verfeinerung: Capability ist optional (Obligation → Procedure → Evidence
|
||||||
|
ohne Capability ist gültig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 3 — AI Act (EU) 2024/1689
|
||||||
|
|
||||||
|
| Modell-Klasse | AI-Act-Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Legal Obligation | `ai_risk_management_system`, `ai_data_governance`, `ai_technical_documentation`, `ai_transparency_disclosure`, `human_oversight`, `accuracy_robustness`, `fundamental_rights_assessment`. |
|
||||||
|
| Capability | `event_logging` (**Reuse**!), `bias_detection`, `accuracy_testing`, `human_oversight_mechanism`, `ai_transparency_notice`. |
|
||||||
|
| Procedure | Risikomanagement-Prozess; FRIA-Durchführung; Human-Oversight-Prozess. |
|
||||||
|
| Evidence | Technische Dokumentation, FRIA-Bericht, Logs. |
|
||||||
|
|
||||||
|
**Stress-Punkt:** **Risiko-Klassifikation** (unacceptable/high/limited/minimal) bestimmt, WELCHE
|
||||||
|
Obligations gelten. → das ist **Applicability** (existiert bereits; analog zu CRA-Produktklasse).
|
||||||
|
`human_oversight` = Procedure + Capability (Oversight-UI). `transparency` = Disclosure (Capability/Evidence,
|
||||||
|
wie Cookie/DSE-Offenlegung). `FRIA` = Procedure + Evidence.
|
||||||
|
|
||||||
|
**Verdikt: KEINE neue Klasse.** Verfeinerung: Risiko-Tier = Applicability-Attribut (vorhanden).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 4 — NIS2 (EU) 2022/2555
|
||||||
|
|
||||||
|
| Modell-Klasse | NIS2-Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Legal Obligation | `nis2_risk_management_measures`, `nis2_incident_reporting`, `supply_chain_security`, `governance_accountability`, `business_continuity`. |
|
||||||
|
| Capability | **MFA, transport_encryption, security_monitoring_alerting, patch/update, backup** — **dieselben Capabilities wie CRA**. Das ist die Auszahlung: NIS2-Obligations `realized_by` die bereits gebaute Capability-Schicht. |
|
||||||
|
| Procedure | Incident-Response-Prozess; Lieferketten-Audit; Governance-Prozess. |
|
||||||
|
| Evidence | Incident-Reports, Audit-Logs, Vorstandsprotokolle. |
|
||||||
|
|
||||||
|
**Stress-Punkt:** **Meldefristen** (24h/72h/1 Monat) = zeitgebundene Procedure → `deadline` = Attribut.
|
||||||
|
`governance_accountability` (Management-Haftung) = organisatorische Obligation → Procedure + Evidence.
|
||||||
|
|
||||||
|
**Verdikt: KEINE neue Klasse.** Stärkster Reuse-Fall (teilt die CRA-Capability-Schicht vollständig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ergebnis: 4 × NEIN → das Metamodell steht
|
||||||
|
|
||||||
|
Alle vier Regulierungen passen in die 6 Klassen **ohne neue Objektklasse** — unter zwei
|
||||||
|
Verfeinerungen, die der Test selbst aufdeckt (beide sind KEINE neuen Klassen):
|
||||||
|
|
||||||
|
1. **`realized_by Capability` ist OPTIONAL.** Vertraglich/dokumentarisch/prozessuale Obligations
|
||||||
|
(Data-Act-FRAND, NIS2-Governance, AI-Act-FRIA) werden rein über Procedure + Evidence erfüllt.
|
||||||
|
2. **Risiko-Niveau / Frist / Hazard-Schwere / Risiko-Tier sind ATTRIBUTE**, keine Klassen
|
||||||
|
(`tier`-Muster: `deadline`, `risk_level`, `severity`, `risk_tier`).
|
||||||
|
|
||||||
|
**Der einzige Watch-Point:** **Hazard / Threat.** Heute implizit (Obligations existieren, um sie zu
|
||||||
|
kontern). Eine eigene Klasse wird *erst* nötig, wenn ihr **quantitatives Risiko first-class** modelliert
|
||||||
|
(FMEA: Hazard→Risiko→Maßnahme als Graph-Knoten). Für die reine Compliance-Abbildung: nicht nötig.
|
||||||
|
→ Das ist die präzise Antwort auf „wo wäre erstmals eine neue Klasse nötig?".
|
||||||
|
|
||||||
|
## Empirische Stütze (nicht nur Theorie)
|
||||||
|
|
||||||
|
Die 3. Session (Reasoning Engine) hat **unabhängig** `proposed=True`-Obligations für MaschVO
|
||||||
|
(`machine_*`) und Data Act (`data_act_*`) geprägt — und brauchte dafür **keine neue Objektklasse**,
|
||||||
|
nur Obligation-IDs. Zwei Sessions kommen unabhängig zum selben Schluss.
|
||||||
|
|
||||||
|
## Konsequenz für die Reasoning-Schicht (Produktvision)
|
||||||
|
|
||||||
|
Heute: `Product → Applicable Regulations → Applicable Obligations`.
|
||||||
|
Mit der Capability-Schicht wird daraus:
|
||||||
|
```
|
||||||
|
Applicable Capabilities → Required Procedures → Expected Evidence
|
||||||
|
```
|
||||||
|
Antwort auf die Kundenaussage „Ich habe X umgesetzt" ist dann nicht „CRA Artikel …", sondern:
|
||||||
|
```
|
||||||
|
✓ Capability A ✓ Capability B ✗ Capability C
|
||||||
|
↓
|
||||||
|
erfüllt CRA, MaschVO, NIS2 (teilweise)
|
||||||
|
```
|
||||||
|
Eine Capability erfüllt Obligations über *mehrere Regulierungen* (n:m) → eine Umsetzung wird gegen
|
||||||
|
das gesamte Regelwerk bewertet. Das ist qualitativ ein anderes Produkt als RAG.
|
||||||
|
|
||||||
|
## Entscheidung / nächster Schritt
|
||||||
|
|
||||||
|
Wenn dieses Dokument akzeptiert ist („keine weitere Klasse nötig"), verschiebt sich die Arbeit von
|
||||||
|
**Architektur** zu **Wissensaufbau**: jede neue Regulierung läuft durch
|
||||||
|
`Parser → Discovery-Pipeline → Review → Registry` (vorhandene Tooling), statt das Modell zu ändern.
|
||||||
|
Offen für den User: (a) Metamodell als stabil einfrieren? (b) den Hazard/Threat-Watch-Point als
|
||||||
|
bewusste Nicht-Klasse dokumentieren (bis FMEA-Quantifizierung)? (c) dann erste Regulierung als DATEN.
|
||||||
|
|
||||||
|
## Bewusst NICHT in diesem Schritt
|
||||||
|
|
||||||
|
Kein Code, keine Regulierung ingestiert, keine neue Klasse angelegt. Reiner Modell-Stresstest.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Obligation Aggregation — Validated Shadow Results (2026-06-24)
|
||||||
|
|
||||||
|
Status: **bewiesen im Shadow auf macmini**, NICHT deployt, NICHT live geschaltet.
|
||||||
|
Code auf Branch `feat/obligation-aggregation`; das LLM-Tiering der recipients/transfer-
|
||||||
|
Controls liegt als DB-Marker nur auf macmini.
|
||||||
|
|
||||||
|
Dieser Stand validiert die Ausführung des [Legal Obligation Layer v1](legal_obligation_layer_v1.md)
|
||||||
|
über vier ineinandergreifende Schichten.
|
||||||
|
|
||||||
|
## Die vier Schichten
|
||||||
|
|
||||||
|
1. **Obligation Aggregation** — `compliance/services/obligation_aggregation.py`.
|
||||||
|
Aggregiert Kriterium-/Control-Bewertungen zu Findings auf OBLIGATION-Ebene
|
||||||
|
(Regulation → Obligation → Control → Criterion). Redundanz kollabiert per OR pro
|
||||||
|
`legal_basis`-Anforderung; fail-safe Status (MET/PARTIAL/FAILED/NA/UNDETERMINED/OPEN).
|
||||||
|
2. **Applicability** — `compliance/services/obligation_applicability.py`.
|
||||||
|
Prädikate (`has_third_country_transfer`, `uses_legitimate_interest`, `direct_marketing`,
|
||||||
|
`legitimate_interest_or_public_task`) entscheiden bedingte Obligations → True/False/None
|
||||||
|
(unbekannt → anwendbar, nie stille NA).
|
||||||
|
3. **Recall-limited Segregation** — `compliance/services/obligation_taxonomy.py` +
|
||||||
|
`specialist_agents/dse/_obligation_shadow.py`.
|
||||||
|
`decision_method_required=LLM` trennt FAILED ehrlich in `failed_by_current_checker`
|
||||||
|
(echte Lücke) vs `recall_limited` (Prüfer kann mit aktueller Methode nicht verifizieren).
|
||||||
|
4. **Targeted LLM Fix** — recipients/transfer-Controls mit `tiered_criteria`
|
||||||
|
(decision_method=LLM) → Layer 3 nutzt den **Haiku-Sufficiency-Judge** statt Keyword/Embedding.
|
||||||
|
|
||||||
|
## Shadow-Zahlen (7 Firmen, Live-Engine, Keyword/Embedding)
|
||||||
|
|
||||||
|
| | Wert |
|
||||||
|
|---|---|
|
||||||
|
| legacy control-findings | 136 |
|
||||||
|
| obligation findings | 29 |
|
||||||
|
| **Kollaps** | **4,7×** |
|
||||||
|
| davon echte Lücken | 23 |
|
||||||
|
| davon recall_limited | 6 (nur 2/7 Firmen, nur Drittland/Garantien) |
|
||||||
|
| MET (FP-Korrektur) | 46 |
|
||||||
|
| N/A (Applicability) | 2 |
|
||||||
|
|
||||||
|
`recall_limited` ist klein + konzentriert: ausschließlich `third_country_transfer_disclosed` /
|
||||||
|
`safeguards_disclosed` / `safeguards_accessible`, je 2/7 Firmen. `recipients_disclosed`
|
||||||
|
manifestierte nie als recall_limited (Keyword/Embedding trägt dort).
|
||||||
|
|
||||||
|
## Targeted LLM Fix — Validierung (teamviewer + safetykon)
|
||||||
|
|
||||||
|
Recall-Defekt-Diagnose (teamviewer): die Drittland-/Garantien-Offenlegung steht dicht in
|
||||||
|
einem Absatz („…außerhalb EU/EWR … Standardvertragsklauseln/Schutzmaßnahmen"), aber
|
||||||
|
**0/22 Controls** trafen — Keyword (Vokabular-Mismatch) und Embedding (cos 0.49–0.57, teils
|
||||||
|
falscher Chunk) versagen. Kein Schwellen-Fix → CONTENT/LLM-Klasse.
|
||||||
|
|
||||||
|
Nach LLM-Tiering (Haiku-Judge):
|
||||||
|
|
||||||
|
| | vorher (kw+emb) | nachher (LLM) |
|
||||||
|
|---|---|---|
|
||||||
|
| teamviewer findings | 5 | **0** |
|
||||||
|
| teamviewer recall_limited | 3 | **0** |
|
||||||
|
| safetykon findings | 7 | **4** |
|
||||||
|
| safetykon recall_limited | 3 | **0** |
|
||||||
|
|
||||||
|
- **teamviewer → 0 Findings:** DSE auf diesen Pflichten real konform; die 5 alten Findings
|
||||||
|
waren Falsch-Positive des Keyword/Embedding-Prüfers.
|
||||||
|
- **safetykon → 4 (keine Über-Korrektur):** Drittland/Garantien → MET, aber
|
||||||
|
`art20_right_exists_core` + `art20_machine_readable_format` bleiben **FAILED** (echte
|
||||||
|
Portability-Lücke), `legitimate_interest_disclosed` → **NA** (Applicability).
|
||||||
|
|
||||||
|
## Eingesetztes Modell
|
||||||
|
|
||||||
|
Der Tiered-/Sufficiency-Pfad ist **fest auf Claude Haiku 4.5 verdrahtet**
|
||||||
|
(`checkers/router.py:build_spec` setzt für CONTENT/LLM `extra.judge="haiku"` →
|
||||||
|
`llm_checker._haiku` → `_call_anthropic`; validierter Judge P0.89/R0.91, Entscheidung
|
||||||
|
2026-06-22). **Nicht** die OVH-Kaskade (35b/120b), **nicht** Opus. Konsequenz: der Fix
|
||||||
|
reproduziert sich überall identisch, braucht aber einen gültigen Anthropic-Key für den
|
||||||
|
Haiku-Judge — auch auf dev.
|
||||||
|
|
||||||
|
## Nächster operativer Block (gegated, NICHT ausgeführt)
|
||||||
|
|
||||||
|
```
|
||||||
|
Deploy-Fenster frei (andere Session fertig)
|
||||||
|
↓
|
||||||
|
dev-DB-Tiering replizieren (die 22 recipients/transfer-Controls)
|
||||||
|
↓
|
||||||
|
Haiku-Judge auf dev bestätigen (gültiger Anthropic-Key — NICHT der OVH-Pfad)
|
||||||
|
↓
|
||||||
|
Shadow aktiv lassen (Telemetrie), Produktverhalten unverändert
|
||||||
|
↓
|
||||||
|
erst dann Umschalten planen
|
||||||
|
```
|
||||||
|
|
||||||
|
Folge-Cleanup: sobald LLM-Tiering Standard ist, wird die `recall_limited`-Segregation für
|
||||||
|
diese 4 Obligations obsolet (dann ist FAILED = echte Lücke, nicht Reichweitenproblem).
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Obligation Discovery Pipeline v1
|
||||||
|
|
||||||
|
Ein **generisches Verfahren zur Ableitung einer regulatorischen Ontologie** (Legal Obligation
|
||||||
|
Registry) aus großen Compliance-Korpora. Validiert über drei Domänen (SBOM, Vulnerability
|
||||||
|
Handling, Authentication). Erzeugt die zitierfähige Mitte aus
|
||||||
|
[obligation_registry_v1.md](obligation_registry_v1.md).
|
||||||
|
|
||||||
|
## Architekturregel (nicht verhandelbar)
|
||||||
|
|
||||||
|
```
|
||||||
|
RUNTIME bleibt deterministisch (Document → Embedding → LLM-Judge → Finding)
|
||||||
|
DISCOVERY darf LLM-gestützt sein (Controls → … → LLM-Synthese → Obligation Registry)
|
||||||
|
```
|
||||||
|
Discovery läuft **einmalig/offline** mit dem stärksten Modell; die Runtime-Prüf-Engine wird
|
||||||
|
davon nicht berührt. Zwei getrennte Probleme, eine gemeinsame Sprache (die Obligation).
|
||||||
|
|
||||||
|
## Stufen (`scripts/obligation_discovery/`)
|
||||||
|
|
||||||
|
| Stufe | Skript | Aufgabe | Key |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `precluster.py` | Controls (scope) → Embedding (gecacht) → **Mikro-Cluster** | – |
|
||||||
|
| 2 | `meta_cluster.py` | Mikro → **Review Units** (Skalierungs-Fix für große Domänen) | – |
|
||||||
|
| 3 | `synthesize_obligations.py` | Review Units → Opus → **Obligation Candidates** | ENV |
|
||||||
|
| 4 | `validate_registry.py` | Belastbarkeits-Checks | – |
|
||||||
|
| 5 | `merge_review_diff.py` | vorgeschlagene Beziehungskanten dedupliziert mergen | – |
|
||||||
|
|
||||||
|
Reine, unit-getestete Helfer in `_core.py`. Ausführung im `bp-compliance-backend`-Container
|
||||||
|
(`PYTHONPATH=/app`); der Key kommt aus `ANTHROPIC_API_KEY` (nie hartcodiert).
|
||||||
|
|
||||||
|
## Zwei-Stufen-Clustering = der Skalierungs-Fix
|
||||||
|
|
||||||
|
Ein flacher Single-Threshold-Pre-Cluster + EIN LLM-Synthese-Call skaliert NICHT auf große
|
||||||
|
Domänen. Lösung: eine Hierarchiestufe. **Review Unit ≠ Meta-Cluster** — die Review Unit ist
|
||||||
|
das, was der LLM sieht (entkoppelt vom Clustering, später merge/split-bar).
|
||||||
|
|
||||||
|
## Belegte Meilensteine
|
||||||
|
|
||||||
|
| Domäne | Controls | → Cluster/Review Units | → Obligations | vs Ground Truth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **SBOM** | 258 | 86 Mikro | 12 (→ 11 final) | manuell ~10 — **reproduziert + verfeinert** |
|
||||||
|
| **Vulnerability** | 531 | 200 Mikro | 8 | manuell ~7 — **reproduziert** |
|
||||||
|
| **Authentication** | 4408 | 2134 Mikro → **170 Review Units** | 54 → Kuration **29** | Skalierung — **generalisiert** |
|
||||||
|
|
||||||
|
## Harte Tier-Regel generalisiert
|
||||||
|
|
||||||
|
`LEGAL_MINIMUM` nur mit Primärrechts-Anker (`legal_basis`), sonst `BEST_PRACTICE` /
|
||||||
|
`IMPLEMENTATION_GUIDANCE` / `EVIDENCE`. Authentication zeigt den Wert: nur **6** harte
|
||||||
|
Pflichten (CRA fordert „angemessene Authentisierung"), MFA/Passwort/Session/Krypto sind
|
||||||
|
`guidance_basis`. So kann der Advisor sagen: *„Gesetzlich gefordert ist Schutz vor unbefugtem
|
||||||
|
Zugriff; MFA ist anerkannte Umsetzung, aber keine CRA-Wortlautpflicht."*
|
||||||
|
|
||||||
|
## Kuration (große Domänen)
|
||||||
|
|
||||||
|
Die Synthese darf über-splitten; ein **key-freier, regelbasierter Kurations-Pass** verdichtet:
|
||||||
|
Krypto-Mikro-Mechanismen → `guidance_basis`; Prüf-/Nachweis-Themen → `evidence`-Facette;
|
||||||
|
Mechanismus-Familien bleiben; domänenfremdes (eID/PSD2) → `out_of_scope`; LEGAL_MINIMUM
|
||||||
|
unangetastet.
|
||||||
|
|
||||||
|
## Lessons
|
||||||
|
|
||||||
|
- Große Opus-Calls brauchen **Streaming** (`messages.stream`); der SDK blockt non-streaming
|
||||||
|
bei `max_tokens` > ~8k mit „Streaming is required for operations that may take longer than 10 minutes".
|
||||||
|
- Provenance pro Obligation (`source_meta_cluster`, `discovery_confidence`, `llm_model`,
|
||||||
|
`synthesis_version`) — für spätere Evolution (CRA-Update, Modellwechsel).
|
||||||
|
- `>8 Obligations / Review Unit` → automatische Review-Warnung (Over-Split-Indikator).
|
||||||
|
- Embedding-Cache (pickle) → THR2-Sweeps ohne Re-Embed.
|
||||||
|
|
||||||
|
## End-to-End-Beispiel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# im bp-compliance-backend-Container, PYTHONPATH=/app, cwd = scripts/obligation_discovery
|
||||||
|
python3 precluster.py --scope auth
|
||||||
|
python3 meta_cluster.py --scope auth --meta-thr 0.62 # → /tmp/auth_review_units.json (inspizieren!)
|
||||||
|
ANTHROPIC_API_KEY=… python3 synthesize_obligations.py \
|
||||||
|
--units /tmp/auth_review_units.json --regulation CRA --theme "Authentisierung" --out /tmp/auth_registry.json
|
||||||
|
python3 validate_registry.py /tmp/auth_registry.json
|
||||||
|
```
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Obligation Registry v1 — Schema, Zitierfähigkeit, Zwei-Graphen-Architektur
|
||||||
|
|
||||||
|
Status: **Spec festgeschrieben (2026-06-24)**. Baut auf
|
||||||
|
[legal_obligation_layer_v1.md](legal_obligation_layer_v1.md) +
|
||||||
|
[obligation_aggregation_validation.md](obligation_aggregation_validation.md).
|
||||||
|
Die Obligation Discovery Pipeline v1 ist gegen Ground Truth validiert
|
||||||
|
(SBOM 12 vs 10, Vuln 8 vs 7, out_of_scope + conditional Applicability korrekt).
|
||||||
|
|
||||||
|
## Leitsatz
|
||||||
|
|
||||||
|
**Die Legal Obligation ist das fachliche Wissensobjekt der Plattform** — nicht der Master
|
||||||
|
Control. Controls sind Prüfstrategien / Erkennungsmuster / Evidenzsammler FÜR eine Obligation.
|
||||||
|
Ohne Zitierfähigkeit ist die Registry fachlich nicht belastbar: die erste Kundenfrage ist
|
||||||
|
immer „**Wo steht das?**".
|
||||||
|
|
||||||
|
## Zwei Assets, zwei Graphen, EIN Join (nicht verschmelzen, verbinden)
|
||||||
|
|
||||||
|
- **Asset 1 — Compliance Knowledge** (bereits gebaut): 313k atomare Controls, 33k Master
|
||||||
|
Controls, ~14k use-case-gemappt, Dedup, Obligation Layer, Applicability, Tiering, G/C/E.
|
||||||
|
- **Asset 2 — Zitierfähige Wissensbasis** (entsteht in anderer Session): Dokument → Chunk →
|
||||||
|
Paragraph → Span → Zitat.
|
||||||
|
|
||||||
|
Die beiden werden **NICHT verschmolzen** (das wäre wie eine normalisierte DB nach CSV zu
|
||||||
|
exportieren und neu zu importieren). Sie werden über die **Obligation gekoppelt**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GRAPH 1 — Legal Knowledge Graph (Chat/Advisor) GRAPH 2 — Compliance Execution Graph (Engine)
|
||||||
|
Regulation → Annex/Artikel → Paragraph → Span Obligation → Control → Criterion → Evidence → Finding
|
||||||
|
\ /
|
||||||
|
\____ LEGAL OBLIGATION ______/ ← gemeinsame Sprache (der Join)
|
||||||
|
```
|
||||||
|
Chat: „diese Aussage stammt aus Absatz X." · Engine: „diese Obligation ist nicht erfüllt." →
|
||||||
|
beide meinen DIESELBE `obligation_id`.
|
||||||
|
|
||||||
|
## Registry-Schema v1
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: # snake_case, regulierungs-agnostisch (z.B. sbom_complete)
|
||||||
|
name: # kurz
|
||||||
|
description: # 1 Satz
|
||||||
|
tier: # LEGAL_MINIMUM | BEST_PRACTICE | IMPLEMENTATION_GUIDANCE | EVIDENCE
|
||||||
|
family: # Organisationshilfe (z.B. sbom, vulnerability_handling)
|
||||||
|
applicability: # universal | conditional:<pred> | domain:<x>
|
||||||
|
facets: # welche Evidenz-Facetten die Pflicht belegt
|
||||||
|
governance: bool
|
||||||
|
capability: bool
|
||||||
|
evidence: bool
|
||||||
|
legal_basis: # PRIMÄRRECHT — Pflicht zwingend (mind. 1 Anker für LEGAL_MINIMUM)
|
||||||
|
- source: CRA
|
||||||
|
regulation_code: eu_2024_2847
|
||||||
|
article: "" # falls zutreffend
|
||||||
|
annex: "Annex I, Part II"
|
||||||
|
section: ""
|
||||||
|
paragraph: ""
|
||||||
|
span_id: "" # harter Anker in die zitierfähige Wissensbasis (Asset 2)
|
||||||
|
document_version: ""
|
||||||
|
citation: "" # menschenlesbar
|
||||||
|
guidance_basis: # SEKUNDÄR — Umsetzung/Best Practice, NICHT Pflicht
|
||||||
|
- source: NIST SSDF
|
||||||
|
anchor: ""
|
||||||
|
role: best_practice # implementation_guidance | best_practice
|
||||||
|
member_controls: # control_uuids (Prüflogik aus Asset 1)
|
||||||
|
citation_anchor_ids: # span/paragraph-Anker (Asset 2) — auf der OBLIGATION, NICHT auf Controls
|
||||||
|
relationships: # siehe Beziehungsgraph
|
||||||
|
decision_method: # CONTENT/LLM | CONTENT/EMBEDDING | FIELD/REGEX | BEHAVIOR/PLAYWRIGHT ...
|
||||||
|
out_of_scope: [] # ausgeschlossene Cluster + Begründung
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zitierfähigkeit hängt an der OBLIGATION (nicht an Controls)
|
||||||
|
|
||||||
|
258 SBOM-Controls → 11 Obligations: nur die **Obligation** speichert
|
||||||
|
`CRA / Annex I / Paragraph X / chunk_id / span_id / document_version`. Die 258 Controls zeigen
|
||||||
|
nur auf die `obligation_id`. Folge: **Regulierungsänderung (CRA v1→v2) = `citation_anchor`
|
||||||
|
tauschen, Controls bleiben identisch.** Massive Pflegeersparnis + Versionsstabilität.
|
||||||
|
|
||||||
|
## `legal_basis` vs `guidance_basis` + `source_role`
|
||||||
|
|
||||||
|
Damit beim Verschmelzen von CRA + NIST + OWASP zu einer Obligation NICHT verloren geht, was
|
||||||
|
Pflicht / Best Practice / Evidenz / Umsetzung ist, klassifiziert die Discovery-Pipeline jeden
|
||||||
|
Member/Cluster mit einer **`source_role`**:
|
||||||
|
|
||||||
|
```
|
||||||
|
LEGAL_BASIS → Primärrecht (begründet die Pflicht)
|
||||||
|
GUIDANCE → NIST/OWASP/ENISA/BSI/ISO (Umsetzung/Best Practice)
|
||||||
|
EVIDENCE → Nachweis/Bericht/Audit
|
||||||
|
IMPLEMENTATION → technische Umsetzungsanweisung
|
||||||
|
OUT_OF_SCOPE → gehört nicht zur Obligation (andere Regulierung/Domäne)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HARTE Tier-Regel
|
||||||
|
|
||||||
|
Eine Obligation wird **`LEGAL_MINIMUM` nur mit mindestens einem Primärrechts-Anker**
|
||||||
|
(`legal_basis` nicht leer). Ohne Primärrechts-Anker:
|
||||||
|
`BEST_PRACTICE | IMPLEMENTATION_GUIDANCE | EVIDENCE` — **aber niemals Pflicht.**
|
||||||
|
|
||||||
|
## Beziehungsgraph (Ontologie)
|
||||||
|
|
||||||
|
**Strukturell** (bereits in der Pipeline): `same_obligation`, `sub_obligation`,
|
||||||
|
`applicability_variant`, `evidence_for`, `governance_for`, `out_of_scope`.
|
||||||
|
|
||||||
|
**Semantisch (NEU, P2-Ergänzung):** `requires`, `implements`, `supports`,
|
||||||
|
`produces_evidence_for`, `depends_on`, `derived_from`. Beispiele:
|
||||||
|
```
|
||||||
|
sbom_established --supports--> vulnerability_handling --supports--> incident_reporting
|
||||||
|
authentication --requires--> credential_management
|
||||||
|
```
|
||||||
|
→ für den Compliance Advisor extrem wertvoll (er kann Pflicht-Ketten erklären).
|
||||||
|
|
||||||
|
## Citation-Anchor-Pipeline (Document → Obligation, NICHT Document → Control)
|
||||||
|
|
||||||
|
Der neue Ingest erzeugt zusätzlich zu Chunk/Embedding: `paragraph_uuid`, `span_uuid`,
|
||||||
|
`document_version`, `legal_citation`, `referenced_articles`, `referenced_regulations`.
|
||||||
|
**Erst danach** läuft Obligation Discovery, sodass jede neu entdeckte Obligation sofort ihre
|
||||||
|
Primärquelle bekommt:
|
||||||
|
```
|
||||||
|
Neue Dokumente → Chunking → Span IDs → LLM („welche Obligation(en)?") → Confidence
|
||||||
|
→ Review → obligation.citation_anchor_ids[]
|
||||||
|
```
|
||||||
|
Die alten Controls werden wiederverwendet; die Pipeline erzeugt zusätzlich Obligation→Evidence
|
||||||
|
und Obligation→Citation-Anchors. **Kein Re-Ingest zum Neubau von Controls.**
|
||||||
|
|
||||||
|
## Sequenz (geändert — Registry vor weiteren Cuts)
|
||||||
|
|
||||||
|
```
|
||||||
|
SBOM ✓ → Vuln ✓ → Registry v1 (DIESE Spec) → Ontologie/Beziehungsgraph ergänzen
|
||||||
|
→ Authentication → Remote Access → Logging → Updates
|
||||||
|
```
|
||||||
|
Begründung: Schema jetzt billig änderbar; bei 300–1000 Obligations wird jede Schemaänderung
|
||||||
|
teuer. Fortschritt wird daran gemessen, ob jede neue Obligation die Registry besser macht —
|
||||||
|
nicht an neuen Controls.
|
||||||
+11
@@ -56,6 +56,17 @@ markdown_extensions:
|
|||||||
|
|
||||||
nav:
|
nav:
|
||||||
- Start: index.md
|
- Start: index.md
|
||||||
|
- Architektur RAG:
|
||||||
|
- Übersicht: architecture/index.md
|
||||||
|
- 01 Retrieval-Pipeline: architecture/01-retrieval.md
|
||||||
|
- 02 Authority-Re-Ranking: architecture/02-authority.md
|
||||||
|
- 03 source_class: architecture/03-source-class.md
|
||||||
|
- 04 source_role: architecture/04-source-role.md
|
||||||
|
- 05 Control-Intent + Diversity: architecture/05-control-intent.md
|
||||||
|
- 06 Assessment: architecture/06-assessment.md
|
||||||
|
- 07 Confidence: architecture/07-confidence.md
|
||||||
|
- 08 Explainability + Supersede: architecture/08-explainability.md
|
||||||
|
- 09 framework_*-Layer: architecture/09-framework-layer.md
|
||||||
- Services:
|
- Services:
|
||||||
- AI Compliance SDK:
|
- AI Compliance SDK:
|
||||||
- Uebersicht: services/ai-compliance-sdk/index.md
|
- Uebersicht: services/ai-compliance-sdk/index.md
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "capability_layer_v1",
|
||||||
|
"model": "Modell C (docs-src/development/capability_model_v1.md)",
|
||||||
|
"note": "Capability = technische Faehigkeit (regulierungs-agnostisch). realized_by = Obligations, die sie erfuellt (n:m). guidance_basis hier KANONISCH hochgezogen aus den realisierten Obligations (die Obligation-Kopien bleiben vorerst als Legacy; Strip = Folge-Cleanup). Sicherheitsziele sind KEINE Capabilities -> cra_core.json.",
|
||||||
|
"dropped": {
|
||||||
|
"access_control": "OVERLAP (credential_confidentiality <-> sbom_confidentiality), nicht materialisiert"
|
||||||
|
},
|
||||||
|
"candidate_capabilities_followup": [
|
||||||
|
"automatic_update_delivery",
|
||||||
|
"update_rollback",
|
||||||
|
"trusted_update_source",
|
||||||
|
"hash_verification",
|
||||||
|
"secure_boot",
|
||||||
|
"least_functionality",
|
||||||
|
"credential_storage"
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"capability_id": "multi_factor_authentication",
|
||||||
|
"name": "Multi-Factor Authentication",
|
||||||
|
"description": "Mehrfaktor-Authentisierung als technische Faehigkeit (Besitz/Wissen/Inhaerenz).",
|
||||||
|
"type": "technical_capability",
|
||||||
|
"realized_by": [
|
||||||
|
"mfa_required",
|
||||||
|
"privileged_op_reauth",
|
||||||
|
"remote_access_authentication",
|
||||||
|
"remote_access_mfa",
|
||||||
|
"remote_access_user_validation_ot",
|
||||||
|
"supplier_access_auth"
|
||||||
|
],
|
||||||
|
"realizes_count": 6,
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-63B",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Out-of-Band-Authentifizierung",
|
||||||
|
"anchor": "",
|
||||||
|
"role": "implementation_guidance",
|
||||||
|
"merged_from": "out_of_band_authentication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Hardware-basierte Authentifizierung (AAL3)",
|
||||||
|
"anchor": "",
|
||||||
|
"role": "implementation_guidance",
|
||||||
|
"merged_from": "hardware_authenticators"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "E-Mail-Authentifizierungsmechanismen (SPF/DKIM/DMARC)",
|
||||||
|
"anchor": "",
|
||||||
|
"role": "implementation_guidance",
|
||||||
|
"merged_from": "email_authentication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "IA-02",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "IA-02(1)",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "AC-17",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-53 IA-2",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "BSI",
|
||||||
|
"anchor": "ICS Security Kompendium",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ISO",
|
||||||
|
"anchor": "ISO 27001 A.5.19",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"authentication",
|
||||||
|
"remote_access"
|
||||||
|
],
|
||||||
|
"provenance": {
|
||||||
|
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability_id": "session_management",
|
||||||
|
"name": "Session Management",
|
||||||
|
"description": "Sichere Sitzungsverwaltung: Timeouts, Bindung, Re-Auth, Beendigung.",
|
||||||
|
"type": "technical_capability",
|
||||||
|
"realized_by": [
|
||||||
|
"reauth_after_inactivity",
|
||||||
|
"remote_session_management",
|
||||||
|
"session_binding_management",
|
||||||
|
"temporary_remote_access_mgmt"
|
||||||
|
],
|
||||||
|
"realizes_count": 4,
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-63B 4.3",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-53 AC-12",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "OWASP",
|
||||||
|
"anchor": "ASVS V3",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "AC-2(5)",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"authentication",
|
||||||
|
"remote_access"
|
||||||
|
],
|
||||||
|
"provenance": {
|
||||||
|
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability_id": "transport_encryption",
|
||||||
|
"name": "Transport Encryption",
|
||||||
|
"description": "Verschluesselter Transport (TLS, mutual-TLS, Zertifikats-Auth, VPN/Tunnel).",
|
||||||
|
"type": "technical_capability",
|
||||||
|
"realized_by": [
|
||||||
|
"encrypted_auth_channel",
|
||||||
|
"mutual_authentication",
|
||||||
|
"reject_insecure_remote_protocols",
|
||||||
|
"remote_access_confidentiality_integrity",
|
||||||
|
"remote_access_encryption",
|
||||||
|
"service_to_service_auth",
|
||||||
|
"tls_certificate_auth"
|
||||||
|
],
|
||||||
|
"realizes_count": 7,
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "BSI",
|
||||||
|
"anchor": "TR-02102-2",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "IA-03",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SC-8",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "BSI",
|
||||||
|
"anchor": "IT-Grundschutz NET.3.3",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "OWASP",
|
||||||
|
"anchor": "API Security Top 10",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "IA-05(2)",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"authentication",
|
||||||
|
"remote_access"
|
||||||
|
],
|
||||||
|
"provenance": {
|
||||||
|
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability_id": "code_signing",
|
||||||
|
"name": "Code & Update Signing",
|
||||||
|
"description": "Digitale Signatur + Integritaets-/Authentizitaetspruefung von Firmware/Software/Updates.",
|
||||||
|
"type": "technical_capability",
|
||||||
|
"realized_by": [
|
||||||
|
"firmware_software_authentication",
|
||||||
|
"signed_update_integrity"
|
||||||
|
],
|
||||||
|
"realizes_count": 2,
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SI-07",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-147 BIOS Protection",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"authentication",
|
||||||
|
"updates"
|
||||||
|
],
|
||||||
|
"provenance": {
|
||||||
|
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability_id": "security_monitoring_alerting",
|
||||||
|
"name": "Security Monitoring & Alerting",
|
||||||
|
"description": "Anomalie-/Bedrohungserkennung und Alarmierung aus Logs/Telemetrie.",
|
||||||
|
"type": "technical_capability",
|
||||||
|
"realized_by": [
|
||||||
|
"log_monitoring_alerting",
|
||||||
|
"remote_access_threat_detection"
|
||||||
|
],
|
||||||
|
"realizes_count": 2,
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "AU-6/SI-4",
|
||||||
|
"role": "best_practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SP 800-94",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domains": [
|
||||||
|
"logging",
|
||||||
|
"remote_access"
|
||||||
|
],
|
||||||
|
"provenance": {
|
||||||
|
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "controls_for_obligation_mapping_v1",
|
||||||
|
"purpose": "Accepted CRA->Framework controls (Compliance Execution Graph) for the Obligation Registry to propose the SEMANTIC control->obligation_id, replacing the coarse citation_unit interim join. Fill proposed_obligation_id per control, then we adopt it into control_mapping.obligation_id.",
|
||||||
|
"source": "ai-compliance-sdk control_mappings, mapping_status=accepted, reviewed_by=benjamin 2026-06-25. OWASP ASVS (7, gefuellt) + NIST SP 800-53 (3, pending).",
|
||||||
|
"filled_by": "obligation-registry-session 2026-06-25. OWASP 7/7 (4 auth/crypto + 3 logging). NIST 3/3 GEFUELLT (Obligation-Session): SI-2->provide_security_updates (stark, (2)(c)/Art.13) · SI-7->signed_update_integrity (update-scoped; SI-7 breiter) · CM-7->remote_access_attack_surface_min (remote-scoped; CM-7 breiter). GAP-BEFUND (Cross-Domain-Review): generische Parent-Obligations software_integrity_protection + attack_surface_minimization FEHLEN — SI-7/CM-7 sind breiter als die domaenen-scoped Treffer. Kandidaten fuer neue generische Obligations (User-Entscheidung). Damit 10/10 proposed_obligation_id gefuellt.",
|
||||||
|
"join_principle": "SEMANTISCH via obligation_id, NICHT via citation_unit/legal_basis-Anker. Die CRA-Anker sind im Registry teils approximativ (siehe anchor_quality_note) — daher ist obligation_id der stabile Primaerschluessel, nicht der Anker.",
|
||||||
|
"anchor_quality_note": "Registry-legal_basis-Anker sind teils CRA-Part-I-fehlzugeordnet (Opus-Synthese): user_authentication_required steht auf (2)(d) statt (2)(c); Crypto-Obligations auf (2)(e) statt (2)(d). CRA Annex I Part I: (2)(c)=Zugriffsschutz, (2)(d)=Vertraulichkeit, (2)(e)=Integritaet. Korrektur kommt mit dem zitierfaehigen Re-Ingest (span-genau). Deshalb: NICHT auf Anker joinen. ABER: der Logging-Cut (V16.*) ist korrekt auf (2)(k) verankert (echte Logging-Subsektion, kein Fehl-Anker).",
|
||||||
|
"mapping_type_note": "NEU: mapping_type=primary_implementation = die kanonische Primaer-Control einer Anforderung (genau eine), staerker als implements/supports. related-Controls (SC-3(3), RA-5, AC-6, SI-16, SA-10, ...) folgen separat als supports. Eine Obligation kann mehrere Controls haben, aber genau einen primary_implementation-Einstieg.",
|
||||||
|
"count": 10,
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V6.3.1",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff",
|
||||||
|
"citation_unit": "Annex I (2)(c)", "family": "auth", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "user_authentication_required",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "Zugriffsschutz/Authentisierung-vor-Zugriff = Nutzer-Auth (NICHT firmware, trotz strukturellem (2)(c)-Join)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V6.1.1",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff",
|
||||||
|
"citation_unit": "Annex I (2)(c)", "family": "auth", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "user_authentication_required",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "wie V6.3.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V11.2.1",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung",
|
||||||
|
"citation_unit": "Annex I (2)(d)", "family": "crypto", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "credential_confidentiality_protection",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "Vertraulichkeit von Auth-Daten. ALT: encrypted_auth_channel, falls V11.2.1 transit-/kanal-spezifisch ist — bitte aus eurem Control-Text bestaetigen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V11.7.1",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung",
|
||||||
|
"citation_unit": "Annex I (2)(d)", "family": "crypto", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "auth_key_management",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "Key Management = Schluessel erzeugen/speichern/HSM"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V16.3.3",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging",
|
||||||
|
"citation_unit": "Annex I (2)(k)", "family": "logging", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "event_logging_security_events",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "Umbrella-LM 'Produkt protokolliert sicherheitsrelevante Ereignisse' (CRA (2)(k)). ALT bei access-decision-spezifischem Control-Text: access_control_event_logging — bitte aus eurem ASVS-V16.3-Text bestaetigen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V16.3.4",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging",
|
||||||
|
"citation_unit": "Annex I (2)(k)", "family": "logging", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "event_logging_security_events",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "Umbrella-LM (CRA (2)(k)). ALT bei admin-/privileg-spezifischem Control-Text: audit_trail_admin_actions — bitte aus eurem ASVS-V16.3-Text bestaetigen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "OWASP ASVS", "control": "V16.1.1",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging",
|
||||||
|
"citation_unit": "Annex I (2)(k)", "family": "logging", "mapping_type": "supports",
|
||||||
|
"proposed_obligation_id": "event_logging_security_events",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "V16.1 = allgemeine Logging-Anforderung -> Umbrella-LM event_logging_security_events. Hohe Konfidenz."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "NIST SP 800-53", "control": "SI-7",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(e) — Integritaet",
|
||||||
|
"citation_unit": "Annex I (2)(e)", "family": "integrity", "mapping_type": "primary_implementation",
|
||||||
|
"proposed_obligation_id": "software_integrity_protection",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "NIST SI-7 = Software/Firmware/Information Integrity (gesamte Produkt-Integritaet). #6 ADOPTIERT (2026-06-26) auf CORE software_integrity_protection (Annex I (2)(f)) — die in #5b materialisierte generische Integritaets-Obligation. Die domaenen-scoped signed_update_integrity (Update-Signatur, (1)(3)(f)) bleibt gueltig als DOMAIN, specializes->CORE. NICHT log_integrity_immutability (Audit-Log-Schutz, andere Ebene)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "NIST SP 800-53", "control": "SI-2",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates",
|
||||||
|
"citation_unit": "Annex I (2)(l)", "family": "update", "mapping_type": "primary_implementation",
|
||||||
|
"proposed_obligation_id": "provide_security_updates",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "NIST SI-2 = Flaw Remediation. STARKER Treffer in eurer NEUEN updates-Familie (93-Stand): provide_security_updates (LEGAL_MINIMUM, Annex I (2)(c) + Art. 13) = DAS sichere-Update-LM. -> SI-2 primary_implementation = provide_security_updates. Verwandt (supports): vuln_remediation_patching (Part II Remediations-PROZESS), support_period_maintenance, update_testing_validation, update_rollback. Mein source_norm-Anker (2)(l) ist approximativ -> bitte (2)(c)/Art.13 via provide_security_updates nutzen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"framework": "NIST SP 800-53", "control": "CM-7",
|
||||||
|
"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren",
|
||||||
|
"citation_unit": "Annex I (2)(i)", "family": "attack_surface", "mapping_type": "primary_implementation",
|
||||||
|
"proposed_obligation_id": "attack_surface_minimization",
|
||||||
|
"mapping_method": "semantic",
|
||||||
|
"mapping_note": "NIST CM-7 = Least Functionality (deaktivierte Ports/Dienste/Funktionen, GESAMTE Angriffsflaeche). #6 ADOPTIERT (2026-06-26) auf CORE attack_surface_minimization (Annex I (2)(j)) — die in #5b materialisierte generische Obligation. Die domaenen-scoped remote_access_attack_surface_min (nur Remote-Access-Flaeche) bleibt gueltig als DOMAIN, specializes->CORE. related (supports): SC-3(3)/AC-6/SI-16."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "obligation_registry_v1",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"regulation_code": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"theme": "CORE Security Objectives (CRA Annex I als regulierungs-agnostische Sicherheitsziele)",
|
||||||
|
"generated_by": "materialize_capabilities.py (#5b, Modell C)",
|
||||||
|
"note": "CORE Legal Obligations = Sicherheitsziele (Modell C: KEINE eigene SecurityObjective-Klasse). DOMAIN-Obligations specializes-en hierauf. objective_tags = Vorwaerts-Kompat zu Modell B.",
|
||||||
|
"citation_status": "pending_span_anchor",
|
||||||
|
"obligations": [
|
||||||
|
{
|
||||||
|
"id": "attack_surface_minimization",
|
||||||
|
"name": "Minimierung der Angriffsflaeche",
|
||||||
|
"family": "core",
|
||||||
|
"description": "Das Produkt minimiert seine Angriffsflaeche: unnoetige Funktionen/Ports/Dienste/Schnittstellen sind deaktiviert (Least Functionality).",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"source_role": "LEGAL_BASIS",
|
||||||
|
"applicability": "universal",
|
||||||
|
"objective_tags": [
|
||||||
|
"attack_surface"
|
||||||
|
],
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"source": "CRA",
|
||||||
|
"anchor": "Annex I Part I (2)(j)",
|
||||||
|
"citation": "limit attack surfaces, including external interfaces"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "CM-7 Least Functionality",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"specialized_by": [
|
||||||
|
"remote_access_attack_surface_min",
|
||||||
|
"component_remote_interface_security"
|
||||||
|
],
|
||||||
|
"primary_implementation": "NIST CM-7",
|
||||||
|
"citation_status": "pending_span_anchor",
|
||||||
|
"review_status": "core_from_5b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "software_integrity_protection",
|
||||||
|
"name": "Schutz der Software-/Firmware-Integritaet",
|
||||||
|
"family": "core",
|
||||||
|
"description": "Das Produkt schuetzt Integritaet und Authentizitaet von Software/Firmware (Manipulationserkennung, Secure Boot, Signaturpruefung, Runtime-Integritaet).",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"source_role": "LEGAL_BASIS",
|
||||||
|
"applicability": "universal",
|
||||||
|
"objective_tags": [
|
||||||
|
"integrity"
|
||||||
|
],
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"source": "CRA",
|
||||||
|
"anchor": "Annex I Part I (2)(f)",
|
||||||
|
"citation": "protect the integrity of stored, transmitted or processed data, software and configuration"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"guidance_basis": [
|
||||||
|
{
|
||||||
|
"source": "NIST",
|
||||||
|
"anchor": "SI-7 Software, Firmware, and Information Integrity",
|
||||||
|
"role": "best_practice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"specialized_by": [
|
||||||
|
"signed_update_integrity",
|
||||||
|
"firmware_software_authentication"
|
||||||
|
],
|
||||||
|
"realized_by_capabilities": [
|
||||||
|
"code_signing"
|
||||||
|
],
|
||||||
|
"primary_implementation": "NIST SI-7",
|
||||||
|
"citation_status": "pending_span_anchor",
|
||||||
|
"review_status": "core_from_5b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relationships": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "obligation_procedures_v1",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"layer": "Regulation -> Legal Obligation -> Procedure -> Control -> Evidence",
|
||||||
|
"note": "Procedure ist KEINE neue Compliance-Pflicht. LEGAL_MINIMUM liegt an der Obligation; die Procedure beschreibt, WIE sie umgesetzt wird; Evidence belegt die Umsetzung. source_role=procedural_requirement (Konvergenz mit der Legal-Knowledge-Engine der anderen Session).",
|
||||||
|
"citation_status": "pending_span_anchor",
|
||||||
|
"scope": "worked examples: SBOM + Vulnerability Handling",
|
||||||
|
"procedures": [
|
||||||
|
{
|
||||||
|
"procedure_id": "sbom_generation_process",
|
||||||
|
"name": "SBOM-Erstellungsprozess",
|
||||||
|
"description": "Erzeugen einer vollstaendigen, maschinenlesbaren Software Bill of Materials fuer ein Produkt mit digitalen Elementen.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["sbom_creation", "sbom_dependency_coverage", "sbom_format_standard", "sbom_tooling_automation"],
|
||||||
|
"steps": [
|
||||||
|
"Komponenten und (direkte + transitive) Abhaengigkeiten inventarisieren",
|
||||||
|
"SBOM automatisiert in der Build-/Toolchain generieren",
|
||||||
|
"Komponenten, Versionen, Lizenzen und Lieferanten erfassen",
|
||||||
|
"in anerkanntem maschinenlesbarem Format (CycloneDX/SPDX) ausgeben",
|
||||||
|
"Format- und Schemavalidierung durchfuehren"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"SBOM-Datei vorhanden",
|
||||||
|
"Format ist maschinenlesbar und standardkonform (CycloneDX/SPDX)",
|
||||||
|
"direkte und transitive Abhaengigkeiten enthalten"
|
||||||
|
],
|
||||||
|
"evidence": ["sbom.cyclonedx.json", "Format-Validierungs-Log", "Build-/Toolchain-Konfiguration"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "sbom_update_process",
|
||||||
|
"name": "SBOM-Aktualisierungsprozess",
|
||||||
|
"description": "Halten der SBOM aktuell ueber den Produktlebenszyklus bei Komponenten-, Versions- und Patch-Aenderungen.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["sbom_maintenance_update"],
|
||||||
|
"steps": [
|
||||||
|
"Komponentenaenderung erkennen (Dependency-/Patch-/Versionsaenderung)",
|
||||||
|
"SBOM neu generieren",
|
||||||
|
"Lieferanten-SBOMs aktualisieren",
|
||||||
|
"neue SBOM-Version speichern",
|
||||||
|
"SBOM in Release-Artefakte uebernehmen"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"CI prueft SBOM vorhanden",
|
||||||
|
"SBOM-Version passt zum Release",
|
||||||
|
"Supplier-Komponenten enthalten"
|
||||||
|
],
|
||||||
|
"evidence": ["sbom.json", "CI-Log", "Release-Artefakt", "Supplier-SBOM"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "sbom_supplier_integration_process",
|
||||||
|
"name": "Lieferanten-SBOM-Integration",
|
||||||
|
"description": "Beschaffen und Einarbeiten von Lieferanten-/Drittkomponenten-SBOMs in die Produkt-SBOM.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["sbom_supply_chain_contracts", "sbom_dependency_coverage"],
|
||||||
|
"steps": [
|
||||||
|
"SBOM-Anforderung in Lieferantenvertraege aufnehmen",
|
||||||
|
"Lieferanten-SBOMs einsammeln",
|
||||||
|
"in die Produkt-SBOM mergen",
|
||||||
|
"Drittkomponenten und deren Abhaengigkeiten nachverfolgen"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"vertragliche SBOM-Klausel vorhanden",
|
||||||
|
"Lieferanten-SBOMs eingegangen",
|
||||||
|
"Drittkomponenten in der SBOM gelistet"
|
||||||
|
],
|
||||||
|
"evidence": ["Lieferantenvertrag-Klausel", "eingegangene Supplier-SBOMs", "gemergte SBOM"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "sbom_provision_process",
|
||||||
|
"name": "SBOM-Bereitstellungsprozess",
|
||||||
|
"description": "Zugaenglichmachen der SBOM fuer berechtigte Parteien (Nutzer, Behoerde) unter Wahrung der Vertraulichkeit.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["sbom_access_provision", "sbom_authority_provision", "sbom_confidentiality"],
|
||||||
|
"steps": [
|
||||||
|
"Zugangskanal definieren (Portal/API/dokumentierter Pfad)",
|
||||||
|
"Nutzer ueber den Zugangsweg informieren",
|
||||||
|
"auf begruendetes Verlangen der Marktueberwachungsbehoerde vertraulich bereitstellen",
|
||||||
|
"Zugriffskontrolle und Vertraulichkeitsmassnahmen anwenden"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"Zugangspfad dokumentiert",
|
||||||
|
"Zugriffskontrolle/Vertraulichkeit umgesetzt",
|
||||||
|
"Behoerden-Bereitstellungsprozess definiert"
|
||||||
|
],
|
||||||
|
"evidence": ["Zugangskanal-Dokumentation", "Behoerden-Anfrage-Log", "Zugriffskontroll-Konfiguration"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "sbom_conformity_documentation_process",
|
||||||
|
"name": "SBOM in technischer Dokumentation/Konformitaet",
|
||||||
|
"description": "Aufnehmen der SBOM in die technische Dokumentation und Verifizieren der Vollstaendigkeit fuer die Konformitaetsbewertung.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["sbom_technical_documentation", "sbom_completeness_verification"],
|
||||||
|
"steps": [
|
||||||
|
"SBOM in die technische Dokumentation aufnehmen",
|
||||||
|
"Vollstaendigkeit gegen die real eingesetzte Softwarekomposition pruefen",
|
||||||
|
"der Konformitaetsbewertung beilegen (ggf. EUCC)"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"SBOM Teil der technischen Dokumentation",
|
||||||
|
"Vollstaendigkeit verifiziert",
|
||||||
|
"Konformitaetsnachweis vorhanden"
|
||||||
|
],
|
||||||
|
"evidence": ["technische Dokumentation", "Vollstaendigkeits-Pruefbericht", "Konformitaetsnachweis"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_handling_process_setup",
|
||||||
|
"name": "Schwachstellenbehandlungsprozess einrichten",
|
||||||
|
"description": "Dokumentierten Prozess und Meldekanal (CVD) fuer die Schwachstellenbehandlung etablieren.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["vuln_handling_process"],
|
||||||
|
"steps": [
|
||||||
|
"dokumentierten Schwachstellenbehandlungsprozess definieren",
|
||||||
|
"Coordinated-Vulnerability-Disclosure-Richtlinie und Meldekanal veroeffentlichen",
|
||||||
|
"eingehende Meldungen triagieren"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"Behandlungsprozess dokumentiert",
|
||||||
|
"Meldekanal/Kontaktstelle auffindbar (z.B. security.txt)",
|
||||||
|
"Triage-Verfahren vorhanden"
|
||||||
|
],
|
||||||
|
"evidence": ["Prozessdokument", "security.txt / Kontaktstelle", "Triage-Log"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_identification_process",
|
||||||
|
"name": "Schwachstellen-Identifikation",
|
||||||
|
"description": "Bekannte Schwachstellen in eingesetzten Komponenten erkennen und inventarisieren.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["vuln_identification_inventory"],
|
||||||
|
"steps": [
|
||||||
|
"Advisories/CVE-Feeds beobachten",
|
||||||
|
"gegen die SBOM-Komponenten abgleichen",
|
||||||
|
"Schwachstellen-Inventar pflegen"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"Advisory-/CVE-Monitoring aktiv",
|
||||||
|
"SBOM-zu-CVE-Abgleich durchgefuehrt",
|
||||||
|
"Schwachstellen-Inventar gepflegt"
|
||||||
|
],
|
||||||
|
"evidence": ["CVE-Abgleich-Report", "Schwachstellen-Register"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_assessment_process",
|
||||||
|
"name": "Schwachstellen-Bewertung/Priorisierung",
|
||||||
|
"description": "Identifizierte Schwachstellen nach Schweregrad, Ausnutzbarkeit und Exposition bewerten und priorisieren.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["vuln_assessment_prioritization"],
|
||||||
|
"steps": [
|
||||||
|
"Schweregrad bewerten (z.B. CVSS)",
|
||||||
|
"Ausnutzbarkeit/Exposition einschaetzen",
|
||||||
|
"risikobasiert priorisieren"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"Schweregrad standardisiert bewertet",
|
||||||
|
"risikobasierte Priorisierung vorhanden"
|
||||||
|
],
|
||||||
|
"evidence": ["Bewertungsdatensatz (CVSS)", "Prioritaetenliste"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_remediation_process",
|
||||||
|
"name": "Schwachstellen-Behebung",
|
||||||
|
"description": "Bekannte Schwachstellen fristgerecht durch Patches/Gegenmassnahmen beheben und Sicherheitsupdates bereitstellen.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["vuln_remediation_patching"],
|
||||||
|
"steps": [
|
||||||
|
"Fix/Gegenmassnahme entwickeln",
|
||||||
|
"testen",
|
||||||
|
"Sicherheitsupdate kostenfrei und zeitnah bereitstellen",
|
||||||
|
"bis zum Abschluss nachverfolgen"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"zeitnahe Behebung",
|
||||||
|
"Sicherheitsupdate bereitgestellt",
|
||||||
|
"Follow-up bis Closure"
|
||||||
|
],
|
||||||
|
"evidence": ["Patch/Release", "Behebungs-Zeitleiste", "Follow-up-Log"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_disclosure_process",
|
||||||
|
"name": "Offenlegung + Nutzerinformation",
|
||||||
|
"description": "Koordinierte Offenlegung behobener Schwachstellen und Information der Nutzer ueber Schutzmassnahmen.",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["coordinated_vulnerability_disclosure", "vuln_info_dissemination_users"],
|
||||||
|
"steps": [
|
||||||
|
"Offenlegungszeitpunkt koordinieren",
|
||||||
|
"Security Advisory / CVE-Eintrag veroeffentlichen",
|
||||||
|
"Nutzer ueber behobene Schwachstelle und Schutzmassnahmen informieren"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"Advisory veroeffentlicht",
|
||||||
|
"Nutzer informiert"
|
||||||
|
],
|
||||||
|
"evidence": ["Security Advisory", "CVE-Eintrag", "Nutzer-Benachrichtigung"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"procedure_id": "vuln_authority_reporting_process",
|
||||||
|
"name": "Behoerdenmeldung aktiv ausgenutzter Schwachstellen",
|
||||||
|
"description": "Aktiv ausgenutzte Schwachstellen fristgerecht an CSIRT/ENISA melden (CRA Art. 14-Kaskade).",
|
||||||
|
"source_role": "procedural_requirement",
|
||||||
|
"fulfills_obligations": ["exploited_vuln_reporting_authorities"],
|
||||||
|
"applicability_note": "bedingt: nur bei aktiv ausgenutzter Schwachstelle",
|
||||||
|
"steps": [
|
||||||
|
"aktive Ausnutzung erkennen",
|
||||||
|
"Fruehwarnung an CSIRT/ENISA (24h)",
|
||||||
|
"vollstaendige Meldung (72h)",
|
||||||
|
"Abschlussbericht (14 Tage)"
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
"24h-Fruehwarnung erfolgt",
|
||||||
|
"72h-Meldung erfolgt",
|
||||||
|
"14d-Abschlussbericht erfolgt"
|
||||||
|
],
|
||||||
|
"evidence": ["CSIRT/ENISA-Meldungsbelege", "Zeitstempel der Kaskade"],
|
||||||
|
"citation_spans": [], "citation_status": "pending_span_anchor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,846 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "obligation_join_keys_v1",
|
||||||
|
"contract": "obligation_id ist der stabile Join-Key. Legal Knowledge Graph haengt citation_spans an obligation_id; Compliance Execution Graph mappt control_mapping.source_norm -> obligation_id. Interim-Bruecke = citation_units. obligation_id NIE neu vergeben (re-link).",
|
||||||
|
"count": 95,
|
||||||
|
"obligation_ids": [
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_creation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_dependency_coverage",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 3(36) i.V.m. Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_format_standard",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_maintenance_update",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_completeness_verification",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_tooling_automation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_access_provision",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_authority_provision",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31 / Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_confidentiality",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31(4)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_supply_chain_contracts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "sbom_technical_documentation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "sbom",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 31 i.V.m. Annex VII"
|
||||||
|
],
|
||||||
|
"source_role": "EVIDENCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_identification_inventory",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_assessment_prioritization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_remediation_patching",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (2) & (8)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_handling_process",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Article 13(8) & Annex VII"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "coordinated_vulnerability_disclosure",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (5)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "exploited_vuln_reporting_authorities",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Article 14 & Article 16"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "vuln_info_dissemination_users",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "vuln",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part II (4) & (6)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "attack_surface_minimization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(j)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "software_integrity_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(f)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "user_authentication_required",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "authentication_policy_documented",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "auth_exceptions_documented",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "mfa_required",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "step_up_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "privileged_op_reauth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "strong_crypto_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "credential_lifecycle_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "credential_confidentiality_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "password_policy",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "no_default_credentials",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(a)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "account_lockout_failed_attempts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "server_side_validation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "session_binding_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "reauth_after_inactivity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "token_validation_lifecycle",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "mutual_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "revocation_check",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "encrypted_auth_channel",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(e)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "tls_certificate_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "service_to_service_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "auth_key_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "biometric_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "federated_auth_assertions",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "separate_authn_authz",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "supplier_access_auth",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "personal_admin_accounts",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "firmware_software_authentication",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "authentication",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "event_logging_security_events",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "access_control_event_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "audit_trail_admin_actions",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_integrity_immutability",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_access_control_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_retention_archival",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "centralized_log_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_monitoring_alerting",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I Part I (2)(k)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_data_minimization_privacy",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_format_standardization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_timestamp_synchronization",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_availability_resilience",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_thread_safety_correctness",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_library_supply_chain",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_config_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "logging_governance_roles",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "incident_response_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "log_transmission_security",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "network_traffic_logging",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "logging",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_control_least_privilege",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_confidentiality_integrity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(b)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_session_management",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_mfa",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_encryption",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "reject_insecure_remote_protocols",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_logging_audit",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(g)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_user_validation_ot",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_training",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_architecture_design",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_attack_surface_min",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)(a)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_vuln_patch_mgmt",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(1)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_threat_detection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_maintenance_governance",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "temporary_remote_access_mgmt",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_data_export_protection",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "component_remote_interface_security",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "remote_access_fallback_concept",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "remote_access",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "provide_security_updates",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)",
|
||||||
|
"Art. 13"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "support_period_maintenance",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Art. 13(8)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "signed_update_integrity",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(3)(f)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "trusted_update_source",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(3)(d)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_testing_validation",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_rollback",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "GUIDANCE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "automatic_updates_optout",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (2)(c)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "update_risk_assessment",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "LEGAL_MINIMUM",
|
||||||
|
"citation_units": [
|
||||||
|
"Annex I (1)(2)"
|
||||||
|
],
|
||||||
|
"source_role": "LEGAL_BASIS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"obligation_id": "secure_modification_control",
|
||||||
|
"regulation": "CRA",
|
||||||
|
"family": "updates",
|
||||||
|
"tier": "BEST_PRACTICE",
|
||||||
|
"citation_units": [],
|
||||||
|
"source_role": "IMPLEMENTATION"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Reine Helfer der Obligation Discovery Pipeline (keine schweren Imports → unit-testbar).
|
||||||
|
|
||||||
|
Die Pipeline leitet aus großen Compliance-Korpora eine regulatorische Ontologie ab:
|
||||||
|
Controls → Mikro-Cluster → Meta-Cluster/Review-Units → LLM-Synthese → Obligation Registry.
|
||||||
|
Architekturregel: RUNTIME bleibt deterministisch; DISCOVERY (dieses Tooling) darf LLM-gestützt
|
||||||
|
sein und läuft EINMALIG/offline. Siehe docs-src/development/obligation_discovery_pipeline_v1.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
SEMANTIC_EDGE_TYPES = ("depends_on", "supports", "produces_evidence_for",
|
||||||
|
"implements", "derived_from")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_req(req) -> list:
|
||||||
|
"""requirements-Spalte (JSON ODER Python-Repr ODER String) robust zu Liste."""
|
||||||
|
if isinstance(req, list):
|
||||||
|
return req
|
||||||
|
if isinstance(req, str):
|
||||||
|
for fn in (json.loads, ast.literal_eval):
|
||||||
|
try:
|
||||||
|
v = fn(req)
|
||||||
|
return v if isinstance(v, list) else [str(v)]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [req]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def cosine(a, b) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(y * y for y in b))
|
||||||
|
return dot / (na * nb) if na and nb else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def greedy_cluster(vecs: list, thr: float) -> list[dict]:
|
||||||
|
"""Single-Pass-Greedy-Clustering: jeder Vektor joint den ersten Cluster, dessen Seed
|
||||||
|
cosine ≥ thr ist, sonst neuer Cluster. Deterministisch (stabile Reihenfolge)."""
|
||||||
|
clusters: list[dict] = []
|
||||||
|
for i, v in enumerate(vecs):
|
||||||
|
if not v:
|
||||||
|
clusters.append({"seed": None, "members": [i]})
|
||||||
|
continue
|
||||||
|
best, best_sim = None, thr
|
||||||
|
for c in clusters:
|
||||||
|
if c["seed"] is None:
|
||||||
|
continue
|
||||||
|
s = cosine(v, c["seed"])
|
||||||
|
if s >= best_sim:
|
||||||
|
best_sim, best = s, c
|
||||||
|
if best:
|
||||||
|
best["members"].append(i)
|
||||||
|
else:
|
||||||
|
clusters.append({"seed": v, "members": [i]})
|
||||||
|
return clusters
|
||||||
|
|
||||||
|
|
||||||
|
def centroid(idxs: list[int], vecs: list) -> Optional[list]:
|
||||||
|
vs = [vecs[i] for i in idxs if vecs[i]]
|
||||||
|
if not vs:
|
||||||
|
return None
|
||||||
|
n = len(vs)
|
||||||
|
return [sum(col) / n for col in zip(*vs)]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_registry(reg: dict) -> dict:
|
||||||
|
"""Belastbarkeits-Checks (User-Regeln): LEGAL_MINIMUM braucht legal_basis,
|
||||||
|
member_controls vollständig, out_of_scope separat, >8-Obligations/Review-Unit-Warnung."""
|
||||||
|
obls = reg.get("obligations", [])
|
||||||
|
lm = [o for o in obls if o.get("tier") == "LEGAL_MINIMUM"]
|
||||||
|
lm_without_basis = [o["id"] for o in lm if not o.get("legal_basis")]
|
||||||
|
empty_members = [o["id"] for o in obls if not o.get("member_controls")]
|
||||||
|
per_unit: dict[str, int] = {}
|
||||||
|
for o in obls:
|
||||||
|
ru = (o.get("provenance") or {}).get("source_meta_cluster")
|
||||||
|
if ru:
|
||||||
|
per_unit[ru] = per_unit.get(ru, 0) + 1
|
||||||
|
over8 = {ru: n for ru, n in per_unit.items() if n > 8}
|
||||||
|
rels = reg.get("relationships", [])
|
||||||
|
return {
|
||||||
|
"obligations": len(obls),
|
||||||
|
"legal_minimum": len(lm),
|
||||||
|
"lm_without_legal_basis": lm_without_basis,
|
||||||
|
"empty_member_controls": empty_members,
|
||||||
|
"over8_per_review_unit": over8,
|
||||||
|
"out_of_scope": sum(1 for r in rels if r.get("type") == "out_of_scope"),
|
||||||
|
"semantic_edges": sum(1 for r in rels if r.get("type") in SEMANTIC_EDGE_TYPES),
|
||||||
|
"passed": not lm_without_basis and not empty_members and not over8,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_edges(relationships: list[dict], proposed: list[dict]) -> tuple[list[dict], int]:
|
||||||
|
"""Proposed semantische Kanten dedupliziert in relationships mergen. Gibt (merged, added)."""
|
||||||
|
existing = {(r.get("type"), r.get("from"), r.get("to"))
|
||||||
|
for r in relationships if r.get("from")}
|
||||||
|
added = 0
|
||||||
|
out = list(relationships)
|
||||||
|
for e in proposed:
|
||||||
|
if e.get("type") not in SEMANTIC_EDGE_TYPES:
|
||||||
|
continue
|
||||||
|
key = (e["type"], e.get("from"), e.get("to"))
|
||||||
|
if key in existing or not e.get("from") or not e.get("to"):
|
||||||
|
continue
|
||||||
|
out.append(e)
|
||||||
|
existing.add(key)
|
||||||
|
added += 1
|
||||||
|
return out, added
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""P3 — Compliance-Advisor-Proof: obligation-basierte Antwort als vollstaendige
|
||||||
|
BEGRUENDUNGSKETTE aus der Registry (NICHT RAG-Text, KEIN LLM):
|
||||||
|
Rechtsgrundlage -> Obligation -> Procedure -> Controls -> Evidence -> Antwort.
|
||||||
|
Deterministisch + zitierfaehig. Der Unterschied zu RAG: RAG beantwortet — BreakPilot
|
||||||
|
begruendet UND operationalisiert.
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/advisor_proof.py --registry obligations/cra.json \
|
||||||
|
--procedures obligations/cra_procedures.json --topic sbom --has-digital-elements
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def applies(obl: dict, has_digital: bool) -> tuple[bool, str]:
|
||||||
|
a = obl.get("applicability", "universal")
|
||||||
|
if a == "universal":
|
||||||
|
return True, ""
|
||||||
|
if a.startswith("domain:products_with_digital_elements"):
|
||||||
|
return has_digital, "nur fuer Produkte mit digitalen Elementen (CRA Art. 3)"
|
||||||
|
if a.startswith("domain:"):
|
||||||
|
return True, a.split(":", 1)[1]
|
||||||
|
if a.startswith("conditional:"):
|
||||||
|
return True, f"bedingt: {a.split(':',1)[1]}"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--registry", required=True)
|
||||||
|
ap.add_argument("--procedures", required=True)
|
||||||
|
ap.add_argument("--topic", default="sbom")
|
||||||
|
ap.add_argument("--has-digital-elements", action="store_true")
|
||||||
|
ap.add_argument("--question", default="Muss ich als Maschinenbauer eine SBOM bereitstellen?")
|
||||||
|
a = ap.parse_args()
|
||||||
|
reg = json.load(open(a.registry, encoding="utf-8"))
|
||||||
|
procs = json.load(open(a.procedures, encoding="utf-8"))["procedures"]
|
||||||
|
|
||||||
|
obls = [o for o in reg["obligations"]
|
||||||
|
if a.topic in o.get("family", "") or a.topic in o["id"]]
|
||||||
|
ids = {o["id"] for o in obls}
|
||||||
|
by_obl: dict[str, list] = {}
|
||||||
|
for p in procs:
|
||||||
|
for oid in p.get("fulfills_obligations", []):
|
||||||
|
by_obl.setdefault(oid, []).append(p)
|
||||||
|
|
||||||
|
pflicht = [o for o in obls if o["tier"] == "LEGAL_MINIMUM" and applies(o, a.has_digital_elements)[0]]
|
||||||
|
best = [o for o in obls if o["tier"] != "LEGAL_MINIMUM"]
|
||||||
|
|
||||||
|
print(f"FRAGE: {a.question}")
|
||||||
|
print(f"\nANTWORT: {'JA' if pflicht and a.has_digital_elements else 'NUR WENN CRA-anwendbar'} — "
|
||||||
|
f"sofern das Produkt unter den CRA faellt (product with digital elements, Art. 3).")
|
||||||
|
print("\n══ BEGRUENDUNGSKETTE (Recht → Obligation → Procedure → Controls → Evidence) ══")
|
||||||
|
|
||||||
|
req_evidence: list[str] = []
|
||||||
|
for o in pflicht:
|
||||||
|
lb = "; ".join(f"{b.get('source','')} {b.get('anchor','')}".strip() for b in o.get("legal_basis", []))
|
||||||
|
print(f"\n● PFLICHT: {o['id']} — {o.get('description','')[:80]}")
|
||||||
|
print(f" Rechtsgrundlage: {lb or '—'}")
|
||||||
|
ps = by_obl.get(o["id"], [])
|
||||||
|
for p in ps:
|
||||||
|
print(f" Procedure (wie umgesetzt): {p['procedure_id']} — Schritte: {len(p.get('steps',[]))}")
|
||||||
|
print(f" Controls (Pruefung): {' · '.join(p.get('controls', []))[:96]}")
|
||||||
|
print(f" Nachweis: {' · '.join(p.get('evidence', []))}")
|
||||||
|
req_evidence += p.get("evidence", [])
|
||||||
|
if not ps:
|
||||||
|
print(" Procedure: (noch keine modelliert)")
|
||||||
|
|
||||||
|
print("\n── REQUIRED EVIDENCE (aggregiert, womit wird es nachgewiesen) ──")
|
||||||
|
print(" " + " · ".join(dict.fromkeys(req_evidence)) if req_evidence else " —")
|
||||||
|
|
||||||
|
print("\n── BEST PRACTICE (anerkannte Umsetzung, KEINE CRA-Wortlautpflicht) ──")
|
||||||
|
for o in best:
|
||||||
|
gb = "; ".join(b.get("source", "") for b in o.get("guidance_basis", []))
|
||||||
|
print(f" • {o['id']} — {o.get('description','')[:64]} | Guidance: {gb or '—'}")
|
||||||
|
|
||||||
|
print("\n── BEZIEHUNG (warum es zaehlt) ──")
|
||||||
|
for r in reg.get("relationships", []):
|
||||||
|
if r.get("from") in ids and r.get("to") not in ids:
|
||||||
|
print(f" • {r['from']} --{r['type']}--> {r['to']}: {r.get('note','')[:64]}")
|
||||||
|
|
||||||
|
pend = sum(1 for o in pflicht if o.get("citation_status") == "pending_span_anchor")
|
||||||
|
print(f"\n── CITATION ──\n {pend}/{len(pflicht)} Pflichten: pending_span_anchor "
|
||||||
|
f"(Textstellen-Anker folgen mit dem zitierfaehigen Re-Ingest)")
|
||||||
|
print("\n(RAG beantwortet — BreakPilot begruendet UND operationalisiert.)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Cross-Domain Relationship Discovery — Stufe 2: Opus klassifiziert jede Kandidaten-Beziehung
|
||||||
|
in GENAU EINE Kategorie. Liefert das Rohmaterial der Compliance-Ontologie (insb. SHARED_CAPABILITY
|
||||||
|
= Capability-Schicht). ANTHROPIC_API_KEY aus ENV (nie hartcodiert). Streaming.
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=… python3 classify_relationships.py --pairs /tmp/cd_pairs.json \
|
||||||
|
--only-cross-family --out /tmp/cd_classified.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
SYS = """Du bist Compliance-Ontologe. Gegeben Paare von Legal Obligations (CRA), bestimme fuer
|
||||||
|
JEDES Paar GENAU EINE Beziehung. Ziel ist NICHT Aehnlichkeit, sondern die STRUKTURELLE Beziehung.
|
||||||
|
|
||||||
|
Kategorien (genau EINE; bei Mehrdeutigkeit gilt diese Prioritaet):
|
||||||
|
1 SAME_OBLIGATION — dieselbe rechtliche Pflicht, nur pro Domaene anders formuliert -> MERGE-Kandidat.
|
||||||
|
2 SUPPORTED_BY — A ist domaenenspezifische Auspraegung/Teilfall von B ODER A traegt zur Erfuellung von B bei. RICHTUNG angeben.
|
||||||
|
3 SHARED_CAPABILITY — beide werden durch DIESELBE technische Faehigkeit erfuellt (z.B. MFA, TLS-Verschluesselung, digitale Signatur, Session-Management, Patch-Management, Logging-Pipeline). capability_name (snake_case) angeben.
|
||||||
|
4 SHARED_PROCEDURE — beide ueber denselben operativen Prozess erfuellt, ohne gemeinsames technisches Artefakt.
|
||||||
|
5 SHARED_EVIDENCE — beide erzeugen/nutzen denselben Nachweis (Audit-Log, SBOM, Release Notes). evidence_name angeben.
|
||||||
|
6 SHARED_GUIDANCE — beide berufen sich auf denselben externen Standard (NIST/OWASP/ISO), sonst distinkt.
|
||||||
|
7 OVERLAP_ONLY — nur oberflaechliche Wort-/Themenueberlappung, keine echte strukturelle Beziehung.
|
||||||
|
8 UNRELATED — Falsch-Positiv der Embedding-Naehe.
|
||||||
|
|
||||||
|
Gib AUSSCHLIESSLICH JSON aus:
|
||||||
|
{"results":[{"i":0,"relation":"SHARED_CAPABILITY","direction":"a->b|b->a|none","capability_name":"","evidence_name":"","reason":"max 18 Woerter"}]}
|
||||||
|
Regeln: relation = genau eine der 8 Strings. direction nur bei SUPPORTED_BY, sonst "none".
|
||||||
|
capability_name NUR bei SHARED_CAPABILITY (sonst ""), evidence_name NUR bei SHARED_EVIDENCE (sonst "").
|
||||||
|
Sei streng: SHARED_GUIDANCE/OVERLAP_ONLY/UNRELATED grosszuegig nutzen; SAME_OBLIGATION nur bei echter Deckungsgleichheit.
|
||||||
|
Gib fuer JEDES Paar (per Index i) genau ein Ergebnis."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_user(pairs: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for i, p in enumerate(pairs):
|
||||||
|
lines.append(f'[{i}] A={p["a"]} ({p["fa"]}/{p["ta"]}): {p["da"]}\n'
|
||||||
|
f' B={p["b"]} ({p["fb"]}/{p["tb"]}): {p["db"]} [sim={p["sim"]}]')
|
||||||
|
return "Paare:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--pairs", required=True)
|
||||||
|
ap.add_argument("--only-cross-family", action="store_true")
|
||||||
|
ap.add_argument("--min-sim", type=float, default=0.0)
|
||||||
|
ap.add_argument("--model", default="claude-opus-4-8")
|
||||||
|
ap.add_argument("--out", required=True)
|
||||||
|
a = ap.parse_args()
|
||||||
|
d = json.load(open(a.pairs, encoding="utf-8"))
|
||||||
|
pairs = [p for p in d["pairs"]
|
||||||
|
if (not a.only_cross_family or p["cross_family"]) and p["sim"] >= a.min_sim]
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
||||||
|
with client.messages.stream(model=a.model, max_tokens=24000, system=SYS,
|
||||||
|
messages=[{"role": "user", "content": build_user(pairs)}]) as st:
|
||||||
|
msg = st.get_final_message()
|
||||||
|
txt = msg.content[0].text
|
||||||
|
m = re.search(r"\{.*\}", txt, re.DOTALL)
|
||||||
|
data = json.loads(m.group(0) if m else txt)
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for r in data.get("results", []):
|
||||||
|
i = r.get("i")
|
||||||
|
if not isinstance(i, int) or i < 0 or i >= len(pairs):
|
||||||
|
continue
|
||||||
|
p = pairs[i]
|
||||||
|
res.append({"a": p["a"], "fa": p["fa"], "b": p["b"], "fb": p["fb"], "sim": p["sim"],
|
||||||
|
"relation": r.get("relation", "?"), "direction": r.get("direction", "none"),
|
||||||
|
"capability_name": r.get("capability_name", ""),
|
||||||
|
"evidence_name": r.get("evidence_name", ""), "reason": r.get("reason", "")})
|
||||||
|
dist = Counter(r["relation"] for r in res)
|
||||||
|
out = {"n_pairs": len(pairs), "n_classified": len(res), "distribution": dict(dist),
|
||||||
|
"model": a.model, "results": res}
|
||||||
|
json.dump(out, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"classified {len(res)}/{len(pairs)} | {dict(dist)}")
|
||||||
|
print("written:", a.out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Cross-Domain Relationship Discovery — Stufe 1 (key-frei, im bp-compliance-backend-Container).
|
||||||
|
Alle Obligations mehrerer Registries -> BGE-M3-Embedding -> je Obligation Top-K Nachbarn ->
|
||||||
|
Kandidaten-Paare (cross- UND same-family) >= min-sim. KEIN Urteil hier — nur Kandidaten.
|
||||||
|
Stufe 2 (classify_relationships.py) klassifiziert die Beziehung per Opus.
|
||||||
|
|
||||||
|
python3 cross_domain_pairs.py /tmp/reg/cra.json /tmp/reg/cra_authentication.json ... \
|
||||||
|
--top-k 8 --min-sim 0.60 --out /tmp/cd_pairs.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from _core import cosine
|
||||||
|
|
||||||
|
|
||||||
|
async def run(paths: list[str], top_k: int, min_sim: float, out: str) -> None:
|
||||||
|
from compliance.services.mc_embedding_matcher import _embed_texts
|
||||||
|
|
||||||
|
obls: list[dict] = []
|
||||||
|
for p in paths:
|
||||||
|
reg = json.load(open(p, encoding="utf-8"))
|
||||||
|
fam = reg.get("family", "")
|
||||||
|
for o in reg.get("obligations", []):
|
||||||
|
obls.append({"id": o["id"], "family": o.get("family", "") or fam,
|
||||||
|
"tier": o.get("tier", ""), "name": o.get("name", ""),
|
||||||
|
"desc": o.get("description", "")})
|
||||||
|
vecs = await _embed_texts([f'{o["name"]}. {o["desc"]}' for o in obls])
|
||||||
|
n = len(obls)
|
||||||
|
print(f"obligations={n}")
|
||||||
|
|
||||||
|
best: dict[tuple[int, int], float] = {}
|
||||||
|
for i in range(n):
|
||||||
|
nbrs = sorted(((cosine(vecs[i], vecs[j]), j) for j in range(n) if j != i), reverse=True)[:top_k]
|
||||||
|
for s, j in nbrs:
|
||||||
|
if s < min_sim:
|
||||||
|
continue
|
||||||
|
a, b = sorted((i, j))
|
||||||
|
if (a, b) not in best or s > best[(a, b)]:
|
||||||
|
best[(a, b)] = s
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
for (a, b), s in sorted(best.items(), key=lambda x: -x[1]):
|
||||||
|
pairs.append({
|
||||||
|
"a": obls[a]["id"], "fa": obls[a]["family"], "ta": obls[a]["tier"], "da": obls[a]["desc"][:220],
|
||||||
|
"b": obls[b]["id"], "fb": obls[b]["family"], "tb": obls[b]["tier"], "db": obls[b]["desc"][:220],
|
||||||
|
"sim": round(s, 3), "cross_family": obls[a]["family"] != obls[b]["family"]})
|
||||||
|
cf = sum(1 for p in pairs if p["cross_family"])
|
||||||
|
json.dump({"n_obligations": n, "n_pairs": len(pairs), "cross_family": cf, "pairs": pairs},
|
||||||
|
open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"pairs={len(pairs)} (cross-family={cf}, same-family={len(pairs) - cf}) written: {out}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("registries", nargs="+")
|
||||||
|
ap.add_argument("--top-k", type=int, default=8)
|
||||||
|
ap.add_argument("--min-sim", type=float, default=0.60)
|
||||||
|
ap.add_argument("--out", default="/tmp/cd_pairs.json")
|
||||||
|
a = ap.parse_args()
|
||||||
|
asyncio.run(run(a.registries, a.top_k, a.min_sim, a.out))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Exportiert den OBLIGATION_ID-Join-Key-Vertrag aus den Registry-Artefakten.
|
||||||
|
Die obligation_id ist der stabile Brueckenschluessel zwischen Legal Knowledge Graph
|
||||||
|
(citation_spans haengen an obligation_id) und Compliance Execution Graph
|
||||||
|
(control_mapping.source_norm -> obligation_id). citation_units = die legal_basis-Anker,
|
||||||
|
ueber die beide Seiten heute (vor obligation_id-Adoption) bruecken koennen.
|
||||||
|
|
||||||
|
DISZIPLIN: obligation_id wird RE-GELINKT, NIE neu vergeben (Pendant zu span_id/control_uuid).
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/export_join_keys.py obligations/cra.json obligations/cra_authentication.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("registries", nargs="+")
|
||||||
|
ap.add_argument("--out", default="obligations/obligation_join_keys.json")
|
||||||
|
a = ap.parse_args()
|
||||||
|
keys = []
|
||||||
|
for path in a.registries:
|
||||||
|
reg = json.load(open(path, encoding="utf-8"))
|
||||||
|
for o in reg.get("obligations", []):
|
||||||
|
citation_units = [b.get("anchor", "") for b in o.get("legal_basis", []) if b.get("anchor")]
|
||||||
|
keys.append({
|
||||||
|
"obligation_id": o["id"],
|
||||||
|
"regulation": reg.get("regulation", ""),
|
||||||
|
"family": o.get("family", ""),
|
||||||
|
"tier": o.get("tier", ""),
|
||||||
|
"citation_units": citation_units,
|
||||||
|
"source_role": o.get("source_role", ""),
|
||||||
|
})
|
||||||
|
out = {
|
||||||
|
"schema_version": "obligation_join_keys_v1",
|
||||||
|
"contract": "obligation_id ist der stabile Join-Key. Legal Knowledge Graph haengt "
|
||||||
|
"citation_spans an obligation_id; Compliance Execution Graph mappt "
|
||||||
|
"control_mapping.source_norm -> obligation_id. Interim-Bruecke = citation_units. "
|
||||||
|
"obligation_id NIE neu vergeben (re-link).",
|
||||||
|
"count": len(keys),
|
||||||
|
"obligation_ids": keys,
|
||||||
|
}
|
||||||
|
json.dump(out, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
from collections import Counter
|
||||||
|
print(f"exportiert: {a.out} ({len(keys)} obligation_ids)")
|
||||||
|
print("Regulierungen:", dict(Counter(k["regulation"] for k in keys)))
|
||||||
|
print("Familien:", dict(Counter(k["family"] for k in keys)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""#5b — Materialisierung der Capability-Schicht (Modell C, User-Entscheidung 2026-06-26).
|
||||||
|
|
||||||
|
Aus `cross_domain_relationships.json` (SHARED_CAPABILITY) + den 6 CRA-P1-Registries:
|
||||||
|
- `obligations/capabilities.json` — Capability-Knoten: realized_by (n:m) + guidance_basis hochgezogen.
|
||||||
|
- `obligations/cra_core.json` — 2 CORE-Obligations (Sicherheitsziele): attack_surface_minimization,
|
||||||
|
software_integrity_protection (Modell C: KEINE eigene SecurityObjective-Klasse; das Ziel IST eine
|
||||||
|
abstrakte CORE-Pflicht).
|
||||||
|
- patcht DOMAIN-Obligations in ihren Registries: `specializes` (→CORE) + `objective_tags` (Vorwärts-
|
||||||
|
Kompat zu Modell B: Tags, keine Klasse).
|
||||||
|
- markiert `vuln_remediation_patching` als deprecated_alias von `provide_security_updates` (Merge).
|
||||||
|
- `remote_access_data_export_protection` bleibt BEST_PRACTICE (Notiz: pending Data-Act-Scope).
|
||||||
|
|
||||||
|
Deterministisch. Lokal lauffähig (nur json). Danach export_join_keys neu (inkl. cra_core).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
REG_FILES = ["obligations/cra.json", "obligations/cra_authentication.json",
|
||||||
|
"obligations/cra_logging.json", "obligations/cra_remote_access.json",
|
||||||
|
"obligations/cra_updates.json"]
|
||||||
|
|
||||||
|
# Cluster-capability_name -> kanonische capability_id (access_control absichtlich gedroppt: zu schwach)
|
||||||
|
CONSOLIDATE = {"mfa": "multi_factor_authentication", "session_management": "session_management",
|
||||||
|
"tls_encryption": "transport_encryption", "mutual_tls": "transport_encryption",
|
||||||
|
"tls_certificate_auth": "transport_encryption", "code_signing": "code_signing",
|
||||||
|
"anomaly_detection": "security_monitoring_alerting"}
|
||||||
|
CAP_META = {
|
||||||
|
"multi_factor_authentication": ("Multi-Factor Authentication",
|
||||||
|
"Mehrfaktor-Authentisierung als technische Faehigkeit (Besitz/Wissen/Inhaerenz)."),
|
||||||
|
"session_management": ("Session Management",
|
||||||
|
"Sichere Sitzungsverwaltung: Timeouts, Bindung, Re-Auth, Beendigung."),
|
||||||
|
"transport_encryption": ("Transport Encryption",
|
||||||
|
"Verschluesselter Transport (TLS, mutual-TLS, Zertifikats-Auth, VPN/Tunnel)."),
|
||||||
|
"code_signing": ("Code & Update Signing",
|
||||||
|
"Digitale Signatur + Integritaets-/Authentizitaetspruefung von Firmware/Software/Updates."),
|
||||||
|
"security_monitoring_alerting": ("Security Monitoring & Alerting",
|
||||||
|
"Anomalie-/Bedrohungserkennung und Alarmierung aus Logs/Telemetrie."),
|
||||||
|
}
|
||||||
|
CAP_ORDER = ["multi_factor_authentication", "session_management", "transport_encryption",
|
||||||
|
"code_signing", "security_monitoring_alerting"]
|
||||||
|
|
||||||
|
# DOMAIN-Obligation -> (CORE-Ziel, objective_tags)
|
||||||
|
SPECIALIZES = {
|
||||||
|
"remote_access_attack_surface_min": ("attack_surface_minimization", ["attack_surface"]),
|
||||||
|
"component_remote_interface_security": ("attack_surface_minimization", ["attack_surface"]),
|
||||||
|
"signed_update_integrity": ("software_integrity_protection", ["integrity"]),
|
||||||
|
"firmware_software_authentication": ("software_integrity_protection", ["integrity"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
regs = {f: json.load(open(f, encoding="utf-8")) for f in REG_FILES}
|
||||||
|
idx = {}
|
||||||
|
for f, r in regs.items():
|
||||||
|
for o in r.get("obligations", []):
|
||||||
|
idx[o["id"]] = (f, o)
|
||||||
|
|
||||||
|
# realized_by aus dem Artefakt
|
||||||
|
art = json.load(open("obligations/cross_domain_relationships.json", encoding="utf-8"))
|
||||||
|
realized = {cid: set() for cid in CONSOLIDATE.values()}
|
||||||
|
for rr in art["raw_results"]:
|
||||||
|
if rr["relation"] == "SHARED_CAPABILITY" and rr.get("capability_name") in CONSOLIDATE:
|
||||||
|
cid = CONSOLIDATE[rr["capability_name"]]
|
||||||
|
realized[cid].update([rr["a"], rr["b"]])
|
||||||
|
|
||||||
|
def guidance_for(ids):
|
||||||
|
seen, out = set(), []
|
||||||
|
for oid in ids:
|
||||||
|
if oid in idx:
|
||||||
|
for g in idx[oid][1].get("guidance_basis", []):
|
||||||
|
k = (g.get("source", ""), g.get("anchor", ""))
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
out.append(g)
|
||||||
|
return out
|
||||||
|
|
||||||
|
caps = []
|
||||||
|
for cid in CAP_ORDER:
|
||||||
|
obls = sorted(realized[cid])
|
||||||
|
name, desc = CAP_META[cid]
|
||||||
|
caps.append({"capability_id": cid, "name": name, "description": desc,
|
||||||
|
"type": "technical_capability", "realized_by": obls, "realizes_count": len(obls),
|
||||||
|
"guidance_basis": guidance_for(obls),
|
||||||
|
"domains": sorted({idx[o][1].get("family", "") for o in obls if o in idx}),
|
||||||
|
"provenance": {"source": "cross_domain_relationships.json SHARED_CAPABILITY"}})
|
||||||
|
|
||||||
|
capabilities = {
|
||||||
|
"schema_version": "capability_layer_v1", "model": "Modell C (docs-src/development/capability_model_v1.md)",
|
||||||
|
"note": "Capability = technische Faehigkeit (regulierungs-agnostisch). realized_by = Obligations, "
|
||||||
|
"die sie erfuellt (n:m). guidance_basis hier KANONISCH hochgezogen aus den realisierten "
|
||||||
|
"Obligations (die Obligation-Kopien bleiben vorerst als Legacy; Strip = Folge-Cleanup). "
|
||||||
|
"Sicherheitsziele sind KEINE Capabilities -> cra_core.json.",
|
||||||
|
"dropped": {"access_control": "OVERLAP (credential_confidentiality <-> sbom_confidentiality), nicht materialisiert"},
|
||||||
|
"candidate_capabilities_followup": ["automatic_update_delivery", "update_rollback",
|
||||||
|
"trusted_update_source", "hash_verification", "secure_boot", "least_functionality",
|
||||||
|
"credential_storage"],
|
||||||
|
"capabilities": caps}
|
||||||
|
json.dump(capabilities, open("obligations/capabilities.json", "w", encoding="utf-8"),
|
||||||
|
ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
core = [
|
||||||
|
{"id": "attack_surface_minimization", "name": "Minimierung der Angriffsflaeche", "family": "core",
|
||||||
|
"description": "Das Produkt minimiert seine Angriffsflaeche: unnoetige Funktionen/Ports/Dienste/"
|
||||||
|
"Schnittstellen sind deaktiviert (Least Functionality).",
|
||||||
|
"tier": "LEGAL_MINIMUM", "source_role": "LEGAL_BASIS", "applicability": "universal",
|
||||||
|
"objective_tags": ["attack_surface"],
|
||||||
|
"legal_basis": [{"source": "CRA", "anchor": "Annex I Part I (2)(j)",
|
||||||
|
"citation": "limit attack surfaces, including external interfaces"}],
|
||||||
|
"guidance_basis": [{"source": "NIST", "anchor": "CM-7 Least Functionality", "role": "best_practice"}],
|
||||||
|
"specialized_by": ["remote_access_attack_surface_min", "component_remote_interface_security"],
|
||||||
|
"primary_implementation": "NIST CM-7", "citation_status": "pending_span_anchor",
|
||||||
|
"review_status": "core_from_5b"},
|
||||||
|
{"id": "software_integrity_protection", "name": "Schutz der Software-/Firmware-Integritaet", "family": "core",
|
||||||
|
"description": "Das Produkt schuetzt Integritaet und Authentizitaet von Software/Firmware "
|
||||||
|
"(Manipulationserkennung, Secure Boot, Signaturpruefung, Runtime-Integritaet).",
|
||||||
|
"tier": "LEGAL_MINIMUM", "source_role": "LEGAL_BASIS", "applicability": "universal",
|
||||||
|
"objective_tags": ["integrity"],
|
||||||
|
"legal_basis": [{"source": "CRA", "anchor": "Annex I Part I (2)(f)",
|
||||||
|
"citation": "protect the integrity of stored, transmitted or processed data, software and configuration"}],
|
||||||
|
"guidance_basis": [{"source": "NIST", "anchor": "SI-7 Software, Firmware, and Information Integrity", "role": "best_practice"}],
|
||||||
|
"specialized_by": ["signed_update_integrity", "firmware_software_authentication"],
|
||||||
|
"realized_by_capabilities": ["code_signing"],
|
||||||
|
"primary_implementation": "NIST SI-7", "citation_status": "pending_span_anchor",
|
||||||
|
"review_status": "core_from_5b"},
|
||||||
|
]
|
||||||
|
json.dump({"schema_version": "obligation_registry_v1", "regulation": "CRA", "regulation_code": "CRA",
|
||||||
|
"family": "core",
|
||||||
|
"theme": "CORE Security Objectives (CRA Annex I als regulierungs-agnostische Sicherheitsziele)",
|
||||||
|
"generated_by": "materialize_capabilities.py (#5b, Modell C)",
|
||||||
|
"note": "CORE Legal Obligations = Sicherheitsziele (Modell C: KEINE eigene SecurityObjective-Klasse). "
|
||||||
|
"DOMAIN-Obligations specializes-en hierauf. objective_tags = Vorwaerts-Kompat zu Modell B.",
|
||||||
|
"citation_status": "pending_span_anchor", "obligations": core, "relationships": []},
|
||||||
|
open("obligations/cra_core.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
dirty = set()
|
||||||
|
patched = []
|
||||||
|
for oid, (coreid, tags) in SPECIALIZES.items():
|
||||||
|
if oid in idx:
|
||||||
|
f, o = idx[oid]
|
||||||
|
o["specializes"] = coreid
|
||||||
|
o["objective_tags"] = tags
|
||||||
|
dirty.add(f)
|
||||||
|
patched.append(oid)
|
||||||
|
if "vuln_remediation_patching" in idx:
|
||||||
|
f, o = idx["vuln_remediation_patching"]
|
||||||
|
o["merged_into"] = "provide_security_updates"
|
||||||
|
o["status"] = "deprecated_alias"
|
||||||
|
o["merge_note"] = ("SAME_OBLIGATION (Cross-Domain-Review). Kanonisch: provide_security_updates "
|
||||||
|
"((2)(c)/Art.13). ID bleibt als Alias aufloesbar; downstream provide_security_updates nutzen.")
|
||||||
|
dirty.add(f)
|
||||||
|
if "remote_access_data_export_protection" in idx:
|
||||||
|
f, o = idx["remote_access_data_export_protection"]
|
||||||
|
o["tier_note"] = ("Bleibt BEST_PRACTICE (NICHT LM) bis Data-Act/Export-Scope sauber ist (User #5b.6). "
|
||||||
|
"Evtl. Capability-or-Procedure statt Obligation.")
|
||||||
|
dirty.add(f)
|
||||||
|
for f in dirty:
|
||||||
|
json.dump(regs[f], open(f, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
print("capabilities.json:", len(caps), "Capabilities")
|
||||||
|
for c in caps:
|
||||||
|
print(f" {c['capability_id']:30s} realizes {c['realizes_count']:2d} | guidance {len(c['guidance_basis'])} | {c['domains']}")
|
||||||
|
print("cra_core.json: 2 CORE (attack_surface_minimization, software_integrity_protection)")
|
||||||
|
print("specializes gepatcht:", patched)
|
||||||
|
print("alias: vuln_remediation_patching -> provide_security_updates")
|
||||||
|
print("dirty registries:", sorted(dirty))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Stufe 5 — Review-Diff mergen: vorgeschlagene Beziehungskanten (review_status=proposed)
|
||||||
|
dedupliziert in die Registry mergen (kein LLM/Key). Kleine Beziehungs-Sprache:
|
||||||
|
depends_on/supports/produces_evidence_for/implements/derived_from.
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/merge_review_diff.py obligations/cra.json /tmp/cra_edges_review.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from _core import SEMANTIC_EDGE_TYPES, merge_edges
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("registry")
|
||||||
|
ap.add_argument("review_diff")
|
||||||
|
ap.add_argument("--write", action="store_true", help="in die Registry schreiben (sonst dry-run)")
|
||||||
|
a = ap.parse_args()
|
||||||
|
reg = json.load(open(a.registry, encoding="utf-8"))
|
||||||
|
diff = json.load(open(a.review_diff, encoding="utf-8"))
|
||||||
|
proposed = diff.get("proposed_edges", diff if isinstance(diff, list) else [])
|
||||||
|
merged, added = merge_edges(reg.get("relationships", []), proposed)
|
||||||
|
print(f"proposed: {len(proposed)} | added (dedupliziert): {added}")
|
||||||
|
if a.write:
|
||||||
|
reg["relationships"] = merged
|
||||||
|
reg["relationship_types"] = list(SEMANTIC_EDGE_TYPES)
|
||||||
|
json.dump(reg, open(a.registry, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"written: {a.registry}")
|
||||||
|
else:
|
||||||
|
print("dry-run (use --write to apply)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Stufe 2 — Meta-Cluster (der Skalierungs-Fix für große Domänen): Mikro-Cluster →
|
||||||
|
REVIEW UNITS. Review Unit = das, was der LLM-Synthese-Pass sieht (entkoppelt vom Clustering,
|
||||||
|
später merge/split-bar). Nutzt den Embedding-Cache aus precluster (kein Re-Embed).
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/meta_cluster.py --scope auth --meta-thr 0.62
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
from _core import centroid, greedy_cluster
|
||||||
|
|
||||||
|
|
||||||
|
def run(scope: str, meta_thr: float, outdir: str) -> None:
|
||||||
|
micro = json.load(open(os.path.join(outdir, f"{scope}_micro_clusters.json"), encoding="utf-8"))
|
||||||
|
vecs = pickle.load(open(os.path.join(outdir, f"{scope}_vecs.pkl"), "rb"))
|
||||||
|
centroids = [centroid(m["member_indices"], vecs) for m in micro]
|
||||||
|
meta = greedy_cluster(centroids, meta_thr)
|
||||||
|
print(f"scope={scope} pass-2 (meta-thr={meta_thr}): {len(micro)} micro → {len(meta)} review-units")
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for mi, m in enumerate(meta):
|
||||||
|
ctrl_ids, titles = [], []
|
||||||
|
for micro_idx in m["members"]:
|
||||||
|
mc = micro[micro_idx]
|
||||||
|
ctrl_ids += mc["control_ids"]
|
||||||
|
titles.append(mc["titles"][0] if mc["titles"] else "")
|
||||||
|
out.append({"review_unit_id": f"M{mi}", "n_micro": len(m["members"]),
|
||||||
|
"n_controls": len(ctrl_ids), "control_ids": ctrl_ids,
|
||||||
|
"sample_titles": titles[:8]})
|
||||||
|
out.sort(key=lambda x: -x["n_controls"])
|
||||||
|
path = os.path.join(outdir, f"{scope}_review_units.json")
|
||||||
|
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
print("=== top review units (inspect for cross-domain mixing BEFORE synthesis) ===")
|
||||||
|
for m in out[:12]:
|
||||||
|
print(f" {m['review_unit_id']:5} ctrl={m['n_controls']:4} micro={m['n_micro']:3} "
|
||||||
|
f"| {' || '.join(t[:30] for t in m['sample_titles'][:3])}")
|
||||||
|
print(f"written: {path} ({len(out)} review units)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--scope", default="auth")
|
||||||
|
ap.add_argument("--meta-thr", type=float, default=0.62)
|
||||||
|
ap.add_argument("--outdir", default="/tmp")
|
||||||
|
a = ap.parse_args()
|
||||||
|
run(a.scope, a.meta_thr, a.outdir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Stufe 1 — Pre-Cluster: Controls (scope) → BGE-M3-Embedding (gecacht) → Mikro-Cluster.
|
||||||
|
Deterministisch. Im bp-compliance-backend-Container ausführen (PYTHONPATH=/app).
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/precluster.py --scope sbom
|
||||||
|
python3 scripts/obligation_discovery/precluster.py --patterns '%sbom%,%software bill%' --micro-thr 0.78
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
from _core import greedy_cluster, parse_req
|
||||||
|
|
||||||
|
SCOPES = {
|
||||||
|
"sbom": ["%SBOM%", "%software bill%", "%stückliste%", "%komponentenliste%"],
|
||||||
|
"vuln": ["%schwachstellenbehandl%", "%schwachstellenmanagement%", "%vulnerability handling%",
|
||||||
|
"%coordinated vulnerab%", "%vulnerability disclosure%", "%cvd-konzept%"],
|
||||||
|
"auth": ["%authentisierung%", "%authentifizierung%", "%authentication%"],
|
||||||
|
"logging": ["%logging%", "%protokollierung%", "%audit-log%", "%audit-trail%",
|
||||||
|
"%ereignisprotokoll%", "%sicherheitsprotokoll%", "%audit-protokoll%",
|
||||||
|
"%log-management%", "%sicherheitsereignis%protokoll%", "%audit-trail%"],
|
||||||
|
"remote_access": ["%fernwartung%", "%fernzugriff%", "%fernzugang%", "%fernwartungs%",
|
||||||
|
"%remote access%", "%remote maintenance%", "%remote management%",
|
||||||
|
"%remote-wartung%", "%remote-zugriff%", "%remote-zugang%",
|
||||||
|
"%sichere fernwartung%", "%fernsteuerung%"],
|
||||||
|
"updates": ["%sicherheitsupdate%", "%security update%", "%sicherheits-update%",
|
||||||
|
"%security patch%", "%sicherheitspatch%", "%patch-management%",
|
||||||
|
"%patchmanagement%", "%patch management%", "%firmware-update%",
|
||||||
|
"%firmware update%", "%software-update%", "%software update%",
|
||||||
|
"%automatische aktualisierung%", "%update-mechanismus%",
|
||||||
|
"%update-bereitstellung%", "%bereitstellung von updates%",
|
||||||
|
"%sichere aktualisierung%", "%signierte update%", "%update-paket%"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run(scope: str, patterns: list[str], micro_thr: float, outdir: str) -> None:
|
||||||
|
import asyncpg
|
||||||
|
from compliance.services.mc_embedding_matcher import _embed_texts
|
||||||
|
|
||||||
|
dsn = os.getenv("DATABASE_URL") or os.getenv("COMPLIANCE_DATABASE_URL")
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
where = " or ".join(f"title ilike ${i+1}" for i in range(len(patterns)))
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"select control_id, title, requirements from compliance.canonical_controls "
|
||||||
|
f"where {where} order by control_id", *patterns)
|
||||||
|
await conn.close()
|
||||||
|
items = [{"control_id": r["control_id"], "title": r["title"] or "",
|
||||||
|
"embed_text": (r["title"] or "") + ". " + " ".join(parse_req(r["requirements"])[:2])}
|
||||||
|
for r in rows]
|
||||||
|
print(f"scope={scope}: {len(items)} controls")
|
||||||
|
|
||||||
|
cache = os.path.join(outdir, f"{scope}_vecs.pkl")
|
||||||
|
if os.path.exists(cache):
|
||||||
|
vecs = pickle.load(open(cache, "rb"))
|
||||||
|
print(f"embeddings from cache ({len(vecs)})")
|
||||||
|
else:
|
||||||
|
vecs = await _embed_texts([it["embed_text"] for it in items])
|
||||||
|
pickle.dump(vecs, open(cache, "wb"))
|
||||||
|
print(f"embeddings fresh+cached ({len(vecs)})")
|
||||||
|
|
||||||
|
micro = greedy_cluster(vecs, micro_thr)
|
||||||
|
print(f"pass-1 (micro-thr={micro_thr}): {len(items)} → {len(micro)} micro-clusters")
|
||||||
|
out = [{"micro_id": i, "size": len(c["members"]), "member_indices": c["members"],
|
||||||
|
"control_ids": [items[j]["control_id"] for j in c["members"]],
|
||||||
|
"titles": [items[j]["title"] for j in c["members"][:6]]}
|
||||||
|
for i, c in enumerate(micro)]
|
||||||
|
path = os.path.join(outdir, f"{scope}_micro_clusters.json")
|
||||||
|
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"written: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--scope", default="sbom")
|
||||||
|
ap.add_argument("--patterns", default="", help="comma-separated SQL ILIKE patterns (overrides --scope)")
|
||||||
|
ap.add_argument("--micro-thr", type=float, default=0.78)
|
||||||
|
ap.add_argument("--outdir", default="/tmp")
|
||||||
|
a = ap.parse_args()
|
||||||
|
patterns = [p for p in a.patterns.split(",") if p] or SCOPES[a.scope]
|
||||||
|
asyncio.run(run(a.scope, patterns, a.micro_thr, a.outdir))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Stufe 3 — LLM-Synthese: REVIEW UNITS → Obligation Registry (Schema obligation_registry_v1).
|
||||||
|
Geschärfter Prompt: kleinste Menge regulatorisch UNTERSCHIEDLICHER Obligations. Harte Tier-
|
||||||
|
Regel in Code erzwungen. Provenance pro Obligation. ANTHROPIC_API_KEY aus ENV (nie hartcodiert).
|
||||||
|
Große Calls → STREAMING (SDK blockt non-streaming >10min).
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=… python3 scripts/obligation_discovery/synthesize_obligations.py \
|
||||||
|
--units /tmp/auth_review_units.json --regulation CRA --theme "Authentisierung" --out /tmp/auth_registry.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from _core import SEMANTIC_EDGE_TYPES
|
||||||
|
|
||||||
|
SYS = """Du bist Knowledge Engineer und baust eine LEGAL OBLIGATION REGISTRY fuer __REGULATION__
|
||||||
|
(Thema: __THEME__). Input: REVIEW UNITS (algorithmisch vor-gebuendelte Control-Gruppen), jede
|
||||||
|
kann MEHRERE unterschiedliche Pflichten enthalten.
|
||||||
|
|
||||||
|
AUFGABE: Zerlege die Review Units in die KLEINSTE MENGE regulatorisch UNTERSCHIEDLICHER Legal
|
||||||
|
Obligations. Regeln:
|
||||||
|
- Nichts zusammenfuehren nur wegen aehnlicher Woerter.
|
||||||
|
- Unterschiedliche Rechtsgrundlage => unterschiedliche Obligation.
|
||||||
|
- Unterschiedliche Applicability => unterschiedliche Obligation.
|
||||||
|
- Unterschiedliche Evidence-Facette (governance/capability/evidence) => GLEICHE Obligation, andere Facette.
|
||||||
|
- Unterschiedliche Umsetzung (NIST/OWASP/ISO/BSI) => guidance_basis, KEINE neue Obligation.
|
||||||
|
- Gleiche Pflicht ueber mehrere Review Units => EINE Obligation (mehrere member_review_units).
|
||||||
|
|
||||||
|
Gib AUSSCHLIESSLICH JSON aus:
|
||||||
|
{"obligations":[{"id":"snake_case","name":"","description":"","tier":"LEGAL_MINIMUM|BEST_PRACTICE|IMPLEMENTATION_GUIDANCE|EVIDENCE","applicability":"universal|conditional:<pred>|domain:<x>","evidence_facets":{"governance":true,"capability":true,"evidence":false},"source_role":"LEGAL_BASIS|GUIDANCE|EVIDENCE|IMPLEMENTATION","legal_basis":[{"source":"__REGULATION__","anchor":"","citation":""}],"guidance_basis":[{"source":"NIST|OWASP|ISO|BSI","anchor":"","role":"best_practice"}],"subdomain":"","member_review_units":["M0"],"source_meta_cluster":"M0","discovery_confidence":0.9}],
|
||||||
|
"relationships":[{"type":"depends_on|supports|produces_evidence_for|implements|derived_from","from":"id","to":"id","note":""},{"type":"out_of_scope","review_units":["M0"],"note":""}]}
|
||||||
|
|
||||||
|
HARTE REGELN:
|
||||||
|
- tier=LEGAL_MINIMUM NUR mit legal_basis (Primaerrecht). Sonst tier=BEST_PRACTICE, legal_basis=[].
|
||||||
|
- legal_basis NUR Primaerrecht der Regulierung; NIST/OWASP/ISO/BSI => guidance_basis.
|
||||||
|
- relationships SPARSAM, gerichtet, nur klar belegbar.
|
||||||
|
- out_of_scope: Review Units, die NICHT zum Thema gehoeren (andere Regulierung/Domaene)."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_user(units: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for u in units:
|
||||||
|
t = " | ".join(str(x)[:46] for x in u.get("sample_titles", [])[:6])
|
||||||
|
lines.append(f"{u['review_unit_id']} (controls={u['n_controls']}): {t}")
|
||||||
|
return "Review Units:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(units, regulation, theme, model):
|
||||||
|
import anthropic
|
||||||
|
key = os.environ["ANTHROPIC_API_KEY"]
|
||||||
|
sys = SYS.replace("__REGULATION__", regulation).replace("__THEME__", theme)
|
||||||
|
client = anthropic.Anthropic(api_key=key)
|
||||||
|
with client.messages.stream(model=model, max_tokens=24000, system=sys,
|
||||||
|
messages=[{"role": "user", "content": build_user(units)}]) as st:
|
||||||
|
msg = st.get_final_message()
|
||||||
|
txt = msg.content[0].text
|
||||||
|
m = re.search(r"\{.*\}", txt, re.DOTALL)
|
||||||
|
return json.loads(m.group(0) if m else txt)
|
||||||
|
|
||||||
|
|
||||||
|
def post_process(data, units, regulation, model):
|
||||||
|
cmap = {u["review_unit_id"]: u["control_ids"] for u in units}
|
||||||
|
size = {u["review_unit_id"]: u["n_controls"] for u in units}
|
||||||
|
obls = []
|
||||||
|
for o in data.get("obligations", []):
|
||||||
|
rus = [r for r in (o.get("member_review_units") or []) if r in cmap]
|
||||||
|
members = sorted({c for ru in rus for c in cmap[ru]})
|
||||||
|
lb = o.get("legal_basis") or []
|
||||||
|
tier, review = o.get("tier", "BEST_PRACTICE"), "draft"
|
||||||
|
if tier == "LEGAL_MINIMUM" and not lb:
|
||||||
|
tier, review = "BEST_PRACTICE", "needs_legal_basis"
|
||||||
|
smc = o.get("source_meta_cluster") or (rus[0] if rus else "")
|
||||||
|
obls.append({
|
||||||
|
"id": o["id"], "name": o.get("name", ""), "description": o.get("description", ""),
|
||||||
|
"tier": tier, "subdomain": o.get("subdomain", ""),
|
||||||
|
"applicability": o.get("applicability", "universal"),
|
||||||
|
"evidence_facets": o.get("evidence_facets", {}), "source_role": o.get("source_role", ""),
|
||||||
|
"legal_basis": lb, "guidance_basis": o.get("guidance_basis") or [],
|
||||||
|
"member_review_units": rus, "member_controls": members, "member_count": len(members),
|
||||||
|
"relationships": [], "citation_anchor_ids": [], "citation_status": "pending_span_anchor",
|
||||||
|
"review_status": review,
|
||||||
|
"provenance": {"discovery_confidence": o.get("discovery_confidence"),
|
||||||
|
"source_meta_cluster": smc, "cluster_size": size.get(smc),
|
||||||
|
"llm_model": model, "synthesis_version": "v1"}})
|
||||||
|
rels = [r for r in data.get("relationships", [])
|
||||||
|
if r.get("type") in SEMANTIC_EDGE_TYPES or r.get("type") == "out_of_scope"]
|
||||||
|
return {"schema_version": "obligation_registry_v1", "regulation": regulation,
|
||||||
|
"generated_by": f"obligation_discovery/{model}", "synthesis_version": "v1",
|
||||||
|
"citation_status": "pending_span_anchor", "obligations": obls, "relationships": rels}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--units", required=True)
|
||||||
|
ap.add_argument("--regulation", default="CRA")
|
||||||
|
ap.add_argument("--theme", default="")
|
||||||
|
ap.add_argument("--model", default="claude-opus-4-8")
|
||||||
|
ap.add_argument("--out", required=True)
|
||||||
|
a = ap.parse_args()
|
||||||
|
units = json.load(open(a.units, encoding="utf-8"))
|
||||||
|
data = synthesize(units, a.regulation, a.theme, a.model)
|
||||||
|
reg = post_process(data, units, a.regulation, a.model)
|
||||||
|
json.dump(reg, open(a.out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||||
|
o = reg["obligations"]
|
||||||
|
print(f"obligations: {len(o)} | tier: {dict(Counter(x['tier'] for x in o))}")
|
||||||
|
print(f"written: {a.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Stufe 4 — Validierung: belastbare Registry-Checks (kein LLM/Key).
|
||||||
|
Prüft die User-Regeln: LEGAL_MINIMUM braucht legal_basis · member_controls vollständig ·
|
||||||
|
out_of_scope separat · >8-Obligations/Review-Unit-Warnung. Exit-Code 1 bei hartem Fehler.
|
||||||
|
|
||||||
|
python3 scripts/obligation_discovery/validate_registry.py obligations/cra_authentication.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from _core import validate_registry
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("registry")
|
||||||
|
a = ap.parse_args()
|
||||||
|
reg = json.load(open(a.registry, encoding="utf-8"))
|
||||||
|
v = validate_registry(reg)
|
||||||
|
print(f"=== validate {a.registry} ===")
|
||||||
|
print(f" obligations: {v['obligations']}")
|
||||||
|
print(f" LEGAL_MINIMUM: {v['legal_minimum']}")
|
||||||
|
print(f" LM ohne legal_basis: {v['lm_without_legal_basis'] or 'keine'}")
|
||||||
|
print(f" member_controls leer: {v['empty_member_controls'] or 'keine'}")
|
||||||
|
print(f" >8 Obligations/Review-Unit: {v['over8_per_review_unit'] or 'keine'}")
|
||||||
|
print(f" out_of_scope: {v['out_of_scope']}")
|
||||||
|
print(f" semantische Kanten: {v['semantic_edges']}")
|
||||||
|
print(f" PASSED: {v['passed']}")
|
||||||
|
sys.exit(0 if v["passed"] else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user