4a5924b8c4
[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>
163 lines
6.6 KiB
Go
163 lines
6.6 KiB
Go
package iace
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// patternCategoryCompatibility mirrors the Go runtime helper
|
|
// `acceptableMeasureCategories` in internal/api/handlers/iace_handler_init_helpers.go.
|
|
// Kept here so the test does not depend on the handlers package (which would
|
|
// introduce an import cycle). If the runtime map changes, this table must be
|
|
// updated in lockstep — there is no fallback any more, so a drift would mean
|
|
// hazards going to the operator with zero mitigations.
|
|
var patternCategoryCompatibility = map[string]map[string]bool{
|
|
"mechanical_hazard": {"mechanical": true},
|
|
"electrical_hazard": {"electrical": true},
|
|
"thermal_hazard": {"thermal": true, "material_environmental": true},
|
|
"noise_hazard": {"noise_vibration": true, "ergonomic": true},
|
|
"vibration_hazard": {"noise_vibration": true, "ergonomic": true},
|
|
"noise_vibration": {"noise_vibration": true, "ergonomic": true},
|
|
"pneumatic_hydraulic": {"pneumatic_hydraulic": true, "mechanical": true},
|
|
"material_environmental": {"material_environmental": true},
|
|
"chemical_risk": {"material_environmental": true, "thermal": true},
|
|
"ergonomic": {"ergonomic": true},
|
|
"ergonomic_hazard": {"ergonomic": true},
|
|
"fire_explosion": {"thermal": true, "material_environmental": true},
|
|
"radiation_hazard": {"material_environmental": true},
|
|
"emc_hazard": {"electrical": true, "software_control": true},
|
|
"maintenance_hazard": {"mechanical": true},
|
|
"safety_function_failure": {"safety_function": true, "software_control": true},
|
|
"software_fault": {"software_control": true},
|
|
"sensor_fault": {"software_control": true},
|
|
"configuration_error": {"software_control": true},
|
|
"update_failure": {"software_control": true},
|
|
"hmi_error": {"software_control": true},
|
|
"mode_confusion": {"software_control": true},
|
|
"unauthorized_access": {"cyber_network": true, "software_control": true},
|
|
"communication_failure": {"cyber_network": true, "software_control": true},
|
|
"firmware_corruption": {"cyber_network": true, "software_control": true},
|
|
"logging_audit_failure": {"cyber_network": true, "software_control": true},
|
|
"ai_misclassification": {"ai_specific": true, "software_control": true},
|
|
"false_classification": {"ai_specific": true, "software_control": true},
|
|
"model_drift": {"ai_specific": true, "software_control": true},
|
|
"data_poisoning": {"ai_specific": true, "software_control": true},
|
|
"sensor_spoofing": {"ai_specific": true, "software_control": true},
|
|
"unintended_bias": {"ai_specific": true, "software_control": true},
|
|
"noise_source": {"noise_vibration": true, "ergonomic": true},
|
|
"vibration_source": {"noise_vibration": true, "ergonomic": true},
|
|
"high_temperature": {"thermal": true, "material_environmental": true},
|
|
"material_environmental_hazard": {"material_environmental": true},
|
|
"cyber_resilience": {"cyber_resilience": true, "cyber_network": true, "software_control": true},
|
|
}
|
|
|
|
// TestEveryPattern_HasCategoryCompatibleMeasure is the contract that replaces
|
|
// the old category fallback: every hazard pattern must reference at least one
|
|
// measure whose HazardCategory is compatible with the pattern's hazard cats,
|
|
// or with "general". Without this, the operator-facing UI shows a hazard with
|
|
// zero mitigations and a "consult expert" placeholder — which is fine for
|
|
// rare edge cases but must not become the default state.
|
|
//
|
|
// New patterns added to the library must satisfy this test. The 2026-05
|
|
// Bremse benchmark drove down zero-surv from 237 → 0 — that floor is now
|
|
// enforced.
|
|
//
|
|
// Patterns explicitly known to have no library coverage today should be added
|
|
// to AllowlistKnownGaps with a TODO and an issue link; the test still fails
|
|
// if anything not on that list has zero coverage.
|
|
var AllowlistKnownGaps = map[string]string{
|
|
// hp-id -> rationale (must be filled when adding)
|
|
}
|
|
|
|
func TestEveryPattern_HasCategoryCompatibleMeasure(t *testing.T) {
|
|
measureCat := map[string]string{}
|
|
for _, m := range GetProtectiveMeasureLibrary() {
|
|
measureCat[m.ID] = m.HazardCategory
|
|
}
|
|
|
|
patterns := collectAllPatterns()
|
|
gaps := []string{}
|
|
for _, p := range patterns {
|
|
if _, allowed := AllowlistKnownGaps[p.ID]; allowed {
|
|
continue
|
|
}
|
|
accepted := map[string]bool{"general": true}
|
|
for _, hc := range p.GeneratedHazardCats {
|
|
for c := range patternCategoryCompatibility[hc] {
|
|
accepted[c] = true
|
|
}
|
|
}
|
|
hasCompatible := false
|
|
for _, mid := range p.SuggestedMeasureIDs {
|
|
mc, ok := measureCat[mid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if mc == "" || accepted[mc] {
|
|
hasCompatible = true
|
|
break
|
|
}
|
|
}
|
|
if !hasCompatible {
|
|
gaps = append(gaps, p.ID+" (cats="+joinCats(p.GeneratedHazardCats)+")")
|
|
}
|
|
}
|
|
sort.Strings(gaps)
|
|
if len(gaps) > 0 {
|
|
t.Errorf("%d patterns have no category-compatible measure (add to AllowlistKnownGaps or fix SuggestedMeasureIDs):", len(gaps))
|
|
const maxList = 20
|
|
for i, g := range gaps {
|
|
if i >= maxList {
|
|
t.Errorf(" ... and %d more", len(gaps)-maxList)
|
|
break
|
|
}
|
|
t.Errorf(" - %s", g)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAllPatterns_UniqueIDs pins that collectAllPatterns() — i.e. every
|
|
// pattern that the engine actually sees at runtime — has globally unique
|
|
// HP-IDs. The older TestGetBuiltinHazardPatterns_UniqueIDs only checks the
|
|
// 44 builtin ones and missed 58 duplicates between extended.go/extended2.go
|
|
// and cobot/press/operational/extended_dguv.go that lived undetected for
|
|
// months (a hazard with the same HP-ID but different scenario would silently
|
|
// shadow its sibling in the persistence layer).
|
|
func TestAllPatterns_UniqueIDs(t *testing.T) {
|
|
seen := map[string]string{} // HP-ID -> first NameDE
|
|
dups := []string{}
|
|
for _, p := range collectAllPatterns() {
|
|
if p.ID == "" {
|
|
t.Errorf("pattern with empty ID: %s", p.NameDE)
|
|
continue
|
|
}
|
|
if prev, ok := seen[p.ID]; ok {
|
|
dups = append(dups, p.ID+" ("+prev+" vs "+p.NameDE+")")
|
|
continue
|
|
}
|
|
seen[p.ID] = p.NameDE
|
|
}
|
|
if len(dups) > 0 {
|
|
t.Errorf("%d duplicate HP-IDs across all pattern sources:", len(dups))
|
|
const maxList = 20
|
|
for i, d := range dups {
|
|
if i >= maxList {
|
|
t.Errorf(" ... and %d more", len(dups)-maxList)
|
|
break
|
|
}
|
|
t.Errorf(" - %s", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func joinCats(cats []string) string {
|
|
out := ""
|
|
for i, c := range cats {
|
|
if i > 0 {
|
|
out += ","
|
|
}
|
|
out += c
|
|
}
|
|
return out
|
|
}
|