feat(cra): Maßnahmen-Provenienz + Lizenzklasse je Normquelle

Jede Normreferenz einer Maßnahme wird lizenzklassifiziert (eu_law /
public_domain / open / paid_reference) — paid-reference-Normen werden nur als
Verweis geführt, nie im Text gespeichert (idea/expression). Kuratierte
Maßnahmen tragen Tier 'core', KI-/Fallback-Maßnahmen 'review' (indikativ).
Frontend zeigt Quellen-Badges + "indikativ"-Kennzeichnung. Methodik in
docs-src/development/mapping-methodology.md (Szenario C, Due-Diligence).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-16 10:10:20 +02:00
parent 6c619ecc42
commit 7a4f086151
8 changed files with 204 additions and 3 deletions
@@ -48,8 +48,23 @@ function FindingDetail({ f, measuresById }: { f: CRAFinding; measuresById: Recor
return ( return (
<li key={mid} className="text-sm text-gray-700 dark:text-gray-200"> <li key={mid} className="text-sm text-gray-700 dark:text-gray-200">
<span className="font-medium">{m ? m.name : mid}</span> <span className="font-medium">{m ? m.name : mid}</span>
{m?.tier === 'review' && (
<span title="KI-vorgeschlagen mit DSB/Auditor verifizieren" className="ml-1 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 px-1 py-0.5 text-[11px]">indikativ</span>
)}
{m?.description ? <span className="text-gray-500"> — {m.description}</span> : null} {m?.description ? <span className="text-gray-500"> — {m.description}</span> : null}
{m?.norm_refs?.length ? <span className="text-gray-400"> · {m.norm_refs.join(', ')}</span> : null} {m?.norm_sources?.length ? (
<span className="mt-1 flex flex-wrap gap-1">
{m.norm_sources.map((s) => (
<span key={s.ref} title={s.label}
className={`rounded px-1.5 py-0.5 text-[11px] ${
s.license_class === 'paid_reference'
? 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'}`}>
{s.ref}{s.license_class === 'paid_reference' ? ' · nur Verweis' : ''}
</span>
))}
</span>
) : (m?.norm_refs?.length ? <span className="text-gray-400"> · {m.norm_refs.join(', ')}</span> : null)}
</li> </li>
) )
})} })}
@@ -72,6 +72,9 @@ function merge(live: any): CRADemo {
name: om.name || DEMO_SCENARIO.open_measures.find((d) => d.id === om.id)?.name || om.id, name: om.name || DEMO_SCENARIO.open_measures.find((d) => d.id === om.id)?.name || om.id,
description: om.description || '', description: om.description || '',
norm_refs: om.norm_refs || [], norm_refs: om.norm_refs || [],
norm_sources: om.norm_sources || [],
tier: om.tier,
provenance: om.provenance,
})) }))
return { return {
@@ -39,11 +39,16 @@ export interface CRAFinding {
objective?: string objective?: string
} }
export interface NormSource { ref: string; license_class: string; label: string }
export interface Measure { export interface Measure {
id: string id: string
name: string name: string
description: string description: string
norm_refs: string[] norm_refs: string[]
norm_sources?: NormSource[] // license-classified refs (provenance)
tier?: string // core (validated) | review (indicative)
provenance?: string
} }
export interface CrossLink { export interface CrossLink {
@@ -268,6 +268,8 @@ SEVERITY_WEIGHT = {
import json as _json import json as _json
import os as _os import os as _os
from compliance.data.norm_sources import classify_refs as _classify_refs
MEASURE_DETAILS: dict = {} MEASURE_DETAILS: dict = {}
try: try:
@@ -275,6 +277,12 @@ try:
with open(_MEAS_PATH, encoding="utf-8") as _fh: with open(_MEAS_PATH, encoding="utf-8") as _fh:
_curated = _json.load(_fh) _curated = _json.load(_fh)
MEASURE_DETAILS = {m["id"]: m for m in _curated if m.get("id")} MEASURE_DETAILS = {m["id"]: m for m in _curated if m.get("id")}
# Provenance: curated measures are expert/standards-based ('core' tier); each
# norm_ref is license-classified (law/public/open vs paid-reference-only).
for _m in MEASURE_DETAILS.values():
_m.setdefault("tier", "core")
_m.setdefault("provenance", "curated_expert_standards")
_m["norm_sources"] = _classify_refs(_m.get("norm_refs", []))
for _m in _curated: for _m in _curated:
if _m.get("id") and _m.get("name"): if _m.get("id") and _m.get("name"):
MEASURES[_m["id"]] = _m["name"] MEASURES[_m["id"]] = _m["name"]
@@ -0,0 +1,55 @@
"""Provenance / license classification for norm references.
Encodes the BreakPilot mapping methodology (idea/expression): a *reference* to
where a topic sits in a standard is a fact and citable; the paid normative *text*
is never stored or reproduced. This classifier marks which sources are freely
usable (EU law, US-gov public domain, open licenses) vs. paid standards we may
only REFERENCE by clause/control ID.
See docs-src/development/mapping-methodology.md.
"""
LAW = "eu_law" # EU legislation — public, reproducible (EUR-Lex)
PUBLIC_DOMAIN = "public_domain" # e.g. NIST (US gov work) — reproducible
OPEN = "open" # OWASP (CC), ETSI EN 303 645, BSI — freely available
PAID_REFERENCE = "paid_reference" # ISO/IEC/EN/DIN — REFERENCE ONLY, no text stored
LABEL = {
LAW: "EU-Recht (frei)",
PUBLIC_DOMAIN: "Public Domain (frei)",
OPEN: "offen lizenziert",
PAID_REFERENCE: "kostenpflichtige Norm — nur Verweis",
}
_LAW = ("2024/2847", "2023/1230", "verordnung (eu)", "maschinenverordnung", "(cra)",
"anhang", "nis2", "nis-2", " art. ", "dsgvo", "2016/679", "2022/2555")
_PUBLIC = ("nist", "ntia", "nvd", "cisa")
_OPEN = ("owasp", "slsa", "etsi en 303 645", "bsi", "cyclonedx", "spdx",
"nist privacy framework")
_PAID = ("iso", "iec", "din", "en iso", "62443", "27002", "27035", "29147",
"30111", "15408", "18045", "13849", "13850", "13857", "14119", "14120",
"61496", "61800", "62061", "60204", "82079", "15066", "10218", "13855", "62061")
def classify_norm_ref(ref: str) -> str:
r = (ref or "").lower()
# NIST Privacy Framework is open-ish; keep public-domain check after open guard.
if "nist privacy" in r:
return OPEN
if any(k in r for k in _LAW):
return LAW
if any(k in r for k in _PUBLIC):
return PUBLIC_DOMAIN
if any(k in r for k in _OPEN):
return OPEN
if any(k in r for k in _PAID):
return PAID_REFERENCE
return PAID_REFERENCE # conservative default: treat unknown as reference-only
def classify_refs(refs) -> list:
"""[{ref, license_class, label}] for each norm reference."""
return [
{"ref": r, "license_class": (lc := classify_norm_ref(r)), "label": LABEL[lc]}
for r in (refs or [])
]
@@ -24,8 +24,10 @@ def _measure_obj(mid: str) -> dict:
d = MEASURE_DETAILS.get(mid) d = MEASURE_DETAILS.get(mid)
if d: if d:
return {"id": mid, "name": d.get("name", ""), "description": d.get("description", ""), return {"id": mid, "name": d.get("name", ""), "description": d.get("description", ""),
"norm_refs": d.get("norm_refs", [])} "norm_refs": d.get("norm_refs", []), "norm_sources": d.get("norm_sources", []),
return {"id": mid, "name": MEASURES.get(mid, ""), "description": MEASURES.get(mid, ""), "norm_refs": []} "tier": d.get("tier", "core"), "provenance": d.get("provenance", "")}
return {"id": mid, "name": MEASURES.get(mid, ""), "description": MEASURES.get(mid, ""),
"norm_refs": [], "norm_sources": [], "tier": "review", "provenance": ""}
_REQ_INDEX = {r["req_id"]: r for r in ANNEX_I_REQUIREMENTS} _REQ_INDEX = {r["req_id"]: r for r in ANNEX_I_REQUIREMENTS}
@@ -0,0 +1,38 @@
"""License/provenance classification of norm references — encodes the line between
freely-usable sources and paid standards we may only reference (not reproduce)."""
from compliance.data.norm_sources import (
LAW, OPEN, PAID_REFERENCE, PUBLIC_DOMAIN, classify_norm_ref,
)
from compliance.api.cra_annex_i_data import MEASURE_DETAILS
def test_eu_law_is_reproducible_class():
assert classify_norm_ref("Verordnung (EU) 2024/2847 (CRA), Anhang I") == LAW
assert classify_norm_ref("MaschinenVO Anhang III") == LAW
assert classify_norm_ref("NIS2 Art. 21") == LAW
def test_nist_is_public_domain():
assert classify_norm_ref("NIST SP 800-53: IA-5") == PUBLIC_DOMAIN
assert classify_norm_ref("NIST SP 800-218") == PUBLIC_DOMAIN
def test_open_licensed():
assert classify_norm_ref("OWASP ASVS V3") == OPEN
assert classify_norm_ref("ETSI EN 303 645") == OPEN
def test_paid_standards_reference_only():
assert classify_norm_ref("IEC 62443-4-1") == PAID_REFERENCE
assert classify_norm_ref("ISO/IEC 27002") == PAID_REFERENCE
assert classify_norm_ref("EN ISO 13849-1") == PAID_REFERENCE
def test_unknown_defaults_conservative():
assert classify_norm_ref("Irgendein Hausstandard XY") == PAID_REFERENCE
def test_curated_measures_carry_provenance():
m = MEASURE_DETAILS["M540"]
assert m.get("tier") == "core"
assert m.get("norm_sources") and all("license_class" in s for s in m["norm_sources"])
@@ -0,0 +1,75 @@
# BreakPilot Mapping Methodology
> Interne Methodik-Doku für Due Diligence, Kunden-, Anwalts- und Lizenzfragen.
> Stand 2026-06-16. Kern: **Wir modellieren gemeinsame Konzepte (Szenario C),
> nicht Normstrukturen.**
## Grundprinzip: Idee vs. Ausdruck
Urheberrecht schützt die **konkrete Formulierung** (den Normtext), **nicht** Ideen,
Fakten oder Themen. Daraus folgt unsere Linie:
- **Normtext** wird **nicht gespeichert** und **nicht reproduziert**.
- **Fundstellen** (Klausel-/Control-IDs, Artikel-Nummern, Titel) sind **Fakten** und
als Quellenhinweis zitierbar — wie ein Buch-Kapitelverweis.
- **Mappings** sind **Tatsachenaussagen über Bezüge** ("dieselbe Anforderungsidee
taucht in CRA, NIST, IEC 62443 auf").
## Unser Modell ist Szenario C, nicht A oder B
```
Cybersecurity-/Safety-Realität
Master Control / Maßnahme ← eigenständig formuliert (unser Werk)
Normen referenzieren darauf ← CRA · MaschinenVO · NIST · OWASP · ETSI · BSI · IEC 62443 · ENISA
```
Wir behaupten **nicht** „die Norm sagt X" (mit Normwortlaut), sondern „diese
Maßnahme adressiert dieselbe Anforderungsidee, die auch in mehreren Standards
auftaucht". Je mehr Quellen auf dasselbe Konzept verweisen (Least Privilege,
Logging, Updates, Authentisierung …), desto klarer ist es ein **eigenständiges
Wissensmodell**, kein Norm-Derivat.
Vergleichbar mit Beck/Juris/Wolters: nicht „§ 823 BGB sagt …", sondern „nach
herrschender Meinung folgt daraus …" — eigene redaktionelle Leistung.
## Quellen-Lizenzklassen (siehe `compliance/data/norm_sources.py`)
| Klasse | Beispiele | Umgang |
|---|---|---|
| `eu_law` | CRA (2024/2847), MaschinenVO (2023/1230), NIS2, DSGVO | EU-Recht, öffentlich — reproduzierbar (EUR-Lex) |
| `public_domain` | NIST SP 800-53/218, NIST CSF, NIST OLIR, NTIA, CISA | US-Gov, gemeinfrei — reproduzierbar |
| `open` | OWASP (CC), ETSI EN 303 645, BSI IT-Grundschutz, SPDX, CycloneDX | offen lizenziert — mit Quellenangabe nutzbar |
| `paid_reference` | ISO/IEC, EN/DIN, IEC 62443, ISO 27002, ISO/IEC 15408, EN ISO 13849 … | **nur Verweis** auf Klausel-/Control-ID — KEIN Text gespeichert |
Crosswalk-Wissen stammt überwiegend aus **publizierten, autoritativen Quellen**
(NIST OLIR = öffentliche Crosswalk-Datenbank, ENISA CRA-Mapping, Norm-Annexe,
EU-Normungsauftrag) plus gemeinfreien Inhalten — nicht aus kostenpflichtigem
Normtext.
## Rolle der KI
KI ist **Skalierer**, nicht Quelle: sie schlägt semantische Zuordnungen vor und
normalisiert; das Fundament sind publizierte Crosswalks + gemeinfreie Quellen, und
die tragenden Zuordnungen werden expertengeprüft. Jede Zuordnung trägt daher
**Provenienz + Tier**: `core` (belegt/expertengeprüft) vs. `review`
(KI-vorgeschlagen, indikativ). Indikative Zuordnungen werden als solche gekennzeichnet
("mit DSB/Auditor verifizieren").
## Die 6 Methodik-Aussagen (Kurzform)
1. Normtexte werden **nicht gespeichert**.
2. Normtexte werden **nicht reproduziert**.
3. Master Controls / Maßnahmen sind **eigenständig formuliert**.
4. Mappings entstehen als **semantische Zuordnung** (Experte/KI), mit Provenienz/Tier.
5. Die Plattform **ersetzt keine Norm** und ermöglicht **keine Rekonstruktion** der Norm.
6. Normreferenzen dienen ausschließlich als **Quellenhinweis** (Klausel-/Control-ID).
## Roter Faden / kritischer Bereich
Risiko ist **nicht** das Mapping, sondern das **Aufkumulieren von Normstruktur**
(viele Kapitelüberschriften + Original-Requirement-IDs + Originaltext + Tabellen),
sodass sich die Originalnorm rekonstruieren ließe. Solange wir auf der Ebene
**Master Control → Maßnahme → Evidenz → Referenzen** bleiben (Szenario C), ist die
Position robust.