Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/iace_handler_init_helpers.go
T
Benjamin Admin 4a5924b8c4 feat(iace): CRA / DIN EN 40000-1-2 cyber-resilience spur
[guardrail-change]

Phase 18 adds an EU Cyber Resilience Act compliance track to IACE:
the engine now fires patterns that surface the manufacturer-side CRA
obligations whenever a project's components carry digital elements.

Patterns (HP1910-HP1918, hazard_patterns_cra.go):
  HP1910  Missing SBOM
  HP1911  Unsigned firmware/software updates
  HP1912  Factory-default credentials still active
  HP1913  No coordinated vulnerability disclosure (CVD) policy
  HP1914  No documented security patch SLA
  HP1915  Missing user-facing hardening guide
  HP1916  No incident-notification process to ENISA / CSIRT
  HP1917  No security assessment prior to placing on market
  HP1918  AI component without cybersecurity risk assessment

Each pattern carries ClarificationQuestionsDE so the operator gets
auditor-grade questions to take back to the Anlagenbauer instead of
the engine inventing prose. PatternMatch carries DefaultAvoidability
(P=1 for all CRA patterns), feeding the PLr graph from Phase 17.

Measures (M540-M548, measures_library_cra.go):
  M540  SBOM (SPDX or CycloneDX) with each machine release
  M541  Signed updates with rollback protection
  M542  Forced default-password change at first boot
  M543  Published CVD policy (security.txt / PSIRT)
  M544  Documented patch SLA with CVSS-tier response times
  M545  User-facing hardening guide in the machine docs
  M546  ENISA incident-notification process (24h/72h/14d)
  M547  Authenticated update channel + integrity check
  M548  Pre-market security assessment / pen-test

The library is urheberrechtlich neutral: identifiers only
(Verordnung (EU) 2024/2847, DIN EN 40000-1-2 Entwurf, IEC 62443,
ETSI EN 303 645, ISO/IEC 5962, ISO/IEC 29147). No normative text
is reproduced — DIN/Beuth proprietary content is referenced by
section number only.

Category-compatibility:
  cyber_resilience pattern category accepts measures with
  HazardCategory cyber_resilience, cyber_network, or
  software_control. Updated in both the runtime helper
  (iace_handler_init_helpers.go) and its test-mirror
  (pattern_coverage_test.go) — both must move in lockstep.

Frontend (clarifications page):
  When at least one clarification references "2024/2847" or
  "40000-1-2" in its norm_references, a blue info-banner is
  rendered at the top of the page:
    "Cyber Resilience Act (CRA) — Hinweis zur Geltung
     Diese Klärungsliste enthält Fragen zur Verordnung (EU)
     2024/2847 (CRA). Die CRA gilt für Produkte mit digitalen
     Elementen, die ab dem 11.12.2027 auf dem EU-Markt bereit-
     gestellt werden. ..."
  Reminds the user that the CRA pflichten are forward-looking
  while still allowing the manufacturer to bake them in now.

LOC exceptions:
  Added three pre-existing files to .claude/rules/loc-exceptions.txt
  (manufacturer_safety_features.go, iace_handler_clarifications.go,
  routes.go). All three grew across Phases 16-17 and are tagged as
  Phase 5+ refactor backlog. [guardrail-change] marker required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00

494 lines
18 KiB
Go

