feat: evidence_type Feld (code/process/hybrid) fuer Controls
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 38s
CI/CD / test-python-backend-compliance (push) Successful in 31s
CI/CD / test-python-document-crawler (push) Successful in 19s
CI/CD / test-python-dsms-gateway (push) Successful in 17s
CI/CD / validate-canonical-controls (push) Successful in 10s
CI/CD / Deploy (push) Successful in 4s

Neues Feld auf canonical_controls klassifiziert, ob ein Control
technisch im Source Code (code), organisatorisch via Dokumente (process)
oder beides (hybrid) nachgewiesen wird. Inklusive Backfill-Endpoint,
Frontend-Badge/Filter und MkDocs-Dokumentation.

- Migration 079: evidence_type VARCHAR(20) + Index
- Backend: Filter, Backfill-Endpoint mit Domain-Heuristik, CRUD
- Frontend: EvidenceTypeBadge (sky/amber/violet), Nachweisart-Dropdown
- Proxy: evidence_type Passthrough fuer controls + controls-count
- Tests: 22 Tests fuer Klassifikations-Heuristik
- Docs: Eigenes MkDocs-Kapitel mit Mermaid-Diagramm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-25 21:53:40 +01:00
parent a29bfdd588
commit 5e9cab6ab5
9 changed files with 390 additions and 11 deletions

View File

