feat(use-case-compiler): MC-based compliance questionnaires with scoring
Build + Deploy / build-admin-compliance (push) Successful in 2m46s
Build + Deploy / build-backend-compliance (push) Successful in 26s
Build + Deploy / build-ai-sdk (push) Successful in 52s
Build + Deploy / build-developer-portal (push) Successful in 22s
Build + Deploy / build-tts (push) Successful in 16s
Build + Deploy / build-document-crawler (push) Successful in 12s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
Build + Deploy / build-dsms-node (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m16s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Successful in 29s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 16s
Build + Deploy / trigger-orca (push) Successful in 2m36s

Implements the Use-Case Compiler that turns Master Controls into
interactive compliance audits. 5 templates (Vendor Check, SAST/DAST,
DSGVO, NIS2, CRA), deterministic + LLM question generation, scoring
engine with regulation/severity breakdown, and gap detection.

- Backend: 9 API endpoints, 22 unit tests (all pass)
- Frontend: Template selector, questionnaire, result dashboard
- Migration 027: usecase_audits + usecase_answers tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-12 13:49:16 +02:00
parent 74f00bbb0f
commit 06bfbd1dca
22 changed files with 3157 additions and 1 deletions
@@ -0,0 +1,315 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
"github.com/breakpilot/ai-compliance-sdk/internal/usecase"
)
// UseCaseHandler handles use-case compiler endpoints.
type UseCaseHandler struct {
store *usecase.Store
compiler *usecase.Compiler
gapDetector *usecase.GapDetector
llmGen *usecase.LLMQuestionGenerator
}
// NewUseCaseHandler creates a new UseCaseHandler.
func NewUseCaseHandler(pool *pgxpool.Pool, registry *llm.ProviderRegistry) *UseCaseHandler {
store := usecase.NewStore(pool)
return &UseCaseHandler{
store: store,
compiler: usecase.NewCompiler(store),
gapDetector: usecase.NewGapDetector(store),
llmGen: usecase.NewLLMQuestionGenerator(registry),
}
}
// GetTemplates returns all available use-case templates.
// GET /sdk/v1/use-case/templates
func (h *UseCaseHandler) GetTemplates(c *gin.Context) {
templates := usecase.TemplateList()
c.JSON(http.StatusOK, gin.H{"templates": templates, "total": len(templates)})
}
// GetTemplate returns a specific template with compiled questions.
// GET /sdk/v1/use-case/templates/:id
func (h *UseCaseHandler) GetTemplate(c *gin.Context) {
id := c.Param("id")
tmpl, ok := usecase.Templates[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
questions, err := h.compiler.Compile(&tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tmpl.Questions = questions
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// Compile generates questions from MC filters ad-hoc.
// POST /sdk/v1/use-case/compile
// Optional: "mode": "llm" to use LLM-based generation
func (h *UseCaseHandler) Compile(c *gin.Context) {
var req struct {
MCFilters []string `json:"mc_filters" binding:"required"`
Regulations []string `json:"regulations"`
Mode string `json:"mode"` // "deterministic" (default) or "llm"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tmpl := &usecase.Template{
ID: "custom",
MCFilters: req.MCFilters,
Regulations: req.Regulations,
}
if req.Mode == "llm" && h.llmGen != nil {
// Fetch MCs first, then generate via LLM
mcs, err := h.store.FetchMCsByFilters(req.MCFilters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
questions, err := h.llmGen.GenerateQuestions(mcs, req.Regulations)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"questions": questions, "total": len(questions), "mode": "llm"})
return
}
questions, err := h.compiler.Compile(tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"questions": questions, "total": len(questions), "mode": "deterministic"})
}
// CreateAudit starts a new audit from a template.
// POST /sdk/v1/use-case/audits
func (h *UseCaseHandler) CreateAudit(c *gin.Context) {
var input usecase.CreateAuditInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
return
}
tmpl, ok := usecase.Templates[input.TemplateID]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown template_id"})
return
}
questions, err := h.compiler.Compile(&tmpl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
audit := &usecase.Audit{
TenantID: tenantID,
TemplateID: input.TemplateID,
Name: input.Name,
TargetName: input.TargetName,
TotalQuestions: len(questions),
Questions: questions,
}
if err := h.store.CreateAudit(audit); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create audit"})
return
}
c.JSON(http.StatusCreated, gin.H{"audit": audit})
}
// ListAudits returns all audits for a tenant.
// GET /sdk/v1/use-case/audits
func (h *UseCaseHandler) ListAudits(c *gin.Context) {
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
return
}
audits, err := h.store.ListAudits(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list audits"})
return
}
c.JSON(http.StatusOK, gin.H{"audits": audits, "total": len(audits)})
}
// GetAudit returns an audit with questions and answers.
// GET /sdk/v1/use-case/audits/:id
func (h *UseCaseHandler) GetAudit(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
answers, err := h.store.ListAnswers(id)
if err != nil {
answers = nil
}
c.JSON(http.StatusOK, gin.H{"audit": audit, "answers": answers})
}
// AnswerQuestion saves an answer for a question in an audit.
// POST /sdk/v1/use-case/audits/:id/answer
func (h *UseCaseHandler) AnswerQuestion(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
var input usecase.AnswerInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Find MC ID from the question
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
var mcID string
for _, q := range audit.Questions {
if q.ID == input.QuestionID {
mcID = q.MCID
break
}
}
status := usecase.AnswerStatusAnswered
if input.Status == "skipped" {
status = usecase.AnswerStatusSkipped
} else if input.Status == "escalated" {
status = usecase.AnswerStatusEscalated
}
answer := &usecase.Answer{
AuditID: auditID,
QuestionID: input.QuestionID,
MCID: mcID,
Value: input.Value,
Comment: input.Comment,
EvidenceIDs: input.EvidenceIDs,
Status: status,
}
if err := h.store.SaveAnswer(answer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save answer"})
return
}
// Update audit counters
answers, _ := h.store.ListAnswers(auditID)
score := usecase.Score(audit, answers)
auditStatus := usecase.StatusInProgress
if score.Answered >= audit.TotalQuestions {
auditStatus = usecase.StatusCompleted
}
h.store.UpdateAuditScore(auditID, score.Answered, score.ComplianceScore, auditStatus)
c.JSON(http.StatusOK, gin.H{"answer": answer, "progress": score})
}
// GetScore calculates and returns the compliance score.
// GET /sdk/v1/use-case/audits/:id/score
func (h *UseCaseHandler) GetScore(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
answers, err := h.store.ListAnswers(auditID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load answers"})
return
}
score := usecase.Score(audit, answers)
c.JSON(http.StatusOK, score)
}
// GetGaps returns missing regulation sources for an audit.
// GET /sdk/v1/use-case/audits/:id/gaps
func (h *UseCaseHandler) GetGaps(c *gin.Context) {
auditID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
return
}
audit, err := h.store.GetAudit(auditID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
return
}
tmpl, ok := usecase.Templates[audit.TemplateID]
if !ok {
c.JSON(http.StatusOK, gin.H{"gaps": []interface{}{}, "audit_gaps": []interface{}{}})
return
}
// Missing regulation sources (from MC analysis)
missingRegs, err := h.gapDetector.DetectMissingRegulations(&tmpl)
if err != nil {
missingRegs = nil
}
// Audit-specific gaps (from answer analysis)
answers, _ := h.store.ListAnswers(auditID)
auditGaps := h.gapDetector.DetectAuditGaps(audit, answers)
c.JSON(http.StatusOK, gin.H{
"missing_sources": missingRegs,
"audit_gaps": auditGaps,
})
}
+4 -1
View File
@@ -155,6 +155,9 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
// Gap Analysis
gapHandler := handlers.NewGapHandler(pool)
// Use-Case Compiler
useCaseHandler := handlers.NewUseCaseHandler(pool, providerRegistry)
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
// Router
@@ -179,7 +182,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
roadmapHandlers, workshopHandlers, portfolioHandlers,
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
gapHandler, maximizerHandlers, regulatoryNewsHandlers)
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
return router
}
+17
View File
@@ -29,6 +29,7 @@ func registerRoutes(
gapHandler *handlers.GapHandler,
maximizerHandlers *handlers.MaximizerHandlers,
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
useCaseHandler *handlers.UseCaseHandler,
) {
v1 := router.Group("/sdk/v1")
{
@@ -51,6 +52,7 @@ func registerRoutes(
registerIACERoutes(v1, iaceHandler)
registerGapRoutes(v1, gapHandler)
registerMaximizerRoutes(v1, maximizerHandlers)
registerUseCaseRoutes(v1, useCaseHandler)
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
}
}
@@ -463,6 +465,21 @@ func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers)
}
}
func registerUseCaseRoutes(v1 *gin.RouterGroup, h *handlers.UseCaseHandler) {
uc := v1.Group("/use-case")
{
uc.GET("/templates", h.GetTemplates)
uc.GET("/templates/:id", h.GetTemplate)
uc.POST("/compile", h.Compile)
uc.POST("/audits", h.CreateAudit)
uc.GET("/audits", h.ListAudits)
uc.GET("/audits/:id", h.GetAudit)
uc.POST("/audits/:id/answer", h.AnswerQuestion)
uc.GET("/audits/:id/score", h.GetScore)
uc.GET("/audits/:id/gaps", h.GetGaps)
}
}
func registerGapRoutes(v1 *gin.RouterGroup, h *handlers.GapHandler) {
g := v1.Group("/gap")
{
@@ -0,0 +1,191 @@
package usecase
import (
"fmt"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Compiler turns Master Controls into audit questionnaires.
type Compiler struct {
store *Store
}
// NewCompiler creates a Compiler.
func NewCompiler(store *Store) *Compiler {
return &Compiler{store: store}
}
// Compile generates questions for a template by combining pre-defined
// questions, existing doc_check_controls, and MC-derived questions.
func (c *Compiler) Compile(tmpl *Template) ([]Question, error) {
// 1. Start with pre-defined template questions
if len(tmpl.Questions) > 0 {
return c.enrichWithMCIDs(tmpl)
}
// 2. Fetch MCs matching the template filters
mcs, err := c.store.FetchMCsByFilters(tmpl.MCFilters)
if err != nil {
return nil, fmt.Errorf("fetch MCs: %w", err)
}
if len(mcs) == 0 {
return nil, fmt.Errorf("no Master Controls found for filters %v", tmpl.MCFilters)
}
// 3. Check for existing doc_check_controls questions
mcIDs := make([]string, len(mcs))
for i, mc := range mcs {
mcIDs[i] = mc.MasterControlID
}
checkQuestions, err := c.store.FetchCheckQuestions(mcIDs)
if err != nil {
return nil, fmt.Errorf("fetch check questions: %w", err)
}
// 4. Generate questions from MCs
var questions []Question
qNum := 1
for _, mc := range mcs {
// Mode A: Use existing doc_check questions
if cqs, ok := checkQuestions[mc.MasterControlID]; ok {
for _, cq := range cqs {
q := Question{
ID: fmt.Sprintf("Q%d", qNum),
MCID: mc.MasterControlID,
MCName: mc.CanonicalName,
Text: cq.Question,
QuestionType: "yes_no",
Severity: normalizeSeverity(cq.Severity),
Regulation: mc.RegSource,
PassCriteria: splitCriteria(cq.PassCriteria),
FailCriteria: splitCriteria(cq.FailCriteria),
}
questions = append(questions, q)
qNum++
}
continue
}
// Mode A fallback: Derive question from MC name
q := Question{
ID: fmt.Sprintf("Q%d", qNum),
MCID: mc.MasterControlID,
MCName: mc.CanonicalName,
Text: deriveQuestion(mc.CanonicalName),
QuestionType: "yes_no",
Severity: inferMCSeverity(mc.CanonicalName),
Regulation: mc.RegSource,
PassCriteria: []string{"Anforderung erfuellt und dokumentiert"},
FailCriteria: []string{"Nicht implementiert oder nicht nachweisbar"},
}
questions = append(questions, q)
qNum++
// Cap at a reasonable number
if qNum > 50 {
break
}
}
return questions, nil
}
// enrichWithMCIDs links pre-defined questions to MCs.
func (c *Compiler) enrichWithMCIDs(tmpl *Template) ([]Question, error) {
mcs, err := c.store.FetchMCsByFilters(tmpl.MCFilters)
if err != nil {
return tmpl.Questions, nil // fallback to questions without MC linkage
}
mcByTopic := make(map[string]MCInfo)
for _, mc := range mcs {
mcByTopic[mc.CanonicalName] = mc
}
questions := make([]Question, len(tmpl.Questions))
copy(questions, tmpl.Questions)
// Try to link questions to MCs by keyword matching
for i := range questions {
if questions[i].MCID != "" {
continue
}
qLower := strings.ToLower(questions[i].Text)
for _, mc := range mcs {
topic := strings.ReplaceAll(mc.CanonicalName, "_", " ")
words := strings.Fields(topic)
matched := 0
for _, w := range words {
if strings.Contains(qLower, w) {
matched++
}
}
if matched >= 2 {
questions[i].MCID = mc.MasterControlID
questions[i].MCName = mc.CanonicalName
break
}
}
}
return questions, nil
}
// deriveQuestion generates a human-readable question from an MC name.
func deriveQuestion(canonicalName string) string {
readable := strings.ReplaceAll(canonicalName, "_", " ")
readable = cases.Title(language.German).String(readable)
return fmt.Sprintf("Ist '%s' implementiert und dokumentiert?", readable)
}
// splitCriteria splits a pipe-separated criteria string.
func splitCriteria(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, "|")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
if len(result) == 0 {
return []string{s}
}
return result
}
// normalizeSeverity maps doc_check severity to our format.
func normalizeSeverity(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
switch s {
case "HIGH", "CRITICAL":
return "HIGH"
case "MEDIUM":
return "MEDIUM"
case "LOW":
return "LOW"
default:
return "MEDIUM"
}
}
// inferMCSeverity guesses severity from the MC topic name.
func inferMCSeverity(name string) string {
high := []string{"encryption", "access_control", "incident", "vulnerability",
"authentication", "key_management", "data_breach", "personal_data",
"consent", "data_transfer"}
for _, h := range high {
if strings.Contains(name, h) {
return "HIGH"
}
}
return "MEDIUM"
}
@@ -0,0 +1,134 @@
package usecase
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
)
// LLMQuestionGenerator uses an LLM to create questions from MC metadata
// when no pre-defined questions or doc_check_controls exist (Mode B).
type LLMQuestionGenerator struct {
registry *llm.ProviderRegistry
}
// NewLLMQuestionGenerator creates a new LLM-based generator.
func NewLLMQuestionGenerator(registry *llm.ProviderRegistry) *LLMQuestionGenerator {
return &LLMQuestionGenerator{registry: registry}
}
// llmQuestion is the JSON structure we expect from the LLM.
type llmQuestion struct {
Question string `json:"question"`
PassCriteria []string `json:"pass_criteria"`
FailCriteria []string `json:"fail_criteria"`
Severity string `json:"severity"`
}
// GenerateQuestions generates questions for a list of MCs using the LLM.
func (g *LLMQuestionGenerator) GenerateQuestions(mcs []MCInfo, regulations []string) ([]Question, error) {
if g.registry == nil {
return nil, fmt.Errorf("no LLM provider configured")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var questions []Question
qNum := 1
for _, mc := range mcs {
prompt := buildPrompt(mc, regulations)
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: prompt},
},
Temperature: 0.3,
MaxTokens: 500,
})
if err != nil {
// Fallback to deterministic generation
questions = append(questions, GenerateFromMC(mc)...)
qNum += len(GenerateFromMC(mc))
continue
}
parsed := parseLLMResponse(resp.Message.Content)
for _, lq := range parsed {
q := Question{
ID: fmt.Sprintf("Q%d", qNum),
MCID: mc.MasterControlID,
MCName: mc.CanonicalName,
Text: lq.Question,
QuestionType: "yes_no",
Severity: normalizeSeverity(lq.Severity),
Regulation: mc.RegSource,
PassCriteria: lq.PassCriteria,
FailCriteria: lq.FailCriteria,
}
questions = append(questions, q)
qNum++
}
// Cap total questions
if qNum > 50 {
break
}
}
return questions, nil
}
const systemPrompt = `Du bist ein Compliance-Experte. Generiere praezise Prueffragen fuer Compliance-Audits.
Antworte NUR mit einem JSON-Array. Jedes Element hat:
- "question": Eine klare Ja/Nein-Frage auf Deutsch
- "pass_criteria": Array mit 1-2 Kriterien fuer "bestanden"
- "fail_criteria": Array mit 1-2 Kriterien fuer "nicht bestanden"
- "severity": "HIGH", "MEDIUM" oder "LOW"
Keine Erklaerungen, nur das JSON-Array.`
func buildPrompt(mc MCInfo, regulations []string) string {
readable := strings.ReplaceAll(mc.CanonicalName, "_", " ")
regStr := strings.Join(regulations, ", ")
return fmt.Sprintf(
`Master Control: "%s" (%d Atomic Controls)
Regulierungen: %s
Regulation Source: %s
Generiere 1-2 praezise Prueffragen fuer diesen Master Control.`,
readable, mc.TotalControls, regStr, mc.RegSource)
}
func parseLLMResponse(content string) []llmQuestion {
content = strings.TrimSpace(content)
// Try to find JSON array in the response
start := strings.Index(content, "[")
end := strings.LastIndex(content, "]")
if start >= 0 && end > start {
content = content[start : end+1]
}
var questions []llmQuestion
if err := json.Unmarshal([]byte(content), &questions); err != nil {
return nil
}
// Validate
var valid []llmQuestion
for _, q := range questions {
if q.Question != "" && len(q.PassCriteria) > 0 {
valid = append(valid, q)
}
}
return valid
}
@@ -0,0 +1,102 @@
package usecase
import (
"testing"
)
func TestParseLLMResponse_ValidJSON(t *testing.T) {
input := `[
{
"question": "Ist eine Datenschutz-Folgenabschaetzung durchgefuehrt?",
"pass_criteria": ["DSFA dokumentiert"],
"fail_criteria": ["Keine DSFA"],
"severity": "HIGH"
},
{
"question": "Sind Betroffenenrechte implementiert?",
"pass_criteria": ["Prozess vorhanden"],
"fail_criteria": ["Kein Prozess"],
"severity": "MEDIUM"
}
]`
result := parseLLMResponse(input)
if len(result) != 2 {
t.Fatalf("Expected 2 questions, got %d", len(result))
}
if result[0].Question != "Ist eine Datenschutz-Folgenabschaetzung durchgefuehrt?" {
t.Errorf("Unexpected question: %s", result[0].Question)
}
if result[0].Severity != "HIGH" {
t.Errorf("Expected HIGH severity, got %s", result[0].Severity)
}
}
func TestParseLLMResponse_WithPreamble(t *testing.T) {
input := `Hier sind die Prueffragen:
[{"question":"Test?","pass_criteria":["OK"],"fail_criteria":["NOK"],"severity":"LOW"}]
Ich hoffe das hilft.`
result := parseLLMResponse(input)
if len(result) != 1 {
t.Fatalf("Expected 1 question from wrapped response, got %d", len(result))
}
}
func TestParseLLMResponse_InvalidJSON(t *testing.T) {
result := parseLLMResponse("This is not JSON at all")
if result != nil {
t.Errorf("Expected nil for invalid JSON, got %v", result)
}
}
func TestParseLLMResponse_EmptyQuestion(t *testing.T) {
input := `[
{"question":"","pass_criteria":["OK"],"fail_criteria":["NOK"],"severity":"HIGH"},
{"question":"Valid?","pass_criteria":["Yes"],"fail_criteria":["No"],"severity":"LOW"}
]`
result := parseLLMResponse(input)
if len(result) != 1 {
t.Fatalf("Expected 1 valid question (empty filtered), got %d", len(result))
}
if result[0].Question != "Valid?" {
t.Errorf("Unexpected question: %s", result[0].Question)
}
}
func TestBuildPrompt(t *testing.T) {
mc := MCInfo{
MasterControlID: "MC-123",
CanonicalName: "access_control_mfa",
TotalControls: 12,
RegSource: "NIS2",
}
prompt := buildPrompt(mc, []string{"nis2", "dsgvo"})
if prompt == "" {
t.Error("Expected non-empty prompt")
}
if !contains(prompt, "access control mfa") {
t.Error("Prompt should contain readable MC name")
}
if !contains(prompt, "12 Atomic Controls") {
t.Error("Prompt should contain control count")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstring(s, sub))
}
func containsSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
@@ -0,0 +1,186 @@
package usecase
import (
"testing"
)
func TestTemplates_AllPresent(t *testing.T) {
expected := []string{
"vendor_check_cloud",
"sast_dast_audit",
"dsgvo_quick_check",
"nis2_readiness",
"cra_product_check",
}
for _, id := range expected {
if _, ok := Templates[id]; !ok {
t.Errorf("Template %q missing from Templates map", id)
}
}
}
func TestTemplates_HaveQuestions(t *testing.T) {
for id, tmpl := range Templates {
if len(tmpl.Questions) == 0 {
t.Errorf("Template %q has no pre-defined questions", id)
}
if tmpl.Name == "" {
t.Errorf("Template %q has no name", id)
}
if len(tmpl.MCFilters) == 0 {
t.Errorf("Template %q has no MC filters", id)
}
if len(tmpl.Regulations) == 0 {
t.Errorf("Template %q has no regulations", id)
}
}
}
func TestTemplates_QuestionIDs_Unique(t *testing.T) {
for id, tmpl := range Templates {
seen := make(map[string]bool)
for _, q := range tmpl.Questions {
if seen[q.ID] {
t.Errorf("Template %q has duplicate question ID %q", id, q.ID)
}
seen[q.ID] = true
}
}
}
func TestTemplateList_ReturnsAll(t *testing.T) {
list := TemplateList()
if len(list) != len(Templates) {
t.Errorf("TemplateList returned %d, expected %d", len(list), len(Templates))
}
}
func TestDeriveQuestion(t *testing.T) {
tests := []struct {
input string
want string
}{
{"encryption_at_rest", "Ist 'Encryption At Rest' implementiert und dokumentiert?"},
{"access_control", "Ist 'Access Control' implementiert und dokumentiert?"},
}
for _, tt := range tests {
got := deriveQuestion(tt.input)
if got != tt.want {
t.Errorf("deriveQuestion(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSplitCriteria(t *testing.T) {
tests := []struct {
input string
expect int
}{
{"", 0},
{"Single criteria", 1},
{"A | B | C", 3},
{"A|B", 2},
}
for _, tt := range tests {
got := splitCriteria(tt.input)
if got == nil && tt.expect == 0 {
continue
}
if len(got) != tt.expect {
t.Errorf("splitCriteria(%q) returned %d items, want %d", tt.input, len(got), tt.expect)
}
}
}
func TestNormalizeSeverity(t *testing.T) {
tests := []struct {
input string
want string
}{
{"HIGH", "HIGH"},
{"high", "HIGH"},
{"CRITICAL", "HIGH"},
{"medium", "MEDIUM"},
{"LOW", "LOW"},
{"unknown", "MEDIUM"},
{"", "MEDIUM"},
}
for _, tt := range tests {
got := normalizeSeverity(tt.input)
if got != tt.want {
t.Errorf("normalizeSeverity(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestInferMCSeverity(t *testing.T) {
tests := []struct {
name string
want string
}{
{"encryption_at_rest_aes256", "HIGH"},
{"access_control_mfa", "HIGH"},
{"incident_response_plan", "HIGH"},
{"documentation_management", "MEDIUM"},
{"training_awareness", "MEDIUM"},
}
for _, tt := range tests {
got := inferMCSeverity(tt.name)
if got != tt.want {
t.Errorf("inferMCSeverity(%q) = %q, want %q", tt.name, got, tt.want)
}
}
}
func TestGenerateFromMC_SmallMC(t *testing.T) {
mc := MCInfo{
MasterControlID: "MC-123",
CanonicalName: "access_control_basic",
TotalControls: 3,
RegSource: "DSGVO",
}
questions := GenerateFromMC(mc)
if len(questions) != 1 {
t.Errorf("Expected 1 question for small MC, got %d", len(questions))
}
if questions[0].Severity != "HIGH" {
t.Errorf("Expected HIGH severity for access_control, got %s", questions[0].Severity)
}
}
func TestGenerateFromMC_MediumMC(t *testing.T) {
mc := MCInfo{
MasterControlID: "MC-456",
CanonicalName: "documentation_management",
TotalControls: 8,
RegSource: "NIS2",
}
questions := GenerateFromMC(mc)
if len(questions) != 2 {
t.Errorf("Expected 2 questions for medium MC, got %d", len(questions))
}
if questions[1].DependsOn == "" {
t.Error("Second question should depend on first")
}
}
func TestGenerateFromMC_LargeMC(t *testing.T) {
mc := MCInfo{
MasterControlID: "MC-789",
CanonicalName: "risk_management_framework",
TotalControls: 25,
RegSource: "NIS2",
}
questions := GenerateFromMC(mc)
if len(questions) != 3 {
t.Errorf("Expected 3 questions for large MC, got %d", len(questions))
}
}
@@ -0,0 +1,185 @@
package usecase
import (
"fmt"
"strings"
)
// GapDetector identifies missing regulations for a use-case template.
type GapDetector struct {
store *Store
}
// NewGapDetector creates a GapDetector.
func NewGapDetector(store *Store) *GapDetector {
return &GapDetector{store: store}
}
// DetectMissingRegulations finds MCs with insufficient source citations.
func (d *GapDetector) DetectMissingRegulations(tmpl *Template) ([]MissingSource, error) {
mcs, err := d.store.FetchMCsByFilters(tmpl.MCFilters)
if err != nil {
return nil, fmt.Errorf("fetch MCs: %w", err)
}
if len(mcs) == 0 {
return nil, nil
}
mcIDs := make([]string, len(mcs))
for i, mc := range mcs {
mcIDs[i] = mc.MasterControlID
}
citations, err := d.store.CountMCSourceCitations(mcIDs)
if err != nil {
return nil, fmt.Errorf("count citations: %w", err)
}
var gaps []MissingSource
for _, mc := range mcs {
citCount := citations[mc.MasterControlID]
// MC with many controls but few citations → gap
if mc.TotalControls > 20 && citCount < 3 {
missing := identifyMissingRegulation(mc, tmpl.Regulations)
if missing != nil {
gaps = append(gaps, *missing)
}
}
// MC topic implies a regulation that's not in source citations
expectedRegs := expectedRegulations(mc.CanonicalName)
for _, expected := range expectedRegs {
if !containsRegulation(tmpl.Regulations, expected.regID) {
continue
}
if mc.RegSource == "" || !strings.Contains(mc.RegSource, expected.keyword) {
gaps = append(gaps, MissingSource{
Regulation: expected.name,
AffectsMCs: []string{mc.CanonicalName},
EstimatedGap: mc.TotalControls / 3,
SourceURL: expected.url,
Priority: expected.priority,
})
}
}
}
return deduplicateGaps(gaps), nil
}
// DetectAuditGaps checks an audit's answers for regulation-specific gaps.
func (d *GapDetector) DetectAuditGaps(audit *Audit, answers []Answer) []MissingSource {
answerMap := make(map[string]Answer)
for _, a := range answers {
answerMap[a.QuestionID] = a
}
// Find regulations with many failures
failsByReg := make(map[string]int)
totalByReg := make(map[string]int)
for _, q := range audit.Questions {
if q.Regulation == "" {
continue
}
totalByReg[q.Regulation]++
a, ok := answerMap[q.ID]
if ok && !isPassed(a) {
failsByReg[q.Regulation]++
}
}
var gaps []MissingSource
for reg, fails := range failsByReg {
total := totalByReg[reg]
if total > 0 && float64(fails)/float64(total) > 0.5 {
gaps = append(gaps, MissingSource{
Regulation: reg,
AffectsMCs: []string{audit.TemplateID},
EstimatedGap: fails,
Priority: "high",
})
}
}
return gaps
}
type expectedReg struct {
regID string
name string
keyword string
url string
priority string
}
func expectedRegulations(mcName string) []expectedReg {
mappings := []struct {
prefix string
regs []expectedReg
}{
{"data_processing_agreement", []expectedReg{
{regID: "dsgvo", name: "DSGVO (EU) 2016/679", keyword: "DSGVO", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32016R0679", priority: "high"},
}},
{"incident_", []expectedReg{
{regID: "nis2", name: "NIS2-Richtlinie (EU) 2022/2555", keyword: "NIS2", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022L2555", priority: "high"},
}},
{"vulnerability_", []expectedReg{
{regID: "cra", name: "Cyber Resilience Act (CRA)", keyword: "CRA", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024R2847", priority: "high"},
}},
{"aml_", []expectedReg{
{regID: "aml", name: "5. Geldwaescherichtlinie (EU) 2024/1624", keyword: "Geldwaesche", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024L1624", priority: "high"},
}},
}
var result []expectedReg
for _, m := range mappings {
if strings.HasPrefix(mcName, m.prefix) {
result = append(result, m.regs...)
}
}
return result
}
func identifyMissingRegulation(mc MCInfo, templateRegs []string) *MissingSource {
if mc.RegSource != "" {
return nil
}
return &MissingSource{
Regulation: fmt.Sprintf("Unbekannte Quelle fuer '%s'", mc.CanonicalName),
AffectsMCs: []string{mc.CanonicalName},
EstimatedGap: mc.TotalControls,
Priority: "medium",
}
}
func containsRegulation(regs []string, id string) bool {
for _, r := range regs {
if strings.EqualFold(r, id) {
return true
}
}
return true // if template doesn't restrict, always check
}
func deduplicateGaps(gaps []MissingSource) []MissingSource {
seen := make(map[string]*MissingSource)
for i := range gaps {
key := gaps[i].Regulation
if existing, ok := seen[key]; ok {
existing.AffectsMCs = append(existing.AffectsMCs, gaps[i].AffectsMCs...)
existing.EstimatedGap += gaps[i].EstimatedGap
} else {
copy := gaps[i]
seen[key] = &copy
}
}
result := make([]MissingSource, 0, len(seen))
for _, g := range seen {
result = append(result, *g)
}
return result
}
@@ -0,0 +1,146 @@
// Package usecase implements the Use-Case Compiler that turns
// Master Controls into interactive compliance questionnaires.
package usecase
import (
"time"
"github.com/google/uuid"
)
// ── Use-Case Template ──────────────────────────────────────────────
// Template defines a reusable compliance audit blueprint.
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
MCFilters []string `json:"mc_filters"`
Regulations []string `json:"regulations"`
Questions []Question `json:"questions,omitempty"`
}
// ── Question ───────────────────────────────────────────────────────
// Question is a single compliance check derived from a Master Control.
type Question struct {
ID string `json:"id"`
MCID string `json:"mc_id"`
MCName string `json:"mc_name"`
Text string `json:"question"`
QuestionType string `json:"question_type"`
EvidenceRequired bool `json:"evidence_required"`
PassCriteria []string `json:"pass_criteria"`
FailCriteria []string `json:"fail_criteria"`
Severity string `json:"severity"`
Regulation string `json:"regulation"`
DependsOn string `json:"depends_on,omitempty"`
}
// ── Audit ──────────────────────────────────────────────────────────
// AuditStatus enumerates the lifecycle of an audit.
type AuditStatus string
const (
StatusDraft AuditStatus = "draft"
StatusInProgress AuditStatus = "in_progress"
StatusCompleted AuditStatus = "completed"
)
// Audit is a running or completed compliance questionnaire.
type Audit struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
TemplateID string `json:"template_id"`
Name string `json:"name"`
TargetName string `json:"target_name,omitempty"`
Status AuditStatus `json:"status"`
TotalQuestions int `json:"total_questions"`
AnsweredQuestions int `json:"answered_questions"`
ComplianceScore float64 `json:"compliance_score"`
Questions []Question `json:"questions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// ── Answer ─────────────────────────────────────────────────────────
// AnswerStatus enumerates how a question was handled.
type AnswerStatus string
const (
AnswerStatusAnswered AnswerStatus = "answered"
AnswerStatusSkipped AnswerStatus = "skipped"
AnswerStatusEscalated AnswerStatus = "escalated"
)
// Answer stores a user's response to a single question.
type Answer struct {
ID uuid.UUID `json:"id"`
AuditID uuid.UUID `json:"audit_id"`
QuestionID string `json:"question_id"`
MCID string `json:"mc_id,omitempty"`
Value interface{} `json:"value"`
Comment string `json:"comment,omitempty"`
EvidenceIDs []string `json:"evidence_ids"`
Status AnswerStatus `json:"status"`
AnsweredAt time.Time `json:"answered_at"`
}
// AnswerInput is the request payload for answering a question.
type AnswerInput struct {
QuestionID string `json:"question_id" binding:"required"`
Value interface{} `json:"value" binding:"required"`
Comment string `json:"comment"`
EvidenceIDs []string `json:"evidence_ids"`
Status string `json:"status"`
}
// ── Scoring ────────────────────────────────────────────────────────
// ScoreResult is the compliance summary for an audit.
type ScoreResult struct {
AuditID uuid.UUID `json:"audit_id"`
TotalQuestions int `json:"total_questions"`
Answered int `json:"answered"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
ComplianceScore float64 `json:"compliance_score"`
ByRegulation map[string]RegulationScore `json:"by_regulation"`
BySeverity map[string]SeverityScore `json:"by_severity"`
}
// RegulationScore breaks down results per regulation.
type RegulationScore struct {
Total int `json:"total"`
Passed int `json:"passed"`
Score float64 `json:"score"`
}
// SeverityScore breaks down results per severity.
type SeverityScore struct {
Total int `json:"total"`
Passed int `json:"passed"`
Failed int `json:"failed"`
}
// ── Gap Detection ──────────────────────────────────────────────────
// MissingSource describes a regulation not yet covered by MCs.
type MissingSource struct {
Regulation string `json:"regulation"`
AffectsMCs []string `json:"affects_mcs"`
EstimatedGap int `json:"estimated_controls"`
SourceURL string `json:"source_url,omitempty"`
Priority string `json:"priority"`
}
// CreateAuditInput is the request to start a new audit.
type CreateAuditInput struct {
TemplateID string `json:"template_id" binding:"required"`
Name string `json:"name" binding:"required"`
TargetName string `json:"target_name"`
}
@@ -0,0 +1,67 @@
package usecase
import (
"fmt"
"strings"
)
// QuestionGenerator creates questions from MC metadata when no
// pre-defined questions or doc_check_controls exist (Mode A fallback).
// For LLM-based generation (Mode B), see compiler_llm.go (Phase 7).
// GenerateFromMC derives 1-3 questions from a single MC.
func GenerateFromMC(mc MCInfo) []Question {
name := mc.CanonicalName
readable := strings.ReplaceAll(name, "_", " ")
var questions []Question
qBase := fmt.Sprintf("MC-%s", mc.MasterControlID)
// Primary question: is the control implemented?
questions = append(questions, Question{
ID: qBase + "-1",
MCID: mc.MasterControlID,
MCName: name,
Text: fmt.Sprintf("Ist '%s' in Ihrem Unternehmen implementiert?", readable),
QuestionType: "yes_no",
Severity: inferMCSeverity(name),
Regulation: mc.RegSource,
PassCriteria: []string{"Massnahme implementiert und aktiv"},
FailCriteria: []string{"Nicht implementiert"},
})
// Secondary question: is there documentation?
if mc.TotalControls >= 5 {
questions = append(questions, Question{
ID: qBase + "-2",
MCID: mc.MasterControlID,
MCName: name,
Text: fmt.Sprintf("Ist '%s' dokumentiert und nachweisbar?", readable),
QuestionType: "yes_no",
EvidenceRequired: true,
Severity: "MEDIUM",
Regulation: mc.RegSource,
PassCriteria: []string{"Dokumentation vorhanden und aktuell"},
FailCriteria: []string{"Keine oder veraltete Dokumentation"},
DependsOn: qBase + "-1",
})
}
// Tertiary question for large MCs: review cycle
if mc.TotalControls >= 15 {
questions = append(questions, Question{
ID: qBase + "-3",
MCID: mc.MasterControlID,
MCName: name,
Text: fmt.Sprintf("Wird '%s' regelmaessig ueberprueft und aktualisiert?", readable),
QuestionType: "yes_no",
Severity: "LOW",
Regulation: mc.RegSource,
PassCriteria: []string{"Regelmaessiger Review-Zyklus definiert"},
FailCriteria: []string{"Kein Review-Prozess"},
DependsOn: qBase + "-1",
})
}
return questions
}
@@ -0,0 +1,92 @@
package usecase
// Score calculates a compliance score from answers and questions.
func Score(audit *Audit, answers []Answer) *ScoreResult {
result := &ScoreResult{
AuditID: audit.ID,
TotalQuestions: len(audit.Questions),
ByRegulation: make(map[string]RegulationScore),
BySeverity: make(map[string]SeverityScore),
}
answerMap := make(map[string]Answer)
for _, a := range answers {
answerMap[a.QuestionID] = a
}
for _, q := range audit.Questions {
a, answered := answerMap[q.ID]
if !answered {
continue
}
result.Answered++
passed := isPassed(a)
switch a.Status {
case AnswerStatusSkipped:
result.Skipped++
default:
if passed {
result.Passed++
} else {
result.Failed++
}
}
// By regulation
if q.Regulation != "" {
rs := result.ByRegulation[q.Regulation]
rs.Total++
if passed {
rs.Passed++
}
result.ByRegulation[q.Regulation] = rs
}
// By severity
sev := q.Severity
if sev == "" {
sev = "MEDIUM"
}
ss := result.BySeverity[sev]
ss.Total++
if passed {
ss.Passed++
} else {
ss.Failed++
}
result.BySeverity[sev] = ss
}
// Calculate scores
if result.Answered > 0 {
answerable := result.Answered - result.Skipped
if answerable > 0 {
result.ComplianceScore = float64(result.Passed) / float64(answerable) * 100
}
}
for reg, rs := range result.ByRegulation {
if rs.Total > 0 {
rs.Score = float64(rs.Passed) / float64(rs.Total) * 100
}
result.ByRegulation[reg] = rs
}
return result
}
// isPassed checks if an answer represents a pass.
func isPassed(a Answer) bool {
switch v := a.Value.(type) {
case bool:
return v
case string:
return v == "yes" || v == "true" || v == "ja"
case float64:
return v > 0
default:
return false
}
}
@@ -0,0 +1,173 @@
package usecase
import (
"testing"
"github.com/google/uuid"
)
func TestScore_AllPassed(t *testing.T) {
audit := &Audit{
ID: uuid.New(),
Questions: []Question{
{ID: "Q1", Severity: "HIGH", Regulation: "DSGVO"},
{ID: "Q2", Severity: "MEDIUM", Regulation: "NIS2"},
{ID: "Q3", Severity: "LOW", Regulation: "DSGVO"},
},
}
answers := []Answer{
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q2", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
}
result := Score(audit, answers)
if result.ComplianceScore != 100 {
t.Errorf("Expected 100%% score, got %.1f%%", result.ComplianceScore)
}
if result.Passed != 3 {
t.Errorf("Expected 3 passed, got %d", result.Passed)
}
if result.Failed != 0 {
t.Errorf("Expected 0 failed, got %d", result.Failed)
}
}
func TestScore_MixedResults(t *testing.T) {
audit := &Audit{
ID: uuid.New(),
Questions: []Question{
{ID: "Q1", Severity: "HIGH", Regulation: "DSGVO"},
{ID: "Q2", Severity: "HIGH", Regulation: "DSGVO"},
{ID: "Q3", Severity: "MEDIUM", Regulation: "NIS2"},
{ID: "Q4", Severity: "LOW", Regulation: "NIS2"},
},
}
answers := []Answer{
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q2", Value: false, Status: AnswerStatusAnswered},
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q4", Value: false, Status: AnswerStatusAnswered},
}
result := Score(audit, answers)
if result.ComplianceScore != 50 {
t.Errorf("Expected 50%% score, got %.1f%%", result.ComplianceScore)
}
if result.Passed != 2 {
t.Errorf("Expected 2 passed, got %d", result.Passed)
}
if result.Failed != 2 {
t.Errorf("Expected 2 failed, got %d", result.Failed)
}
// Check regulation breakdown
dsgvo := result.ByRegulation["DSGVO"]
if dsgvo.Total != 2 || dsgvo.Passed != 1 {
t.Errorf("DSGVO: expected 1/2, got %d/%d", dsgvo.Passed, dsgvo.Total)
}
nis2 := result.ByRegulation["NIS2"]
if nis2.Total != 2 || nis2.Passed != 1 {
t.Errorf("NIS2: expected 1/2, got %d/%d", nis2.Passed, nis2.Total)
}
}
func TestScore_WithSkipped(t *testing.T) {
audit := &Audit{
ID: uuid.New(),
Questions: []Question{
{ID: "Q1", Severity: "HIGH"},
{ID: "Q2", Severity: "MEDIUM"},
},
}
answers := []Answer{
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q2", Value: nil, Status: AnswerStatusSkipped},
}
result := Score(audit, answers)
if result.Skipped != 1 {
t.Errorf("Expected 1 skipped, got %d", result.Skipped)
}
if result.ComplianceScore != 100 {
t.Errorf("Expected 100%% (1 passed / 1 answerable), got %.1f%%", result.ComplianceScore)
}
}
func TestScore_NoAnswers(t *testing.T) {
audit := &Audit{
ID: uuid.New(),
Questions: []Question{
{ID: "Q1", Severity: "HIGH"},
},
}
result := Score(audit, nil)
if result.ComplianceScore != 0 {
t.Errorf("Expected 0%% score, got %.1f%%", result.ComplianceScore)
}
if result.Answered != 0 {
t.Errorf("Expected 0 answered, got %d", result.Answered)
}
}
func TestScore_BySeverity(t *testing.T) {
audit := &Audit{
ID: uuid.New(),
Questions: []Question{
{ID: "Q1", Severity: "HIGH"},
{ID: "Q2", Severity: "HIGH"},
{ID: "Q3", Severity: "MEDIUM"},
},
}
answers := []Answer{
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
{QuestionID: "Q2", Value: false, Status: AnswerStatusAnswered},
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
}
result := Score(audit, answers)
high := result.BySeverity["HIGH"]
if high.Total != 2 || high.Passed != 1 || high.Failed != 1 {
t.Errorf("HIGH: expected 1/2 (1 fail), got %d/%d (%d fail)",
high.Passed, high.Total, high.Failed)
}
med := result.BySeverity["MEDIUM"]
if med.Total != 1 || med.Passed != 1 {
t.Errorf("MEDIUM: expected 1/1, got %d/%d", med.Passed, med.Total)
}
}
func TestIsPassed_BoolValues(t *testing.T) {
tests := []struct {
name string
value interface{}
expect bool
}{
{"true bool", true, true},
{"false bool", false, false},
{"yes string", "yes", true},
{"no string", "no", false},
{"ja string", "ja", true},
{"positive float", float64(1), true},
{"zero float", float64(0), false},
{"nil value", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Answer{Value: tt.value, Status: AnswerStatusAnswered}
got := isPassed(a)
if got != tt.expect {
t.Errorf("isPassed(%v) = %v, want %v", tt.value, got, tt.expect)
}
})
}
}
+347
View File
@@ -0,0 +1,347 @@
package usecase
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles database operations for use-case audits.
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new Store.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ── Audit CRUD ─────────────────────────────────────────────────────
// CreateAudit inserts a new audit.
func (s *Store) CreateAudit(a *Audit) error {
ctx := context.Background()
a.ID = uuid.New()
a.CreatedAt = time.Now()
a.UpdatedAt = time.Now()
a.Status = StatusDraft
questionsJSON, err := json.Marshal(a.Questions)
if err != nil {
return fmt.Errorf("marshal questions: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO compliance.usecase_audits
(id, tenant_id, template_id, name, target_name, status,
total_questions, answered_questions, compliance_score,
questions, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
a.ID, a.TenantID, a.TemplateID, a.Name, a.TargetName,
a.Status, a.TotalQuestions, a.AnsweredQuestions, a.ComplianceScore,
questionsJSON, a.CreatedAt, a.UpdatedAt,
)
return err
}
// GetAudit loads an audit by ID.
func (s *Store) GetAudit(id uuid.UUID) (*Audit, error) {
ctx := context.Background()
a := &Audit{}
var questionsJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, template_id, name, target_name, status,
total_questions, answered_questions, compliance_score,
questions, created_at, updated_at, completed_at
FROM compliance.usecase_audits WHERE id = $1`, id,
).Scan(
&a.ID, &a.TenantID, &a.TemplateID, &a.Name, &a.TargetName,
&a.Status, &a.TotalQuestions, &a.AnsweredQuestions,
&a.ComplianceScore, &questionsJSON,
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
)
if err != nil {
return nil, err
}
if len(questionsJSON) > 0 {
json.Unmarshal(questionsJSON, &a.Questions)
}
return a, nil
}
// ListAudits returns all audits for a tenant.
func (s *Store) ListAudits(tenantID uuid.UUID) ([]Audit, error) {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, template_id, name, target_name, status,
total_questions, answered_questions, compliance_score,
created_at, updated_at, completed_at
FROM compliance.usecase_audits
WHERE tenant_id = $1
ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var audits []Audit
for rows.Next() {
var a Audit
a.TenantID = tenantID
if err := rows.Scan(
&a.ID, &a.TemplateID, &a.Name, &a.TargetName, &a.Status,
&a.TotalQuestions, &a.AnsweredQuestions, &a.ComplianceScore,
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
); err != nil {
return nil, err
}
audits = append(audits, a)
}
return audits, nil
}
// UpdateAuditScore updates the score and status of an audit.
func (s *Store) UpdateAuditScore(id uuid.UUID, answered int, score float64, status AuditStatus) error {
ctx := context.Background()
now := time.Now()
query := `
UPDATE compliance.usecase_audits
SET answered_questions = $2, compliance_score = $3,
status = $4, updated_at = $5`
args := []interface{}{id, answered, score, status, now}
if status == StatusCompleted {
query += `, completed_at = $6 WHERE id = $1`
args = append(args, now)
} else {
query += ` WHERE id = $1`
}
_, err := s.pool.Exec(ctx, query, args...)
return err
}
// ── Answer CRUD ────────────────────────────────────────────────────
// SaveAnswer upserts an answer (INSERT ... ON CONFLICT UPDATE).
func (s *Store) SaveAnswer(a *Answer) error {
ctx := context.Background()
a.ID = uuid.New()
a.AnsweredAt = time.Now()
answerJSON, err := json.Marshal(map[string]interface{}{
"value": a.Value,
"comment": a.Comment,
})
if err != nil {
return fmt.Errorf("marshal answer: %w", err)
}
evidenceJSON, _ := json.Marshal(a.EvidenceIDs)
_, err = s.pool.Exec(ctx, `
INSERT INTO compliance.usecase_answers
(id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (audit_id, question_id)
DO UPDATE SET answer = $5, evidence_ids = $6, status = $7, answered_at = $8`,
a.ID, a.AuditID, a.QuestionID, a.MCID,
answerJSON, evidenceJSON, a.Status, a.AnsweredAt,
)
return err
}
// ListAnswers returns all answers for an audit.
func (s *Store) ListAnswers(auditID uuid.UUID) ([]Answer, error) {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at
FROM compliance.usecase_answers
WHERE audit_id = $1
ORDER BY answered_at`, auditID)
if err != nil {
return nil, err
}
defer rows.Close()
var answers []Answer
for rows.Next() {
var a Answer
var answerJSON, evidenceJSON []byte
if err := rows.Scan(
&a.ID, &a.AuditID, &a.QuestionID, &a.MCID,
&answerJSON, &evidenceJSON, &a.Status, &a.AnsweredAt,
); err != nil {
return nil, err
}
var payload map[string]interface{}
if json.Unmarshal(answerJSON, &payload) == nil {
a.Value = payload["value"]
if c, ok := payload["comment"].(string); ok {
a.Comment = c
}
}
json.Unmarshal(evidenceJSON, &a.EvidenceIDs)
answers = append(answers, a)
}
return answers, nil
}
// ── MC Queries ─────────────────────────────────────────────────────
// MCInfo holds minimal data about a Master Control for compilation.
type MCInfo struct {
MasterControlID string `json:"master_control_id"`
CanonicalName string `json:"canonical_name"`
TotalControls int `json:"total_controls"`
RegSource string `json:"regulation_source"`
}
// FetchMCsByFilters returns MCs whose canonical_name matches any filter pattern.
func (s *Store) FetchMCsByFilters(filters []string) ([]MCInfo, error) {
if len(filters) == 0 {
return nil, nil
}
ctx := context.Background()
// Build LIKE conditions from filter patterns (support trailing *)
conditions := make([]string, len(filters))
args := make([]interface{}, len(filters))
for i, f := range filters {
// Convert "third_party_management_*" → "third_party_management_%"
pattern := f
if len(pattern) > 0 && pattern[len(pattern)-1] == '*' {
pattern = pattern[:len(pattern)-1] + "%"
}
conditions[i] = fmt.Sprintf("mc.canonical_name LIKE $%d", i+1)
args[i] = pattern
}
query := fmt.Sprintf(`
SELECT DISTINCT mc.master_control_id, mc.canonical_name, mc.total_controls,
COALESCE(
(SELECT pc.source_citation::jsonb->>'source'
FROM compliance.master_control_members mcm2
JOIN compliance.canonical_controls cc2 ON cc2.id = mcm2.control_uuid
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc2.parent_control_uuid
WHERE mcm2.master_control_uuid = mc.id
AND pc.source_citation IS NOT NULL
LIMIT 1), ''
) as regulation_source
FROM compliance.master_controls mc
WHERE %s
ORDER BY mc.total_controls DESC
LIMIT 200`,
joinOr(conditions))
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("fetch MCs: %w", err)
}
defer rows.Close()
var mcs []MCInfo
for rows.Next() {
var m MCInfo
if err := rows.Scan(&m.MasterControlID, &m.CanonicalName,
&m.TotalControls, &m.RegSource); err != nil {
return nil, err
}
mcs = append(mcs, m)
}
return mcs, nil
}
// FetchCheckQuestions loads existing doc_check_controls for MCs.
func (s *Store) FetchCheckQuestions(mcIDs []string) (map[string][]CheckQuestion, error) {
if len(mcIDs) == 0 {
return nil, nil
}
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT control_id, check_question, pass_criteria, fail_criteria, severity
FROM compliance.doc_check_controls
WHERE control_id = ANY($1)`, mcIDs)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string][]CheckQuestion)
for rows.Next() {
var cq CheckQuestion
if err := rows.Scan(&cq.ControlID, &cq.Question,
&cq.PassCriteria, &cq.FailCriteria, &cq.Severity); err != nil {
return nil, err
}
result[cq.ControlID] = append(result[cq.ControlID], cq)
}
return result, nil
}
// CheckQuestion holds an existing doc_check_control question.
type CheckQuestion struct {
ControlID string `json:"control_id"`
Question string `json:"check_question"`
PassCriteria string `json:"pass_criteria"`
FailCriteria string `json:"fail_criteria"`
Severity string `json:"severity"`
}
// CountMCSourceCitations counts controls with source_citation per MC.
func (s *Store) CountMCSourceCitations(mcIDs []string) (map[string]int, error) {
if len(mcIDs) == 0 {
return nil, nil
}
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT mc.master_control_id,
COUNT(CASE WHEN cc.source_citation IS NOT NULL
AND cc.source_citation != '' THEN 1 END)
FROM compliance.master_controls mc
JOIN compliance.master_control_members mcm ON mcm.master_control_uuid = mc.id
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
WHERE mc.master_control_id = ANY($1)
GROUP BY mc.master_control_id`, mcIDs)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]int)
for rows.Next() {
var id string
var count int
if err := rows.Scan(&id, &count); err != nil {
return nil, err
}
result[id] = count
}
return result, nil
}
func joinOr(conditions []string) string {
if len(conditions) == 1 {
return conditions[0]
}
result := "("
for i, c := range conditions {
if i > 0 {
result += " OR "
}
result += c
}
return result + ")"
}
@@ -0,0 +1,105 @@
package usecase
// Templates holds the built-in use-case templates.
var Templates = map[string]Template{
"vendor_check_cloud": {
ID: "vendor_check_cloud",
Name: "Vendor Check (Cloud-Anbieter)",
Description: "Prueft Cloud-Anbieter auf DSGVO- und NIS2-Konformitaet: AVV, Drittlandtransfer, Zertifizierungen, Incident Response.",
MCFilters: []string{"third_party_management_*", "data_processing_agreement_*", "data_transfer_*", "vendor_*", "supply_chain_*"},
Regulations: []string{"dsgvo", "nis2"},
Questions: []Question{
{ID: "VC01", Text: "Hat der Anbieter ISO 27001 oder eine vergleichbare Zertifizierung?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Gueltiges ISO 27001 Zertifikat vorhanden"}, FailCriteria: []string{"Kein Zertifikat, nur Selbstauskunft"}, Severity: "HIGH", Regulation: "NIS2"},
{ID: "VC02", Text: "Ist ein Auftragsverarbeitungsvertrag (AVV) nach Art. 28 DSGVO geschlossen?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"AVV liegt vor und ist unterschrieben"}, FailCriteria: []string{"Kein AVV vorhanden"}, Severity: "HIGH", Regulation: "DSGVO Art. 28"},
{ID: "VC03", Text: "Werden personenbezogene Daten in Drittlaender uebermittelt?", QuestionType: "yes_no", PassCriteria: []string{"Nein, Verarbeitung nur im EWR"}, FailCriteria: []string{"Ja, Drittlandtransfer ohne Absicherung"}, Severity: "HIGH", Regulation: "DSGVO Art. 44-49"},
{ID: "VC04", Text: "Gibt es Standardvertragsklauseln (SCC) oder einen Angemessenheitsbeschluss?", QuestionType: "yes_no", EvidenceRequired: true, DependsOn: "VC03", PassCriteria: []string{"SCC der EU-Kommission oder Angemessenheitsbeschluss vorhanden"}, FailCriteria: []string{"Weder SCC noch Angemessenheitsbeschluss"}, Severity: "HIGH", Regulation: "DSGVO Art. 46"},
{ID: "VC05", Text: "Hat der Anbieter ein dokumentiertes Schwachstellenmanagement?", QuestionType: "yes_no", PassCriteria: []string{"Schwachstellenmanagement-Prozess dokumentiert"}, FailCriteria: []string{"Kein formaler Prozess"}, Severity: "MEDIUM", Regulation: "NIS2"},
{ID: "VC06", Text: "Gibt es einen Incident-Response-Prozess mit definierten Meldefristen?", QuestionType: "yes_no", PassCriteria: []string{"Incident-Response-Plan mit Meldefristen vorhanden"}, FailCriteria: []string{"Kein dokumentierter Prozess"}, Severity: "HIGH", Regulation: "NIS2 Art. 23"},
{ID: "VC07", Text: "Sind Sub-Auftragsverarbeiter dokumentiert und genehmigt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Liste der Sub-Auftragsverarbeiter aktuell und genehmigt"}, FailCriteria: []string{"Keine Uebersicht oder nicht genehmigt"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 28 (2)"},
{ID: "VC08", Text: "Unterstuetzt der Anbieter Betroffenenrechte (Auskunft, Loeschung)?", QuestionType: "yes_no", PassCriteria: []string{"Prozess zur Unterstuetzung bei Betroffenenanfragen dokumentiert"}, FailCriteria: []string{"Keine Unterstuetzung vorgesehen"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 28 (3e)"},
{ID: "VC09", Text: "Gibt es ein Loeschkonzept mit definierten Aufbewahrungsfristen?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Loeschkonzept mit Fristen vorhanden"}, FailCriteria: []string{"Kein Loeschkonzept"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 17"},
{ID: "VC10", Text: "Werden regelmaessige Penetrationstests oder Audits durchgefuehrt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Jaehrliche Pentests oder SOC2-Bericht"}, FailCriteria: []string{"Keine regelmaessigen Sicherheitspruefungen"}, Severity: "MEDIUM", Regulation: "NIS2"},
},
},
"sast_dast_audit": {
ID: "sast_dast_audit",
Name: "SAST/DAST Security Audit",
Description: "Prueft Sicherheitspraktiken in der Softwareentwicklung: Secure Coding, Schwachstellenscans, API-Sicherheit.",
MCFilters: []string{"secure_development_*", "vulnerability_*", "input_validation_*", "api_security_*", "code_review_*", "software_testing_*"},
Regulations: []string{"cra", "owasp"},
Questions: []Question{
{ID: "SA01", Text: "Werden SAST-Tools (Static Application Security Testing) in der CI/CD-Pipeline eingesetzt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SAST in CI/CD integriert (z.B. Semgrep, SonarQube)"}, FailCriteria: []string{"Kein SAST im Build-Prozess"}, Severity: "HIGH", Regulation: "CRA Annex I"},
{ID: "SA02", Text: "Werden DAST-Tools (Dynamic Application Security Testing) regelmaessig ausgefuehrt?", QuestionType: "yes_no", PassCriteria: []string{"DAST mindestens quartalsweise"}, FailCriteria: []string{"Kein DAST"}, Severity: "HIGH", Regulation: "CRA Annex I"},
{ID: "SA03", Text: "Gibt es einen Secure-Coding-Standard fuer Entwickler?", QuestionType: "yes_no", PassCriteria: []string{"Dokumentierter Coding-Standard vorhanden"}, FailCriteria: []string{"Kein Standard"}, Severity: "MEDIUM", Regulation: "CRA"},
{ID: "SA04", Text: "Werden bekannte Schwachstellen in Dependencies ueberwacht (SCA)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SCA/Dependency-Check aktiv (z.B. Dependabot, Snyk)"}, FailCriteria: []string{"Keine Ueberwachung"}, Severity: "HIGH", Regulation: "CRA Annex I"},
{ID: "SA05", Text: "Werden API-Endpoints gegen OWASP Top 10 abgesichert?", QuestionType: "yes_no", PassCriteria: []string{"OWASP Top 10 systematisch adressiert"}, FailCriteria: []string{"Keine systematische Absicherung"}, Severity: "HIGH", Regulation: "OWASP"},
{ID: "SA06", Text: "Gibt es einen Prozess fuer Security Code Reviews?", QuestionType: "yes_no", PassCriteria: []string{"Pflicht-Review bei sicherheitskritischen Aenderungen"}, FailCriteria: []string{"Kein Review-Prozess"}, Severity: "MEDIUM", Regulation: "CRA"},
{ID: "SA07", Text: "Existiert eine SBOM (Software Bill of Materials)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SBOM automatisch generiert und aktuell"}, FailCriteria: []string{"Keine SBOM"}, Severity: "HIGH", Regulation: "CRA Art. 13"},
{ID: "SA08", Text: "Werden Sicherheitsupdates innerhalb definierter Fristen bereitgestellt?", QuestionType: "yes_no", PassCriteria: []string{"SLA fuer Security-Patches definiert"}, FailCriteria: []string{"Keine definierten Fristen"}, Severity: "HIGH", Regulation: "CRA Annex I"},
},
},
"dsgvo_quick_check": {
ID: "dsgvo_quick_check",
Name: "DSGVO Quick-Check",
Description: "Schnelle Selbstpruefung der wichtigsten DSGVO-Anforderungen: Verarbeitungsverzeichnis, Betroffenenrechte, DSFA, Einwilligungen.",
MCFilters: []string{"personal_data_*", "consent_*", "data_subject_rights_*", "dpia_*", "data_retention_*", "privacy_*", "data_protection_*"},
Regulations: []string{"dsgvo"},
Questions: []Question{
{ID: "DS01", Text: "Fuehren Sie ein Verarbeitungsverzeichnis nach Art. 30 DSGVO?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Aktuelles Verarbeitungsverzeichnis vorhanden"}, FailCriteria: []string{"Kein oder veraltetes Verzeichnis"}, Severity: "HIGH", Regulation: "DSGVO Art. 30"},
{ID: "DS02", Text: "Ist ein Datenschutzbeauftragter (DSB) benannt (falls erforderlich)?", QuestionType: "yes_no", PassCriteria: []string{"DSB benannt und gemeldet"}, FailCriteria: []string{"Kein DSB trotz Pflicht"}, Severity: "HIGH", Regulation: "DSGVO Art. 37"},
{ID: "DS03", Text: "Gibt es einen Prozess fuer Betroffenenanfragen (Auskunft, Loeschung, Berichtigung)?", QuestionType: "yes_no", PassCriteria: []string{"Dokumentierter Prozess mit Fristenueberwachung"}, FailCriteria: []string{"Kein Prozess definiert"}, Severity: "HIGH", Regulation: "DSGVO Art. 15-22"},
{ID: "DS04", Text: "Werden Einwilligungen nachweisbar eingeholt und dokumentiert?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Consent-Management mit Nachweis"}, FailCriteria: []string{"Keine nachweisbare Einwilligung"}, Severity: "HIGH", Regulation: "DSGVO Art. 7"},
{ID: "DS05", Text: "Wurde eine DSFA fuer risikoreiche Verarbeitungen durchgefuehrt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"DSFA durchgefuehrt und dokumentiert"}, FailCriteria: []string{"Keine DSFA trotz hohem Risiko"}, Severity: "HIGH", Regulation: "DSGVO Art. 35"},
{ID: "DS06", Text: "Gibt es eine aktuelle Datenschutzerklaerung auf der Website?", QuestionType: "yes_no", PassCriteria: []string{"DSE aktuell und vollstaendig"}, FailCriteria: []string{"Keine oder veraltete DSE"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 13/14"},
{ID: "DS07", Text: "Sind Aufbewahrungsfristen fuer personenbezogene Daten definiert?", QuestionType: "yes_no", PassCriteria: []string{"Loeschkonzept mit Fristen vorhanden"}, FailCriteria: []string{"Keine definierten Fristen"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 5 (1e)"},
{ID: "DS08", Text: "Gibt es einen Meldeprozess fuer Datenschutzverletzungen (72h)?", QuestionType: "yes_no", PassCriteria: []string{"Data-Breach-Prozess mit 72h-Frist dokumentiert"}, FailCriteria: []string{"Kein Meldeprozess"}, Severity: "HIGH", Regulation: "DSGVO Art. 33"},
},
},
"nis2_readiness": {
ID: "nis2_readiness",
Name: "NIS2 Readiness Check",
Description: "Prueft die Bereitschaft fuer NIS2: Risikomanagement, Incident Handling, Netzwerksicherheit, Supply Chain.",
MCFilters: []string{"critical_infrastructure_*", "incident_*", "network_security_*", "risk_management_*", "business_continuity_*", "supply_chain_*"},
Regulations: []string{"nis2"},
Questions: []Question{
{ID: "N201", Text: "Gibt es ein formales Risikomanagement fuer IT-Sicherheit?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Risikomanagement-Framework dokumentiert"}, FailCriteria: []string{"Kein formales Risikomanagement"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2a)"},
{ID: "N202", Text: "Gibt es einen Incident-Handling-Prozess mit Meldefristen (24h/72h)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Incident-Response-Plan mit NIS2-konformen Fristen"}, FailCriteria: []string{"Kein oder unvollstaendiger Prozess"}, Severity: "HIGH", Regulation: "NIS2 Art. 23"},
{ID: "N203", Text: "Ist ein Business-Continuity-Plan vorhanden und getestet?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"BCP vorhanden und regelmaessig getestet"}, FailCriteria: []string{"Kein BCP oder nie getestet"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2c)"},
{ID: "N204", Text: "Werden Lieferanten auf Cybersicherheit geprueft?", QuestionType: "yes_no", PassCriteria: []string{"Supply-Chain-Security-Anforderungen definiert"}, FailCriteria: []string{"Keine Lieferantenpruefung"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2d)"},
{ID: "N205", Text: "Ist Multi-Faktor-Authentifizierung fuer kritische Systeme aktiviert?", QuestionType: "yes_no", PassCriteria: []string{"MFA fuer Admin-Zugaenge und VPN"}, FailCriteria: []string{"Kein MFA"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2j)"},
{ID: "N206", Text: "Werden Mitarbeiter regelmaessig in Cybersicherheit geschult?", QuestionType: "yes_no", PassCriteria: []string{"Jaehrliche Security-Awareness-Schulung"}, FailCriteria: []string{"Keine Schulungen"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2g)"},
{ID: "N207", Text: "Gibt es eine Netzwerksegmentierung fuer kritische Systeme?", QuestionType: "yes_no", PassCriteria: []string{"Netzwerksegmentierung implementiert"}, FailCriteria: []string{"Flat Network ohne Segmentierung"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2e)"},
{ID: "N208", Text: "Ist die Geschaeftsfuehrung ueber NIS2-Pflichten informiert (persoenliche Haftung)?", QuestionType: "yes_no", PassCriteria: []string{"Management-Briefing dokumentiert"}, FailCriteria: []string{"Management nicht informiert"}, Severity: "HIGH", Regulation: "NIS2 Art. 20"},
},
},
"cra_product_check": {
ID: "cra_product_check",
Name: "CRA Product Compliance Check",
Description: "Prueft digitale Produkte auf Konformitaet mit dem Cyber Resilience Act: Schwachstellen, Updates, Verschluesselung, SBOM.",
MCFilters: []string{"vulnerability_*", "patch_management_*", "encryption_*", "asset_management_inventory*", "secure_development_*", "product_security_*"},
Regulations: []string{"cra"},
Questions: []Question{
{ID: "CR01", Text: "Wird das Produkt ohne bekannte ausnutzbare Schwachstellen ausgeliefert?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Schwachstellenscan vor Release, keine kritischen CVEs"}, FailCriteria: []string{"Bekannte Schwachstellen bei Auslieferung"}, Severity: "HIGH", Regulation: "CRA Annex I (1)"},
{ID: "CR02", Text: "Gibt es einen Security-Update-Mechanismus fuer das Produkt?", QuestionType: "yes_no", PassCriteria: []string{"Automatische oder manuelle Update-Funktion vorhanden"}, FailCriteria: []string{"Kein Update-Mechanismus"}, Severity: "HIGH", Regulation: "CRA Annex I (2)"},
{ID: "CR03", Text: "Werden Daten bei Uebertragung und Speicherung verschluesselt?", QuestionType: "yes_no", PassCriteria: []string{"TLS/HTTPS + verschluesselte Speicherung"}, FailCriteria: []string{"Unverschluesselte Kommunikation oder Speicherung"}, Severity: "HIGH", Regulation: "CRA Annex I (3d)"},
{ID: "CR04", Text: "Existiert eine SBOM (Software Bill of Materials) fuer das Produkt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SBOM vorhanden und automatisch generiert"}, FailCriteria: []string{"Keine SBOM"}, Severity: "HIGH", Regulation: "CRA Art. 13 (15)"},
{ID: "CR05", Text: "Werden Security-Patches mindestens 5 Jahre nach Verkaufsende bereitgestellt?", QuestionType: "yes_no", PassCriteria: []string{"Support-Zeitraum >= 5 Jahre dokumentiert"}, FailCriteria: []string{"Kuerzerer oder kein definierter Support"}, Severity: "HIGH", Regulation: "CRA Art. 13 (8)"},
{ID: "CR06", Text: "Ist das Produkt standardmaessig sicher konfiguriert (Secure by Default)?", QuestionType: "yes_no", PassCriteria: []string{"Default-Konfiguration gehaertet"}, FailCriteria: []string{"Unsichere Defaults (z.B. Standard-Passwoerter)"}, Severity: "HIGH", Regulation: "CRA Annex I (3a)"},
{ID: "CR07", Text: "Gibt es eine Kontaktmoeglichkeit fuer Schwachstellenmeldungen?", QuestionType: "yes_no", PassCriteria: []string{"security.txt oder Vulnerability Disclosure Policy"}, FailCriteria: []string{"Keine Meldemoelichkeit"}, Severity: "MEDIUM", Regulation: "CRA Art. 13 (6)"},
{ID: "CR08", Text: "Werden Schwachstellen innerhalb von 24h an ENISA gemeldet (bei aktiver Ausnutzung)?", QuestionType: "yes_no", PassCriteria: []string{"Meldeprozess mit 24h-Frist dokumentiert"}, FailCriteria: []string{"Kein Meldeprozess"}, Severity: "HIGH", Regulation: "CRA Art. 14"},
},
},
}
// TemplateList returns all templates as a slice.
func TemplateList() []Template {
list := make([]Template, 0, len(Templates))
for _, t := range Templates {
list = append(list, t)
}
return list
}
@@ -0,0 +1,39 @@
-- Use-Case Compiler: Audits + Answers
-- Turns Master Controls into interactive questionnaires
CREATE TABLE IF NOT EXISTS compliance.usecase_audits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
template_id VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
target_name VARCHAR(200),
status VARCHAR(20) DEFAULT 'draft',
total_questions INT DEFAULT 0,
answered_questions INT DEFAULT 0,
compliance_score FLOAT DEFAULT 0,
questions JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_usecase_audits_tenant
ON compliance.usecase_audits (tenant_id);
CREATE INDEX IF NOT EXISTS idx_usecase_audits_template
ON compliance.usecase_audits (template_id);
CREATE TABLE IF NOT EXISTS compliance.usecase_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
audit_id UUID NOT NULL REFERENCES compliance.usecase_audits(id) ON DELETE CASCADE,
question_id VARCHAR(50) NOT NULL,
mc_id VARCHAR(50),
answer JSONB NOT NULL,
evidence_ids JSONB DEFAULT '[]',
status VARCHAR(20) DEFAULT 'answered',
answered_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usecase_answers_audit
ON compliance.usecase_answers (audit_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_usecase_answers_unique
ON compliance.usecase_answers (audit_id, question_id);