diff --git a/ai-compliance-sdk/data/evidence_requirements/owasp_evidence.jsonl b/ai-compliance-sdk/data/evidence_requirements/owasp_evidence.jsonl new file mode 100644 index 00000000..927d9b2b --- /dev/null +++ b/ai-compliance-sdk/data/evidence_requirements/owasp_evidence.jsonl @@ -0,0 +1,16 @@ +// Evidence-Requirements je OWASP-ASVS-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz. +// Autoriert/kuratiert (nicht Retriever). Der Advisor kann eine CRA-Anforderung erst dann als erfuellt melden, +// wenn die required Evidenzen der gemappten, accepted Controls vorliegen + frisch genug sind. +// Stand 2026-06-25, Basis: die 7 accepted CRA->OWASP-Mappings (Auth V6, Crypto V11, Logging V16). +{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "IAM-/Zugriffskonfiguration als Nachweis der Authentisierungs-Anforderung.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Automatisierter Zugriffstest (CI) belegt funktionierende Zugriffskontrolle.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "pentest", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": false, "rationale": "Jaehrlicher PenTest der Authentisierung — vertieft, aber nicht Pflicht je Release.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V6.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Rollenmodell/Auth-Architektur als Nachweis.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Krypto-Konfiguration (zugelassene Algorithmen) als Nachweis der Verschluesselung.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die eingesetzten Krypto-Bibliotheken/-Versionen nach.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "policy", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": true, "rationale": "Key-Management-Policy (Rotation, Aufbewahrung) als organisatorischer Nachweis.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration der Schluesselverwaltung als technischer Nachweis.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs belegen, dass sicherheitsrelevante Ereignisse protokolliert werden.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Konfiguration als Nachweis der erfassten Ereignisarten.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V16.3.4", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs.", "version": "2026-06-25"} +{"framework": "OWASP ASVS", "control": "V16.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Architektur-Konfiguration als Nachweis.", "version": "2026-06-25"} diff --git a/ai-compliance-sdk/internal/ucca/evidence_requirement.go b/ai-compliance-sdk/internal/ucca/evidence_requirement.go new file mode 100644 index 00000000..7828e9d0 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/evidence_requirement.go @@ -0,0 +1,117 @@ +package ucca + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// EvidenceRequirement is the last edge of the compliance graph: it says WHAT concrete +// evidence proves a framework control is met, and how fresh that evidence must be. This is +// what lets the Advisor eventually state "the CRA requirement is fulfilled" — not because a +// document exists, but because the required, current evidence is present. Authored/curated, +// not retriever-generated. +type EvidenceRequirement struct { + Framework string `json:"framework"` // e.g. "OWASP ASVS" + Control string `json:"control"` // e.g. "V6.3.1" + EvidenceType string `json:"evidence_type"` // sbom|test_report|config_export|repo_scan|policy|ticket|audit_log|pentest + EvidenceSource string `json:"evidence_source"` // github|ci|scanner|manual_upload + FreshnessRequirement string `json:"freshness_requirement"` // per_release|quarterly|annually|continuous + Required bool `json:"required"` + Rationale string `json:"rationale"` + Version string `json:"version"` +} + +// Allowed enum values — the rule layer that keeps the evidence catalog clean. +var ( + evidenceTypeValues = map[string]bool{"sbom": true, "test_report": true, "config_export": true, "repo_scan": true, "policy": true, "ticket": true, "audit_log": true, "pentest": true} + evidenceSourceValues = map[string]bool{"github": true, "ci": true, "scanner": true, "manual_upload": true} + freshnessValues = map[string]bool{"per_release": true, "quarterly": true, "annually": true, "continuous": true} +) + +// Validate checks required fields + enum membership. Fail-closed at load. +func (e EvidenceRequirement) Validate() error { + switch { + case e.Framework == "": + return fmt.Errorf("evidence requirement: framework required") + case e.Control == "": + return fmt.Errorf("evidence requirement: control required") + case !evidenceTypeValues[e.EvidenceType]: + return fmt.Errorf("evidence requirement: invalid evidence_type %q", e.EvidenceType) + case !evidenceSourceValues[e.EvidenceSource]: + return fmt.Errorf("evidence requirement: invalid evidence_source %q", e.EvidenceSource) + case !freshnessValues[e.FreshnessRequirement]: + return fmt.Errorf("evidence requirement: invalid freshness_requirement %q", e.FreshnessRequirement) + } + return nil +} + +// EvidenceRequirementSet is the loaded, indexed evidence catalog. +type EvidenceRequirementSet struct { + All []EvidenceRequirement + byControl map[string][]EvidenceRequirement +} + +// For returns all evidence requirements declared for a framework control. +func (s *EvidenceRequirementSet) For(framework, control string) []EvidenceRequirement { + return s.byControl[controlKey(framework, control)] +} + +// RequiredFor returns only the required evidence for a control — the minimum that must be +// present before the control may be treated as met. +func (s *EvidenceRequirementSet) RequiredFor(framework, control string) []EvidenceRequirement { + out := make([]EvidenceRequirement, 0) + for _, e := range s.byControl[controlKey(framework, control)] { + if e.Required { + out = append(out, e) + } + } + return out +} + +// LoadEvidenceRequirements reads every *.jsonl file under dir (one requirement per line; +// blank and //-prefixed lines ignored), validates each, and builds the per-control index. +// An invalid row aborts the load — fail-closed. +func LoadEvidenceRequirements(dir string) (*EvidenceRequirementSet, error) { + files, err := filepath.Glob(filepath.Join(dir, "*.jsonl")) + if err != nil { + return nil, err + } + set := &EvidenceRequirementSet{byControl: map[string][]EvidenceRequirement{}} + for _, f := range files { + fh, err := os.Open(f) + if err != nil { + return nil, err + } + sc := bufio.NewScanner(fh) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + line := 0 + for sc.Scan() { + line++ + raw := strings.TrimSpace(sc.Text()) + if raw == "" || strings.HasPrefix(raw, "//") { + continue + } + var e EvidenceRequirement + if err := json.Unmarshal([]byte(raw), &e); err != nil { + fh.Close() + return nil, fmt.Errorf("%s:%d: %w", f, line, err) + } + if err := e.Validate(); err != nil { + fh.Close() + return nil, fmt.Errorf("%s:%d: %w", f, line, err) + } + set.All = append(set.All, e) + k := controlKey(e.Framework, e.Control) + set.byControl[k] = append(set.byControl[k], e) + } + fh.Close() + if err := sc.Err(); err != nil { + return nil, err + } + } + return set, nil +} diff --git a/ai-compliance-sdk/internal/ucca/evidence_requirement_test.go b/ai-compliance-sdk/internal/ucca/evidence_requirement_test.go new file mode 100644 index 00000000..53cdb62a --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/evidence_requirement_test.go @@ -0,0 +1,84 @@ +package ucca + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEvidenceRequirement_Validate(t *testing.T) { + valid := EvidenceRequirement{Framework: "OWASP ASVS", Control: "V6.3.1", EvidenceType: "config_export", EvidenceSource: "github", FreshnessRequirement: "per_release", Required: true} + if err := valid.Validate(); err != nil { + t.Fatalf("valid rejected: %v", err) + } + bad := []struct { + name string + e EvidenceRequirement + }{ + {"no control", EvidenceRequirement{Framework: "X", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "per_release"}}, + {"bad evidence_type", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "screenshot", EvidenceSource: "ci", FreshnessRequirement: "per_release"}}, + {"bad evidence_source", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "email", FreshnessRequirement: "per_release"}}, + {"bad freshness", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "weekly"}}, + } + for _, tt := range bad { + if err := tt.e.Validate(); err == nil { + t.Errorf("%s: expected rejection", tt.name) + } + } +} + +func TestLoadEvidenceRequirements(t *testing.T) { + dir := t.TempDir() + content := `// header +{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"config_export","evidence_source":"github","freshness_requirement":"per_release","required":true,"version":"2026-06-25"} +{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"pentest","evidence_source":"manual_upload","freshness_requirement":"annually","required":false,"version":"2026-06-25"} +` + if err := os.WriteFile(filepath.Join(dir, "e.jsonl"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + set, err := LoadEvidenceRequirements(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(set.All) != 2 { + t.Fatalf("want 2, got %d", len(set.All)) + } + if got := set.For("OWASP ASVS", "V6.3.1"); len(got) != 2 { + t.Errorf("For: want 2, got %d", len(got)) + } + if got := set.RequiredFor("OWASP ASVS", "V6.3.1"); len(got) != 1 { + t.Errorf("RequiredFor: want 1 (pentest is optional), got %d", len(got)) + } +} + +func TestEvidenceRequirements_SeedFileValid(t *testing.T) { + set, err := LoadEvidenceRequirements("../../data/evidence_requirements") + if err != nil { + t.Fatalf("seed evidence_requirements failed to load: %v", err) + } + if len(set.All) == 0 { + t.Fatal("seed evidence_requirements is empty") + } +} + +// TestGraph_AcceptedControlsHaveEvidence wires the two layers: every control an accepted +// CRA->OWASP mapping points to must have >=1 required evidence — the Obligation -> Control -> +// Evidence chain must be connected, no dangling control nodes. +func TestGraph_AcceptedControlsHaveEvidence(t *testing.T) { + maps, err := LoadControlMappings("../../data/control_mappings") + if err != nil { + t.Fatal(err) + } + ev, err := LoadEvidenceRequirements("../../data/evidence_requirements") + if err != nil { + t.Fatal(err) + } + for _, m := range maps.All { + if !m.IsAccepted() { + continue + } + if len(ev.RequiredFor(m.TargetFramework, m.TargetControl)) == 0 { + t.Errorf("accepted control %s %s has no required evidence (dangling graph node)", m.TargetFramework, m.TargetControl) + } + } +}