@@ -26,7 +26,7 @@ export async function GET(request: NextRequest) {
case 'controls': { case 'controls': {
const controlParams = new URLSearchParams() const controlParams = new URLSearchParams()
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset'] 'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
for (const key of passthrough) { for (const key of passthrough) {
const val = searchParams.get(key) const val = searchParams.get(key)
@@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
case 'controls-count': { case 'controls-count': {
const countParams = new URLSearchParams() const countParams = new URLSearchParams()
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates'] 'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
for (const key of countPassthrough) { for (const key of countPassthrough) {
const val = searchParams.get(key) const val = searchParams.get(key)

View File

@@ -8,10 +8,10 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
CanonicalControl, EFFORT_LABELS, BACKEND_URL, CanonicalControl, EFFORT_LABELS, BACKEND_URL,
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge, SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
ObligationTypeBadge, GenerationStrategyBadge, ObligationTypeBadge, GenerationStrategyBadge,
ExtractionMethodBadge, RegulationCountBadge, ExtractionMethodBadge, RegulationCountBadge,
VERIFICATION_METHODS, CATEGORY_OPTIONS, VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary, ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
} from './helpers' } from './helpers'
@@ -185,6 +185,7 @@ export function ControlDetail({
<LicenseRuleBadge rule={ctrl.license_rule} /> <LicenseRuleBadge rule={ctrl.license_rule} />
<VerificationMethodBadge method={ctrl.verification_method} /> <VerificationMethodBadge method={ctrl.verification_method} />
<CategoryBadge category={ctrl.category} /> <CategoryBadge category={ctrl.category} />
<EvidenceTypeBadge type={ctrl.evidence_type} />
<TargetAudienceBadge audience={ctrl.target_audience} /> <TargetAudienceBadge audience={ctrl.target_audience} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} /> <GenerationStrategyBadge strategy={ctrl.generation_strategy} />
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} /> <ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />

View File

@@ -44,6 +44,7 @@ export interface CanonicalControl {
customer_visible?: boolean customer_visible?: boolean
verification_method: string | null verification_method: string | null
category: string | null category: string | null
evidence_type: string | null
target_audience: string | string[] | null target_audience: string | string[] | null
generation_metadata?: Record<string, unknown> | null generation_metadata?: Record<string, unknown> | null
generation_strategy?: string | null generation_strategy?: string | null
@@ -102,6 +103,7 @@ export const EMPTY_CONTROL = {
tags: [] as string[], tags: [] as string[],
verification_method: null as string | null, verification_method: null as string | null,
category: null as string | null, category: null as string | null,
evidence_type: null as string | null,
target_audience: null as string | null, target_audience: null as string | null,
} }
@@ -145,6 +147,18 @@ export const CATEGORY_OPTIONS = [
{ value: 'identity', label: 'Identitaetsmanagement' }, { value: 'identity', label: 'Identitaetsmanagement' },
] ]
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
}
export const EVIDENCE_TYPE_OPTIONS = [
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
]
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = { export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
// Legacy English keys // Legacy English keys
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' }, enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
@@ -244,6 +258,13 @@ export function CategoryBadge({ category }: { category: string | null }) {
) )
} }
export function EvidenceTypeBadge({ type }: { type: string | null }) {
if (!type) return null
const config = EVIDENCE_TYPE_CONFIG[type]
if (!config) return null
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
}
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) { export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
if (!audience) return null if (!audience) return null

View File

@@ -8,9 +8,9 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL, CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge, SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
GenerationStrategyBadge, ObligationTypeBadge, GenerationStrategyBadge, ObligationTypeBadge,
VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS, VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, TARGET_AUDIENCE_OPTIONS,
} from './components/helpers' } from './components/helpers'
import { ControlForm } from './components/ControlForm' import { ControlForm } from './components/ControlForm'
import { ControlDetail } from './components/ControlDetail' import { ControlDetail } from './components/ControlDetail'
@@ -51,6 +51,7 @@ export default function ControlLibraryPage() {
const [stateFilter, setStateFilter] = useState<string>('') const [stateFilter, setStateFilter] = useState<string>('')
const [verificationFilter, setVerificationFilter] = useState<string>('') const [verificationFilter, setVerificationFilter] = useState<string>('')
const [categoryFilter, setCategoryFilter] = useState<string>('') const [categoryFilter, setCategoryFilter] = useState<string>('')
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
const [audienceFilter, setAudienceFilter] = useState<string>('') const [audienceFilter, setAudienceFilter] = useState<string>('')
const [sourceFilter, setSourceFilter] = useState<string>('') const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<string>('') const [typeFilter, setTypeFilter] = useState<string>('')
@@ -94,6 +95,7 @@ export default function ControlLibraryPage() {
if (stateFilter) p.set('release_state', stateFilter) if (stateFilter) p.set('release_state', stateFilter)
if (verificationFilter) p.set('verification_method', verificationFilter) if (verificationFilter) p.set('verification_method', verificationFilter)
if (categoryFilter) p.set('category', categoryFilter) if (categoryFilter) p.set('category', categoryFilter)
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
if (audienceFilter) p.set('target_audience', audienceFilter) if (audienceFilter) p.set('target_audience', audienceFilter)
if (sourceFilter) p.set('source', sourceFilter) if (sourceFilter) p.set('source', sourceFilter)
if (typeFilter) p.set('control_type', typeFilter) if (typeFilter) p.set('control_type', typeFilter)
@@ -101,7 +103,7 @@ export default function ControlLibraryPage() {
if (debouncedSearch) p.set('search', debouncedSearch) if (debouncedSearch) p.set('search', debouncedSearch)
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v) if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
return p.toString() return p.toString()
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch]) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
// Load metadata (domains, sources — once + on refresh) // Load metadata (domains, sources — once + on refresh)
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
@@ -169,7 +171,7 @@ export default function ControlLibraryPage() {
useEffect(() => { loadControls() }, [loadControls]) useEffect(() => { loadControls() }, [loadControls])
// Reset page when filters change // Reset page when filters change
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy]) useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
// Pagination // Pagination
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
@@ -654,6 +656,16 @@ export default function ControlLibraryPage() {
<option key={c.value} value={c.value}>{c.label}</option> <option key={c.value} value={c.value}>{c.label}</option>
))} ))}
</select> </select>
<select
value={evidenceTypeFilter}
onChange={e => setEvidenceTypeFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Nachweisart</option>
{EVIDENCE_TYPE_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<select <select
value={audienceFilter} value={audienceFilter}
onChange={e => setAudienceFilter(e.target.value)} onChange={e => setAudienceFilter(e.target.value)}
@@ -792,6 +804,7 @@ export default function ControlLibraryPage() {
<LicenseRuleBadge rule={ctrl.license_rule} /> <LicenseRuleBadge rule={ctrl.license_rule} />
<VerificationMethodBadge method={ctrl.verification_method} /> <VerificationMethodBadge method={ctrl.verification_method} />
<CategoryBadge category={ctrl.category} /> <CategoryBadge category={ctrl.category} />
<EvidenceTypeBadge type={ctrl.evidence_type} />
<TargetAudienceBadge audience={ctrl.target_audience} /> <TargetAudienceBadge audience={ctrl.target_audience} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} /> <GenerationStrategyBadge strategy={ctrl.generation_strategy} />
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} /> <ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />

View File

@@ -80,6 +80,7 @@ class ControlResponse(BaseModel):
customer_visible: Optional[bool] = None customer_visible: Optional[bool] = None
verification_method: Optional[str] = None verification_method: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
evidence_type: Optional[str] = None
target_audience: Optional[str] = None target_audience: Optional[str] = None
generation_metadata: Optional[dict] = None generation_metadata: Optional[dict] = None
generation_strategy: Optional[str] = "ungrouped" generation_strategy: Optional[str] = "ungrouped"
@@ -113,6 +114,7 @@ class ControlCreateRequest(BaseModel):
customer_visible: Optional[bool] = True customer_visible: Optional[bool] = True
verification_method: Optional[str] = None verification_method: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
evidence_type: Optional[str] = None
target_audience: Optional[str] = None target_audience: Optional[str] = None
generation_metadata: Optional[dict] = None generation_metadata: Optional[dict] = None
applicable_industries: Optional[list] = None applicable_industries: Optional[list] = None
@@ -141,6 +143,7 @@ class ControlUpdateRequest(BaseModel):
customer_visible: Optional[bool] = None customer_visible: Optional[bool] = None
verification_method: Optional[str] = None verification_method: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
evidence_type: Optional[str] = None
target_audience: Optional[str] = None target_audience: Optional[str] = None
generation_metadata: Optional[dict] = None generation_metadata: Optional[dict] = None
applicable_industries: Optional[list] = None applicable_industries: Optional[list] = None
@@ -172,7 +175,7 @@ _CONTROL_COLS = """id, framework_id, control_id, title, objective, rationale,
severity, risk_score, implementation_effort, severity, risk_score, implementation_effort,
evidence_confidence, open_anchors, release_state, tags, evidence_confidence, open_anchors, release_state, tags,
license_rule, source_original_text, source_citation, license_rule, source_original_text, source_citation,
customer_visible, verification_method, category, customer_visible, verification_method, category, evidence_type,
target_audience, generation_metadata, generation_strategy, target_audience, generation_metadata, generation_strategy,
applicable_industries, applicable_company_size, scope_conditions, applicable_industries, applicable_company_size, scope_conditions,
parent_control_uuid, decomposition_method, pipeline_version, parent_control_uuid, decomposition_method, pipeline_version,
@@ -312,6 +315,7 @@ async def list_controls(
release_state: Optional[str] = Query(None), release_state: Optional[str] = Query(None),
verification_method: Optional[str] = Query(None), verification_method: Optional[str] = Query(None),
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
evidence_type: Optional[str] = Query(None, description="Filter: code, process, hybrid"),
target_audience: Optional[str] = Query(None), target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None, description="Filter by source_citation->source"), source: Optional[str] = Query(None, description="Filter by source_citation->source"),
search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"), search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"),
@@ -348,6 +352,9 @@ async def list_controls(
if category: if category:
query += " AND category = :cat" query += " AND category = :cat"
params["cat"] = category params["cat"] = category
if evidence_type:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience: if target_audience:
query += " AND target_audience LIKE :ta_pattern" query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%' params["ta_pattern"] = f'%"{target_audience}"%'
@@ -398,6 +405,7 @@ async def count_controls(
release_state: Optional[str] = Query(None), release_state: Optional[str] = Query(None),
verification_method: Optional[str] = Query(None), verification_method: Optional[str] = Query(None),
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
evidence_type: Optional[str] = Query(None),
target_audience: Optional[str] = Query(None), target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None), source: Optional[str] = Query(None),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
@@ -426,6 +434,9 @@ async def count_controls(
if category: if category:
query += " AND category = :cat" query += " AND category = :cat"
params["cat"] = category params["cat"] = category
if evidence_type:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience: if target_audience:
query += " AND target_audience LIKE :ta_pattern" query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%' params["ta_pattern"] = f'%"{target_audience}"%'
@@ -998,6 +1009,109 @@ async def backfill_normative_strength(
} }
# =============================================================================
# EVIDENCE TYPE BACKFILL
# =============================================================================
# Domains that are primarily technical (code-verifiable)
_CODE_DOMAINS = frozenset({
"SEC", "AUTH", "CRYPT", "CRYP", "CRY", "NET", "LOG", "ACC", "APP", "SYS",
"CI", "CONT", "API", "CLOUD", "IAC", "SAST", "DAST", "DEP", "SBOM",
"WEB", "DEV", "SDL", "PKI", "HSM", "TEE", "TPM", "CRX", "CRF",
"FWU", "STO", "RUN", "VUL", "MAL", "PLT", "AUT",
})
# Domains that are primarily process-based (document-verifiable)
_PROCESS_DOMAINS = frozenset({
"GOV", "ORG", "COMP", "LEGAL", "HR", "TRAIN", "AML", "FIN",
"RISK", "AUDIT", "AUD", "PROC", "DOC", "PHYS", "PHY", "PRIV", "DPO",
"BCDR", "BCP", "VENDOR", "SUPPLY", "SUP", "CERT", "POLICY",
"ENV", "HLT", "TRD", "LAB", "PER", "REL", "ISM", "COM",
"GAM", "RIS", "PCA", "GNT", "HCA", "RES", "ISS",
})
# Domains that are typically hybrid
_HYBRID_DOMAINS = frozenset({
"DATA", "AI", "INC", "ID", "IAM", "IDF", "IDP", "IDA", "IDN",
"OPS", "MNT", "INT", "BCK",
})
def _classify_evidence_type(control_id: str, category: str | None) -> str:
"""Heuristic: classify a control as code/process/hybrid based on domain prefix."""
domain = control_id.split("-")[0].upper() if control_id else ""
if domain in _CODE_DOMAINS:
return "code"
if domain in _PROCESS_DOMAINS:
return "process"
if domain in _HYBRID_DOMAINS:
return "hybrid"
# Fallback: use category if available
code_categories = {"encryption", "authentication", "network", "application", "system", "identity"}
process_categories = {"compliance", "personnel", "physical", "governance", "risk"}
if category in code_categories:
return "code"
if category in process_categories:
return "process"
return "process" # Conservative default
@router.post("/controls/backfill-evidence-type")
async def backfill_evidence_type(
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
):
"""
Klassifiziert Controls als code/process/hybrid basierend auf Domain-Prefix.
Heuristik:
- SEC, AUTH, CRYPT, NET, LOG, ... → code
- GOV, ORG, COMP, LEGAL, HR, ... → process
- DATA, AI, INC → hybrid
"""
with SessionLocal() as db:
rows = db.execute(text("""
SELECT id, control_id, category, evidence_type
FROM canonical_controls
WHERE release_state NOT IN ('rejected', 'merged')
ORDER BY control_id
""")).fetchall()
changes = []
stats = {"total": len(rows), "already_set": 0, "code": 0, "process": 0, "hybrid": 0}
for row in rows:
if row.evidence_type is not None:
stats["already_set"] += 1
continue
new_type = _classify_evidence_type(row.control_id, row.category)
stats[new_type] += 1
changes.append({
"id": str(row.id),
"control_id": row.control_id,
"evidence_type": new_type,
})
if not dry_run and changes:
for change in changes:
db.execute(text("""
UPDATE canonical_controls
SET evidence_type = :et
WHERE id = CAST(:cid AS uuid)
"""), {"et": change["evidence_type"], "cid": change["id"]})
db.commit()
return {
"dry_run": dry_run,
"stats": stats,
"total_changes": len(changes),
"sample_changes": changes[:20],
}
# ============================================================================= # =============================================================================
# CONTROL CRUD (CREATE / UPDATE / DELETE) # CONTROL CRUD (CREATE / UPDATE / DELETE)
# ============================================================================= # =============================================================================
@@ -1040,7 +1154,7 @@ async def create_control(body: ControlCreateRequest):
severity, risk_score, implementation_effort, evidence_confidence, severity, risk_score, implementation_effort, evidence_confidence,
open_anchors, release_state, tags, open_anchors, release_state, tags,
license_rule, source_original_text, source_citation, license_rule, source_original_text, source_citation,
customer_visible, verification_method, category, customer_visible, verification_method, category, evidence_type,
target_audience, generation_metadata, target_audience, generation_metadata,
applicable_industries, applicable_company_size, scope_conditions applicable_industries, applicable_company_size, scope_conditions
) VALUES ( ) VALUES (
@@ -1051,7 +1165,7 @@ async def create_control(body: ControlCreateRequest):
CAST(:anchors AS jsonb), :release_state, CAST(:tags AS jsonb), CAST(:anchors AS jsonb), :release_state, CAST(:tags AS jsonb),
:license_rule, :source_original_text, :license_rule, :source_original_text,
CAST(:source_citation AS jsonb), CAST(:source_citation AS jsonb),
:customer_visible, :verification_method, :category, :customer_visible, :verification_method, :category, :evidence_type,
:target_audience, CAST(:generation_metadata AS jsonb), :target_audience, CAST(:generation_metadata AS jsonb),
CAST(:applicable_industries AS jsonb), CAST(:applicable_industries AS jsonb),
CAST(:applicable_company_size AS jsonb), CAST(:applicable_company_size AS jsonb),
@@ -1082,6 +1196,7 @@ async def create_control(body: ControlCreateRequest):
"customer_visible": body.customer_visible, "customer_visible": body.customer_visible,
"verification_method": body.verification_method, "verification_method": body.verification_method,
"category": body.category, "category": body.category,
"evidence_type": body.evidence_type,
"target_audience": body.target_audience, "target_audience": body.target_audience,
"generation_metadata": _json.dumps(body.generation_metadata) if body.generation_metadata else None, "generation_metadata": _json.dumps(body.generation_metadata) if body.generation_metadata else None,
"applicable_industries": _json.dumps(body.applicable_industries) if body.applicable_industries else None, "applicable_industries": _json.dumps(body.applicable_industries) if body.applicable_industries else None,
@@ -1312,6 +1427,7 @@ def _control_row(r) -> dict:
"customer_visible": r.customer_visible, "customer_visible": r.customer_visible,
"verification_method": r.verification_method, "verification_method": r.verification_method,
"category": r.category, "category": r.category,
"evidence_type": getattr(r, "evidence_type", None),
"target_audience": r.target_audience, "target_audience": r.target_audience,
"generation_metadata": r.generation_metadata, "generation_metadata": r.generation_metadata,
"generation_strategy": getattr(r, "generation_strategy", "ungrouped"), "generation_strategy": getattr(r, "generation_strategy", "ungrouped"),

View File

@@ -0,0 +1,16 @@
-- Migration 079: Add evidence_type to canonical_controls
-- Classifies HOW a control is evidenced:
-- code = Technical control, verifiable in source code / IaC / CI-CD
-- process = Organizational / governance control, verified via documents / policies
-- hybrid = Both code and process evidence required
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'compliance' AND table_name = 'canonical_controls') THEN
ALTER TABLE canonical_controls ADD COLUMN IF NOT EXISTS
evidence_type VARCHAR(20) DEFAULT NULL
CHECK (evidence_type IN ('code', 'process', 'hybrid'));
CREATE INDEX IF NOT EXISTS idx_cc_evidence_type ON canonical_controls(evidence_type);
END IF;
END $$;

View File

@@ -0,0 +1,79 @@
"""Tests for evidence_type classification heuristic."""
import sys
sys.path.insert(0, ".")
from compliance.api.canonical_control_routes import _classify_evidence_type
class TestClassifyEvidenceType:
"""Tests for _classify_evidence_type()."""
# --- Code domains ---
def test_sec_is_code(self):
assert _classify_evidence_type("SEC-042", None) == "code"
def test_auth_is_code(self):
assert _classify_evidence_type("AUTH-001", None) == "code"
def test_crypt_is_code(self):
assert _classify_evidence_type("CRYPT-003", None) == "code"
def test_cryp_is_code(self):
assert _classify_evidence_type("CRYP-010", None) == "code"
def test_net_is_code(self):
assert _classify_evidence_type("NET-015", None) == "code"
def test_log_is_code(self):
assert _classify_evidence_type("LOG-007", None) == "code"
def test_acc_is_code(self):
assert _classify_evidence_type("ACC-012", None) == "code"
def test_api_is_code(self):
assert _classify_evidence_type("API-001", None) == "code"
# --- Process domains ---
def test_gov_is_process(self):
assert _classify_evidence_type("GOV-001", None) == "process"
def test_comp_is_process(self):
assert _classify_evidence_type("COMP-001", None) == "process"
def test_fin_is_process(self):
assert _classify_evidence_type("FIN-001", None) == "process"
def test_hr_is_process(self):
assert _classify_evidence_type("HR-001", None) == "process"
def test_org_is_process(self):
assert _classify_evidence_type("ORG-001", None) == "process"
def test_env_is_process(self):
assert _classify_evidence_type("ENV-001", None) == "process"
# --- Hybrid domains ---
def test_data_is_hybrid(self):
assert _classify_evidence_type("DATA-005", None) == "hybrid"
def test_ai_is_hybrid(self):
assert _classify_evidence_type("AI-001", None) == "hybrid"
def test_inc_is_hybrid(self):
assert _classify_evidence_type("INC-003", None) == "hybrid"
def test_iam_is_hybrid(self):
assert _classify_evidence_type("IAM-001", None) == "hybrid"
# --- Category fallback ---
def test_unknown_domain_encryption_category(self):
assert _classify_evidence_type("XYZ-001", "encryption") == "code"
def test_unknown_domain_governance_category(self):
assert _classify_evidence_type("XYZ-001", "governance") == "process"
def test_unknown_domain_no_category(self):
assert _classify_evidence_type("XYZ-001", None) == "process"
def test_empty_control_id(self):
assert _classify_evidence_type("", None) == "process"

View File

@@ -0,0 +1,132 @@
# Evidence Type — Code vs. Prozess Controls
## Uebersicht
Nicht jedes Control kann gleich nachgewiesen werden. Ein Verschluesselungs-Control
ist im Quellcode pruefbar, ein Risikomanagement-Control erfordert Dokumente und
Nachweise. Diese Unterscheidung bestimmt, **wie** die Compliance-Plattform den
Nachweis automatisiert oder den Nutzer unterstuetzt.
Das Feld `evidence_type` auf `canonical_controls` klassifiziert jeden Control
in eine von drei Kategorien.
## Die drei Typen
```mermaid
graph LR
subgraph "code"
C1["Source Code"]
C2["IaC / Terraform"]
C3["CI/CD Pipeline"]
C4["Cloud Config"]
end
subgraph "process"
P1["Policies"]
P2["Schulungsnachweise"]
P3["Vertraege"]
P4["Screenshots / Audits"]
end
subgraph "hybrid"
H1["Code + Doku"]
H2["Config + Schulung"]
end
```
### Code Controls
| Eigenschaft | Beschreibung |
|---|---|
| **evidence_type** | `code` |
| **Nachweis** | Automatisiert pruefbar: Source Code, IaC, CI/CD, Cloud-Konfiguration |
| **Automatisierung** | Hoch — SAST, Dependency Scan, Config Audit |
| **Beispiel-Domains** | SEC, AUTH, CRYPT, NET, LOG, ACC, API |
| **Beispiel** | "AES-256 Verschluesselung at rest" → pruefbar via Code Review / IaC Scan |
### Prozess Controls
| Eigenschaft | Beschreibung |
|---|---|
| **evidence_type** | `process` |
| **Nachweis** | Dokumente, Policies, Schulungsnachweise, Vertraege, Screenshots |
| **Automatisierung** | Gering — erfordert manuelle Uploads oder MCP-Dokumenten-Scan |
| **Beispiel-Domains** | GOV, ORG, COMP, LEGAL, HR, FIN, RISK, AUDIT, ENV |
| **Beispiel** | "Reallabor-Zugang fuer KMUs bereitstellen" → Nachweis ueber Programm-Dokumentation |
!!! info "Governance & Regulatorische Controls"
Controls wie "Behoerde muss KMUs Zugang zu Reallaboren geben" sind Prozess-Controls.
Der Nachweis erfolgt ueber Dokumente — nicht im Source Code.
Auch regulatorische Umsetzungspflichten (GOV-Domain) fallen hierunter.
### Hybrid Controls
| Eigenschaft | Beschreibung |
|---|---|
| **evidence_type** | `hybrid` |
| **Nachweis** | Sowohl Code als auch Dokumente erforderlich |
| **Automatisierung** | Teilweise — Code-Teil automatisiert, Prozess-Teil manuell |
| **Beispiel-Domains** | DATA, AI, INC, IAM |
| **Beispiel** | "MFA implementieren" → Config pruefbar (code) + Nutzer-Schulung noetig (process) |
## Backfill-Heuristik
Der Backfill klassifiziert Controls automatisch anhand des Domain-Prefix:
```
POST /api/compliance/v1/canonical/controls/backfill-evidence-type?dry_run=true
```
**Algorithmus:**
1. Domain-Prefix extrahieren (z.B. `SEC` aus `SEC-042`)
2. Gegen vordefinierte Domain-Sets pruefen (code / process / hybrid)
3. Falls Domain unbekannt: Category als Fallback nutzen
4. Falls auch keine Category: `process` (konservativ)
### Domain-Zuordnung
| Typ | Domains |
|---|---|
| **code** | SEC, AUTH, CRYPT, CRYP, NET, LOG, ACC, APP, SYS, API, WEB, DEV, SDL, PKI, HSM, TEE, TPM, VUL, ... |
| **process** | GOV, ORG, COMP, LEGAL, HR, FIN, RISK, AUDIT, ENV, HLT, TRD, LAB, PHYS, PRIV, DPO, ... |
| **hybrid** | DATA, AI, INC, IAM, OPS, MNT, INT, ... |
## Frontend-Anzeige
In der Control-Library werden Controls mit farbcodierten Badges angezeigt:
| evidence_type | Badge | Farbe | Bedeutung |
|---|---|---|---|
| `code` | **Code** | Blau (sky) | Technisch, im Source Code pruefbar |
| `process` | **Prozess** | Amber/Orange | Organisatorisch, Dokument-basiert |
| `hybrid` | **Hybrid** | Violett | Beides erforderlich |
Zusaetzlich steht ein Dropdown-Filter "Nachweisart" zur Verfuegung.
## Zusammenspiel mit anderen Feldern
| Feld | Zweck | Beispiel |
|---|---|---|
| `evidence_type` | **WAS** wird nachgewiesen (Code oder Prozess) | `code` |
| `verification_method` | **WIE** wird verifiziert | `code_review`, `document`, `tool`, `hybrid` |
| `evidence_confidence` | **WIE SICHER** ist der Nachweis (0.0 - 1.0) | `0.92` |
| `normative_strength` | **WIE VERBINDLICH** ist das Control | `must`, `should`, `may` |
!!! warning "evidence_type vs. verification_method"
`evidence_type` sagt, ob ein Control technisch oder organisatorisch ist.
`verification_method` sagt, mit welcher Methode es geprueft wird.
Ein `process`-Control kann trotzdem `verification_method = tool` haben
(z.B. wenn ein MCP-Scan Dokumente automatisch prueft).
## Kuenftige Automatisierung
### Code Controls
- **Git-Repository-Scan**: SAST, Secret Detection, Dependency Check
- **IaC-Analyse**: Terraform/Pulumi/CloudFormation Policies
- **CI/CD-Integration**: Pipeline-Ergebnisse als Evidence sammeln
### Prozess Controls
- **MCP-Dokumenten-Scan**: Kunden-Laufwerk anbinden, Dokumente automatisch pruefen
- **Screenshot-Analyse**: OCR + LLM-Validierung von Screenshots
- **Interview-Protokolle**: Strukturierte Audit-Checklisten

View File

@@ -110,6 +110,7 @@ nav:
- Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md - Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md
- Control Provenance Wiki: services/sdk-modules/control-provenance.md - Control Provenance Wiki: services/sdk-modules/control-provenance.md
- Normative Verbindlichkeit (Dreistufenmodell): services/sdk-modules/normative-verbindlichkeit.md - Normative Verbindlichkeit (Dreistufenmodell): services/sdk-modules/normative-verbindlichkeit.md
- Evidence Type (Code vs. Prozess): services/sdk-modules/evidence-type.md
- Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md - Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md
- Strategie: - Strategie:
- Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md - Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md