Compare commits

..

4 Commits

Author SHA1 Message Date
Benjamin Admin 66d30568e2 feat(dsms): Stufe 1 — Gap-Analyse Report wird in DSMS archiviert
Build + Deploy / build-admin-compliance (push) Successful in 1m41s
Build + Deploy / build-backend-compliance (push) Successful in 14s
Build + Deploy / build-ai-sdk (push) Successful in 41s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 10s
Build + Deploy / build-document-crawler (push) Successful in 10s
Build + Deploy / build-dsms-gateway (push) Successful in 10s
Build + Deploy / build-dsms-node (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 14s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m31s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 48s
CI / test-python-backend (push) Failing after 1s
CI / test-python-document-crawler (push) Successful in 32s
CI / test-python-dsms-gateway (push) Successful in 25s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m23s
- Go DSMS Client (internal/dsms/client.go): Archive() + Verify()
- Python DSMS Client (compliance/services/dsms_client.py): archive_to_dsms() + verify_dsms()
- Gap-Analyse AnalyzeProject() archiviert Report-JSON nach DSMS
- Response enthält dsms_cid wenn Archivierung erfolgreich
- Frontend: Grünes "Revisionssicher archiviert" Badge mit CID im GapDashboard
- DSMS Proxy Route (/api/sdk/v1/dsms/[...path]) für Verify-Abfragen

Stufe 2 (Evidence Upload → DSMS) und Stufe 3 (Version Chains) folgen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 23:39:26 +02:00
Benjamin Admin 36afbadc01 fix(mc-browser): add all missing field fallbacks for ControlDetail
tags, generation_metadata, source_citation, verification_method,
evidence_type, similar_controls, source_original_text, parent_control_uuid

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 23:22:41 +02:00
Benjamin Admin 7ca3624a1f fix(mc-browser): scope fallback + severity/domain filters
- Add scope/risk_score/implementation_effort fallbacks to prevent
  'undefined is not an object' crash in ControlDetail
- Add severity filter (high/medium/low based on total_controls)
- Add domain filter (L1 token prefix match)
- Fix sort options (source → canonical_name)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 23:13:22 +02:00
Benjamin Admin 397de741c1 feat(cmp): Phase 2 — script blocking + cookie tracking
Migration 108: scripts_blocked, scripts_released, cookies_set JSONB columns.
Backend models/schema/service/serializer/routes extended.
Admin detail modal shows released scripts and set cookies with categories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 22:52:26 +02:00
15 changed files with 376 additions and 4 deletions
@@ -0,0 +1,22 @@
/**
* DSMS Gateway Proxy — forwards verify/history requests to dsms-gateway.
*/
import { NextRequest, NextResponse } from 'next/server'
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const target = `${DSMS_URL}/api/v1/${path.join('/')}`
try {
const resp = await fetch(target, {
headers: { Authorization: 'Bearer system-frontend' },
signal: AbortSignal.timeout(15000),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json({ error: 'DSMS not available' }, { status: 503 })
}
}
@@ -60,8 +60,23 @@ async function handleControls(params: URLSearchParams) {
idx++
}
const severity = params.get('severity') || ''
if (severity) {
if (severity === 'high') { where += ` AND mc.total_controls > 100` }
else if (severity === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` }
else if (severity === 'low') { where += ` AND mc.total_controls < 20` }
}
const domain = params.get('domain') || ''
if (domain) {
where += ` AND mc.canonical_name LIKE $${idx}`
args.push(`${domain}%`)
idx++
}
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
sort === 'created_at' ? 'mc.created_at' : 'mc.master_control_id'
sort === 'created_at' ? 'mc.created_at' :
sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id'
args.push(limit, offset)
const res = await pool.query(`
@@ -102,6 +117,9 @@ async function handleControls(params: URLSearchParams) {
total_controls: r.total_controls,
phases_covered: r.phases_covered,
created_at: r.created_at,
scope: { platforms: [], components: [], data_classes: [] },
risk_score: null,
implementation_effort: null,
}))
return NextResponse.json(controls)
@@ -203,6 +221,9 @@ async function handleDetail(params: URLSearchParams) {
open_anchors: [],
target_audience: [],
source_citation: null,
scope: { platforms: [], components: [], data_classes: [] },
risk_score: null,
implementation_effort: null,
created_at: mc.created_at,
})
}
@@ -223,6 +223,37 @@ export default function BannerConsentsTab() {
</div>
</div>
{/* Scripts & Cookies */}
{(detail.scripts_released?.length > 0 || detail.cookies_set?.length > 0) && (
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Scripts & Cookies</p>
{detail.scripts_released?.length > 0 && (
<div className="mb-2">
<span className="text-gray-500 text-xs">Freigegebene Scripts</span>
{detail.scripts_released.map((s, i) => (
<p key={i} className="text-xs text-gray-600 font-mono truncate">{s.src} <span className={`px-1 rounded ${categoryColors[s.category] || 'bg-gray-100'}`}>{s.category}</span></p>
))}
</div>
)}
{detail.scripts_blocked?.length > 0 && (
<div className="mb-2">
<span className="text-gray-500 text-xs">Blockierte Scripts</span>
{detail.scripts_blocked.map((s, i) => (
<p key={i} className="text-xs text-red-600 font-mono truncate">{s.src} <span className="px-1 rounded bg-red-100 text-red-700">{s.category}</span></p>
))}
</div>
)}
{detail.cookies_set?.length > 0 && (
<div>
<span className="text-gray-500 text-xs">Gesetzte Cookies</span>
{detail.cookies_set.map((c, i) => (
<p key={i} className="text-xs text-gray-600 font-mono">{c.name} <span className="text-gray-400">({c.domain})</span> <span className={`px-1 rounded ${categoryColors[c.category] || 'bg-gray-100'}`}>{c.category}</span></p>
))}
</div>
)}
</div>
)}
{/* Technische Details */}
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
@@ -126,6 +126,10 @@ export interface BannerConsentRecord {
os: string | null
screen_resolution: string | null
session_id: string | null
// Script/Cookie-Tracking (Migration 108)
scripts_blocked: { src: string; category: string }[]
scripts_released: { src: string; category: string }[]
cookies_set: { name: string; domain: string; expiry_days: number; category: string }[]
expires_at: string | null
created_at: string | null
updated_at: string | null
@@ -3,6 +3,7 @@
import React, { useState } from 'react'
interface GapReport {
dsms_cid?: string
profile_name: string
regulations: Array<{
id: string
@@ -79,6 +80,20 @@ export function GapDashboard({ report, onBack }: Props) {
&larr; Neue Analyse
</button>
{/* DSMS Archive Badge */}
{report.dsms_cid && (
<div className="mb-4 flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
<svg className="w-4 h-4 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="text-sm text-emerald-800 font-medium">Revisionssicher archiviert</span>
<code className="text-xs text-emerald-600 bg-emerald-100 px-2 py-0.5 rounded font-mono">
{report.dsms_cid.length > 20 ? report.dsms_cid.slice(0, 8) + '...' + report.dsms_cid.slice(-6) : report.dsms_cid}
</code>
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<SummaryCard
@@ -35,11 +35,34 @@ export default function MasterControlsPage() {
)
}
// DETAIL mode
// DETAIL mode — add fallback fields that ControlDetail expects
if (state.mode === 'detail' && state.selectedControl) {
const c = state.selectedControl
const safeCtrl = {
...c,
scope: c.scope || { platforms: [], components: [], data_classes: [] },
target_audience: c.target_audience || [],
requirements: c.requirements || [],
test_procedure: c.test_procedure || [],
evidence: c.evidence || [],
open_anchors: c.open_anchors || [],
tags: c.tags || [],
risk_score: c.risk_score ?? null,
implementation_effort: c.implementation_effort ?? null,
generation_metadata: c.generation_metadata || null,
source_citation: c.source_citation || null,
source_original_text: c.source_original_text || '',
verification_method: c.verification_method || null,
evidence_type: c.evidence_type || null,
release_state: c.release_state || 'active',
category: c.category || 'master_control',
severity: c.severity || 'medium',
parent_control_uuid: c.parent_control_uuid || null,
similar_controls: c.similar_controls || [],
}
return (
<ControlDetail
ctrl={state.selectedControl}
ctrl={safeCtrl}
onBack={() => { state.setMode('list'); state.setSelectedControl(null) }}
onEdit={() => {}}
onDelete={async () => {}}
@@ -1,12 +1,16 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/breakpilot/ai-compliance-sdk/internal/dsms"
"github.com/breakpilot/ai-compliance-sdk/internal/gap"
)
@@ -104,7 +108,27 @@ func (h *GapHandler) AnalyzeProject(c *gin.Context) {
return
}
c.JSON(http.StatusOK, report)
// Archive gap report to DSMS (non-blocking, best-effort)
var dsmsCID string
reportJSON, _ := json.Marshal(report)
filename := fmt.Sprintf("gap-report-%s-%s.json", id.String()[:8], time.Now().Format("2006-01-02"))
if result := dsms.Archive(reportJSON, filename, "gap_report", id.String(), "1"); result != nil {
dsmsCID = result.CID
}
// Return report with DSMS CID appended
response := gin.H{
"profile_id": report.ProfileID,
"profile_name": report.ProfileName,
"regulations": report.Regulations,
"summary": report.Summary,
"gaps": report.Gaps,
"created_at": report.CreatedAt,
}
if dsmsCID != "" {
response["dsms_cid"] = dsmsCID
}
c.JSON(http.StatusOK, response)
}
// QuickAnalyze runs gap analysis without saving a project.
+103
View File
@@ -0,0 +1,103 @@
// Package dsms provides a client for archiving compliance artifacts to the
// DSMS gateway (IPFS-backed tamper-proof document storage).
package dsms
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"time"
)
// ArchiveResult is the response from a successful DSMS archive operation.
type ArchiveResult struct {
CID string `json:"cid"`
Size int `json:"size"`
GatewayURL string `json:"gateway_url"`
}
var gatewayURL = getEnv("DSMS_GATEWAY_URL", "http://dsms-gateway:8082")
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Archive stores content in DSMS and returns the CID.
// Non-critical: returns nil on failure (logs warning).
func Archive(content []byte, filename, documentType, documentID, version string) *ArchiveResult {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", filename)
if err != nil {
log.Printf("[dsms] multipart error: %v", err)
return nil
}
if _, err := part.Write(content); err != nil {
log.Printf("[dsms] write error: %v", err)
return nil
}
_ = w.WriteField("document_type", documentType)
_ = w.WriteField("document_id", documentID)
_ = w.WriteField("version", version)
_ = w.WriteField("language", "de")
w.Close()
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("POST", gatewayURL+"/api/v1/documents", &buf)
if err != nil {
log.Printf("[dsms] request error: %v", err)
return nil
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer system-backend")
resp, err := client.Do(req)
if err != nil {
log.Printf("[dsms] unavailable: %v", err)
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
log.Printf("[dsms] archive failed (%d): %s", resp.StatusCode, string(body[:min(len(body), 200)]))
return nil
}
var result ArchiveResult
if err := json.Unmarshal(body, &result); err != nil {
log.Printf("[dsms] parse error: %v", err)
return nil
}
log.Printf("[dsms] archived: %s → CID %s (%d bytes)", filename, result.CID, result.Size)
return &result
}
// Verify checks document integrity by CID.
func Verify(cid string) (bool, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/api/v1/verify/%s", gatewayURL, cid))
if err != nil {
return false, err
}
defer resp.Body.Close()
return resp.StatusCode == 200, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -87,6 +87,9 @@ async def record_consent(
screen_resolution=body.screen_resolution,
session_id=body.session_id,
consent_scope=body.consent_scope,
scripts_blocked=body.scripts_blocked,
scripts_released=body.scripts_released,
cookies_set=body.cookies_set,
)
@@ -50,6 +50,10 @@ class BannerConsentDB(Base):
os = Column(Text)
screen_resolution = Column(Text)
session_id = Column(Text)
# Script/Cookie-Tracking (Migration 108)
scripts_blocked = Column(JSON, default=list)
scripts_released = Column(JSON, default=list)
cookies_set = Column(JSON, default=list)
expires_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -30,6 +30,10 @@ class ConsentCreate(BaseModel):
screen_resolution: Optional[str] = None
session_id: Optional[str] = None
consent_scope: Optional[str] = None
# Script/Cookie-Tracking (Migration 108)
scripts_blocked: List[dict[str, Any]] = []
scripts_released: List[dict[str, Any]] = []
cookies_set: List[dict[str, Any]] = []
class SiteConfigCreate(BaseModel):
@@ -41,6 +41,9 @@ def consent_to_dict(c: BannerConsentDB) -> dict[str, Any]:
"os": c.os,
"screen_resolution": c.screen_resolution,
"session_id": c.session_id,
"scripts_blocked": c.scripts_blocked or [],
"scripts_released": c.scripts_released or [],
"cookies_set": c.cookies_set or [],
"expires_at": c.expires_at.isoformat() if c.expires_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
@@ -181,6 +181,9 @@ class BannerConsentService:
screen_resolution: Optional[str] = None,
session_id: Optional[str] = None,
consent_scope: Optional[str] = None,
scripts_blocked: Optional[list[dict]] = None,
scripts_released: Optional[list[dict]] = None,
cookies_set: Optional[list[dict]] = None,
) -> dict[str, Any]:
"""Upsert a device consent row for (tenant, site, device_fingerprint).
@@ -213,6 +216,9 @@ class BannerConsentService:
"screen_resolution": screen_resolution,
"session_id": session_id,
"consent_scope": consent_scope or "domain",
"scripts_blocked": scripts_blocked or [],
"scripts_released": scripts_released or [],
"cookies_set": cookies_set or [],
}
existing = (
@@ -0,0 +1,91 @@
"""DSMS (Dezentrales Daten Speicher System) Client — archives compliance
artifacts to IPFS via the dsms-gateway for tamper-proof versioning.
Usage:
from compliance.services.dsms_client import archive_to_dsms, verify_dsms
result = await archive_to_dsms(
content=pdf_bytes,
filename="gap-report-2026-05-11.json",
document_type="gap_report",
document_id=str(report_id),
version="1",
)
cid = result["cid"] # IPFS Content Identifier
"""
import logging
import os
import httpx
logger = logging.getLogger(__name__)
DSMS_GATEWAY_URL = os.environ.get("DSMS_GATEWAY_URL", "http://dsms-gateway:8082")
async def archive_to_dsms(
content: bytes,
filename: str,
document_type: str,
document_id: str,
version: str = "1",
parent_cid: str | None = None,
language: str = "de",
tenant_id: str | None = None,
) -> dict:
"""Archive binary content to DSMS (IPFS).
Returns dict with keys: cid, size, gateway_url.
Returns empty dict on failure (non-critical logs warning).
"""
data = {
"document_type": document_type,
"document_id": document_id,
"version": version,
"language": language,
}
if parent_cid:
data["parent_cid"] = parent_cid
if tenant_id:
data["tenant_id"] = tenant_id
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{DSMS_GATEWAY_URL}/api/v1/documents",
files={"file": (filename, content)},
data=data,
headers={"Authorization": "Bearer system-backend"},
)
if resp.status_code != 200:
logger.warning("DSMS archive failed (%d): %s", resp.status_code, resp.text[:200])
return {}
result = resp.json()
logger.info("DSMS archived: %s → CID %s (%d bytes)", filename, result.get("cid", "?"), result.get("size", 0))
return result
except Exception as e:
logger.warning("DSMS archive unavailable: %s", e)
return {}
async def verify_dsms(cid: str) -> dict:
"""Verify a document's integrity by CID.
Returns dict with verification result or empty dict on failure.
"""
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{DSMS_GATEWAY_URL}/api/v1/verify/{cid}")
if resp.status_code != 200:
return {"valid": False, "error": f"HTTP {resp.status_code}"}
return resp.json()
except Exception as e:
logger.warning("DSMS verify unavailable: %s", e)
return {"valid": False, "error": str(e)}
@@ -0,0 +1,18 @@
-- Migration 108: Script- und Cookie-Tracking fuer Banner-Consents
-- Erfasst welche Scripts blockiert/freigegeben und welche Cookies gesetzt wurden.
-- Alle Felder JSONB + nullable → backward-compatible.
-- Scripts die VOR Consent blockiert waren
ALTER TABLE compliance_banner_consents
ADD COLUMN IF NOT EXISTS scripts_blocked JSONB DEFAULT '[]'::jsonb;
-- [{"src": "https://www.googletagmanager.com/gtag/js", "category": "analytics"}]
-- Scripts die NACH Consent freigegeben wurden
ALTER TABLE compliance_banner_consents
ADD COLUMN IF NOT EXISTS scripts_released JSONB DEFAULT '[]'::jsonb;
-- [{"src": "https://www.googletagmanager.com/gtag/js", "category": "analytics"}]
-- Cookies die NACH Consent gesetzt wurden
ALTER TABLE compliance_banner_consents
ADD COLUMN IF NOT EXISTS cookies_set JSONB DEFAULT '[]'::jsonb;
-- [{"name": "_ga", "domain": ".breakpilot.ai", "expiry_days": 730, "category": "analytics"}]