16fd406c1a
Task #17 — Folgegefahren-Modell as Vorbereitungs-Commit (no DB schema change yet; persistence via separate [migration-approved] commit). New: - secondary_harms.go: SecondaryHarm struct + six canonical categories (consumer_safety, product_liability, food_safety, environmental, reputation, financial) with DE labels. - hazard_pattern_types.go: HazardPattern extended with optional SecondaryHarms field — pattern library can now attach consequential- damage chains. - hazard_patterns_secondary_demo.go: two worked examples - HP2000 Glasbruch carbonated bottling (the "Cola splitter" scenario from the IACE strategy discussion) with consumer_safety + food_safety + reputation chains - HP2001 Pharma fill-finish cross-contamination with consumer_safety + product_liability under AMG §84 Bonus fix: - compliance_crossover.go AllPatterns() was a duplicate enumeration that silently drifted from collectAllPatterns() in pattern_registry.go. Pre-fix: 1058 patterns visible. Post-fix: 1213 patterns. The 155 invisible patterns included CRA, ISO12100 gaps, robot-cell, CNC extended, VDMA, textile-agri, GT-bremse — anything added after the original AllPatterns was authored. Audit-Suite (cmd/iace-audit) now sees the full set. Next steps for full secondary-harm rollout: - DB migration: hazards table + secondary_harms array column - API: surface secondary_harms in /projects/:id/hazards response - Frontend: collapsible Folgegefahren-Panel in HazardTable
230 lines
6.4 KiB
Go
230 lines
6.4 KiB
Go
package iace
|
|
|
|
import "sort"
|
|
|
|
// GetProjectComplianceTriggers analyses a project's hazards and the full
|
|
// pattern library to determine which DSGVO/AI Act/CRA/NIS2/Data Act
|
|
// obligations are triggered. It returns a deduplicated, severity-sorted
|
|
// summary with boolean flags for each regulation family.
|
|
func GetProjectComplianceTriggers(hazards []Hazard, patterns []HazardPattern) *ComplianceTriggerSummary {
|
|
triggerMap := GetComplianceTriggerMap()
|
|
|
|
// Build set of pattern IDs present in the pattern library for quick lookup
|
|
patternByID := make(map[string]HazardPattern, len(patterns))
|
|
for _, p := range patterns {
|
|
patternByID[p.ID] = p
|
|
}
|
|
|
|
// Collect all fired pattern IDs from the project's hazards.
|
|
// Hazards created from pattern matching store the source pattern ID
|
|
// in their Description ("Pattern HPXXX") or Name field.
|
|
firedPatterns := make(map[string]bool)
|
|
for _, h := range hazards {
|
|
extractPatternIDs(h.Description, firedPatterns)
|
|
extractPatternIDs(h.Name, firedPatterns)
|
|
extractPatternIDs(h.Scenario, firedPatterns)
|
|
}
|
|
|
|
// Also check each pattern against the hazard categories present
|
|
// in the project — if a pattern generates a category that exists
|
|
// among the hazards, consider the pattern relevant.
|
|
hazardCats := make(map[string]bool)
|
|
for _, h := range hazards {
|
|
if h.Category != "" {
|
|
hazardCats[h.Category] = true
|
|
}
|
|
}
|
|
for _, p := range patterns {
|
|
for _, cat := range p.GeneratedHazardCats {
|
|
if hazardCats[cat] {
|
|
firedPatterns[p.ID] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect tag-level information from hazard metadata
|
|
tags := collectHazardTags(hazards)
|
|
tagTriggers := GetTagBasedTriggers(tags)
|
|
|
|
// Gather all triggers from fired patterns
|
|
var results []TriggerResult
|
|
seenRegulation := make(map[string]bool)
|
|
|
|
for pid := range firedPatterns {
|
|
triggers, ok := triggerMap[pid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, t := range triggers {
|
|
key := t.Regulation + "|" + t.Module
|
|
if seenRegulation[key] {
|
|
continue
|
|
}
|
|
seenRegulation[key] = true
|
|
|
|
// Try to find a hazard name for context
|
|
hName := findHazardNameForPattern(pid, hazards)
|
|
results = append(results, TriggerResult{
|
|
HazardID: "",
|
|
HazardName: hName,
|
|
PatternID: pid,
|
|
Trigger: t,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Append tag-based triggers (deduplicated against pattern triggers)
|
|
for _, t := range tagTriggers {
|
|
key := t.Regulation + "|" + t.Module
|
|
if seenRegulation[key] {
|
|
continue
|
|
}
|
|
seenRegulation[key] = true
|
|
results = append(results, TriggerResult{
|
|
HazardID: "",
|
|
HazardName: "Tag-basiert",
|
|
PatternID: "",
|
|
Trigger: t,
|
|
})
|
|
}
|
|
|
|
// Sort by severity: high > medium > low
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return severityRank(results[i].Trigger.Severity) > severityRank(results[j].Trigger.Severity)
|
|
})
|
|
|
|
// Build boolean summary flags
|
|
summary := buildSummaryFlags(results)
|
|
|
|
return &ComplianceTriggerSummary{
|
|
Triggers: results,
|
|
Total: len(results),
|
|
Summary: summary,
|
|
}
|
|
}
|
|
|
|
// AllPatterns returns every registered hazard pattern. Delegates to
|
|
// collectAllPatterns() in pattern_registry.go so new pattern sources only
|
|
// need to be added in one place. Pre-2026-05-21 this function maintained
|
|
// a duplicate enumeration which silently drifted from the registry —
|
|
// CRA, ISO12100-gap, robot-cell, CNC, VDMA, textile-agri, GT-bremse and
|
|
// secondary-harm patterns were invisible to AllPatterns callers.
|
|
func AllPatterns() []HazardPattern {
|
|
return collectAllPatterns()
|
|
}
|
|
|
|
// extractPatternIDs scans a text for "HP" followed by digits and adds
|
|
// any found pattern IDs to the set.
|
|
func extractPatternIDs(text string, set map[string]bool) {
|
|
for i := 0; i < len(text)-2; i++ {
|
|
if text[i] == 'H' && text[i+1] == 'P' && i+2 < len(text) && text[i+2] >= '0' && text[i+2] <= '9' {
|
|
end := i + 2
|
|
for end < len(text) && text[end] >= '0' && text[end] <= '9' {
|
|
end++
|
|
}
|
|
set[text[i:end]] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// findHazardNameForPattern returns the name of the first hazard whose
|
|
// description/name/scenario mentions the given pattern ID.
|
|
func findHazardNameForPattern(pid string, hazards []Hazard) string {
|
|
for _, h := range hazards {
|
|
if containsPatternID(h.Description, pid) || containsPatternID(h.Name, pid) || containsPatternID(h.Scenario, pid) {
|
|
return h.Name
|
|
}
|
|
}
|
|
if len(hazards) > 0 {
|
|
return hazards[0].Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// containsPatternID checks whether text contains the exact pattern ID token.
|
|
func containsPatternID(text, pid string) bool {
|
|
idx := 0
|
|
for idx <= len(text)-len(pid) {
|
|
if text[idx:idx+len(pid)] == pid {
|
|
// Ensure it is not a substring of a longer ID
|
|
after := idx + len(pid)
|
|
if after >= len(text) || text[after] < '0' || text[after] > '9' {
|
|
return true
|
|
}
|
|
}
|
|
idx++
|
|
}
|
|
return false
|
|
}
|
|
|
|
// collectHazardTags extracts tag-like signals from hazard fields.
|
|
func collectHazardTags(hazards []Hazard) []string {
|
|
tagSet := make(map[string]bool)
|
|
for _, h := range hazards {
|
|
// Infer tags from hazard category names
|
|
switch h.Category {
|
|
case "software", "steuerung", "steuerungsfehler":
|
|
tagSet["has_software"] = true
|
|
tagSet["programmable"] = true
|
|
case "cyber", "cybersicherheit", "netzwerk":
|
|
tagSet["is_networked"] = true
|
|
tagSet["has_software"] = true
|
|
case "ki", "kuenstliche_intelligenz", "ai_ml":
|
|
tagSet["has_ai"] = true
|
|
tagSet["has_software"] = true
|
|
case "sensorik", "sensor":
|
|
tagSet["sensor_part"] = true
|
|
}
|
|
}
|
|
tags := make([]string, 0, len(tagSet))
|
|
for t := range tagSet {
|
|
tags = append(tags, t)
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// severityRank maps severity strings to sort-order integers.
|
|
func severityRank(s string) int {
|
|
switch s {
|
|
case "high":
|
|
return 3
|
|
case "medium":
|
|
return 2
|
|
case "low":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// buildSummaryFlags derives boolean flags from the collected trigger results.
|
|
func buildSummaryFlags(results []TriggerResult) map[string]bool {
|
|
summary := map[string]bool{
|
|
"dsfa_required": false,
|
|
"ai_act_relevant": false,
|
|
"cra_relevant": false,
|
|
"nis2_relevant": false,
|
|
"data_act_relevant": false,
|
|
}
|
|
for _, r := range results {
|
|
reg := r.Trigger.Regulation
|
|
if len(reg) >= 4 && reg[:4] == "DSGV" {
|
|
summary["dsfa_required"] = true
|
|
}
|
|
if len(reg) >= 6 && reg[:6] == "AI Act" {
|
|
summary["ai_act_relevant"] = true
|
|
}
|
|
if len(reg) >= 3 && reg[:3] == "CRA" {
|
|
summary["cra_relevant"] = true
|
|
}
|
|
if len(reg) >= 4 && reg[:4] == "NIS2" {
|
|
summary["nis2_relevant"] = true
|
|
}
|
|
if len(reg) >= 11 && reg[:11] == "EU Data Act" {
|
|
summary["data_act_relevant"] = true
|
|
}
|
|
}
|
|
return summary
|
|
}
|