Compare commits
10 Commits
feature/be
...
feature/pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062e827801 | ||
|
|
f404226d6e | ||
|
|
8dfab4ba14 | ||
|
|
5c1a514b52 | ||
|
|
e091bbc855 | ||
|
|
ff4c359d46 | ||
|
|
f169b13dbf | ||
|
|
42d0c7b1fc | ||
|
|
4fcb842a92 | ||
|
|
38d3d24121 |
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
let path: string
|
||||
switch (endpoint) {
|
||||
case 'controls':
|
||||
const domain = searchParams.get('domain') || ''
|
||||
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
|
||||
break
|
||||
case 'assessments':
|
||||
path = '/sdk/v1/payment-compliance/assessments'
|
||||
break
|
||||
default:
|
||||
path = '/sdk/v1/payment-compliance/controls'
|
||||
}
|
||||
|
||||
const resp = await fetch(`${SDK_URL}${path}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/assessments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}`)
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'extract'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const formData = await request.formData()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
body: formData,
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface PaymentControl {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
objective: string
|
||||
check_target: string
|
||||
evidence: string[]
|
||||
automation: string
|
||||
}
|
||||
|
||||
interface PaymentDomain {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Assessment {
|
||||
id: string
|
||||
project_name: string
|
||||
tender_reference: string
|
||||
customer_name: string
|
||||
system_type: string
|
||||
total_controls: number
|
||||
controls_passed: number
|
||||
controls_failed: number
|
||||
controls_partial: number
|
||||
controls_not_applicable: number
|
||||
controls_not_checked: number
|
||||
compliance_score: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TenderAnalysis {
|
||||
id: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
project_name: string
|
||||
customer_name: string
|
||||
status: string
|
||||
total_requirements: number
|
||||
matched_count: number
|
||||
unmatched_count: number
|
||||
partial_count: number
|
||||
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
|
||||
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
low: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
const TARGET_ICONS: Record<string, string> = {
|
||||
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
|
||||
repository: '📦', certificate: '📜',
|
||||
}
|
||||
|
||||
export default function PaymentCompliancePage() {
|
||||
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||
])
|
||||
if (ctrlResp.ok) {
|
||||
const data = await ctrlResp.json()
|
||||
setControls(data.controls || [])
|
||||
setDomains(data.domains || [])
|
||||
}
|
||||
if (assessResp.ok) {
|
||||
const data = await assessResp.json()
|
||||
setAssessments(data.assessments || [])
|
||||
}
|
||||
if (tenderResp.ok) {
|
||||
const data = await tenderResp.json()
|
||||
setTenderAnalyses(data.analyses || [])
|
||||
}
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
// Auto-start extraction + matching
|
||||
setProcessing(true)
|
||||
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
|
||||
if (extractResp.ok) {
|
||||
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
|
||||
}
|
||||
// Reload and show result
|
||||
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
|
||||
if (detailResp.ok) {
|
||||
const detail = await detailResp.json()
|
||||
setSelectedTender(detail)
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploading(false)
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewTender(id: string) {
|
||||
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
|
||||
if (resp.ok) {
|
||||
setSelectedTender(await resp.json())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAssessment() {
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProject),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowNewAssessment(false)
|
||||
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredControls = selectedDomain === 'all'
|
||||
? controls
|
||||
: controls.filter(c => c.domain === selectedDomain)
|
||||
|
||||
const domainStats = domains.map(d => ({
|
||||
...d,
|
||||
count: controls.filter(c => c.domain === d.id).length,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Technische Pruefbibliothek fuer Zahlungssysteme — {controls.length} Controls in {domains.length} Domaenen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setTab('controls')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Controls ({controls.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('assessments')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Assessments ({assessments.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('tender')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Ausschreibung ({tenderAnalyses.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
|
||||
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="font-medium mb-1">1. Controls durchsuchen</div>
|
||||
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme — von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">2. Assessment erstellen</div>
|
||||
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung — z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
|
||||
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : tab === 'controls' ? (
|
||||
<>
|
||||
{/* Domain Filter */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
<button onClick={() => setSelectedDomain('all')}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
|
||||
<div className="text-xs text-gray-500">Alle</div>
|
||||
</button>
|
||||
{domainStats.map(d => (
|
||||
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-gray-900">{d.count}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{d.id}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Domain Description */}
|
||||
{selectedDomain !== 'all' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
|
||||
{domains.find(d => d.id === selectedDomain)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => {
|
||||
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
|
||||
return (
|
||||
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
|
||||
{ctrl.automation}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{ctrl.evidence.map(ev => (
|
||||
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : tab === 'assessments' ? (
|
||||
<>
|
||||
{/* Assessments Tab */}
|
||||
<div className="mb-4">
|
||||
<button onClick={() => setShowNewAssessment(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Neues Assessment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewAssessment && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
|
||||
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
|
||||
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
|
||||
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="full_stack">Full Stack (Terminal + Backend)</option>
|
||||
<option value="terminal">Nur Terminal</option>
|
||||
<option value="backend">Nur Backend</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
|
||||
<button onClick={() => setShowNewAssessment(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Assessments</p>
|
||||
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assessments.map(a => (
|
||||
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
{a.customer_name && <span>{a.customer_name} · </span>}
|
||||
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
|
||||
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
a.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold">{a.total_controls}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 rounded">
|
||||
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
|
||||
<div className="text-xs text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-red-50 rounded">
|
||||
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-yellow-50 rounded">
|
||||
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
|
||||
<div className="text-xs text-gray-500">Partial</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
|
||||
<div className="text-xs text-gray-500">N/A</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
|
||||
<div className="text-xs text-gray-500">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : tab === 'tender' ? (
|
||||
<>
|
||||
{/* Tender Analysis Tab */}
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
|
||||
</p>
|
||||
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
|
||||
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
|
||||
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Tender Detail */}
|
||||
{selectedTender && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
|
||||
<p className="text-sm text-gray-500">{selectedTender.file_name} — {selectedTender.status}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
|
||||
<div className="text-xs text-gray-500">Anforderungen</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
|
||||
<div className="text-xs text-gray-500">Abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
|
||||
<div className="text-xs text-gray-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
|
||||
<div className="text-xs text-gray-500">Luecken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Results */}
|
||||
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900">Requirement → Control Matching</h4>
|
||||
{selectedTender.match_results.map((mr, idx) => (
|
||||
<div key={idx} className={`p-4 rounded-lg border ${
|
||||
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
|
||||
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
|
||||
'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
|
||||
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-red-200 text-red-800'
|
||||
}`}>
|
||||
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{mr.req_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mr.matched_controls && mr.matched_controls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{mr.matched_controls.map(mc => (
|
||||
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
|
||||
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mr.gap_description && (
|
||||
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous Analyses */}
|
||||
{tenderAnalyses.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
|
||||
<div className="space-y-3">
|
||||
{tenderAnalyses.map(ta => (
|
||||
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
|
||||
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
|
||||
<p className="text-xs text-gray-500">{ta.file_name} — {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
|
||||
{ta.unmatched_count > 0 && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>{ta.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -546,6 +546,89 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KI-Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
KI-Compliance
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/advisory-board"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
}
|
||||
label="Use Case Erfassung"
|
||||
isActive={pathname === '/sdk/advisory-board'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/use-cases"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
}
|
||||
label="Use Cases"
|
||||
isActive={pathname?.startsWith('/sdk/use-cases') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/ai-act"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
}
|
||||
label="AI Act"
|
||||
isActive={pathname?.startsWith('/sdk/ai-act') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/ai-registration"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
}
|
||||
label="EU Registrierung"
|
||||
isActive={pathname?.startsWith('/sdk/ai-registration') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Payment / Terminal
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/payment-compliance"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
}
|
||||
label="Payment Compliance"
|
||||
isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Modules */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -106,6 +106,8 @@ func main() {
|
||||
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
||||
registrationStore := ucca.NewRegistrationStore(pool)
|
||||
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
||||
paymentHandlers := handlers.NewPaymentHandlers(pool)
|
||||
tenderHandlers := handlers.NewTenderHandlers(pool, paymentHandlers.GetControlLibrary())
|
||||
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||
@@ -298,6 +300,23 @@ func main() {
|
||||
regRoutes.GET("/:id/export", registrationHandlers.Export)
|
||||
}
|
||||
|
||||
// Payment Compliance routes
|
||||
payRoutes := v1.Group("/payment-compliance")
|
||||
{
|
||||
payRoutes.GET("/controls", paymentHandlers.ListControls)
|
||||
payRoutes.POST("/assessments", paymentHandlers.CreateAssessment)
|
||||
payRoutes.GET("/assessments", paymentHandlers.ListAssessments)
|
||||
payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment)
|
||||
payRoutes.PATCH("/assessments/:id/verdict", paymentHandlers.UpdateControlVerdict)
|
||||
|
||||
// Tender Analysis
|
||||
payRoutes.POST("/tender/upload", tenderHandlers.Upload)
|
||||
payRoutes.POST("/tender/:id/extract", tenderHandlers.Extract)
|
||||
payRoutes.POST("/tender/:id/match", tenderHandlers.Match)
|
||||
payRoutes.GET("/tender", tenderHandlers.ListAnalyses)
|
||||
payRoutes.GET("/tender/:id", tenderHandlers.GetAnalysis)
|
||||
}
|
||||
|
||||
// RAG routes - Legal Corpus Search & Versioning
|
||||
ragRoutes := v1.Group("/rag")
|
||||
{
|
||||
|
||||
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PaymentHandlers handles payment compliance endpoints
|
||||
type PaymentHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// PaymentControlLibrary holds the control catalog
|
||||
type PaymentControlLibrary struct {
|
||||
Domains []PaymentDomain `json:"domains"`
|
||||
Controls []PaymentControl `json:"controls"`
|
||||
}
|
||||
|
||||
type PaymentDomain struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type PaymentControl struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Domain string `json:"domain"`
|
||||
Title string `json:"title"`
|
||||
Objective string `json:"objective"`
|
||||
CheckTarget string `json:"check_target"`
|
||||
Evidence []string `json:"evidence"`
|
||||
Automation string `json:"automation"`
|
||||
}
|
||||
|
||||
type PaymentAssessment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
TenderReference string `json:"tender_reference,omitempty"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SystemType string `json:"system_type,omitempty"`
|
||||
PaymentMethods json.RawMessage `json:"payment_methods,omitempty"`
|
||||
Protocols json.RawMessage `json:"protocols,omitempty"`
|
||||
TotalControls int `json:"total_controls"`
|
||||
ControlsPassed int `json:"controls_passed"`
|
||||
ControlsFailed int `json:"controls_failed"`
|
||||
ControlsPartial int `json:"controls_partial"`
|
||||
ControlsNA int `json:"controls_not_applicable"`
|
||||
ControlsUnchecked int `json:"controls_not_checked"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
Status string `json:"status"`
|
||||
ControlResults json.RawMessage `json:"control_results,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// NewPaymentHandlers creates payment handlers with loaded control library
|
||||
func NewPaymentHandlers(pool *pgxpool.Pool) *PaymentHandlers {
|
||||
lib := loadControlLibrary()
|
||||
return &PaymentHandlers{pool: pool, controls: lib}
|
||||
}
|
||||
|
||||
func loadControlLibrary() *PaymentControlLibrary {
|
||||
// Try to load from policies directory
|
||||
paths := []string{
|
||||
"policies/payment_controls_v1.json",
|
||||
"/app/policies/payment_controls_v1.json",
|
||||
}
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
// Try relative to executable
|
||||
execDir, _ := os.Executable()
|
||||
altPath := filepath.Join(filepath.Dir(execDir), p)
|
||||
data, err = os.ReadFile(altPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var lib PaymentControlLibrary
|
||||
if err := json.Unmarshal(data, &lib); err == nil {
|
||||
return &lib
|
||||
}
|
||||
}
|
||||
return &PaymentControlLibrary{}
|
||||
}
|
||||
|
||||
// GetControlLibrary returns the loaded control library (for tender matching)
|
||||
func (h *PaymentHandlers) GetControlLibrary() *PaymentControlLibrary {
|
||||
return h.controls
|
||||
}
|
||||
|
||||
// ListControls returns the control library
|
||||
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
||||
domain := c.Query("domain")
|
||||
automation := c.Query("automation")
|
||||
|
||||
controls := h.controls.Controls
|
||||
if domain != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Domain == domain {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
if automation != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Automation == automation {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"domains": h.controls.Domains,
|
||||
"total": len(controls),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAssessment creates a new payment compliance assessment
|
||||
func (h *PaymentHandlers) CreateAssessment(c *gin.Context) {
|
||||
var req PaymentAssessment
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
req.ID = uuid.New()
|
||||
req.TenantID = tenantID
|
||||
req.Status = "draft"
|
||||
req.TotalControls = len(h.controls.Controls)
|
||||
req.ControlsUnchecked = req.TotalControls
|
||||
req.CreatedAt = time.Now()
|
||||
req.UpdatedAt = time.Now()
|
||||
|
||||
_, err := h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO payment_compliance_assessments (
|
||||
id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_not_checked, status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
req.ID, req.TenantID, req.ProjectName, req.TenderReference, req.CustomerName, req.Description,
|
||||
req.SystemType, req.PaymentMethods, req.Protocols,
|
||||
req.TotalControls, req.ControlsUnchecked, req.Status, req.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, req)
|
||||
}
|
||||
|
||||
// ListAssessments lists all payment assessments for a tenant
|
||||
func (h *PaymentHandlers) ListAssessments(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name,
|
||||
system_type, total_controls, controls_passed, controls_failed,
|
||||
controls_partial, controls_not_applicable, controls_not_checked,
|
||||
compliance_score, status, created_at, updated_at
|
||||
FROM payment_compliance_assessments
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assessments []PaymentAssessment
|
||||
for rows.Next() {
|
||||
var a PaymentAssessment
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName,
|
||||
&a.SystemType, &a.TotalControls, &a.ControlsPassed, &a.ControlsFailed,
|
||||
&a.ControlsPartial, &a.ControlsNA, &a.ControlsUnchecked,
|
||||
&a.ComplianceScore, &a.Status, &a.CreatedAt, &a.UpdatedAt)
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
if assessments == nil {
|
||||
assessments = []PaymentAssessment{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assessments": assessments, "total": len(assessments)})
|
||||
}
|
||||
|
||||
// GetAssessment returns a single assessment with control results
|
||||
func (h *PaymentHandlers) GetAssessment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a PaymentAssessment
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_passed, controls_failed, controls_partial,
|
||||
controls_not_applicable, controls_not_checked, compliance_score,
|
||||
status, control_results, created_at, updated_at, created_by
|
||||
FROM payment_compliance_assessments WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName, &a.Description,
|
||||
&a.SystemType, &a.PaymentMethods, &a.Protocols,
|
||||
&a.TotalControls, &a.ControlsPassed, &a.ControlsFailed, &a.ControlsPartial,
|
||||
&a.ControlsNA, &a.ControlsUnchecked, &a.ComplianceScore,
|
||||
&a.Status, &a.ControlResults, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// UpdateControlVerdict updates the verdict for a single control
|
||||
func (h *PaymentHandlers) UpdateControlVerdict(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Verdict string `json:"verdict"` // passed, failed, partial, na, unchecked
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the control_results JSONB and recalculate scores
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
WITH updated AS (
|
||||
SELECT id,
|
||||
COALESCE(control_results, '[]'::jsonb) AS existing_results
|
||||
FROM payment_compliance_assessments WHERE id = $1
|
||||
)
|
||||
UPDATE payment_compliance_assessments SET
|
||||
control_results = (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'control_id' = $2 THEN
|
||||
jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5)
|
||||
ELSE elem END
|
||||
) FROM updated, jsonb_array_elements(
|
||||
CASE WHEN existing_results @> jsonb_build_array(jsonb_build_object('control_id', $2))
|
||||
THEN existing_results
|
||||
ELSE existing_results || jsonb_build_array(jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5))
|
||||
END
|
||||
) AS elem
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, body.ControlID, body.Verdict, body.Evidence, body.Notes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "control_id": body.ControlID, "verdict": body.Verdict})
|
||||
}
|
||||
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TenderHandlers handles tender upload and requirement extraction
|
||||
type TenderHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// TenderAnalysis represents a tender document analysis
|
||||
type TenderAnalysis struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Status string `json:"status"` // uploaded, extracting, extracted, matched, completed
|
||||
Requirements []ExtractedReq `json:"requirements,omitempty"`
|
||||
MatchResults []MatchResult `json:"match_results,omitempty"`
|
||||
TotalRequirements int `json:"total_requirements"`
|
||||
MatchedCount int `json:"matched_count"`
|
||||
UnmatchedCount int `json:"unmatched_count"`
|
||||
PartialCount int `json:"partial_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ExtractedReq represents a single requirement extracted from a tender document
|
||||
type ExtractedReq struct {
|
||||
ReqID string `json:"req_id"`
|
||||
Text string `json:"text"`
|
||||
SourcePage int `json:"source_page,omitempty"`
|
||||
SourceSection string `json:"source_section,omitempty"`
|
||||
ObligationLevel string `json:"obligation_level"` // MUST, SHALL, SHOULD, MAY
|
||||
TechnicalDomain string `json:"technical_domain"` // crypto, logging, payment_flow, etc.
|
||||
CheckTarget string `json:"check_target"` // code, system, config, process, certificate
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// MatchResult represents the matching of a requirement to controls
|
||||
type MatchResult struct {
|
||||
ReqID string `json:"req_id"`
|
||||
ReqText string `json:"req_text"`
|
||||
ObligationLevel string `json:"obligation_level"`
|
||||
MatchedControls []ControlMatch `json:"matched_controls"`
|
||||
Verdict string `json:"verdict"` // matched, partial, unmatched
|
||||
GapDescription string `json:"gap_description,omitempty"`
|
||||
}
|
||||
|
||||
// ControlMatch represents a single control match for a requirement
|
||||
type ControlMatch struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Title string `json:"title"`
|
||||
Relevance float64 `json:"relevance"` // 0-1
|
||||
CheckTarget string `json:"check_target"`
|
||||
}
|
||||
|
||||
// NewTenderHandlers creates tender handlers
|
||||
func NewTenderHandlers(pool *pgxpool.Pool, controls *PaymentControlLibrary) *TenderHandlers {
|
||||
return &TenderHandlers{pool: pool, controls: controls}
|
||||
}
|
||||
|
||||
// Upload handles tender document upload
|
||||
func (h *TenderHandlers) Upload(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
projectName := c.PostForm("project_name")
|
||||
if projectName == "" {
|
||||
projectName = header.Filename
|
||||
}
|
||||
customerName := c.PostForm("customer_name")
|
||||
|
||||
// Read file content
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store analysis record
|
||||
analysisID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO tender_analyses (
|
||||
id, tenant_id, file_name, file_size, file_content,
|
||||
project_name, customer_name, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8, $9)`,
|
||||
analysisID, tenantID, header.Filename, header.Size, content,
|
||||
projectName, customerName, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": analysisID,
|
||||
"file_name": header.Filename,
|
||||
"file_size": header.Size,
|
||||
"project_name": projectName,
|
||||
"status": "uploaded",
|
||||
"message": "Dokument hochgeladen. Starte Analyse mit POST /extract.",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract extracts requirements from an uploaded tender document using LLM
|
||||
func (h *TenderHandlers) Extract(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get file content
|
||||
var fileContent []byte
|
||||
var fileName string
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT file_content, file_name FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&fileContent, &fileName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET status = 'extracting', updated_at = NOW() WHERE id = $1`, id)
|
||||
|
||||
// Extract text (simple: treat as text for now, PDF extraction would use embedding-service)
|
||||
text := string(fileContent)
|
||||
|
||||
// Use LLM to extract requirements
|
||||
requirements := h.extractRequirementsWithLLM(c.Request.Context(), text)
|
||||
|
||||
// Store results
|
||||
reqJSON, _ := json.Marshal(requirements)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'extracted',
|
||||
requirements = $2,
|
||||
total_requirements = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, reqJSON, len(requirements))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "extracted",
|
||||
"requirements": requirements,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// Match matches extracted requirements against the control library
|
||||
func (h *TenderHandlers) Match(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get requirements
|
||||
var reqJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT requirements FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&reqJSON)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var requirements []ExtractedReq
|
||||
json.Unmarshal(reqJSON, &requirements)
|
||||
|
||||
// Match each requirement against controls
|
||||
var results []MatchResult
|
||||
matched, unmatched, partial := 0, 0, 0
|
||||
|
||||
for _, req := range requirements {
|
||||
matches := h.findMatchingControls(req)
|
||||
result := MatchResult{
|
||||
ReqID: req.ReqID,
|
||||
ReqText: req.Text,
|
||||
ObligationLevel: req.ObligationLevel,
|
||||
MatchedControls: matches,
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
result.Verdict = "unmatched"
|
||||
result.GapDescription = "Kein passender Control gefunden — manueller Review erforderlich"
|
||||
unmatched++
|
||||
} else if matches[0].Relevance >= 0.7 {
|
||||
result.Verdict = "matched"
|
||||
matched++
|
||||
} else {
|
||||
result.Verdict = "partial"
|
||||
result.GapDescription = "Teilweise Abdeckung — Control deckt Anforderung nicht vollstaendig ab"
|
||||
partial++
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Store results
|
||||
resultsJSON, _ := json.Marshal(results)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'matched',
|
||||
match_results = $2,
|
||||
matched_count = $3,
|
||||
unmatched_count = $4,
|
||||
partial_count = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, resultsJSON, matched, unmatched, partial)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "matched",
|
||||
"results": results,
|
||||
"matched": matched,
|
||||
"unmatched": unmatched,
|
||||
"partial": partial,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// ListAnalyses lists all tender analyses for a tenant
|
||||
func (h *TenderHandlers) ListAnalyses(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var analyses []TenderAnalysis
|
||||
for rows.Next() {
|
||||
var a TenderAnalysis
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
analyses = append(analyses, a)
|
||||
}
|
||||
if analyses == nil {
|
||||
analyses = []TenderAnalysis{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"analyses": analyses, "total": len(analyses)})
|
||||
}
|
||||
|
||||
// GetAnalysis returns a single analysis with all details
|
||||
func (h *TenderHandlers) GetAnalysis(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a TenderAnalysis
|
||||
var reqJSON, matchJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, requirements, match_results,
|
||||
total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &reqJSON, &matchJSON,
|
||||
&a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if reqJSON != nil {
|
||||
json.Unmarshal(reqJSON, &a.Requirements)
|
||||
}
|
||||
if matchJSON != nil {
|
||||
json.Unmarshal(matchJSON, &a.MatchResults)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsWithLLM(ctx context.Context, text string) []ExtractedReq {
|
||||
// Try Anthropic API for requirement extraction
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Fallback: simple keyword-based extraction
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Analysiere das folgende Ausschreibungsdokument und extrahiere alle technischen Anforderungen.
|
||||
|
||||
Fuer jede Anforderung gib zurueck:
|
||||
- req_id: fortlaufende ID (REQ-001, REQ-002, ...)
|
||||
- text: die Anforderung als kurzer Satz
|
||||
- obligation_level: MUST, SHALL, SHOULD oder MAY
|
||||
- technical_domain: eines von: payment_flow, logging, crypto, api_security, terminal_comm, firmware, reporting, access_control, error_handling, build_deploy
|
||||
- check_target: eines von: code, system, config, process, certificate
|
||||
|
||||
Antworte NUR mit JSON Array. Keine Erklaerung.
|
||||
|
||||
Dokument:
|
||||
%s`, text[:min(len(text), 15000)])
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"max_tokens": 4096,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if len(result.Content) == 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Parse LLM response
|
||||
responseText := result.Content[0].Text
|
||||
// Find JSON array in response
|
||||
start := strings.Index(responseText, "[")
|
||||
end := strings.LastIndex(responseText, "]")
|
||||
if start < 0 || end < 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
if err := json.Unmarshal([]byte(responseText[start:end+1]), &reqs); err != nil {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Set confidence for LLM-extracted requirements
|
||||
for i := range reqs {
|
||||
reqs[i].Confidence = 0.8
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsKeyword(text string) []ExtractedReq {
|
||||
// Simple keyword-based extraction as fallback
|
||||
keywords := map[string]string{
|
||||
"muss": "MUST",
|
||||
"muessen": "MUST",
|
||||
"ist sicherzustellen": "MUST",
|
||||
"soll": "SHOULD",
|
||||
"sollte": "SHOULD",
|
||||
"kann": "MAY",
|
||||
"wird gefordert": "MUST",
|
||||
"nachzuweisen": "MUST",
|
||||
"zertifiziert": "MUST",
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
lines := strings.Split(text, "\n")
|
||||
reqNum := 1
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 20 || len(line) > 500 {
|
||||
continue
|
||||
}
|
||||
|
||||
for keyword, level := range keywords {
|
||||
if strings.Contains(strings.ToLower(line), keyword) {
|
||||
reqs = append(reqs, ExtractedReq{
|
||||
ReqID: fmt.Sprintf("REQ-%03d", reqNum),
|
||||
Text: line,
|
||||
ObligationLevel: level,
|
||||
TechnicalDomain: inferDomain(line),
|
||||
CheckTarget: inferCheckTarget(line),
|
||||
Confidence: 0.5,
|
||||
})
|
||||
reqNum++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) findMatchingControls(req ExtractedReq) []ControlMatch {
|
||||
var matches []ControlMatch
|
||||
|
||||
reqLower := strings.ToLower(req.Text + " " + req.TechnicalDomain)
|
||||
|
||||
for _, ctrl := range h.controls.Controls {
|
||||
titleLower := strings.ToLower(ctrl.Title + " " + ctrl.Objective)
|
||||
relevance := calculateRelevance(reqLower, titleLower, req.TechnicalDomain, ctrl.Domain)
|
||||
|
||||
if relevance > 0.3 {
|
||||
matches = append(matches, ControlMatch{
|
||||
ControlID: ctrl.ControlID,
|
||||
Title: ctrl.Title,
|
||||
Relevance: relevance,
|
||||
CheckTarget: ctrl.CheckTarget,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance (simple bubble sort for small lists)
|
||||
for i := 0; i < len(matches); i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[j].Relevance > matches[i].Relevance {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top 5
|
||||
if len(matches) > 5 {
|
||||
matches = matches[:5]
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func calculateRelevance(reqText, ctrlText, reqDomain, ctrlDomain string) float64 {
|
||||
score := 0.0
|
||||
|
||||
// Domain match bonus
|
||||
domainMap := map[string]string{
|
||||
"payment_flow": "PAY",
|
||||
"logging": "LOG",
|
||||
"crypto": "CRYPTO",
|
||||
"api_security": "API",
|
||||
"terminal_comm": "TERM",
|
||||
"firmware": "FW",
|
||||
"reporting": "REP",
|
||||
"access_control": "ACC",
|
||||
"error_handling": "ERR",
|
||||
"build_deploy": "BLD",
|
||||
}
|
||||
|
||||
if mapped, ok := domainMap[reqDomain]; ok && mapped == ctrlDomain {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Keyword overlap
|
||||
reqWords := strings.Fields(reqText)
|
||||
for _, word := range reqWords {
|
||||
if len(word) > 3 && strings.Contains(ctrlText, word) {
|
||||
score += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func inferDomain(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
domainKeywords := map[string][]string{
|
||||
"payment_flow": {"zahlung", "transaktion", "buchung", "payment", "betrag"},
|
||||
"logging": {"log", "protokoll", "audit", "nachvollzieh"},
|
||||
"crypto": {"verschlüssel", "schlüssel", "krypto", "tls", "ssl", "hsm", "pin"},
|
||||
"api_security": {"api", "schnittstelle", "authentifiz", "autorisier"},
|
||||
"terminal_comm": {"terminal", "zvt", "opi", "gerät", "kontaktlos", "nfc"},
|
||||
"firmware": {"firmware", "update", "signatur", "boot"},
|
||||
"reporting": {"bericht", "report", "abrechnung", "export", "abgleich"},
|
||||
"access_control": {"zugang", "benutzer", "passwort", "rolle", "berechtigung"},
|
||||
"error_handling": {"fehler", "ausfall", "recovery", "offline", "störung"},
|
||||
"build_deploy": {"build", "deploy", "release", "ci", "pipeline"},
|
||||
}
|
||||
|
||||
for domain, keywords := range domainKeywords {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(textLower, kw) {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
func inferCheckTarget(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
if strings.Contains(textLower, "zertifik") || strings.Contains(textLower, "zulassung") {
|
||||
return "certificate"
|
||||
}
|
||||
if strings.Contains(textLower, "prozess") || strings.Contains(textLower, "verfahren") {
|
||||
return "process"
|
||||
}
|
||||
if strings.Contains(textLower, "konfigur") {
|
||||
return "config"
|
||||
}
|
||||
return "code"
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration 024: Payment Compliance Schema
|
||||
-- Tracks payment terminal compliance assessments against control library
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_compliance_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Project / Tender
|
||||
project_name VARCHAR(500) NOT NULL,
|
||||
tender_reference VARCHAR(200),
|
||||
customer_name VARCHAR(500),
|
||||
description TEXT,
|
||||
|
||||
-- Scope
|
||||
system_type VARCHAR(100), -- terminal, backend, both, full_stack
|
||||
payment_methods JSONB DEFAULT '[]'::jsonb, -- ["card", "nfc", "girocard", "credit"]
|
||||
protocols JSONB DEFAULT '[]'::jsonb, -- ["zvt", "opi", "emv"]
|
||||
|
||||
-- Assessment
|
||||
total_controls INT DEFAULT 0,
|
||||
controls_passed INT DEFAULT 0,
|
||||
controls_failed INT DEFAULT 0,
|
||||
controls_partial INT DEFAULT 0,
|
||||
controls_not_applicable INT DEFAULT 0,
|
||||
controls_not_checked INT DEFAULT 0,
|
||||
compliance_score NUMERIC(5,2) DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
-- CHECK (status IN ('draft', 'in_progress', 'completed', 'approved'))
|
||||
|
||||
-- Results (per control)
|
||||
control_results JSONB DEFAULT '[]'::jsonb,
|
||||
-- Each entry: {"control_id": "PAY-001", "verdict": "passed|failed|partial|na|unchecked", "evidence": "...", "notes": "..."}
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(200),
|
||||
approved_by VARCHAR(200),
|
||||
approved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pca_tenant ON payment_compliance_assessments (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pca_status ON payment_compliance_assessments (status);
|
||||
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal file
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Migration 025: Tender Analysis Schema
|
||||
-- Stores uploaded tender documents, extracted requirements, and control matching results
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tender_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Document
|
||||
file_name VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT DEFAULT 0,
|
||||
file_content BYTEA,
|
||||
|
||||
-- Project
|
||||
project_name VARCHAR(500),
|
||||
customer_name VARCHAR(500),
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'uploaded',
|
||||
-- CHECK (status IN ('uploaded', 'extracting', 'extracted', 'matched', 'completed', 'error'))
|
||||
|
||||
-- Extracted requirements
|
||||
requirements JSONB DEFAULT '[]'::jsonb,
|
||||
total_requirements INT DEFAULT 0,
|
||||
|
||||
-- Match results
|
||||
match_results JSONB DEFAULT '[]'::jsonb,
|
||||
matched_count INT DEFAULT 0,
|
||||
unmatched_count INT DEFAULT 0,
|
||||
partial_count INT DEFAULT 0,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ta_tenant ON tender_analyses (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ta_status ON tender_analyses (status);
|
||||
65
ai-compliance-sdk/payment-compliance-pack/README.md
Normal file
65
ai-compliance-sdk/payment-compliance-pack/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Payment Compliance Pack
|
||||
|
||||
Ausfuehrbares Pruefpaket fuer Payment-Terminal-Systeme.
|
||||
|
||||
## Inhalt
|
||||
|
||||
### Semgrep-Regeln (25 Regeln)
|
||||
|
||||
| Datei | Regeln | Controls |
|
||||
|-------|--------|----------|
|
||||
| `payment_logging.yml` | 5 | LOG-001, LOG-002, LOG-014 |
|
||||
| `payment_crypto.yml` | 6 | CRYPTO-001, CRYPTO-008, CRYPTO-009, KEYMGMT-001 |
|
||||
| `payment_api.yml` | 5 | API-004, API-005, API-014, API-017 |
|
||||
| `payment_config.yml` | 5 | CONFIG-001 bis CONFIG-004 |
|
||||
| `payment_data.yml` | 5 | DATA-004, DATA-005, DATA-013, TELEMETRY-001 |
|
||||
|
||||
### CodeQL-Specs (5 Queries)
|
||||
|
||||
| Datei | Ziel | Controls |
|
||||
|-------|------|----------|
|
||||
| `sensitive-data-to-logs.md` | Datenfluss zu Loggern | LOG-001, LOG-002, DATA-013 |
|
||||
| `sensitive-data-to-response.md` | Datenfluss in HTTP-Responses | API-009, ERROR-005 |
|
||||
| `tenant-context-loss.md` | Mandantenkontext-Verlust | TENANT-001, TENANT-002 |
|
||||
| `sensitive-data-to-telemetry.md` | Datenfluss in Telemetrie | TELEMETRY-001, TELEMETRY-002 |
|
||||
| `cache-export-leak.md` | Leaks in Cache/Export | DATA-004, DATA-011 |
|
||||
|
||||
### State-Machine-Tests (10 Testfaelle)
|
||||
|
||||
| Datei | Inhalt |
|
||||
|-------|--------|
|
||||
| `terminal_states.md` | 11 Zustaende, 15 Events, Transitions |
|
||||
| `terminal_invariants.md` | 8 Invarianten |
|
||||
| `terminal_testcases.json` | 10 ausfuehrbare Testfaelle |
|
||||
|
||||
### Finding-Schema
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|-------------|
|
||||
| `finding.schema.json` | JSON Schema fuer Pruefergebnisse |
|
||||
|
||||
## Ausfuehrung
|
||||
|
||||
### Semgrep
|
||||
|
||||
```bash
|
||||
semgrep --config payment-compliance-pack/semgrep/ /path/to/source
|
||||
```
|
||||
|
||||
### State-Machine-Tests
|
||||
|
||||
Die Testfaelle in `terminal_testcases.json` definieren:
|
||||
- Ausgangszustand
|
||||
- Event-Sequenz
|
||||
- Erwarteten Endzustand
|
||||
- Zu pruefende Invarianten
|
||||
- Gemappte Controls
|
||||
|
||||
Diese koennen gegen einen Terminal-Adapter oder Simulator ausgefuehrt werden.
|
||||
|
||||
## Priorisierte Umsetzung
|
||||
|
||||
1. **Welle 1:** 25 Semgrep-Regeln sofort produktiv
|
||||
2. **Welle 2:** 5 CodeQL-Queries fuer Datenfluesse
|
||||
3. **Welle 3:** 10 State-Machine-Tests gegen Terminal-Simulator
|
||||
4. **Welle 4:** Tender-Mapping (Requirement → Control → Finding → Verdict)
|
||||
@@ -0,0 +1,20 @@
|
||||
# CodeQL Query: Cache and Export Leak
|
||||
|
||||
## Ziel
|
||||
Finde Leaks sensibler Daten in Caches, Files, Reports und Exportpfaden.
|
||||
|
||||
## Sources
|
||||
- Sensitive payment attributes (pan, cvv, track2)
|
||||
- Full transaction objects with sensitive fields
|
||||
|
||||
## Sinks
|
||||
- Redis/Memcache writes
|
||||
- Temp file writes
|
||||
- CSV/PDF/Excel exports
|
||||
- Report builders
|
||||
|
||||
## Mapped Controls
|
||||
- `DATA-004`: Temporaere Speicher ohne sensitive Daten
|
||||
- `DATA-005`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
- `DATA-011`: Batch/Queue ohne unnoetige sensitive Felder
|
||||
- `REPORT-005`: Berichte beruecksichtigen Zeitzonen konsistent
|
||||
@@ -0,0 +1,32 @@
|
||||
# CodeQL Query: Sensitive Data to Logs
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse von sensitiven Zahlungsdaten zu Loggern.
|
||||
|
||||
## Sources
|
||||
Variablen, Felder, Parameter oder JSON-Felder mit Namen:
|
||||
- `pan`, `cardNumber`, `card_number`
|
||||
- `cvv`, `cvc`
|
||||
- `track2`, `track_2`
|
||||
- `pin`
|
||||
- `expiry`, `ablauf`
|
||||
|
||||
## Sinks
|
||||
- Logger-Aufrufe (`logging.*`, `logger.*`, `console.*`, `log.*`)
|
||||
- Telemetrie-/Tracing-Emitter (`span.set_attribute`, `tracer.*)
|
||||
- Audit-Logger (wenn nicht maskiert)
|
||||
|
||||
## Expected Result
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| file | string |
|
||||
| line | int |
|
||||
| source_name | string |
|
||||
| sink_call | string |
|
||||
| path | string[] |
|
||||
|
||||
## Mapped Controls
|
||||
- `LOG-001`: Keine sensitiven Zahlungsdaten im Log
|
||||
- `LOG-002`: PAN maskiert in Logs
|
||||
- `DATA-013`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
- `TELEMETRY-001`: Telemetriedaten ohne sensitive Zahlungsdaten
|
||||
@@ -0,0 +1,19 @@
|
||||
# CodeQL Query: Sensitive Data to HTTP Response
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse sensibler Daten in HTTP-/API-Responses oder Exception-Bodies.
|
||||
|
||||
## Sources
|
||||
- Sensible Payment-Felder: pan, cvv, track2, cardNumber, pin, expiry
|
||||
- Interne Payment DTOs mit sensitiven Attributen
|
||||
|
||||
## Sinks
|
||||
- JSON serializer / response builder
|
||||
- Exception payload / error handler response
|
||||
- Template rendering output
|
||||
|
||||
## Mapped Controls
|
||||
- `API-009`: API-Antworten minimieren sensible Daten
|
||||
- `API-015`: Interne Fehler ohne sensitive Daten an Client
|
||||
- `ERROR-005`: Ausnahmebehandlung gibt keine sensitiven Rohdaten zurueck
|
||||
- `REPORT-006`: Reports offenbaren nur rollenerforderliche Daten
|
||||
@@ -0,0 +1,19 @@
|
||||
# CodeQL Query: Sensitive Data to Telemetry
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse sensibler Daten in Metriken, Traces und Telemetrie-Events.
|
||||
|
||||
## Sources
|
||||
- Payment DTO fields (pan, cvv, track2, cardNumber)
|
||||
- Token/Session related fields
|
||||
|
||||
## Sinks
|
||||
- Span attributes / trace tags
|
||||
- Metric labels
|
||||
- Telemetry events / exporters
|
||||
|
||||
## Mapped Controls
|
||||
- `TELEMETRY-001`: Telemetriedaten ohne sensitive Zahlungsdaten
|
||||
- `TELEMETRY-002`: Tracing maskiert identifizierende Felder
|
||||
- `TELEMETRY-003`: Metriken ohne hochkartesische sensitive Labels
|
||||
- `DATA-013`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
@@ -0,0 +1,21 @@
|
||||
# CodeQL Query: Tenant Context Loss
|
||||
|
||||
## Ziel
|
||||
Finde Datenbank-, Cache- oder Exportpfade ohne durchgehenden Tenant-Kontext.
|
||||
|
||||
## Sources
|
||||
- Request tenant (header, token, session)
|
||||
- Device tenant
|
||||
- User tenant
|
||||
|
||||
## Danger Patterns
|
||||
- DB Query ohne tenant filter / WHERE clause
|
||||
- Cache key ohne tenant prefix
|
||||
- Export job ohne tenant binding
|
||||
- Report query ohne Mandanteneinschraenkung
|
||||
|
||||
## Mapped Controls
|
||||
- `TENANT-001`: Mandantenkontext serverseitig validiert
|
||||
- `TENANT-002`: Datenabfragen mandantenbeschraenkt
|
||||
- `TENANT-006`: Caching beruecksichtigt Mandantenkontext
|
||||
- `TENANT-008`: Datenexporte erzwingen Mandantenisolation
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Payment Compliance Finding",
|
||||
"type": "object",
|
||||
"required": ["control_id", "engine", "status", "confidence", "evidence", "verdict_text"],
|
||||
"properties": {
|
||||
"control_id": { "type": "string" },
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["semgrep", "codeql", "contract_test", "state_machine_test", "integration_test", "manual"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["passed", "failed", "warning", "not_tested", "needs_manual_review"]
|
||||
},
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"]
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"line": { "type": "integer" },
|
||||
"snippet_type": { "type": "string" },
|
||||
"scenario": { "type": "string" },
|
||||
"observed_state": { "type": "string" },
|
||||
"expected_state": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"mapped_requirements": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"verdict_text": { "type": "string" },
|
||||
"next_action": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
rules:
|
||||
- id: payment-debug-route
|
||||
message: Debug- oder Diagnosepfad im produktiven API-Code pruefen.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(/debug|/internal|/test|/actuator|/swagger|/openapi)
|
||||
|
||||
- id: payment-admin-route-without-auth
|
||||
message: Administrative Route ohne offensichtlichen Auth-Schutz pruefen.
|
||||
severity: WARNING
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern: |
|
||||
@app.$METHOD($ROUTE)
|
||||
def $FUNC(...):
|
||||
...
|
||||
- metavariable-pattern:
|
||||
metavariable: $ROUTE
|
||||
pattern-regex: (?i).*(admin|config|terminal|maintenance|device|key).*
|
||||
|
||||
- id: payment-raw-exception-response
|
||||
message: Roh-Exceptions duerfen nicht direkt an Clients zurueckgegeben werden.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript]
|
||||
pattern-regex: (?i)(return .*str\(e\)|res\.status\(500\)\.send\(e|json\(.*error.*e)
|
||||
|
||||
- id: payment-missing-input-validation
|
||||
message: Zahlungsrelevanter Endpunkt ohne offensichtliche Validierung pruefen.
|
||||
severity: INFO
|
||||
languages: [python, javascript, typescript]
|
||||
pattern-regex: (?i)(amount|currency|terminalId|transactionId)
|
||||
|
||||
- id: payment-idor-risk
|
||||
message: Direkter Zugriff ueber terminalId/transactionId ohne Pruefung.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(get.*terminalId|find.*terminalId|get.*transactionId|find.*transactionId)
|
||||
@@ -0,0 +1,30 @@
|
||||
rules:
|
||||
- id: payment-prod-config-test-endpoint
|
||||
message: Test- oder Sandbox-Endpunkt in produktionsnaher Konfiguration erkannt.
|
||||
severity: ERROR
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(sandbox|test-endpoint|mock-terminal|dummy-acquirer)
|
||||
|
||||
- id: payment-prod-debug-flag
|
||||
message: Unsicherer Debug-Flag in Konfiguration erkannt.
|
||||
severity: WARNING
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(debug:\s*true|"debug"\s*:\s*true)
|
||||
|
||||
- id: payment-open-cors
|
||||
message: Offene CORS-Freigabe pruefen.
|
||||
severity: WARNING
|
||||
languages: [yaml, json, javascript, typescript]
|
||||
pattern-regex: (?i)(Access-Control-Allow-Origin.*\*|origin:\s*["']\*["'])
|
||||
|
||||
- id: payment-insecure-session-cookie
|
||||
message: Unsicher gesetzte Session-Cookies pruefen.
|
||||
severity: ERROR
|
||||
languages: [javascript, typescript, python]
|
||||
pattern-regex: (?i)(httpOnly\s*:\s*false|secure\s*:\s*false|sameSite\s*:\s*["']none["'])
|
||||
|
||||
- id: payment-unbounded-retry
|
||||
message: Retry-Konfiguration scheint unbegrenzt oder zu hoch.
|
||||
severity: WARNING
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(retry.*(9999|infinite|unbounded))
|
||||
@@ -0,0 +1,43 @@
|
||||
rules:
|
||||
- id: payment-no-md5-sha1
|
||||
message: Unsichere Hash-Algorithmen erkannt.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)\b(md5|sha1)\b
|
||||
|
||||
- id: payment-no-des-3des
|
||||
message: Veraltete symmetrische Verfahren erkannt.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)\b(des|3des|tripledes)\b
|
||||
|
||||
- id: payment-no-ecb
|
||||
message: ECB-Modus ist fuer sensible Daten ungeeignet.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)\becb\b
|
||||
|
||||
- id: payment-hardcoded-secret
|
||||
message: Moeglicherweise hartkodiertes Secret erkannt.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: $KEY = "..."
|
||||
- pattern: const $KEY = "..."
|
||||
- pattern: final String $KEY = "..."
|
||||
- metavariable-pattern:
|
||||
metavariable: $KEY
|
||||
pattern-regex: (?i).*(secret|apikey|api_key|password|passwd|privatekey|private_key|terminalkey|zvtkey|opiKey).*
|
||||
|
||||
- id: payment-weak-random
|
||||
message: Nicht-kryptographischer Zufall in Sicherheitskontext erkannt.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java]
|
||||
pattern-regex: (?i)(Math\.random|random\.random|new Random\()
|
||||
|
||||
- id: payment-disable-tls-verify
|
||||
message: TLS-Zertifikatspruefung scheint deaktiviert zu sein.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(verify\s*=\s*False|rejectUnauthorized\s*:\s*false|InsecureSkipVerify\s*:\s*true|trustAll)
|
||||
@@ -0,0 +1,30 @@
|
||||
rules:
|
||||
- id: payment-sensitive-in-telemetry
|
||||
message: Sensitive Zahlungsdaten in Telemetrie oder Tracing pruefen.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(trace|span|metric|telemetry).*(pan|cvv|track2|cardnumber|pin|expiry)
|
||||
|
||||
- id: payment-sensitive-in-cache
|
||||
message: Sensitiver Wert in Cache-Key oder Cache-Payload pruefen.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(cache|redis|memcache).*(pan|cvv|track2|cardnumber|pin)
|
||||
|
||||
- id: payment-sensitive-export
|
||||
message: Export oder Report mit sensitiven Feldern pruefen.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(export|report|csv|xlsx|pdf).*(pan|cvv|track2|cardnumber|pin)
|
||||
|
||||
- id: payment-test-fixture-real-data
|
||||
message: Testdaten mit moeglichen echten Kartendaten pruefen.
|
||||
severity: WARNING
|
||||
languages: [json, yaml, python, javascript, typescript]
|
||||
pattern-regex: (?i)(4111111111111111|5555555555554444|track2|cvv)
|
||||
|
||||
- id: payment-queue-sensitive-payload
|
||||
message: Queue-Nachricht mit sensitiven Zahlungsfeldern pruefen.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(publish|send|enqueue).*(pan|cvv|track2|cardnumber|pin)
|
||||
@@ -0,0 +1,42 @@
|
||||
rules:
|
||||
- id: payment-no-sensitive-logging-python
|
||||
message: Sensitive Zahlungsdaten duerfen nicht geloggt werden.
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: logging.$METHOD(..., $X, ...)
|
||||
- pattern: logger.$METHOD(..., $X, ...)
|
||||
- metavariable-pattern:
|
||||
metavariable: $X
|
||||
pattern-regex: (?i).*(pan|cvv|cvc|track2|track_2|cardnumber|card_number|karten|pin|expiry|ablauf).*
|
||||
|
||||
- id: payment-no-sensitive-logging-js
|
||||
message: Sensitive Zahlungsdaten duerfen nicht geloggt werden.
|
||||
severity: ERROR
|
||||
languages: [javascript, typescript]
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: console.$METHOD(..., $X, ...)
|
||||
- pattern: logger.$METHOD(..., $X, ...)
|
||||
- metavariable-pattern:
|
||||
metavariable: $X
|
||||
pattern-regex: (?i).*(pan|cvv|cvc|track2|cardnumber|pin|expiry).*
|
||||
|
||||
- id: payment-no-token-logging
|
||||
message: Tokens oder Session-IDs duerfen nicht geloggt werden.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(log|logger|logging|console)\.(debug|info|warn|error).*?(token|sessionid|session_id|authheader|authorization)
|
||||
|
||||
- id: payment-no-debug-logging-prod-flag
|
||||
message: Debug-Logging darf in produktiven Pfaden nicht fest aktiviert sein.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(DEBUG\s*=\s*true|debug\s*:\s*true|setLevel\(.*DEBUG.*\))
|
||||
|
||||
- id: payment-audit-log-admin-action
|
||||
message: Administrative sicherheitsrelevante Aktion ohne Audit-Hinweis pruefen.
|
||||
severity: INFO
|
||||
languages: [python, javascript, typescript]
|
||||
pattern-regex: (?i)(deleteTerminal|rotateKey|updateConfig|disableDevice|enableMaintenance)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Terminal State Machine Invariants
|
||||
|
||||
## Invariant 1
|
||||
APPROVED darf ohne expliziten Reversal-Pfad nicht in WAITING_FOR_TERMINAL zurueckgehen.
|
||||
|
||||
## Invariant 2
|
||||
DECLINED darf keinen Buchungserfolg oder Success-Report erzeugen.
|
||||
|
||||
## Invariant 3
|
||||
duplicate_response darf keinen zweiten Commit und keine zweite Success-Bestaetigung erzeugen.
|
||||
|
||||
## Invariant 4
|
||||
DESYNC muss Audit-Logging und Klaerungsstatus ausloesen.
|
||||
|
||||
## Invariant 5
|
||||
REVERSAL_PENDING darf nicht mehrfach parallel ausgeloest werden.
|
||||
|
||||
## Invariant 6
|
||||
invalid_command darf nie zu APPROVED fuehren.
|
||||
|
||||
## Invariant 7
|
||||
terminal_timeout darf nie stillschweigend als Erfolg interpretiert werden.
|
||||
|
||||
## Invariant 8
|
||||
Late responses nach finalem Zustand muessen kontrolliert behandelt werden.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Terminal Payment State Machine
|
||||
|
||||
## States
|
||||
- IDLE
|
||||
- SESSION_OPEN
|
||||
- PAYMENT_REQUESTED
|
||||
- WAITING_FOR_TERMINAL
|
||||
- APPROVED
|
||||
- DECLINED
|
||||
- CANCELLED
|
||||
- REVERSAL_PENDING
|
||||
- REVERSED
|
||||
- ERROR
|
||||
- DESYNC
|
||||
|
||||
## Events
|
||||
- open_session
|
||||
- close_session
|
||||
- send_payment
|
||||
- terminal_ack
|
||||
- terminal_approve
|
||||
- terminal_decline
|
||||
- terminal_timeout
|
||||
- backend_timeout
|
||||
- reconnect
|
||||
- cancel_request
|
||||
- reversal_request
|
||||
- reversal_success
|
||||
- reversal_fail
|
||||
- duplicate_response
|
||||
- invalid_command
|
||||
|
||||
## Transitions
|
||||
| From | Event | To |
|
||||
|------|-------|----|
|
||||
| IDLE | open_session | SESSION_OPEN |
|
||||
| SESSION_OPEN | send_payment | PAYMENT_REQUESTED |
|
||||
| PAYMENT_REQUESTED | terminal_ack | WAITING_FOR_TERMINAL |
|
||||
| WAITING_FOR_TERMINAL | terminal_approve | APPROVED |
|
||||
| WAITING_FOR_TERMINAL | terminal_decline | DECLINED |
|
||||
| WAITING_FOR_TERMINAL | terminal_timeout | DESYNC |
|
||||
| WAITING_FOR_TERMINAL | cancel_request | CANCELLED |
|
||||
| APPROVED | reversal_request | REVERSAL_PENDING |
|
||||
| REVERSAL_PENDING | reversal_success | REVERSED |
|
||||
| REVERSAL_PENDING | reversal_fail | ERROR |
|
||||
| * | invalid_command | ERROR |
|
||||
| * | backend_timeout | DESYNC |
|
||||
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"test_id": "ZVT-SM-001",
|
||||
"name": "Duplicate approved response",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["terminal_approve", "duplicate_response"],
|
||||
"expected_final_state": "APPROVED",
|
||||
"invariants": ["Invariant 3"],
|
||||
"mapped_controls": ["TRANS-004", "TRANS-009", "ZVT-RESP-005"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-002",
|
||||
"name": "Timeout then late success",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["terminal_timeout", "terminal_approve"],
|
||||
"expected_final_state": "DESYNC",
|
||||
"invariants": ["Invariant 4", "Invariant 7", "Invariant 8"],
|
||||
"mapped_controls": ["TRANS-005", "TRANS-007", "TERMSYNC-009", "TERMSYNC-010"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-003",
|
||||
"name": "Decline must not produce booking",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["terminal_decline"],
|
||||
"expected_final_state": "DECLINED",
|
||||
"invariants": ["Invariant 2"],
|
||||
"mapped_controls": ["TRANS-011", "TRANS-025", "ZVT-RESP-002"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-004",
|
||||
"name": "Invalid reversal before approval",
|
||||
"initial_state": "PAYMENT_REQUESTED",
|
||||
"events": ["reversal_request"],
|
||||
"expected_final_state": "ERROR",
|
||||
"invariants": ["Invariant 6"],
|
||||
"mapped_controls": ["ZVT-REV-001", "ZVT-STATE-002", "ZVT-CMD-001"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-005",
|
||||
"name": "Cancel during waiting",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["cancel_request"],
|
||||
"expected_final_state": "CANCELLED",
|
||||
"invariants": ["Invariant 7"],
|
||||
"mapped_controls": ["TRANS-006", "ZVT-CMD-001", "ZVT-STATE-003"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-006",
|
||||
"name": "Backend timeout after terminal ack",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["terminal_ack", "backend_timeout"],
|
||||
"expected_final_state": "DESYNC",
|
||||
"invariants": ["Invariant 4", "Invariant 7"],
|
||||
"mapped_controls": ["TERMSYNC-010", "TRANS-012", "ZVT-SESSION-003"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-007",
|
||||
"name": "Parallel reversal requests",
|
||||
"initial_state": "APPROVED",
|
||||
"events": ["reversal_request", "reversal_request"],
|
||||
"expected_final_state": "REVERSAL_PENDING",
|
||||
"invariants": ["Invariant 5"],
|
||||
"mapped_controls": ["ZVT-REV-003", "TRANS-016", "TRANS-019"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-008",
|
||||
"name": "Unknown response code",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["terminal_ack", "invalid_command"],
|
||||
"expected_final_state": "ERROR",
|
||||
"invariants": ["Invariant 6"],
|
||||
"mapped_controls": ["ZVT-RESP-003", "ZVT-COM-005", "ZVT-STATE-005"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-009",
|
||||
"name": "Reconnect and resume controlled",
|
||||
"initial_state": "SESSION_OPEN",
|
||||
"events": ["send_payment", "terminal_timeout", "reconnect"],
|
||||
"expected_final_state": "WAITING_FOR_TERMINAL",
|
||||
"invariants": ["Invariant 7"],
|
||||
"mapped_controls": ["ZVT-SESSION-004", "TRANS-007", "ZVT-RT-004"]
|
||||
},
|
||||
{
|
||||
"test_id": "ZVT-SM-010",
|
||||
"name": "Late response after cancel",
|
||||
"initial_state": "WAITING_FOR_TERMINAL",
|
||||
"events": ["cancel_request", "terminal_approve"],
|
||||
"expected_final_state": "DESYNC",
|
||||
"invariants": ["Invariant 4", "Invariant 8"],
|
||||
"mapped_controls": ["TERMSYNC-008", "TERMSYNC-009", "TRANS-018"]
|
||||
}
|
||||
]
|
||||
5553
ai-compliance-sdk/policies/payment_controls_v1.json
Normal file
5553
ai-compliance-sdk/policies/payment_controls_v1.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user