Files
breakpilot-compliance/backend-compliance/compliance/services/cra_applicability.py
T
Benjamin Admin 62fafaaec5 feat(cra): MaschinenVO-Gefährdungs-Ableitung + Cyber-Safety-Brücke
3-Tier-MaschinenVO-Verdict (direkt / sicherheitsrelevant / nicht relevant) aus
Personengefährdungs-Signal: eine Komponente ist keine Maschine, aber wenn ihre
Funktion bei Fehler ODER Manipulation Personen gefaehrden kann (Bewegung, Laser/
Auge, Kraft, Temperatur, elektrisch), ist sie sicherheitsrelevant — Pflicht
trifft den Maschinenbauer, Zulieferer liefert Nachweise, und ein Cyber-Angriff
kann die Sicherheitsfunktion aushebeln (Cyber-Safety-Bruecke). OWIS-mit-Laser
landet so korrekt als 'sicherheitsrelevante Komponente'. Engine + /readiness
additiv; Frontend: Gefährdungs-Frage + -Typen, MaschinenVO-Ergebnisblock.
Presets aktualisiert (OWIS: Laser+Bewegung, Zwick: Bewegung). 22 Tests gruen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 18:48:52 +02:00

170 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Neutral CRA applicability verdict for the 'Eingangstür' readiness check.
Separates LEGAL obligation from MARKET pull — the distinction SME manufacturers
actually care about: a product may not yet be legally in CRA scope, but its B2B
customers (machine/plant builders) will demand CRA evidence anyway.
Key rule: not the development date but the PLACING ON THE MARKET (Inverkehrbringen)
decides. A legacy product (e.g. designed 2019) still placed on the market after
the CRA cutoff must be conformant.
Pure + deterministic: no DB, no LLM, no network. Reuses the existing Annex III/IV
classifier (passed in as `cra_class`) — this is the verdict layer on top, not a
fourth classifier.
"""
CRA_CUTOFF = "2027-12-11" # CRA main obligations apply to products placed on the market from here
# Verdict tiers
ZWINGEND = "zwingend"
RATSAM = "ratsam"
NICHT_BETROFFEN = "nicht_betroffen"
_TIER_LABEL = {
ZWINGEND: "CRA zwingend (Rechtspflicht)",
RATSAM: "CRA nicht zwingend, aber dringend ratsam",
NICHT_BETROFFEN: "CRA nicht betroffen",
}
# Producer archetypes — drive default assumptions + verdict emphasis.
COMPONENT = "component" # Zulieferteil/Komponente (B2B), z. B. OWIS PS90+
END_DEVICE = "end_device" # Endgerät
MACHINE_INTEGRATOR = "machine_integrator" # Anlagen-/Maschinenbauer — Internet/OTA meist gegeben
SOFTWARE_APP = "software_app" # App/Frontend/Cloud, keine Hardware
PRODUCER_TYPES = (COMPONENT, END_DEVICE, MACHINE_INTEGRATOR, SOFTWARE_APP)
# Standard CRA evidence the cyber technical file needs (Annex I/II + Art. 13/14).
EVIDENCE_ITEMS = [
{"key": "sbom", "label": "Software-Stückliste (SBOM)"},
{"key": "vdp", "label": "Vulnerability-Disclosure-Policy / Security-Kontakt"},
{"key": "patch_process", "label": "Patch-/Update-Prozess (signiert)"},
{"key": "support_lifecycle", "label": "Support-Zeitraum / Lifecycle-Zusage"},
{"key": "threat_model", "label": "Threat Model / Cyber-Risikobeurteilung"},
{"key": "security_logging", "label": "Security-Logging"},
{"key": "auth_concept", "label": "Authentisierung / Passwortkonzept (Netzwerk)"},
{"key": "incident_process", "label": "Incident-/Meldeprozess (Art. 14, 24/72h)"},
]
_EVIDENCE_KEYS = {e["key"] for e in EVIDENCE_ITEMS}
def in_scope(cra_class: str) -> bool:
"""A product is in CRA scope once it has any digital element / Annex match."""
return (cra_class or "").upper() not in ("", "NOT_IN_SCOPE")
def compute_verdict(
cra_class: str,
placed_on_market_after_cutoff,
producer_type: str = "",
customers_request: bool = False,
) -> dict:
"""Neutral 3-tier verdict. `placed_on_market_after_cutoff`: True/False/None
(None = unknown → assumed True, conservative: most products keep being sold)."""
scope = in_scope(cra_class)
after_cutoff = True if placed_on_market_after_cutoff is None else bool(placed_on_market_after_cutoff)
# Components are inherently subject to downstream demand even absent an explicit signal.
market_pull = bool(customers_request) or producer_type == COMPONENT
reasons: list = []
if not scope:
tier = NICHT_BETROFFEN
reasons.append("Keine digitalen Elemente / keine CRA-Kategorie erkannt.")
if market_pull:
reasons.append("Hinweis: Kunden könnten dennoch CRA-Nachweise anfragen.")
elif after_cutoff:
tier = ZWINGEND
reasons.append(
f"Produkt mit digitalen Elementen und Inverkehrbringen ab {CRA_CUTOFF} → Rechtspflicht."
)
reasons.append("Maßgeblich ist das Inverkehrbringen, nicht der Entwicklungszeitpunkt.")
else:
tier = RATSAM
reasons.append("Produkt mit digitalen Elementen, aber kein Inverkehrbringen im CRA-Geltungszeitraum.")
reasons.append(f"Sobald ab dem {CRA_CUTOFF} (weiter) in Verkehr gebracht wird, wird CRA zwingend.")
if market_pull and tier != NICHT_BETROFFEN:
reasons.append("Markt-Druck: B2B-Kunden (Maschinen-/Anlagenbauer) fordern CRA-Nachweise zur Komponente.")
return {
"tier": tier,
"label": _TIER_LABEL[tier],
"in_scope": scope,
"market_pull": market_pull,
"cra_class": (cra_class or "").upper(),
"cutoff": CRA_CUTOFF,
"placed_on_market_after_cutoff": after_cutoff,
"reasons": reasons,
}
def maturity(provided_evidence_keys) -> dict:
"""Reifegrad over the standard CRA evidence checklist."""
have = {k for k in (provided_evidence_keys or []) if k in _EVIDENCE_KEYS}
present = [e for e in EVIDENCE_ITEMS if e["key"] in have]
missing = [e for e in EVIDENCE_ITEMS if e["key"] not in have]
total = len(EVIDENCE_ITEMS)
pct = round(100.0 * len(present) / total) if total else 0
return {"pct": pct, "present": present, "missing": missing, "total": total}
# --- Machinery Regulation (2023/1230) applicability — person-safety axis ---
# A bare control component is not "machinery" itself, but if its function can
# endanger persons (movement, laser, stored energy …) it is safety-relevant: the
# duty hits the machine builder, the component maker supplies evidence, and a
# cyber compromise can defeat a safety function (the CRA × MaschinenVO bridge).
HAZARD_TYPES = [
{"key": "movement_crush", "label": "Bewegung / Quetschen"},
{"key": "laser_radiation", "label": "Laser / Strahlung (Auge)"},
{"key": "force_energy", "label": "Kraft / gespeicherte Energie"},
{"key": "temperature", "label": "Temperatur / Hitze"},
{"key": "electrical", "label": "Elektrische Gefährdung"},
]
_HAZARD_KEYS = {h["key"]: h["label"] for h in HAZARD_TYPES}
MV_DIREKT = "direkt"
MV_SICHERHEITSRELEVANT = "sicherheitsrelevant"
MV_NICHT = "nicht_relevant"
_MV_LABEL = {
MV_DIREKT: "Maschinenverordnung direkt betroffen",
MV_SICHERHEITSRELEVANT: "Sicherheitsrelevante Komponente (indirekt)",
MV_NICHT: "Maschinenverordnung nicht relevant",
}
def compute_machinery_verdict(
producer_type: str = "",
is_machinery: bool = False,
safety_relevant: bool = False,
hazard_types=None,
is_safety_component: bool = False,
) -> dict:
"""3-tier Machinery-Regulation verdict + cyber→safety bridge flag.
`safety_relevant`: can the function endanger persons on fault OR manipulation?"""
hazards = [{"key": k, "label": _HAZARD_KEYS[k]} for k in (hazard_types or []) if k in _HAZARD_KEYS]
direct = bool(is_machinery) or producer_type == MACHINE_INTEGRATOR or bool(is_safety_component)
reasons: list = []
if direct:
tier = MV_DIREKT
reasons.append("Maschine/Anlage bzw. Sicherheitsbauteil → MaschinenVO-Pflichten direkt.")
elif safety_relevant or hazards:
tier = MV_SICHERHEITSRELEVANT
reasons.append("Komponente, deren Funktion bei Fehler oder Manipulation Personen gefährden kann.")
reasons.append("MaschinenVO-Pflicht trifft den Maschinenbauer; als Zulieferer liefern Sie Sicherheits-/Cyber-Nachweise zu.")
else:
tier = MV_NICHT
reasons.append("Keine Personengefährdung erkennbar.")
bridge = tier in (MV_DIREKT, MV_SICHERHEITSRELEVANT)
if bridge:
reasons.append(
"Cyber-trifft-Safety: Ein Angriff auf die Steuerung kann eine Sicherheitsfunktion aushebeln "
"(Geschwindigkeit/Position/Verriegelung) → Personenschaden."
)
return {
"tier": tier,
"label": _MV_LABEL[tier],
"hazards": hazards,
"cyber_safety_bridge": bridge,
"reasons": reasons,
}