diff --git a/admin-compliance/app/sdk/cra/_components/ReadinessCheck.tsx b/admin-compliance/app/sdk/cra/_components/ReadinessCheck.tsx index 86227ebd..5352af7c 100644 --- a/admin-compliance/app/sdk/cra/_components/ReadinessCheck.tsx +++ b/admin-compliance/app/sdk/cra/_components/ReadinessCheck.tsx @@ -30,6 +30,13 @@ const EVIDENCE = [ { k: 'auth_concept', label: 'Auth-/Passwortkonzept' }, { k: 'incident_process', label: 'Incident-/Meldeprozess' }, ] +const HAZARDS = [ + { k: 'movement_crush', label: 'Bewegung / Quetschen' }, + { k: 'laser_radiation', label: 'Laser / Strahlung (Auge)' }, + { k: 'force_energy', label: 'Kraft / Energie' }, + { k: 'temperature', label: 'Temperatur / Hitze' }, + { k: 'electrical', label: 'Elektrisch' }, +] export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => void }) { const [intendedUse, setIntendedUse] = useState('') @@ -37,6 +44,8 @@ export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => vo const [flags, setFlags] = useState>({}) const [after2027, setAfter2027] = useState(true) const [customersAsk, setCustomersAsk] = useState(false) + const [safetyRelevant, setSafetyRelevant] = useState(false) + const [hazards, setHazards] = useState>({}) const [evidence, setEvidence] = useState>({}) const [digitalElements, setDigitalElements] = useState([]) const [result, setResult] = useState(null) @@ -44,6 +53,7 @@ export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => vo const toggle = (k: string) => setFlags((f) => ({ ...f, [k]: !f[k] })) const toggleEv = (k: string) => setEvidence((e) => ({ ...e, [k]: !e[k] })) + const toggleHz = (k: string) => setHazards((h) => ({ ...h, [k]: !h[k] })) const applyPreset = (p: ReadinessPreset) => { setIntendedUse(p.intended_use) @@ -51,6 +61,8 @@ export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => vo setFlags({ ...p.flags }) setAfter2027(p.placed_on_market_after_2027) setCustomersAsk(p.customers_request_cra_evidence) + setSafetyRelevant(p.safety_relevant) + setHazards(Object.fromEntries(p.hazard_types.map((k) => [k, true]))) setEvidence(Object.fromEntries(p.provided_evidence.map((k) => [k, true]))) setDigitalElements(p.digital_elements) setResult(null) @@ -68,6 +80,8 @@ export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => vo customers_request_cra_evidence: customersAsk, provided_evidence: Object.keys(evidence).filter((k) => evidence[k]), digital_elements: digitalElements, + safety_relevant: safetyRelevant, + hazard_types: Object.keys(hazards).filter((k) => hazards[k]), ...flags, }), }) @@ -137,6 +151,25 @@ export function ReadinessCheck({ onCreateProject }: { onCreateProject?: () => vo + {/* Personengefährdung → MaschinenVO-Bezug + Cyber-Safety-Brücke */} + + {safetyRelevant && ( +
+ {HAZARDS.map((h) => ( + + ))} +
+ )} + {/* Vorhandene Nachweise → Reifegrad */}

Welche Nachweise haben Sie bereits?

diff --git a/admin-compliance/app/sdk/cra/_components/ReadinessResult.tsx b/admin-compliance/app/sdk/cra/_components/ReadinessResult.tsx index 9484c824..ab7c97ce 100644 --- a/admin-compliance/app/sdk/cra/_components/ReadinessResult.tsx +++ b/admin-compliance/app/sdk/cra/_components/ReadinessResult.tsx @@ -30,6 +30,13 @@ export interface ReadinessResult { maturity?: { pct: number; present: EvidenceItem[]; missing: EvidenceItem[]; total: number } digital_elements?: string[] producer_type?: string + machinery_verdict?: { + tier: string + label: string + hazards: { key: string; label: string }[] + cyber_safety_bridge: boolean + reasons: string[] + } } const CLASS_LABEL: Record = { @@ -48,10 +55,16 @@ const TIER_STYLE: Record = { ratsam: 'border-blue-300 bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-200', nicht_betroffen: 'border-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-900 dark:text-emerald-200', } +const MV_STYLE: Record = { + direkt: 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200', + sicherheitsrelevant: 'border-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-900 dark:text-indigo-200', + nicht_relevant: 'border-gray-200 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300', +} export function ReadinessResultView({ result, onCreateProject }: { result: ReadinessResult; onCreateProject?: () => void }) { const v = result.verdict const m = result.maturity + const mv = result.machinery_verdict return (
@@ -74,6 +87,28 @@ export function ReadinessResultView({ result, onCreateProject }: { result: Readi
)} + {/* Maschinenverordnung — Personensicherheit + Cyber-Safety-Brücke */} + {mv && mv.tier !== 'nicht_relevant' && ( +
+
+ Maschinenverordnung: {mv.label} + {mv.cyber_safety_bridge && ( + Cyber → Safety aktiv + )} +
+ {mv.hazards.length > 0 && ( +
+ {mv.hazards.map((h) => ( + {h.label} + ))} +
+ )} +
    + {mv.reasons.map((r, i) =>
  • • {r}
  • )} +
+
+ )} + {/* Reifegrad + digitale Elemente */}
{m && ( diff --git a/admin-compliance/app/sdk/cra/_components/readiness-presets.ts b/admin-compliance/app/sdk/cra/_components/readiness-presets.ts index 1bb957dc..f3d35860 100644 --- a/admin-compliance/app/sdk/cra/_components/readiness-presets.ts +++ b/admin-compliance/app/sdk/cra/_components/readiness-presets.ts @@ -13,6 +13,8 @@ export interface ReadinessPreset { customers_request_cra_evidence: boolean digital_elements: string[] provided_evidence: string[] + safety_relevant: boolean + hazard_types: string[] } export const READINESS_PRESETS: ReadinessPreset[] = [ @@ -31,6 +33,8 @@ export const READINESS_PRESETS: ReadinessPreset[] = [ 'SPS-I/O', 'Firmware', 'OWISoft', 'Triggerfunktionen', ], provided_evidence: [], + safety_relevant: true, + hazard_types: ['laser_radiation', 'movement_crush'], }, { id: 'zwick', @@ -50,5 +54,7 @@ export const READINESS_PRESETS: ReadinessPreset[] = [ 'Tablet-Status', 'Host-System-Anbindung', 'Benutzer-/Rechteverwaltung', 'Barcode/QR-Scanner', ], provided_evidence: ['support_lifecycle'], + safety_relevant: true, + hazard_types: ['movement_crush'], }, ] diff --git a/backend-compliance/compliance/api/cra_assess_routes.py b/backend-compliance/compliance/api/cra_assess_routes.py index 6a4c3b14..f0832c7e 100644 --- a/backend-compliance/compliance/api/cra_assess_routes.py +++ b/backend-compliance/compliance/api/cra_assess_routes.py @@ -15,7 +15,9 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from compliance.services.cra_finding_mapper import assess_findings_payload -from compliance.services.cra_applicability import compute_verdict, maturity as evidence_maturity, MACHINE_INTEGRATOR +from compliance.services.cra_applicability import ( + compute_verdict, compute_machinery_verdict, maturity as evidence_maturity, MACHINE_INTEGRATOR, +) from compliance.services.scanner_mcp_client import fetch_findings from compliance.services.cra_snapshot_store import save_snapshot, list_snapshots, get_snapshot from compliance.services.cra_use_case_controls import enrich_findings_with_breadth @@ -191,6 +193,10 @@ class ReadinessRequest(BaseModel): customers_request_cra_evidence: Optional[bool] = False provided_evidence: Optional[List[str]] = None # evidence keys already in place (sbom, vdp, …) digital_elements: Optional[List[str]] = None # detected/declared digital elements + # Machinery-Regulation person-safety axis + safety_relevant: Optional[bool] = False # function can endanger persons on fault/manipulation + hazard_types: Optional[List[str]] = None # movement_crush, laser_radiation, force_energy, … + is_safety_component: Optional[bool] = False # marketed as a safety device (Sicherheitsbauteil) # CRA Annex I evidence_type -> guideline bucket (Code / Prozess / Dokumentation). @@ -292,6 +298,10 @@ async def readiness(body: ReadinessRequest): "deadlines": list(DEADLINES), # Eingangstür verdict layer "verdict": verdict, + "machinery_verdict": compute_machinery_verdict( + body.producer_type or "", bool(body.is_machinery), + bool(body.safety_relevant), body.hazard_types, bool(body.is_safety_component), + ), "maturity": evidence_maturity(body.provided_evidence), "digital_elements": body.digital_elements or [], "producer_type": body.producer_type or "", diff --git a/backend-compliance/compliance/services/cra_applicability.py b/backend-compliance/compliance/services/cra_applicability.py index e6666432..3b01b15f 100644 --- a/backend-compliance/compliance/services/cra_applicability.py +++ b/backend-compliance/compliance/services/cra_applicability.py @@ -105,3 +105,65 @@ def maturity(provided_evidence_keys) -> dict: 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, + } diff --git a/backend-compliance/tests/test_cra_applicability.py b/backend-compliance/tests/test_cra_applicability.py index fb9e9011..00fa7593 100644 --- a/backend-compliance/tests/test_cra_applicability.py +++ b/backend-compliance/tests/test_cra_applicability.py @@ -1,7 +1,8 @@ """Neutral CRA applicability verdict (Eingangstür): legal duty vs market pull.""" from compliance.services.cra_applicability import ( ZWINGEND, RATSAM, NICHT_BETROFFEN, COMPONENT, MACHINE_INTEGRATOR, - compute_verdict, maturity, in_scope, EVIDENCE_ITEMS, + MV_DIREKT, MV_SICHERHEITSRELEVANT, MV_NICHT, + compute_verdict, compute_machinery_verdict, maturity, in_scope, EVIDENCE_ITEMS, ) @@ -60,6 +61,44 @@ class TestVerdict: assert v["cra_class"] == "IMPORTANT_II" +class TestMachineryVerdict: + def test_machine_integrator_is_direct(self): + v = compute_machinery_verdict(producer_type=MACHINE_INTEGRATOR) + assert v["tier"] == MV_DIREKT + assert v["cyber_safety_bridge"] is True + + def test_is_machinery_flag_is_direct(self): + v = compute_machinery_verdict(producer_type="end_device", is_machinery=True) + assert v["tier"] == MV_DIREKT + + def test_component_with_person_hazard_is_safety_relevant(self): + # OWIS-with-laser borderline: component, not a machine, but can harm persons + v = compute_machinery_verdict( + producer_type=COMPONENT, safety_relevant=True, hazard_types=["laser_radiation", "movement_crush"], + ) + assert v["tier"] == MV_SICHERHEITSRELEVANT + assert v["cyber_safety_bridge"] is True + assert {h["key"] for h in v["hazards"]} == {"laser_radiation", "movement_crush"} + + def test_hazard_types_alone_imply_safety_relevant(self): + v = compute_machinery_verdict(producer_type=COMPONENT, hazard_types=["force_energy"]) + assert v["tier"] == MV_SICHERHEITSRELEVANT + + def test_component_no_hazard_not_relevant(self): + v = compute_machinery_verdict(producer_type=COMPONENT) + assert v["tier"] == MV_NICHT + assert v["cyber_safety_bridge"] is False + + def test_safety_component_is_direct(self): + v = compute_machinery_verdict(producer_type=COMPONENT, is_safety_component=True) + assert v["tier"] == MV_DIREKT + + def test_unknown_hazard_keys_ignored(self): + v = compute_machinery_verdict(producer_type=COMPONENT, hazard_types=["nonsense"]) + assert v["hazards"] == [] + assert v["tier"] == MV_NICHT + + class TestMaturity: def test_empty_is_zero(self): m = maturity([])