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 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
|
||||
RUN adduser -D -u 1000 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)
|
||||
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
|
||||
allV2Regs, err := ucca.LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
@@ -201,7 +207,8 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler,
|
||||
complianceGraphHandlers)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func registerRoutes(
|
||||
maximizerHandlers *handlers.MaximizerHandlers,
|
||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||
useCaseHandler *handlers.UseCaseHandler,
|
||||
complianceGraphHandlers *handlers.ComplianceGraphHandlers,
|
||||
) {
|
||||
v1 := router.Group("/sdk/v1")
|
||||
{
|
||||
@@ -54,6 +55,7 @@ func registerRoutes(
|
||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||
registerUseCaseRoutes(v1, useCaseHandler)
|
||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||
complianceGraphHandlers.RegisterRoutes(v1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ var domains = []domainDef{
|
||||
{"data_protection",
|
||||
[]string{"DSGVO", "GDPR", "BDSG", "EDPB", "DSK", "BfDI", "BayLfD", "DPF"},
|
||||
[]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",
|
||||
[]string{"CRA", "NIS2", "NIS-2", "ENISA", "DORA", "EUCC"},
|
||||
[]string{"security update", "sicherheitsupdate", "sicherheitsaktualisierung", "schwachstelle", "sbom",
|
||||
@@ -126,6 +126,16 @@ var domains = []domainDef{
|
||||
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 {
|
||||
ql := strings.ToLower(query)
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
domainMatchGain = 0.15
|
||||
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
|
||||
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
|
||||
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
|
||||
@@ -102,6 +103,15 @@ func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign
|
||||
if qDomain == "data_protection" && scopeClass(r) == "law_enforcement" {
|
||||
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) {
|
||||
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) {
|
||||
in := []LegalSearchResult{
|
||||
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:
|
||||
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 = {
|
||||
"layer_0_field_hits": len(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),
|
||||
"gate_excluded": len(organizational),
|
||||
"organizational_checklist": organizational,
|
||||
"obligation_shadow": obligation_shadow,
|
||||
}
|
||||
logger.info("dse v3 telemetry: %s", 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:
|
||||
- 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:
|
||||
- AI Compliance SDK:
|
||||
- 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