Implements privacy-preserving synchronization of production audit logs to development for comprehensive governance research analysis. Backend Components: - SyncMetadata.model.js: Track sync state and statistics - audit-sanitizer.util.js: Privacy sanitization utility - Redacts credentials, API keys, user identities - Sanitizes file paths and violation content - Preserves statistical patterns for research - sync-prod-audit-logs.js: CLI sync script - Incremental sync with deduplication - Dry-run mode for testing - Configurable date range - AuditLog.model.js: Enhanced schema with environment tracking - environment field (development/production/staging) - sync_metadata tracking (original_id, synced_from, etc.) - New indexes for cross-environment queries - audit.controller.js: New /api/admin/audit-export endpoint - Privacy-sanitized export for cross-environment sync - Environment filter support in getAuditLogs - MemoryProxy.service.js: Environment tagging in auditDecision() - Tags new logs with NODE_ENV or override - Sets is_local flag for tracking Frontend Components: - audit-analytics.html: Environment filter dropdown - audit-analytics.js: Environment filter query parameter handling Research Benefits: - Combine dev and prod governance statistics - Longitudinal analysis across environments - Validate framework consistency - Privacy-preserving data sharing Security: - API-based export (not direct DB access) - Admin-only endpoints with JWT authentication - Comprehensive credential redaction - One-way sync (production → development) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
580 lines
18 KiB
JavaScript
580 lines
18 KiB
JavaScript
/*
|
|
* Copyright 2025 John G Stroh
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
/**
|
|
* Audit Controller
|
|
* Serves audit logs from MemoryProxy for analytics dashboard
|
|
*/
|
|
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const logger = require('../utils/logger.util');
|
|
|
|
// Default cost factors (user-configurable)
|
|
const DEFAULT_COST_FACTORS = {
|
|
CRITICAL: { amount: 50000, currency: 'USD', rationale: 'Average cost of critical security incident' },
|
|
HIGH: { amount: 10000, currency: 'USD', rationale: 'Average cost of high-impact client-facing error' },
|
|
MEDIUM: { amount: 2000, currency: 'USD', rationale: 'Average cost of medium-impact issue requiring hotfix' },
|
|
LOW: { amount: 500, currency: 'USD', rationale: 'Average developer time cost to fix low-impact issue' }
|
|
};
|
|
|
|
let userCostFactors = { ...DEFAULT_COST_FACTORS };
|
|
|
|
/**
|
|
* Get audit logs for analytics
|
|
* GET /api/admin/audit-logs?days=7&environment=production
|
|
*/
|
|
async function getAuditLogs(req, res) {
|
|
try {
|
|
const { days = 7, limit = 10000, environment } = req.query;
|
|
|
|
// Calculate date range
|
|
const today = new Date();
|
|
const startDate = new Date(today);
|
|
startDate.setDate(today.getDate() - parseInt(days));
|
|
|
|
// Build query
|
|
const query = { timestamp: { $gte: startDate } };
|
|
|
|
// Add environment filter if specified
|
|
if (environment && environment !== 'all') {
|
|
query.environment = environment;
|
|
}
|
|
|
|
// Read from MongoDB instead of JSONL files
|
|
const db = require('../utils/db.util');
|
|
const collection = await db.getCollection('auditLogs');
|
|
|
|
const decisions = await collection
|
|
.find(query)
|
|
.sort({ timestamp: -1 })
|
|
.limit(parseInt(limit))
|
|
.toArray();
|
|
|
|
res.json({
|
|
success: true,
|
|
decisions,
|
|
total: decisions.length,
|
|
limited: decisions.length,
|
|
environment: environment || 'all',
|
|
dateRange: {
|
|
start: startDate.toISOString(),
|
|
end: today.toISOString()
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Error fetching audit logs:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get audit analytics summary
|
|
* GET /api/admin/audit-analytics
|
|
*/
|
|
async function getAuditAnalytics(req, res) {
|
|
try {
|
|
const { days = 7 } = req.query;
|
|
|
|
// Get audit logs
|
|
const auditLogsResponse = await getAuditLogs(req, { json: data => data });
|
|
const decisions = auditLogsResponse.decisions;
|
|
|
|
// Calculate analytics
|
|
const blockedDecisions = decisions.filter(d => !d.allowed);
|
|
const allowedDecisions = decisions.filter(d => d.allowed);
|
|
|
|
const analytics = {
|
|
total: decisions.length,
|
|
allowed: allowedDecisions.length,
|
|
blocked: blockedDecisions.length,
|
|
violations: decisions.filter(d => d.violations && d.violations.length > 0).length,
|
|
|
|
// Block metrics
|
|
blockRate: decisions.length > 0 ? ((blockedDecisions.length / decisions.length) * 100).toFixed(1) : 0,
|
|
allowedRate: decisions.length > 0 ? ((allowedDecisions.length / decisions.length) * 100).toFixed(1) : 0,
|
|
|
|
// Block breakdown
|
|
blocksByReason: {},
|
|
blocksBySeverity: {},
|
|
frameworkSaves: [], // High severity blocks
|
|
|
|
byAction: {},
|
|
byService: {},
|
|
bySession: {},
|
|
byDate: {},
|
|
|
|
timeline: [],
|
|
topViolations: [],
|
|
|
|
// Business intelligence metrics
|
|
byActivityType: {},
|
|
byRiskLevel: {},
|
|
byStakeholderImpact: {},
|
|
riskHeatmap: [],
|
|
businessImpactTotal: 0,
|
|
roiProjections: {},
|
|
|
|
dateRange: auditLogsResponse.dateRange
|
|
};
|
|
|
|
// Group by action
|
|
decisions.forEach(d => {
|
|
const action = d.action || 'unknown';
|
|
analytics.byAction[action] = (analytics.byAction[action] || 0) + 1;
|
|
});
|
|
|
|
// Group by service
|
|
decisions.forEach(d => {
|
|
const service = d.service || 'unknown';
|
|
analytics.byService[service] = (analytics.byService[service] || 0) + 1;
|
|
});
|
|
|
|
// Group by session
|
|
decisions.forEach(d => {
|
|
const session = d.sessionId || 'unknown';
|
|
analytics.bySession[session] = (analytics.bySession[session] || 0) + 1;
|
|
});
|
|
|
|
// Group by date
|
|
decisions.forEach(d => {
|
|
const date = new Date(d.timestamp).toISOString().split('T')[0];
|
|
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
|
|
});
|
|
|
|
// Timeline (last 24 hours by hour)
|
|
const hourCounts = {};
|
|
decisions.forEach(d => {
|
|
const hour = new Date(d.timestamp).getHours();
|
|
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
|
|
});
|
|
|
|
for (let i = 0; i < 24; i++) {
|
|
analytics.timeline.push({
|
|
hour: i,
|
|
count: hourCounts[i] || 0
|
|
});
|
|
}
|
|
|
|
// Analyze blocks by reason and severity
|
|
blockedDecisions.forEach(d => {
|
|
// Extract reason from violations or metadata
|
|
let reason = 'Unknown';
|
|
let severity = 'MEDIUM';
|
|
|
|
if (d.violations && d.violations.length > 0) {
|
|
// Use first violation's rule ID as reason
|
|
reason = d.violations[0].ruleId || d.violations[0].ruleText || 'Unknown';
|
|
severity = d.violations[0].severity || 'MEDIUM';
|
|
|
|
// Count by severity
|
|
analytics.blocksBySeverity[severity] = (analytics.blocksBySeverity[severity] || 0) + 1;
|
|
|
|
// Track high severity blocks as "Framework Saves"
|
|
if (severity === 'HIGH' || severity === 'CRITICAL') {
|
|
analytics.frameworkSaves.push({
|
|
timestamp: d.timestamp,
|
|
reason: d.violations[0].ruleText || d.violations[0].details,
|
|
ruleId: d.violations[0].ruleId,
|
|
severity: severity,
|
|
file: d.metadata?.filePath || d.metadata?.file || 'N/A',
|
|
service: d.service
|
|
});
|
|
}
|
|
} else if (d.metadata?.reason) {
|
|
reason = d.metadata.reason;
|
|
}
|
|
|
|
// Count by reason
|
|
analytics.blocksByReason[reason] = (analytics.blocksByReason[reason] || 0) + 1;
|
|
});
|
|
|
|
// Sort framework saves by timestamp (most recent first)
|
|
analytics.frameworkSaves.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
analytics.frameworkSaves = analytics.frameworkSaves.slice(0, 10); // Top 10 saves
|
|
|
|
// Top violations (enhanced with more detail)
|
|
const violationCounts = {};
|
|
decisions.forEach(d => {
|
|
if (d.violations && d.violations.length > 0) {
|
|
d.violations.forEach(v => {
|
|
const key = v.ruleId || v.ruleText || 'unknown';
|
|
if (!violationCounts[key]) {
|
|
violationCounts[key] = {
|
|
ruleId: v.ruleId,
|
|
ruleText: v.ruleText,
|
|
severity: v.severity,
|
|
count: 0
|
|
};
|
|
}
|
|
violationCounts[key].count++;
|
|
});
|
|
}
|
|
});
|
|
|
|
analytics.topViolations = Object.values(violationCounts)
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 10);
|
|
|
|
// === BUSINESS INTELLIGENCE METRICS ===
|
|
|
|
// Activity type breakdown
|
|
decisions.forEach(d => {
|
|
const activityType = d.activityType || 'Unknown';
|
|
if (!analytics.byActivityType[activityType]) {
|
|
analytics.byActivityType[activityType] = {
|
|
total: 0,
|
|
allowed: 0,
|
|
blocked: 0,
|
|
blockRate: 0
|
|
};
|
|
}
|
|
analytics.byActivityType[activityType].total++;
|
|
if (d.allowed) {
|
|
analytics.byActivityType[activityType].allowed++;
|
|
} else {
|
|
analytics.byActivityType[activityType].blocked++;
|
|
}
|
|
});
|
|
|
|
// Calculate block rates for each activity type
|
|
Object.keys(analytics.byActivityType).forEach(type => {
|
|
const data = analytics.byActivityType[type];
|
|
data.blockRate = data.total > 0 ? ((data.blocked / data.total) * 100).toFixed(1) : '0.0';
|
|
});
|
|
|
|
// Risk level distribution
|
|
decisions.forEach(d => {
|
|
const riskLevel = d.riskLevel || 'low';
|
|
if (!analytics.byRiskLevel[riskLevel]) {
|
|
analytics.byRiskLevel[riskLevel] = {
|
|
total: 0,
|
|
blocked: 0
|
|
};
|
|
}
|
|
analytics.byRiskLevel[riskLevel].total++;
|
|
if (!d.allowed) {
|
|
analytics.byRiskLevel[riskLevel].blocked++;
|
|
}
|
|
});
|
|
|
|
// Stakeholder impact distribution
|
|
decisions.forEach(d => {
|
|
const impact = d.stakeholderImpact || 'individual';
|
|
if (!analytics.byStakeholderImpact[impact]) {
|
|
analytics.byStakeholderImpact[impact] = {
|
|
total: 0,
|
|
blocked: 0,
|
|
businessImpact: 0
|
|
};
|
|
}
|
|
analytics.byStakeholderImpact[impact].total++;
|
|
if (!d.allowed) {
|
|
analytics.byStakeholderImpact[impact].blocked++;
|
|
analytics.byStakeholderImpact[impact].businessImpact += (d.businessImpact || 0);
|
|
}
|
|
});
|
|
|
|
// Risk heatmap: Activity Type vs Risk Level
|
|
const heatmapData = {};
|
|
decisions.forEach(d => {
|
|
const activityType = d.activityType || 'Unknown';
|
|
const riskLevel = d.riskLevel || 'low';
|
|
const key = `${activityType}|${riskLevel}`;
|
|
|
|
if (!heatmapData[key]) {
|
|
heatmapData[key] = {
|
|
activityType,
|
|
riskLevel,
|
|
total: 0,
|
|
blocked: 0
|
|
};
|
|
}
|
|
heatmapData[key].total++;
|
|
if (!d.allowed) {
|
|
heatmapData[key].blocked++;
|
|
}
|
|
});
|
|
analytics.riskHeatmap = Object.values(heatmapData);
|
|
|
|
// Business impact total
|
|
analytics.businessImpactTotal = blockedDecisions.reduce((sum, d) => sum + (d.businessImpact || 0), 0);
|
|
|
|
// === COST AVOIDANCE CALCULATIONS ===
|
|
|
|
// Calculate cost avoided based on user-configured factors
|
|
let totalCostAvoided = 0;
|
|
const costBreakdown = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
|
|
blockedDecisions.forEach(d => {
|
|
if (d.violations && d.violations.length > 0) {
|
|
d.violations.forEach(v => {
|
|
const severity = v.severity || 'LOW';
|
|
const costFactor = userCostFactors[severity];
|
|
if (costFactor) {
|
|
totalCostAvoided += costFactor.amount;
|
|
costBreakdown[severity] += costFactor.amount;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
analytics.costAvoidance = {
|
|
total: totalCostAvoided,
|
|
currency: userCostFactors.CRITICAL.currency,
|
|
breakdown: costBreakdown,
|
|
costFactors: userCostFactors
|
|
};
|
|
|
|
// === AI vs HUMAN PERFORMANCE ===
|
|
|
|
// Detect AI vs Human based on service patterns
|
|
// AI = FileEditHook, Framework services
|
|
// Human = Manual overrides, direct database operations
|
|
const aiDecisions = decisions.filter(d =>
|
|
d.service === 'FileEditHook' ||
|
|
d.service === 'BoundaryEnforcer' ||
|
|
d.service === 'ContextPressureMonitor' ||
|
|
d.service === 'MetacognitiveVerifier'
|
|
);
|
|
const humanDecisions = decisions.filter(d =>
|
|
!aiDecisions.includes(d)
|
|
);
|
|
|
|
const aiBlocked = aiDecisions.filter(d => !d.allowed).length;
|
|
const humanBlocked = humanDecisions.filter(d => !d.allowed).length;
|
|
|
|
analytics.teamComparison = {
|
|
ai: {
|
|
total: aiDecisions.length,
|
|
blocked: aiBlocked,
|
|
blockRate: aiDecisions.length > 0 ? ((aiBlocked / aiDecisions.length) * 100).toFixed(1) : '0.0',
|
|
label: 'AI Assistant'
|
|
},
|
|
human: {
|
|
total: humanDecisions.length,
|
|
blocked: humanBlocked,
|
|
blockRate: humanDecisions.length > 0 ? ((humanBlocked / humanDecisions.length) * 100).toFixed(1) : '0.0',
|
|
label: 'Human Direct'
|
|
}
|
|
};
|
|
|
|
// Framework Maturity Score (0-100)
|
|
// Based on: block rate trend, severity distribution, learning curve
|
|
const recentDays = 7;
|
|
const recentDate = new Date();
|
|
recentDate.setDate(recentDate.getDate() - recentDays);
|
|
const recentDecisions = decisions.filter(d => new Date(d.timestamp) >= recentDate);
|
|
const recentBlocked = recentDecisions.filter(d => !d.allowed).length;
|
|
const recentBlockRate = recentDecisions.length > 0 ? (recentBlocked / recentDecisions.length) : 0;
|
|
|
|
// Lower block rate = higher maturity (framework teaching good habits)
|
|
const blockRateScore = Math.max(0, 100 - (recentBlockRate * 1000));
|
|
|
|
// More AI decisions with low block rate = higher maturity
|
|
const aiAdoptionScore = aiDecisions.length > 0 ? ((aiDecisions.length / decisions.length) * 100) : 0;
|
|
|
|
// Fewer critical/high severity blocks = higher maturity
|
|
const criticalCount = blockedDecisions.filter(d =>
|
|
d.violations && d.violations.some(v => v.severity === 'CRITICAL' || v.severity === 'HIGH')
|
|
).length;
|
|
const severityScore = Math.max(0, 100 - (criticalCount / decisions.length * 1000));
|
|
|
|
analytics.frameworkMaturity = {
|
|
score: Math.round((blockRateScore + aiAdoptionScore + severityScore) / 3),
|
|
components: {
|
|
blockRateScore: Math.round(blockRateScore),
|
|
aiAdoptionScore: Math.round(aiAdoptionScore),
|
|
severityScore: Math.round(severityScore)
|
|
},
|
|
trend: recentBlockRate < avgBlockRate ? 'improving' : 'stable',
|
|
message: recentBlockRate < 0.05 ? 'Excellent - Framework teaching good practices' :
|
|
recentBlockRate < 0.10 ? 'Good - Team adapting well to governance' :
|
|
'Learning - Framework actively preventing violations'
|
|
};
|
|
|
|
// ROI Projections
|
|
const totalDecisions = decisions.length;
|
|
const totalBlocks = blockedDecisions.length;
|
|
const avgBlockRate = totalDecisions > 0 ? (totalBlocks / totalDecisions) : 0;
|
|
|
|
analytics.roiProjections = {
|
|
// Current period metrics
|
|
decisionsPerDay: days > 0 ? (totalDecisions / days).toFixed(1) : '0',
|
|
blocksPerDay: days > 0 ? (totalBlocks / days).toFixed(1) : '0',
|
|
|
|
// Projections for scaled deployment
|
|
projections: [
|
|
{
|
|
userCount: 1000,
|
|
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 1000 / decisions.length * 10), // Estimated
|
|
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 1000 / decisions.length * 10),
|
|
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 1000 / decisions.length * 10),
|
|
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 1000 / decisions.length * 10)
|
|
},
|
|
{
|
|
userCount: 10000,
|
|
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 10000 / decisions.length * 10),
|
|
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 10000 / decisions.length * 10),
|
|
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 10000 / decisions.length * 10),
|
|
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 10000 / decisions.length * 10)
|
|
},
|
|
{
|
|
userCount: 70000,
|
|
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 70000 / decisions.length * 10),
|
|
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 70000 / decisions.length * 10),
|
|
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 70000 / decisions.length * 10),
|
|
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 70000 / decisions.length * 10)
|
|
}
|
|
]
|
|
};
|
|
|
|
res.json({
|
|
success: true,
|
|
analytics
|
|
});
|
|
|
|
} catch (error) {
|
|
logger.error('Error calculating audit analytics:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cost configuration
|
|
* GET /api/admin/cost-config
|
|
*/
|
|
async function getCostConfig(req, res) {
|
|
try {
|
|
res.json({
|
|
success: true,
|
|
costFactors: userCostFactors
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching cost config:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update cost configuration
|
|
* POST /api/admin/cost-config
|
|
*/
|
|
async function updateCostConfig(req, res) {
|
|
try {
|
|
const { costFactors } = req.body;
|
|
|
|
if (!costFactors) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'costFactors required'
|
|
});
|
|
}
|
|
|
|
// Validate and update
|
|
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].forEach(severity => {
|
|
if (costFactors[severity]) {
|
|
userCostFactors[severity] = {
|
|
amount: parseFloat(costFactors[severity].amount) || userCostFactors[severity].amount,
|
|
currency: costFactors[severity].currency || userCostFactors[severity].currency,
|
|
rationale: costFactors[severity].rationale || userCostFactors[severity].rationale
|
|
};
|
|
}
|
|
});
|
|
|
|
logger.info('Cost factors updated by', req.user?.username || 'admin');
|
|
|
|
res.json({
|
|
success: true,
|
|
costFactors: userCostFactors
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error updating cost config:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export audit logs for cross-environment research (privacy-preserving)
|
|
* GET /api/admin/audit-export?since=YYYY-MM-DD
|
|
*/
|
|
async function exportAuditLogs(req, res) {
|
|
try {
|
|
const { since } = req.query;
|
|
|
|
if (!since) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'since parameter required (ISO date format)'
|
|
});
|
|
}
|
|
|
|
// Parse date
|
|
const sinceDate = new Date(since);
|
|
if (isNaN(sinceDate.getTime())) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid date format for since parameter'
|
|
});
|
|
}
|
|
|
|
// Fetch logs since date
|
|
const logs = await AuditLog.find({
|
|
timestamp: { $gte: sinceDate }
|
|
}).sort({ timestamp: 1 }).lean();
|
|
|
|
// Sanitize for privacy
|
|
const { sanitizeBatch } = require('../utils/audit-sanitizer.util');
|
|
const sanitized = sanitizeBatch(logs);
|
|
|
|
logger.info(`Exported ${sanitized.length} audit logs since ${since} for ${req.user?.username || 'unknown'}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
count: sanitized.length,
|
|
since: sinceDate,
|
|
exported_at: new Date(),
|
|
logs: sanitized
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error exporting audit logs:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getAuditLogs,
|
|
getAuditAnalytics,
|
|
getCostConfig,
|
|
updateCostConfig,
|
|
exportAuditLogs
|
|
};
|