77a497d930
State Graph: - 9 Standard-Betriebszustaende (startup, homing, automatic_operation, manual_operation, teach_mode, maintenance, cleaning, emergency_stop, recovery_mode) - 20 State-Transitions als gerichteter Graph - OperationalStates + StateTransitions Felder in HazardPattern, MatchInput, PatternMatch - patternMatches() filtert Patterns nach Betriebszustand (nil = feuert immer) - Narrative-Parser extrahiert States aus Maschinenbeschreibung (22 Keywords + 4 Transition-Keywords) - 27 bestehende Patterns mit State-Einschraenkungen annotiert (10 operational, 15 maintenance, 2 cobot) - MatchReason um operational_state + state_transition Typen erweitert (Explainability) - 6 neue Tests: NilFiresAlways, MaintenanceFilter, StateTransition, MatchReasons, Count, TransitionValid UCCA fix: - Stabiler Tiebreaker (Pattern-ID aufsteigend) bei gleichem Keyword-Score in MatchByKeywords - Behebt flaky TestControlPatternIndex_MatchByKeywords (1/10 Failure-Rate durch Go map iteration order) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
8.1 KiB
Go
266 lines
8.1 KiB
Go
package ucca
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ControlPattern represents a reusable control pattern template.
|
|
// Pattern ID format: CP-{DOMAIN}-{NNN} (e.g. CP-AUTH-001).
|
|
type ControlPattern struct {
|
|
ID string `yaml:"id" json:"id"`
|
|
Name string `yaml:"name" json:"name"`
|
|
NameDE string `yaml:"name_de" json:"name_de"`
|
|
Domain string `yaml:"domain" json:"domain"`
|
|
Category string `yaml:"category" json:"category"`
|
|
Description string `yaml:"description" json:"description"`
|
|
ObjectiveTemplate string `yaml:"objective_template" json:"objective_template"`
|
|
RationaleTemplate string `yaml:"rationale_template" json:"rationale_template"`
|
|
RequirementsTemplate []string `yaml:"requirements_template" json:"requirements_template"`
|
|
TestProcedureTemplate []string `yaml:"test_procedure_template" json:"test_procedure_template"`
|
|
EvidenceTemplate []string `yaml:"evidence_template" json:"evidence_template"`
|
|
SeverityDefault string `yaml:"severity_default" json:"severity_default"`
|
|
ImplementationEffortDefault string `yaml:"implementation_effort_default,omitempty" json:"implementation_effort_default,omitempty"`
|
|
OpenAnchorRefs []AnchorRef `yaml:"open_anchor_refs,omitempty" json:"open_anchor_refs,omitempty"`
|
|
ObligationMatchKeywords []string `yaml:"obligation_match_keywords" json:"obligation_match_keywords"`
|
|
Tags []string `yaml:"tags" json:"tags"`
|
|
ComposableWith []string `yaml:"composable_with,omitempty" json:"composable_with,omitempty"`
|
|
}
|
|
|
|
// AnchorRef links a pattern to an open-source framework reference.
|
|
type AnchorRef struct {
|
|
Framework string `yaml:"framework" json:"framework"`
|
|
Ref string `yaml:"ref" json:"ref"`
|
|
}
|
|
|
|
// patternFile is the top-level YAML structure.
|
|
type patternFile struct {
|
|
Version string `yaml:"version"`
|
|
Description string `yaml:"description"`
|
|
Patterns []ControlPattern `yaml:"patterns"`
|
|
}
|
|
|
|
// ControlPatternIndex provides fast lookup of control patterns.
|
|
type ControlPatternIndex struct {
|
|
ByID map[string]*ControlPattern
|
|
ByDomain map[string][]*ControlPattern
|
|
ByCategory map[string][]*ControlPattern
|
|
ByTag map[string][]*ControlPattern
|
|
ByKeyword map[string][]*ControlPattern // keyword -> patterns (for obligation matching)
|
|
All []*ControlPattern
|
|
}
|
|
|
|
// LoadControlPatterns loads all YAML pattern files from the control_patterns directory.
|
|
func LoadControlPatterns() (*ControlPatternIndex, error) {
|
|
dir, err := findPatternsDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read patterns directory: %w", err)
|
|
}
|
|
|
|
var allPatterns []ControlPattern
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, "_") {
|
|
continue // skip schema and metadata files
|
|
}
|
|
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
|
|
continue
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read %s: %w", name, err)
|
|
}
|
|
|
|
var pf patternFile
|
|
if err := yaml.Unmarshal(data, &pf); err != nil {
|
|
return nil, fmt.Errorf("failed to parse %s: %w", name, err)
|
|
}
|
|
|
|
allPatterns = append(allPatterns, pf.Patterns...)
|
|
}
|
|
|
|
if len(allPatterns) == 0 {
|
|
return nil, fmt.Errorf("no control patterns found in %s", dir)
|
|
}
|
|
|
|
idx, err := buildPatternIndex(allPatterns)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return idx, nil
|
|
}
|
|
|
|
func findPatternsDir() (string, error) {
|
|
candidates := []string{
|
|
"policies/control_patterns",
|
|
"../policies/control_patterns",
|
|
"../../policies/control_patterns",
|
|
}
|
|
|
|
_, filename, _, ok := runtime.Caller(0)
|
|
if ok {
|
|
srcDir := filepath.Dir(filename)
|
|
candidates = append(candidates,
|
|
filepath.Join(srcDir, "../../policies/control_patterns"),
|
|
)
|
|
}
|
|
|
|
for _, p := range candidates {
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err == nil && info.IsDir() {
|
|
return abs, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("control_patterns directory not found in any candidate path")
|
|
}
|
|
|
|
func buildPatternIndex(patterns []ControlPattern) (*ControlPatternIndex, error) {
|
|
idx := &ControlPatternIndex{
|
|
ByID: make(map[string]*ControlPattern),
|
|
ByDomain: make(map[string][]*ControlPattern),
|
|
ByCategory: make(map[string][]*ControlPattern),
|
|
ByTag: make(map[string][]*ControlPattern),
|
|
ByKeyword: make(map[string][]*ControlPattern),
|
|
}
|
|
|
|
for i := range patterns {
|
|
p := &patterns[i]
|
|
|
|
// Validate ID uniqueness
|
|
if _, exists := idx.ByID[p.ID]; exists {
|
|
return nil, fmt.Errorf("duplicate pattern ID: %s", p.ID)
|
|
}
|
|
|
|
idx.ByID[p.ID] = p
|
|
idx.ByDomain[p.Domain] = append(idx.ByDomain[p.Domain], p)
|
|
idx.ByCategory[p.Category] = append(idx.ByCategory[p.Category], p)
|
|
idx.All = append(idx.All, p)
|
|
|
|
for _, tag := range p.Tags {
|
|
idx.ByTag[tag] = append(idx.ByTag[tag], p)
|
|
}
|
|
|
|
for _, kw := range p.ObligationMatchKeywords {
|
|
lower := strings.ToLower(kw)
|
|
idx.ByKeyword[lower] = append(idx.ByKeyword[lower], p)
|
|
}
|
|
}
|
|
|
|
return idx, nil
|
|
}
|
|
|
|
// GetPattern returns a pattern by its ID (e.g. "CP-AUTH-001").
|
|
func (idx *ControlPatternIndex) GetPattern(id string) (*ControlPattern, bool) {
|
|
p, ok := idx.ByID[strings.ToUpper(id)]
|
|
return p, ok
|
|
}
|
|
|
|
// GetPatternsByDomain returns all patterns for a domain (e.g. "AUTH").
|
|
func (idx *ControlPatternIndex) GetPatternsByDomain(domain string) []*ControlPattern {
|
|
return idx.ByDomain[strings.ToUpper(domain)]
|
|
}
|
|
|
|
// GetPatternsByCategory returns all patterns for a category (e.g. "authentication").
|
|
func (idx *ControlPatternIndex) GetPatternsByCategory(category string) []*ControlPattern {
|
|
return idx.ByCategory[strings.ToLower(category)]
|
|
}
|
|
|
|
// GetPatternsByTag returns all patterns with a given tag.
|
|
func (idx *ControlPatternIndex) GetPatternsByTag(tag string) []*ControlPattern {
|
|
return idx.ByTag[strings.ToLower(tag)]
|
|
}
|
|
|
|
// MatchByKeywords returns patterns whose obligation_match_keywords overlap with
|
|
// the given text. Returns matches sorted by score (number of keyword hits) descending.
|
|
func (idx *ControlPatternIndex) MatchByKeywords(text string) []PatternMatch {
|
|
textLower := strings.ToLower(text)
|
|
scores := make(map[string]int)
|
|
|
|
for kw, patterns := range idx.ByKeyword {
|
|
if strings.Contains(textLower, kw) {
|
|
for _, p := range patterns {
|
|
scores[p.ID]++
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(scores) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Collect and sort by score descending
|
|
matches := make([]PatternMatch, 0, len(scores))
|
|
for id, score := range scores {
|
|
p := idx.ByID[id]
|
|
matches = append(matches, PatternMatch{
|
|
Pattern: p,
|
|
KeywordHits: score,
|
|
TotalKeywords: len(p.ObligationMatchKeywords),
|
|
})
|
|
}
|
|
|
|
// Sort by keyword hits descending, then by pattern ID ascending (stable tiebreaker)
|
|
for i := 1; i < len(matches); i++ {
|
|
for j := i; j > 0; j-- {
|
|
higher := matches[j].KeywordHits > matches[j-1].KeywordHits
|
|
sameTie := matches[j].KeywordHits == matches[j-1].KeywordHits && matches[j].Pattern.ID < matches[j-1].Pattern.ID
|
|
if !higher && !sameTie {
|
|
break
|
|
}
|
|
matches[j], matches[j-1] = matches[j-1], matches[j]
|
|
}
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
// PatternMatch represents a keyword-based match result.
|
|
type PatternMatch struct {
|
|
Pattern *ControlPattern
|
|
KeywordHits int
|
|
TotalKeywords int
|
|
}
|
|
|
|
// Score returns the match score as a ratio of hits to total keywords.
|
|
func (m PatternMatch) Score() float64 {
|
|
if m.TotalKeywords == 0 {
|
|
return 0
|
|
}
|
|
return float64(m.KeywordHits) / float64(m.TotalKeywords)
|
|
}
|
|
|
|
// ValidatePatternID checks if a pattern ID exists in the index.
|
|
func (idx *ControlPatternIndex) ValidatePatternID(id string) bool {
|
|
_, ok := idx.ByID[strings.ToUpper(id)]
|
|
return ok
|
|
}
|
|
|
|
// Domains returns the list of unique domains that have patterns.
|
|
func (idx *ControlPatternIndex) Domains() []string {
|
|
domains := make([]string, 0, len(idx.ByDomain))
|
|
for d := range idx.ByDomain {
|
|
domains = append(domains, d)
|
|
}
|
|
return domains
|
|
}
|