feat(iace): hazard block view — parent/child grouping
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-tts (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 15s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / nodejs-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m14s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 59s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m54s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-tts (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 15s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / nodejs-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m14s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 59s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m54s
Backend: - hazard_blocks.go: ComputeHazardBlocks() groups hazards by category + component + zone. Parent = highest risk in group. Children covered by parent's measures are flagged (no separate assessment needed). - iace_handler_blocks.go: GET /projects/:id/hazard-blocks endpoint with summary stats (blocks, covered children, assessments saved) Frontend: - HazardBlockView.tsx: Expandable block view with summary cards, parent-child hierarchy, coverage badges, and "abgedeckt" indicators - hazards/page.tsx: New "Bloecke" tab alongside "Hazard-Liste" and "Risikobewertung" No database schema changes — grouping is computed at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetHazardBlocks handles GET /projects/:id/hazard-blocks
|
||||
// Returns hazards grouped into parent-child blocks based on shared category,
|
||||
// component, and zone. The parent hazard in each block has the highest risk.
|
||||
// Children covered by the parent's measures are flagged accordingly.
|
||||
func (h *IACEHandler) GetHazardBlocks(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
|
||||
return
|
||||
}
|
||||
|
||||
assessmentMap, _ := h.store.GetLatestAssessmentsByProject(ctx, projectID)
|
||||
var assessments []iace.RiskAssessment
|
||||
for _, a := range assessmentMap {
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
|
||||
blocks := iace.ComputeHazardBlocks(hazards, assessments, mitigations)
|
||||
|
||||
// Compute summary stats
|
||||
totalBlocks := len(blocks)
|
||||
parentOnly := 0
|
||||
coveredChildren := 0
|
||||
uncoveredChildren := 0
|
||||
for _, b := range blocks {
|
||||
if len(b.Children) == 0 {
|
||||
parentOnly++
|
||||
} else if b.ChildrenCoveredByParent {
|
||||
coveredChildren += len(b.Children)
|
||||
} else {
|
||||
uncoveredChildren += len(b.Children)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocks": blocks,
|
||||
"summary": gin.H{
|
||||
"total_blocks": totalBlocks,
|
||||
"parent_only_blocks": parentOnly,
|
||||
"blocks_with_children": totalBlocks - parentOnly,
|
||||
"total_hazards": len(hazards),
|
||||
"covered_children": coveredChildren,
|
||||
"uncovered_children": uncoveredChildren,
|
||||
"assessments_needed": totalBlocks - parentOnly + uncoveredChildren + parentOnly,
|
||||
"assessments_saved": coveredChildren,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -432,6 +432,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes.POST("/library-search", h.SearchLibrary)
|
||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
||||
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
|
||||
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HazardBlock groups related hazards under a parent hazard.
|
||||
// The parent is the hazard with the highest inherent risk in the group.
|
||||
// Child hazards are covered by the same or similar protective measures.
|
||||
type HazardBlock struct {
|
||||
ParentHazard HazardBlockEntry `json:"parent_hazard"`
|
||||
Children []HazardBlockEntry `json:"children"`
|
||||
BlockKey string `json:"block_key"`
|
||||
SharedMeasureCount int `json:"shared_measure_count"`
|
||||
// If true, the parent's measures cover all children → children
|
||||
// don't need individual risk assessment.
|
||||
ChildrenCoveredByParent bool `json:"children_covered_by_parent"`
|
||||
}
|
||||
|
||||
// HazardBlockEntry is a hazard with its assessment and linked measures.
|
||||
type HazardBlockEntry struct {
|
||||
Hazard Hazard `json:"hazard"`
|
||||
Assessment *RiskAssessment `json:"assessment,omitempty"`
|
||||
MitigationIDs []uuid.UUID `json:"mitigation_ids"`
|
||||
}
|
||||
|
||||
// ComputeHazardBlocks groups hazards into blocks based on category + component.
|
||||
// Within each block, the hazard with the highest risk becomes the parent.
|
||||
// Children whose measures are a subset of the parent's measures are marked as covered.
|
||||
func ComputeHazardBlocks(
|
||||
hazards []Hazard,
|
||||
assessments []RiskAssessment,
|
||||
mitigations []Mitigation,
|
||||
) []HazardBlock {
|
||||
if len(hazards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build assessment lookup: hazard_id → latest assessment
|
||||
assessMap := make(map[uuid.UUID]*RiskAssessment)
|
||||
for i := range assessments {
|
||||
a := &assessments[i]
|
||||
if existing, ok := assessMap[a.HazardID]; !ok || a.Version > existing.Version {
|
||||
assessMap[a.HazardID] = a
|
||||
}
|
||||
}
|
||||
|
||||
// Build mitigation lookup: hazard_id → []mitigation_ids
|
||||
mitsByHazard := make(map[uuid.UUID][]uuid.UUID)
|
||||
for _, m := range mitigations {
|
||||
mitsByHazard[m.HazardID] = append(mitsByHazard[m.HazardID], m.ID)
|
||||
}
|
||||
|
||||
// Group by blockKey = category + ":" + componentID
|
||||
groups := make(map[string][]HazardBlockEntry)
|
||||
for _, h := range hazards {
|
||||
key := buildBlockKey(h)
|
||||
entry := HazardBlockEntry{
|
||||
Hazard: h,
|
||||
Assessment: assessMap[h.ID],
|
||||
MitigationIDs: mitsByHazard[h.ID],
|
||||
}
|
||||
groups[key] = append(groups[key], entry)
|
||||
}
|
||||
|
||||
// Build blocks: sort each group by risk, first is parent
|
||||
var blocks []HazardBlock
|
||||
for key, entries := range groups {
|
||||
sortByRiskDesc(entries, assessMap)
|
||||
|
||||
parent := entries[0]
|
||||
children := entries[1:]
|
||||
|
||||
// Check if parent's measures cover children
|
||||
parentMitSet := toUUIDSet(parent.MitigationIDs)
|
||||
allCovered := true
|
||||
for _, child := range children {
|
||||
if !mitigationsCoveredBy(child, parent, mitigations) {
|
||||
allCovered = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
block := HazardBlock{
|
||||
ParentHazard: parent,
|
||||
Children: children,
|
||||
BlockKey: key,
|
||||
SharedMeasureCount: len(parentMitSet),
|
||||
ChildrenCoveredByParent: allCovered && len(children) > 0,
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
// Sort blocks: largest (most children) first, then by parent risk
|
||||
sort.Slice(blocks, func(i, j int) bool {
|
||||
ri := inherentRisk(blocks[i].ParentHazard, assessMap)
|
||||
rj := inherentRisk(blocks[j].ParentHazard, assessMap)
|
||||
if len(blocks[i].Children) != len(blocks[j].Children) {
|
||||
return len(blocks[i].Children) > len(blocks[j].Children)
|
||||
}
|
||||
return ri > rj
|
||||
})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func buildBlockKey(h Hazard) string {
|
||||
comp := h.ComponentID.String()
|
||||
zone := NormalizeDEPublic(h.HazardousZone)
|
||||
// Use first 3 significant words of zone for grouping
|
||||
words := strings.Fields(zone)
|
||||
var sig []string
|
||||
for _, w := range words {
|
||||
w = strings.Trim(w, ".,;:!?()/-")
|
||||
if len(w) >= 4 {
|
||||
sig = append(sig, w)
|
||||
}
|
||||
if len(sig) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
zoneKey := strings.Join(sig, "_")
|
||||
return h.Category + ":" + comp + ":" + zoneKey
|
||||
}
|
||||
|
||||
func sortByRiskDesc(entries []HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
ri := inherentRisk(entries[i], assessMap)
|
||||
rj := inherentRisk(entries[j], assessMap)
|
||||
return ri > rj
|
||||
})
|
||||
}
|
||||
|
||||
func inherentRisk(entry HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) float64 {
|
||||
if entry.Assessment != nil {
|
||||
return entry.Assessment.InherentRisk
|
||||
}
|
||||
if a, ok := assessMap[entry.Hazard.ID]; ok {
|
||||
return a.InherentRisk
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mitigationsCoveredBy checks if child's measures are functionally covered
|
||||
// by parent's measures (same reduction type and hazard category).
|
||||
func mitigationsCoveredBy(child, parent HazardBlockEntry, allMits []Mitigation) bool {
|
||||
if len(child.MitigationIDs) == 0 {
|
||||
return true // No measures needed → covered by default
|
||||
}
|
||||
|
||||
mitMap := make(map[uuid.UUID]Mitigation)
|
||||
for _, m := range allMits {
|
||||
mitMap[m.ID] = m
|
||||
}
|
||||
|
||||
// Check: for each child mitigation type, parent has same type
|
||||
parentTypes := make(map[ReductionType]bool)
|
||||
for _, mid := range parent.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
parentTypes[m.ReductionType] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, mid := range child.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
if !parentTypes[m.ReductionType] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func toUUIDSet(ids []uuid.UUID) map[uuid.UUID]bool {
|
||||
s := make(map[uuid.UUID]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
s[id] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user