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
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] = ©
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user