0943f11999
Pentest-Finding "Klartext-Passwörter über API abrufbar (HIGH, post-auth)" adressiert: reversible Verschlüsselung der Anbieter-/Portal-Logins ist by-design (Feature "Login anzeigen" braucht sie zwingend), aber jeder einzelne Decrypt-Vorgang muss im Audit-Log nachvollziehbar sein. Bisher schrieb KEINER der 6 betroffenen Endpoints einen Eintrag. Behoben in: - getPortalPassword (Customer-Portal-Login) - getContractPassword (Anbieter-Login z.B. Vattenfall, EWE, …) - getSimCardCredentials (PIN/PUK) - getInternetCredentials (DSL-Login) - getSipCredentials (Telefon-/VoIP-Login) - getMailboxCredentials (Stressfrei-IMAP/SMTP) Alle nutzen `action: 'READ'` mit eigenem ResourceType + Sensitivity CRITICAL via determineSensitivity-Map. Label nennt explizit "Klartext … entschlüsselt" + Resource-ID, damit im AuditLog-Viewer auf einen Blick erkennbar ist, wer wann welches Passwort eingesehen hat (DSGVO + Insider-Threat-Erkennung). Live verifiziert: nach Klick auf getPortalPassword erscheint im AuditLog der Eintrag "READ PortalPassword CRITICAL – Klartext-Portal- Passwort von Kunde #1 entschlüsselt". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
586 lines
15 KiB
TypeScript
586 lines
15 KiB
TypeScript
import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client';
|
||
import crypto from 'crypto';
|
||
import { encrypt, decrypt } from '../utils/encryption.js';
|
||
import prisma from '../lib/prisma.js';
|
||
|
||
/**
|
||
* Vereinfachte Audit-Log-Funktion für gezielte Änderungsprotokolle.
|
||
* Wird direkt in Controllern aufgerufen mit aussagekräftigen Details.
|
||
*/
|
||
export async function logChange(opts: {
|
||
req: any; // Express Request (für userId, email, IP)
|
||
action: AuditAction;
|
||
resourceType: string;
|
||
resourceId?: string;
|
||
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
|
||
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
|
||
customerId?: number;
|
||
}) {
|
||
try {
|
||
const user = opts.req?.user;
|
||
await createAuditLog({
|
||
userId: user?.userId,
|
||
userEmail: user?.email || 'system',
|
||
userRole: user?.isCustomerPortal ? 'Kundenportal' : 'Mitarbeiter',
|
||
customerId: user?.customerId,
|
||
isCustomerPortal: user?.isCustomerPortal,
|
||
action: opts.action,
|
||
resourceType: opts.resourceType,
|
||
resourceId: opts.resourceId,
|
||
resourceLabel: opts.label,
|
||
endpoint: opts.req?.path || '',
|
||
httpMethod: opts.req?.method || '',
|
||
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
|
||
dataSubjectId: opts.customerId,
|
||
changesAfter: opts.details,
|
||
});
|
||
} catch (error) {
|
||
console.error('[logChange] Fehler:', error);
|
||
}
|
||
}
|
||
|
||
export interface CreateAuditLogData {
|
||
userId?: number;
|
||
userEmail: string;
|
||
userRole?: string;
|
||
customerId?: number;
|
||
isCustomerPortal?: boolean;
|
||
action: AuditAction;
|
||
sensitivity?: AuditSensitivity;
|
||
resourceType: string;
|
||
resourceId?: string;
|
||
resourceLabel?: string;
|
||
endpoint: string;
|
||
httpMethod: string;
|
||
ipAddress: string;
|
||
userAgent?: string;
|
||
changesBefore?: Record<string, unknown>;
|
||
changesAfter?: Record<string, unknown>;
|
||
dataSubjectId?: number;
|
||
legalBasis?: string;
|
||
success?: boolean;
|
||
errorMessage?: string;
|
||
durationMs?: number;
|
||
}
|
||
|
||
export interface AuditLogSearchParams {
|
||
userId?: number;
|
||
customerId?: number;
|
||
dataSubjectId?: number;
|
||
action?: AuditAction;
|
||
sensitivity?: AuditSensitivity;
|
||
resourceType?: string;
|
||
resourceId?: string;
|
||
startDate?: Date;
|
||
endDate?: Date;
|
||
success?: boolean;
|
||
search?: string;
|
||
page?: number;
|
||
limit?: number;
|
||
}
|
||
|
||
/**
|
||
* Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag
|
||
*/
|
||
function generateHash(data: {
|
||
userEmail: string;
|
||
action: AuditAction;
|
||
resourceType: string;
|
||
resourceId?: string | null;
|
||
endpoint: string;
|
||
createdAt: Date;
|
||
previousHash?: string | null;
|
||
}): string {
|
||
const content = JSON.stringify({
|
||
userEmail: data.userEmail,
|
||
action: data.action,
|
||
resourceType: data.resourceType,
|
||
resourceId: data.resourceId,
|
||
endpoint: data.endpoint,
|
||
createdAt: data.createdAt.toISOString(),
|
||
previousHash: data.previousHash || '',
|
||
});
|
||
return crypto.createHash('sha256').update(content).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
|
||
*/
|
||
function determineSensitivity(resourceType: string): AuditSensitivity {
|
||
const sensitivityMap: Record<string, AuditSensitivity> = {
|
||
// CRITICAL
|
||
Authentication: 'CRITICAL',
|
||
BankCard: 'CRITICAL',
|
||
IdentityDocument: 'CRITICAL',
|
||
// Klartext-Passwort-Reads – jeder Decrypt-Vorgang muss nachvollziehbar sein
|
||
PortalPassword: 'CRITICAL',
|
||
ContractPassword: 'CRITICAL',
|
||
SimCardCredentials: 'CRITICAL',
|
||
InternetCredentials: 'CRITICAL',
|
||
SipCredentials: 'CRITICAL',
|
||
MailboxCredentials: 'CRITICAL',
|
||
// HIGH
|
||
Customer: 'HIGH',
|
||
User: 'HIGH',
|
||
CustomerConsent: 'HIGH',
|
||
DataDeletionRequest: 'HIGH',
|
||
// MEDIUM
|
||
Contract: 'MEDIUM',
|
||
Address: 'MEDIUM',
|
||
Meter: 'MEDIUM',
|
||
MeterReading: 'MEDIUM',
|
||
StressfreiEmail: 'MEDIUM',
|
||
CachedEmail: 'MEDIUM',
|
||
// LOW
|
||
Provider: 'LOW',
|
||
Tariff: 'LOW',
|
||
SalesPlatform: 'LOW',
|
||
AppSetting: 'LOW',
|
||
ContractCategory: 'LOW',
|
||
CancellationPeriod: 'LOW',
|
||
ContractDuration: 'LOW',
|
||
};
|
||
return sensitivityMap[resourceType] || 'MEDIUM';
|
||
}
|
||
|
||
/**
|
||
* Prüft ob Änderungen verschlüsselt werden sollen.
|
||
* Deaktiviert - sensible Felder werden bereits von der Prisma-Middleware als [REDACTED] gefiltert.
|
||
*/
|
||
function shouldEncryptChanges(_resourceType: string): boolean {
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
|
||
*/
|
||
export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
|
||
try {
|
||
// Letzten Hash abrufen für die Kette
|
||
const lastLog = await prisma.auditLog.findFirst({
|
||
orderBy: { id: 'desc' },
|
||
select: { hash: true },
|
||
});
|
||
|
||
const previousHash = lastLog?.hash || null;
|
||
const createdAt = new Date();
|
||
|
||
// Sensitivität bestimmen falls nicht angegeben
|
||
const sensitivity = data.sensitivity || determineSensitivity(data.resourceType);
|
||
|
||
// Änderungen serialisieren und ggf. verschlüsseln
|
||
let changesBefore: string | null = null;
|
||
let changesAfter: string | null = null;
|
||
let changesEncrypted = false;
|
||
|
||
if (data.changesBefore || data.changesAfter) {
|
||
changesEncrypted = shouldEncryptChanges(data.resourceType);
|
||
|
||
if (data.changesBefore) {
|
||
const json = JSON.stringify(data.changesBefore);
|
||
changesBefore = changesEncrypted ? encrypt(json) : json;
|
||
}
|
||
|
||
if (data.changesAfter) {
|
||
const json = JSON.stringify(data.changesAfter);
|
||
changesAfter = changesEncrypted ? encrypt(json) : json;
|
||
}
|
||
}
|
||
|
||
// Hash generieren
|
||
const hash = generateHash({
|
||
userEmail: data.userEmail,
|
||
action: data.action,
|
||
resourceType: data.resourceType,
|
||
resourceId: data.resourceId,
|
||
endpoint: data.endpoint,
|
||
createdAt,
|
||
previousHash,
|
||
});
|
||
|
||
// Eintrag erstellen
|
||
await prisma.auditLog.create({
|
||
data: {
|
||
userId: data.userId,
|
||
userEmail: data.userEmail,
|
||
userRole: data.userRole,
|
||
customerId: data.customerId,
|
||
isCustomerPortal: data.isCustomerPortal || false,
|
||
action: data.action,
|
||
sensitivity,
|
||
resourceType: data.resourceType,
|
||
resourceId: data.resourceId,
|
||
resourceLabel: data.resourceLabel,
|
||
endpoint: data.endpoint,
|
||
httpMethod: data.httpMethod,
|
||
ipAddress: data.ipAddress,
|
||
userAgent: data.userAgent,
|
||
changesBefore,
|
||
changesAfter,
|
||
changesEncrypted,
|
||
dataSubjectId: data.dataSubjectId,
|
||
legalBasis: data.legalBasis,
|
||
success: data.success ?? true,
|
||
errorMessage: data.errorMessage,
|
||
durationMs: data.durationMs,
|
||
createdAt,
|
||
hash,
|
||
previousHash,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
// Audit-Logging darf niemals die Hauptoperation blockieren
|
||
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sucht Audit-Logs mit Filtern und Paginierung
|
||
*/
|
||
export async function searchAuditLogs(params: AuditLogSearchParams) {
|
||
const {
|
||
userId,
|
||
customerId,
|
||
dataSubjectId,
|
||
action,
|
||
sensitivity,
|
||
resourceType,
|
||
resourceId,
|
||
startDate,
|
||
endDate,
|
||
success,
|
||
search,
|
||
page = 1,
|
||
limit = 50,
|
||
} = params;
|
||
|
||
const where: Prisma.AuditLogWhereInput = {};
|
||
|
||
if (userId !== undefined) where.userId = userId;
|
||
if (customerId !== undefined) where.customerId = customerId;
|
||
if (dataSubjectId !== undefined) where.dataSubjectId = dataSubjectId;
|
||
if (action) where.action = action;
|
||
if (sensitivity) where.sensitivity = sensitivity;
|
||
if (resourceType) where.resourceType = resourceType;
|
||
if (resourceId) where.resourceId = resourceId;
|
||
if (success !== undefined) where.success = success;
|
||
|
||
if (startDate || endDate) {
|
||
where.createdAt = {};
|
||
if (startDate) where.createdAt.gte = startDate;
|
||
if (endDate) where.createdAt.lte = endDate;
|
||
}
|
||
|
||
if (search) {
|
||
where.OR = [
|
||
{ userEmail: { contains: search } },
|
||
{ resourceLabel: { contains: search } },
|
||
{ endpoint: { contains: search } },
|
||
];
|
||
}
|
||
|
||
const [logs, total] = await Promise.all([
|
||
prisma.auditLog.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * limit,
|
||
take: limit,
|
||
}),
|
||
prisma.auditLog.count({ where }),
|
||
]);
|
||
|
||
// Entschlüsselung der Änderungen wenn nötig
|
||
const decryptedLogs = logs.map((log) => ({
|
||
...log,
|
||
changesBefore: log.changesBefore && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesBefore))
|
||
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
|
||
changesAfter: log.changesAfter && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesAfter))
|
||
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
|
||
}));
|
||
|
||
return {
|
||
data: decryptedLogs,
|
||
pagination: {
|
||
page,
|
||
limit,
|
||
total,
|
||
totalPages: Math.ceil(total / limit),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Holt einen einzelnen Audit-Log-Eintrag
|
||
*/
|
||
export async function getAuditLogById(id: number) {
|
||
const log = await prisma.auditLog.findUnique({
|
||
where: { id },
|
||
});
|
||
|
||
if (!log) return null;
|
||
|
||
return {
|
||
...log,
|
||
changesBefore: log.changesBefore && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesBefore))
|
||
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
|
||
changesAfter: log.changesAfter && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesAfter))
|
||
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Holt alle Audit-Logs für eine betroffene Person (DSGVO)
|
||
*/
|
||
export async function getAuditLogsByDataSubject(customerId: number) {
|
||
const logs = await prisma.auditLog.findMany({
|
||
where: { dataSubjectId: customerId },
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
|
||
return logs.map((log) => ({
|
||
...log,
|
||
changesBefore: log.changesBefore && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesBefore))
|
||
: log.changesBefore ? JSON.parse(log.changesBefore) : null,
|
||
changesAfter: log.changesAfter && log.changesEncrypted
|
||
? JSON.parse(decrypt(log.changesAfter))
|
||
: log.changesAfter ? JSON.parse(log.changesAfter) : null,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Verifiziert die Integrität der Hash-Kette
|
||
*/
|
||
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||
valid: boolean;
|
||
checkedCount: number;
|
||
invalidEntries: number[];
|
||
}> {
|
||
const where: Prisma.AuditLogWhereInput = {};
|
||
|
||
if (fromId !== undefined) where.id = { gte: fromId };
|
||
if (toId !== undefined) where.id = { ...(where.id as object || {}), lte: toId };
|
||
|
||
const logs = await prisma.auditLog.findMany({
|
||
where,
|
||
orderBy: { id: 'asc' },
|
||
select: {
|
||
id: true,
|
||
userEmail: true,
|
||
action: true,
|
||
resourceType: true,
|
||
resourceId: true,
|
||
endpoint: true,
|
||
createdAt: true,
|
||
hash: true,
|
||
previousHash: true,
|
||
},
|
||
});
|
||
|
||
const invalidEntries: number[] = [];
|
||
|
||
for (let i = 0; i < logs.length; i++) {
|
||
const log = logs[i];
|
||
|
||
// Hash neu berechnen
|
||
const expectedHash = generateHash({
|
||
userEmail: log.userEmail,
|
||
action: log.action,
|
||
resourceType: log.resourceType,
|
||
resourceId: log.resourceId,
|
||
endpoint: log.endpoint,
|
||
createdAt: log.createdAt,
|
||
previousHash: log.previousHash,
|
||
});
|
||
|
||
// Prüfen ob Hash übereinstimmt
|
||
if (log.hash !== expectedHash) {
|
||
invalidEntries.push(log.id);
|
||
continue;
|
||
}
|
||
|
||
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
|
||
if (i > 0) {
|
||
const previousLog = logs[i - 1];
|
||
if (log.previousHash !== previousLog.hash) {
|
||
invalidEntries.push(log.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: invalidEntries.length === 0,
|
||
checkedCount: logs.length,
|
||
invalidEntries,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Hash-Kette komplett neu berechnen (Reparatur)
|
||
*/
|
||
export async function rehashAll(): Promise<{ rehashedCount: number }> {
|
||
const logs = await prisma.auditLog.findMany({
|
||
orderBy: { id: 'asc' },
|
||
select: {
|
||
id: true,
|
||
userEmail: true,
|
||
action: true,
|
||
resourceType: true,
|
||
resourceId: true,
|
||
endpoint: true,
|
||
createdAt: true,
|
||
},
|
||
});
|
||
|
||
let previousHash: string | null = null;
|
||
let count = 0;
|
||
|
||
for (const log of logs) {
|
||
const hash = generateHash({
|
||
userEmail: log.userEmail,
|
||
action: log.action,
|
||
resourceType: log.resourceType,
|
||
resourceId: log.resourceId,
|
||
endpoint: log.endpoint,
|
||
createdAt: log.createdAt,
|
||
previousHash,
|
||
});
|
||
|
||
await prisma.auditLog.update({
|
||
where: { id: log.id },
|
||
data: { hash, previousHash },
|
||
});
|
||
|
||
previousHash = hash;
|
||
count++;
|
||
}
|
||
|
||
return { rehashedCount: count };
|
||
}
|
||
|
||
/**
|
||
* Exportiert Audit-Logs als JSON oder CSV
|
||
*/
|
||
export async function exportAuditLogs(
|
||
params: AuditLogSearchParams,
|
||
format: 'json' | 'csv' = 'json'
|
||
): Promise<string> {
|
||
// Alle Logs ohne Paginierung
|
||
const result = await searchAuditLogs({ ...params, limit: 100000, page: 1 });
|
||
const logs = result.data;
|
||
|
||
if (format === 'json') {
|
||
return JSON.stringify(logs, null, 2);
|
||
}
|
||
|
||
// CSV Export
|
||
const headers = [
|
||
'ID',
|
||
'Zeitstempel',
|
||
'Benutzer',
|
||
'Aktion',
|
||
'Ressource',
|
||
'Ressource-ID',
|
||
'Bezeichnung',
|
||
'Endpoint',
|
||
'IP-Adresse',
|
||
'Erfolg',
|
||
'Sensitivität',
|
||
];
|
||
|
||
const rows = logs.map((log) => [
|
||
log.id.toString(),
|
||
log.createdAt.toISOString(),
|
||
log.userEmail,
|
||
log.action,
|
||
log.resourceType,
|
||
log.resourceId || '',
|
||
log.resourceLabel || '',
|
||
log.endpoint,
|
||
log.ipAddress,
|
||
log.success ? 'Ja' : 'Nein',
|
||
log.sensitivity,
|
||
]);
|
||
|
||
const csvContent = [
|
||
headers.join(';'),
|
||
...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(';')),
|
||
].join('\n');
|
||
|
||
return csvContent;
|
||
}
|
||
|
||
/**
|
||
* Löscht alte Audit-Logs basierend auf Retention-Policies
|
||
* Hinweis: Diese Funktion sollte nur von einem autorisierten Admin-Prozess aufgerufen werden
|
||
*/
|
||
export async function runRetentionCleanup(): Promise<{
|
||
deletedCount: number;
|
||
policies: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }>;
|
||
}> {
|
||
const policies = await prisma.auditRetentionPolicy.findMany({
|
||
where: { isActive: true },
|
||
});
|
||
|
||
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
|
||
let totalDeleted = 0;
|
||
|
||
for (const policy of policies) {
|
||
const cutoffDate = new Date();
|
||
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
|
||
|
||
const where: Prisma.AuditLogWhereInput = {
|
||
createdAt: { lt: cutoffDate },
|
||
};
|
||
|
||
if (policy.resourceType !== '*') {
|
||
where.resourceType = policy.resourceType;
|
||
}
|
||
|
||
if (policy.sensitivity) {
|
||
where.sensitivity = policy.sensitivity;
|
||
}
|
||
|
||
const deleted = await prisma.auditLog.deleteMany({ where });
|
||
|
||
results.push({
|
||
resourceType: policy.resourceType,
|
||
sensitivity: policy.sensitivity,
|
||
deletedCount: deleted.count,
|
||
});
|
||
|
||
totalDeleted += deleted.count;
|
||
}
|
||
|
||
return {
|
||
deletedCount: totalDeleted,
|
||
policies: results,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Holt die Retention-Policies
|
||
*/
|
||
export async function getRetentionPolicies() {
|
||
return prisma.auditRetentionPolicy.findMany({
|
||
orderBy: [{ resourceType: 'asc' }, { sensitivity: 'asc' }],
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert eine Retention-Policy
|
||
*/
|
||
export async function updateRetentionPolicy(
|
||
id: number,
|
||
data: { retentionDays?: number; description?: string; legalBasis?: string; isActive?: boolean }
|
||
) {
|
||
return prisma.auditRetentionPolicy.update({
|
||
where: { id },
|
||
data,
|
||
});
|
||
}
|