package handlers
import (
"encoding/json"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/google/uuid"
)
// extractNarrativeFromMetadata builds a combined text from the limits_form.
func extractNarrativeFromMetadata(metadata json.RawMessage) string {
if metadata == nil {
return ""
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return ""
}
limitsRaw, ok := meta["limits_form"]
if !ok {
return ""
}
var limits map[string]interface{}
if err := json.Unmarshal(limitsRaw, &limits); err != nil {
return ""
}
textFields := []string{
"general_description", "intended_purpose", "foreseeable_misuse",
"space_limits", "time_limits", "environmental_conditions",
"energy_sources", "materials_processed", "operating_modes",
"maintenance_requirements", "personnel_requirements",
"interfaces_description", "control_system_description",
"safety_functions_description",
}
var result string
for _, field := range textFields {
if v, ok := limits[field]; ok {
if s, ok := v.(string); ok && s != "" {
result += s + "\n\n"
}
}
}
return result
}
// acceptableMeasureCategories returns the set of measure HazardCategory values
// that are semantically applicable to a hazard with the given pattern category.
// The mapping is a *set*, not a single value — many pattern categories accept
// measures from several measure-library categories that are conceptually
// related. E.g. a safety_function_failure hazard is sensibly mitigated by
// software_control measures like watchdogs, plausibility checks or self-tests,
// not just by the (almost empty) safety_function category.
//
// "general" is implicit — handled in isCategoryCompatible and not duplicated
// in every set below.
func acceptableMeasureCategories(patternCat string) map[string]bool {
sets := map[string][]string{
"mechanical_hazard": {"mechanical"},
"electrical_hazard": {"electrical"},
"thermal_hazard": {"thermal", "material_environmental"},
// ISO 12100 Anhang B splits Nr. 4 Laerm and Nr. 5 Vibration into
// two top-level groups. The legacy combined alias noise_vibration
// is kept for backwards compat — all three resolve to the same
// measure pool today (the library doesn't separate noise vs
// vibration measures), but the pattern category now matches the
// norm structure.
"noise_hazard": {"noise_vibration", "ergonomic"},
"vibration_hazard": {"noise_vibration", "ergonomic"},
"noise_vibration": {"noise_vibration", "ergonomic"},
"pneumatic_hydraulic": {"pneumatic_hydraulic", "mechanical"},
"material_environmental": {"material_environmental"},
"chemical_risk": {"material_environmental", "thermal"},
"ergonomic": {"ergonomic"},
"ergonomic_hazard": {"ergonomic"},
"fire_explosion": {"thermal", "material_environmental"},
"radiation_hazard": {"material_environmental"},
"emc_hazard": {"electrical", "software_control"},
"maintenance_hazard": {"mechanical"},
"safety_function_failure": {"safety_function", "software_control"},
"software_fault": {"software_control"},
"sensor_fault": {"software_control"},
"configuration_error": {"software_control"},
"update_failure": {"software_control"},
"hmi_error": {"software_control"},
"mode_confusion": {"software_control"},
"unauthorized_access": {"cyber_network", "software_control"},
"communication_failure": {"cyber_network", "software_control"},
"firmware_corruption": {"cyber_network", "software_control"},
"logging_audit_failure": {"cyber_network", "software_control"},
"ai_misclassification": {"ai_specific", "software_control"},
"false_classification": {"ai_specific", "software_control"},
"model_drift": {"ai_specific", "software_control"},
"data_poisoning": {"ai_specific", "software_control"},
"sensor_spoofing": {"ai_specific", "software_control"},
"unintended_bias": {"ai_specific", "software_control"},
// CRA / DIN EN 40000-1-2 cyber-resilience patterns (HP1910+).
// cyber_resilience is the umbrella category used by patterns that
// fire on the manufacturer-side obligations: SBOM, signed updates,
// CVD policy, patch-SLA, hardening docs, incident notification.
// Accept measures from the dedicated cyber_resilience pool plus the
// broader cyber_network and software_control pools (existing
// measures like "intrusion detection" or "audit logging" are
// applicable here too).
"cyber_resilience": {"cyber_resilience", "cyber_network", "software_control"},
// Edge-case pattern categories from legacy authors. Treated as
// synonyms of their primary hazard category so existing patterns
// keep matching the right measure pool.
"noise_source": {"noise_vibration", "ergonomic"},
"vibration_source": {"noise_vibration", "ergonomic"},
"high_temperature": {"thermal", "material_environmental"},
"material_environmental_hazard": {"material_environmental"},
}
out := map[string]bool{"general": true}
if list, ok := sets[patternCat]; ok {
for _, c := range list {
out[c] = true
}
}
return out
}
// isCategoryCompatible reports whether a measure with HazardCategory measureCat
// is semantically applicable to a hazard whose acceptable measure categories
// are listed in accepted. Empty measureCat is always allowed (legacy entries),
// "general" measures are pre-seeded into accepted by acceptableMeasureCategories.
//
// Without this guard, patterns silently inherit nonsense mitigations (e.g.
// HP1651 "robot restart while person in cell" inheriting M054 "Sichere
// thermische Auslegung" — a thermal-design measure used as generic default in
// ~100 mechanical patterns). The Fachmann benchmark rejects such mismatches.
func isCategoryCompatible(measureCat string, accepted map[string]bool) bool {
if measureCat == "" {
return true
}
return accepted[measureCat]
}
// keysOf returns the sorted keys of a string-bool set, used for diagnostic
// log messages that report which measure categories were accepted for a hazard.
func keysOf(s map[string]bool) []string {
out := make([]string, 0, len(s))
for k := range s {
out = append(out, k)
}
return out
}
// patternCatToMeasureCat maps pattern hazard categories to measure categories.
func patternCatToMeasureCat(patternCat string) string {
m := map[string]string{
"mechanical_hazard": "mechanical", "electrical_hazard": "electrical",
"thermal_hazard": "thermal", "noise_vibration": "noise_vibration",
"pneumatic_hydraulic": "pneumatic_hydraulic", "material_environmental": "material_environmental",
"ergonomic": "ergonomic", "ergonomic_hazard": "ergonomic",
"software_fault": "software_control", "safety_function_failure": "safety_function",
"fire_explosion": "thermal", "radiation_hazard": "material_environmental",
"unauthorized_access": "cyber_network", "communication_failure": "cyber_network",
"firmware_corruption": "cyber_network", "logging_audit_failure": "cyber_network",
"ai_misclassification": "ai_specific", "false_classification": "ai_specific",
"model_drift": "ai_specific", "data_poisoning": "ai_specific",
"sensor_spoofing": "ai_specific", "unintended_bias": "ai_specific",
"sensor_fault": "software_control", "configuration_error": "software_control",
"update_failure": "software_control", "hmi_error": "software_control",
"emc_hazard": "electrical", "maintenance_hazard": "mechanical",
"mode_confusion": "software_control", "chemical_risk": "material_environmental",
"cyber_resilience": "cyber_resilience",
}
if cat, ok := m[patternCat]; ok {
return cat
}
return "general"
}
// deriveComponentType guesses the component type from its tags.
func deriveComponentType(tags []string) iace.ComponentType {
for _, t := range tags {
switch {
case t == "software" || t == "has_software":
return iace.ComponentTypeSoftware
case t == "firmware" || t == "has_firmware":
return iace.ComponentTypeFirmware
case t == "has_ai" || t == "ai_model":
return iace.ComponentTypeAIModel
case t == "hmi" || t == "display" || t == "touchscreen":
return iace.ComponentTypeHMI
case t == "sensor" || t == "camera":
return iace.ComponentTypeSensor
case t == "electric_motor" || t == "electric_drive":
return iace.ComponentTypeElectrical
case t == "networked" || t == "ethernet" || t == "wifi":
return iace.ComponentTypeNetwork
case t == "hydraulic" || t == "pneumatic":
return iace.ComponentTypeActuator
}
}
return iace.ComponentTypeMechanical
}
// extractOperationalStatesFromMetadata reads the explicit operational_states
// selection that the user set via the Betriebszustand-UI.
func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string {
if metadata == nil {
return nil
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return nil
}
raw, ok := meta["operational_states"]
if !ok {
return nil
}
var states []string
if err := json.Unmarshal(raw, &states); err != nil {
return nil
}
return states
}
// mergeStringSlices merges two string slices, deduplicating entries.
func mergeStringSlices(a, b []string) []string {
seen := make(map[string]bool, len(a)+len(b))
var result []string
for _, s := range a {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
for _, s := range b {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
// extractIndustrySectorsFromMetadata reads the industry_sectors selection
// from project metadata and maps them to MachineTypes for pattern filtering.
func extractIndustrySectorsFromMetadata(metadata json.RawMessage) []string {
if metadata == nil {
return nil
}
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadata, &meta); err != nil {
return nil
}
limitsRaw, ok := meta["limits_form"]
if !ok {
return nil
}
var limits map[string]json.RawMessage
if err := json.Unmarshal(limitsRaw, &limits); err != nil {
return nil
}
sectorsRaw, ok := limits["industry_sectors"]
if !ok {
return nil
}
var sectors []string
if err := json.Unmarshal(sectorsRaw, &sectors); err != nil {
return nil
}
labelMap := map[string][]string{
"Allgemeiner Maschinenbau": {"general_industry"},
"Automobil / Zulieferer": {"automotive"},
"Robotik / Cobot": {"robotics_cobot", "cobot"},
"Medizintechnik": {"medical_device", "infusion_pump", "ventilator", "patient_monitor"},
"Lebensmittel / Getraenke": {"food_processing"},
"Verpackung": {"packaging"},
"Pharma / Chemie": {"chemical", "pharmaceutical"},
"Bau / Baumaschinen": {"construction", "crane", "excavator"},
"Forst / Holzbearbeitung": {"forestry", "woodworking", "circular_saw"},
"Aufzuege / Foerdertechnik": {"elevator", "lift", "escalator", "conveyor"},
"Textil": {"textile", "spinning", "weaving", "finishing"},
"Landmaschinen": {"agricultural", "tractor", "harvester"},
"Druck / Papier": {"printing"},
"Metall / CNC": {"cnc", "metalworking", "lathe", "milling"},
"Schweissen / Oberflaechentechnik": {"welding", "surface_treatment"},
}
var result []string
seen := make(map[string]bool)
for _, sector := range sectors {
for _, mt := range labelMap[sector] {
if !seen[mt] {
seen[mt] = true
result = append(result, mt)
}
}
}
return result
}
// containsSubstring checks if haystack contains needle (case-insensitive, normalized).
func containsSubstring(haystack, needle string) bool {
return strings.Contains(
strings.ToLower(haystack),
strings.ToLower(needle),
)
}
// genericSafetyTerms are words that appear in almost all risk assessments
// and should NOT be used to determine machine-specificity.
var genericSafetyTerms = map[string]bool{
"maschine": true, "anlage": true, "bereich": true, "gesamte": true,
"arbeitsplatz": true, "gefahrbereich": true, "gefahrstelle": true,
"gefahrenstelle": true, "person": true, "werker": true, "bediener": true,
"steuerung": true, "schutzeinrichtung": true, "sicherheit": true,
"betrieb": true, "wartung": true, "instandhaltung": true, "reinigung": true,
"bewegung": true, "beweglich": true, "feststehend": true, "teil": true,
"teile": true, "oeffnung": true, "zugang": true, "gefahr": true,
"verletzung": true, "quetsch": true, "scher": true, "schneid": true,
"stoss": true, "schlag": true, "einzug": true, "brand": true,
"motor": true, "antrieb": true, "achse": true, "achsen": true,
"kabel": true, "leitung": true, "schaltschrank": true, "spannung": true,
"schutz": true, "gehaeuse": true, "oberflaeche": true, "boden": true,
"leitfaehig": true, "elektrisch": true, "mechanisch": true,
"bedienfeld": true, "display": true, "anzeige": true,
"energie": true, "druck": true, "temperatur": true,
// Abbreviations and synonyms that should not trigger relevance filter
"kss": true, "emv": true, "esd": true, "dcs": true, "plr": true, "sil": true,
"hmi": true, "sps": true, "rcd": true, "loto": true, "psa": true,
// Common action words
"bersten": true, "platzen": true, "abspringen": true, "spritzen": true,
"einatmen": true, "ausrutschen": true, "herabfallen": true,
"durchschlaegen": true, "wegschleudern": true,
// Common structural terms that don't indicate a specific machine
"gesamter": true, "gesamtes": true, "bereichs": true, "stelle": true,
"innen": true, "aussen": true, "transport": true, "seite": true,
"front": true, "rueck": true, "ober": true, "unter": true,
"fuehrung": true, "lager": true, "verschleiss": true, "welle": true,
"getriebe": true, "kette": true, "riemen": true, "feder": true,
"spindel": true, "werkzeug": true, "werkstueck": true, "flucht": true,
}
// isPatternRelevant checks whether a pattern match is relevant to the actual
// machine described in the narrative. Uses narrative vocabulary overlap:
// if the pattern's zone/scenario contains machine-specific words (not generic
// safety terms) and NONE of them appear in the narrative → irrelevant.
func isPatternRelevant(mp iace.PatternMatch, narrative string, compNames []string) bool {
patternText := iace.NormalizeDEPublic(mp.ZoneDE + " " + mp.ScenarioDE + " " + mp.PatternName)
narrativeNorm := iace.NormalizeDEPublic(narrative)
// Extract machine-specific words from pattern (not generic safety terms)
patternWords := strings.Fields(patternText)
var specificWords []string
for _, w := range patternWords {
// Clean punctuation
w = strings.Trim(w, ".,;:!?()/-")
if len(w) < 5 || genericSafetyTerms[w] {
continue
}
specificWords = append(specificWords, w)
}
// If pattern has no specific words, it's generic → always relevant
if len(specificWords) == 0 {
return true
}
// Check if at least one specific word appears in the narrative or components
for _, sw := range specificWords {
if strings.Contains(narrativeNorm, sw) {
return true
}
for _, cn := range compNames {
if strings.Contains(cn, sw) {
return true
}
}
}
// No specific word found in narrative → pattern is for a different machine
return false
}
// categoryHazardCap returns the maximum number of hazards to generate per category.
// Caps are based on typical ISO 12100 risk assessment proportions:
// - Core physical categories (mechanical, electrical): scale with component count
// - Secondary categories (thermal, noise, material): smaller fixed caps
// - Software/IT/organizational categories: minimal (these are usually covered by
// other standards like IEC 62443, not ISO 12100 machinery risk assessment)
func categoryHazardCap(cat string, componentCount int) int {
// Core machinery hazard categories — scale with complexity
switch cat {
case "mechanical_hazard":
// Typically 1-3 hazards per component (quetschen, scheren, stoss...)
cap := componentCount * 3
if cap < 15 {
cap = 15
}
if cap > 60 {
cap = 60
}
return cap
case "electrical_hazard":
// Typically 8-15 for a standard machine
cap := componentCount
if cap < 8 {
cap = 8
}
if cap > 20 {
cap = 20
}
return cap
case "pneumatic_hydraulic":
return 8
case "thermal_hazard":
return 6
case "noise_vibration":
return 4
case "material_environmental":
return 6
case "ergonomic", "ergonomic_hazard":
return 4
case "fire_explosion":
return 4
case "radiation_hazard", "emc_hazard":
return 3
// Software/IT/organizational — minimal for machinery assessment
case "safety_function_failure":
return 5
case "software_fault":
return 3
case "configuration_error":
return 3
case "hmi_error":
return 3
case "maintenance_hazard":
return 4
case "mode_confusion":
return 2
default:
return 3
}
}
// normalizeZoneKey reduces a zone string to its core components for better dedup.
// E.g. "Schaltschrank, Sammelschiene" and "Schaltschrank-Innenraum, Sammelschienen"
// should dedup to the same key.
func normalizeZoneKey(zone string) string {
if zone == "" {
return ""
}
norm := iace.NormalizeDEPublic(zone)
// Remove filler words and punctuation
for _, r := range []string{",", "/", "(", ")", "-", ".", ":", ";"} {
norm = strings.ReplaceAll(norm, r, " ")
}
// Extract significant words (>3 chars), sort for stable key
words := strings.Fields(norm)
var sig []string
seen := make(map[string]bool)
stopWords := map[string]bool{
"der": true, "die": true, "das": true, "und": true, "oder": true,
"von": true, "des": true, "den": true, "dem": true, "ein": true,
"eine": true, "fuer": true, "bei": true, "mit": true, "nach": true,
"alle": true, "aller": true, "allem": true, "sowie": true,
"insbesondere": true, "bereich": true, "gesamte": true, "gesamter": true,
"innerhalb": true, "ausserhalb": true, "umgebung": true,
}
for _, w := range words {
if len(w) < 4 || stopWords[w] || seen[w] {
continue
}
seen[w] = true
sig = append(sig, w)
}
if len(sig) == 0 {
return norm
}
// Take first 3 significant words as key (enough for dedup)
if len(sig) > 3 {
sig = sig[:3]
}
return strings.Join(sig, "_")
}
// findHazardsForMeasureByCategory finds all hazards matching a measure's category.
func findHazardsForMeasureByCategory(measureCat string, hazardsByCategory map[string][]uuid.UUID) []uuid.UUID {
if ids, ok := hazardsByCategory[measureCat]; ok {
return ids
}
for cat, ids := range hazardsByCategory {
if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] {
return ids
}
}
return nil
}