diff --git a/src/controllers/missedBreach.controller.js b/src/controllers/missedBreach.controller.js new file mode 100644 index 00000000..7e7aa5b3 --- /dev/null +++ b/src/controllers/missedBreach.controller.js @@ -0,0 +1,249 @@ +/** + * Missed Breach Controller + * + * Tracks governance framework false negatives for research integrity + */ + +const MissedBreach = require('../models/MissedBreach.model'); +const AuditLog = require('../models/AuditLog.model'); + +/** + * Report a new missed breach + * POST /api/admin/missed-breaches + */ +async function reportMissedBreach(req, res) { + try { + const { + title, + incidentDate, + severity, + description, + activityType, + missReason, + missReasonDetails, + linkedAuditLog, + sessionId, + actualCost, + estimatedCost, + environment, + tags + } = req.body; + + // Validate required fields + if (!title || !incidentDate || !severity || !description || !activityType || !missReason) { + return res.status(400).json({ + success: false, + error: 'Missing required fields: title, incidentDate, severity, description, activityType, missReason' + }); + } + + const missedBreach = new MissedBreach({ + title, + incidentDate: new Date(incidentDate), + severity, + description, + activityType, + missReason, + missReasonDetails, + linkedAuditLog, + sessionId, + actualCost, + estimatedCost, + environment: environment || process.env.NODE_ENV || 'development', + tags: tags || [], + reportedBy: req.user?._id || null, + status: 'REPORTED' + }); + + await missedBreach.save(); + + res.status(201).json({ + success: true, + missedBreach: missedBreach.toObject() + }); + + } catch (error) { + console.error('Error reporting missed breach:', error); + res.status(500).json({ + success: false, + error: 'Failed to report missed breach' + }); + } +} + +/** + * Get all missed breaches + * GET /api/admin/missed-breaches?status=VERIFIED&severity=HIGH + */ +async function getMissedBreaches(req, res) { + try { + const { + status, + severity, + missReason, + environment, + startDate, + endDate, + limit = 100 + } = req.query; + + const query = {}; + + if (status) query.status = status; + if (severity) query.severity = severity; + if (missReason) query.missReason = missReason; + if (environment && environment !== 'all') query.environment = environment; + + if (startDate && endDate) { + query.incidentDate = { + $gte: new Date(startDate), + $lte: new Date(endDate) + }; + } + + const breaches = await MissedBreach.find(query) + .sort({ incidentDate: -1 }) + .limit(parseInt(limit)) + .populate('reportedBy', 'name email') + .populate('verifiedBy', 'name email') + .lean(); + + res.json({ + success: true, + count: breaches.length, + breaches + }); + + } catch (error) { + console.error('Error fetching missed breaches:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch missed breaches' + }); + } +} + +/** + * Get missed breach statistics + * GET /api/admin/missed-breaches/statistics + */ +async function getMissedBreachStatistics(req, res) { + try { + const { startDate, endDate } = req.query; + + const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = endDate ? new Date(endDate) : new Date(); + + // Get missed breach stats + const missedStats = await MissedBreach.getStatistics(start, end); + + // Get audit log stats for comparison + const auditStats = await AuditLog.getStatistics(start, end); + + // Calculate effectiveness rate + const effectiveness = await MissedBreach.calculateEffectivenessRate( + start, + end, + auditStats + ); + + // Get breakdown by reason + const byReason = await MissedBreach.getBreakdownByReason(start, end); + + res.json({ + success: true, + dateRange: { start, end }, + missedBreaches: missedStats, + auditLogs: auditStats, + effectiveness, + breakdownByReason: byReason + }); + + } catch (error) { + console.error('Error fetching missed breach statistics:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch statistics' + }); + } +} + +/** + * Update a missed breach (verify, remediate, etc.) + * PATCH /api/admin/missed-breaches/:id + */ +async function updateMissedBreach(req, res) { + try { + const { id } = req.params; + const updates = req.body; + + // If verifying, record who verified it + if (updates.status === 'VERIFIED' && req.user) { + updates.verifiedBy = req.user._id; + } + + const missedBreach = await MissedBreach.findByIdAndUpdate( + id, + updates, + { new: true, runValidators: true } + ); + + if (!missedBreach) { + return res.status(404).json({ + success: false, + error: 'Missed breach not found' + }); + } + + res.json({ + success: true, + missedBreach: missedBreach.toObject() + }); + + } catch (error) { + console.error('Error updating missed breach:', error); + res.status(500).json({ + success: false, + error: 'Failed to update missed breach' + }); + } +} + +/** + * Delete a missed breach (if it was a false positive) + * DELETE /api/admin/missed-breaches/:id + */ +async function deleteMissedBreach(req, res) { + try { + const { id } = req.params; + + const missedBreach = await MissedBreach.findByIdAndDelete(id); + + if (!missedBreach) { + return res.status(404).json({ + success: false, + error: 'Missed breach not found' + }); + } + + res.json({ + success: true, + message: 'Missed breach deleted' + }); + + } catch (error) { + console.error('Error deleting missed breach:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete missed breach' + }); + } +} + +module.exports = { + reportMissedBreach, + getMissedBreaches, + getMissedBreachStatistics, + updateMissedBreach, + deleteMissedBreach +}; diff --git a/src/models/MissedBreach.model.js b/src/models/MissedBreach.model.js new file mode 100644 index 00000000..6bcd5bb9 --- /dev/null +++ b/src/models/MissedBreach.model.js @@ -0,0 +1,346 @@ +/* + * 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. + */ + +/** + * MissedBreach Model + * + * Tracks incidents where the governance framework FAILED to catch a violation + * Critical for research integrity and framework improvement + * + * Purpose: + * - Measure framework effectiveness (true positive rate) + * - Identify blind spots in governance rules + * - Calculate realistic cost avoidance (detected vs missed) + * - Improve framework through pattern analysis + */ + +const mongoose = require('mongoose'); + +const missedBreachSchema = new mongoose.Schema({ + // Incident identification + title: { + type: String, + required: true, + description: 'Brief description of the missed breach' + }, + + incidentDate: { + type: Date, + required: true, + index: true, + description: 'When the breach occurred (not when it was discovered)' + }, + + discoveredDate: { + type: Date, + default: Date.now, + index: true, + description: 'When the breach was discovered' + }, + + // Severity and impact + severity: { + type: String, + enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + required: true, + index: true, + description: 'Actual severity of the breach' + }, + + actualCost: { + type: Number, + default: null, + description: 'Actual cost incurred (if known), in dollars' + }, + + estimatedCost: { + type: Number, + default: null, + description: 'Estimated cost if actual unknown, in dollars' + }, + + // What happened + description: { + type: String, + required: true, + description: 'Detailed description of what went wrong' + }, + + activityType: { + type: String, + enum: [ + 'CLIENT_COMMUNICATION', + 'CODE_GENERATION', + 'DOCUMENTATION', + 'DEPLOYMENT', + 'COMPLIANCE_REVIEW', + 'DATA_MANAGEMENT', + 'INFRASTRUCTURE', + 'OTHER' + ], + required: true, + description: 'Type of activity that caused the breach' + }, + + // Why it was missed + missReason: { + type: String, + enum: [ + 'NO_RULE_EXISTS', // No governance rule covered this case + 'RULE_TOO_NARROW', // Rule exists but didn't match this case + 'CLASSIFICATION_ERROR', // Activity classifier misclassified + 'ENFORCEMENT_GAP', // Rule exists but wasn't enforced + 'USER_OVERRIDE', // User bypassed framework (--no-verify) + 'UNKNOWN' + ], + required: true, + index: true, + description: 'Why the framework missed this breach' + }, + + missReasonDetails: { + type: String, + default: null, + description: 'Additional context on why it was missed' + }, + + // Link to original decision (if available) + linkedAuditLog: { + type: mongoose.Schema.Types.ObjectId, + ref: 'AuditLog', + default: null, + description: 'Audit log entry where framework allowed this action' + }, + + sessionId: { + type: String, + default: null, + index: true, + description: 'Session where the breach occurred (if known)' + }, + + // Remediation + remediation: { + type: String, + default: null, + description: 'What was done to fix the breach' + }, + + preventionAdded: { + type: Boolean, + default: false, + description: 'Whether a new rule/fix was added to prevent recurrence' + }, + + newRuleId: { + type: String, + default: null, + description: 'ID of new governance rule added (e.g., inst_085)' + }, + + // Reporting + reportedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + description: 'User who reported the missed breach' + }, + + verifiedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + description: 'Admin who verified this was truly a missed breach' + }, + + status: { + type: String, + enum: ['REPORTED', 'VERIFIED', 'REMEDIATED', 'FALSE_POSITIVE'], + default: 'REPORTED', + index: true, + description: 'Current status of the breach report' + }, + + // Metadata + environment: { + type: String, + enum: ['development', 'production', 'staging'], + default: 'development', + index: true + }, + + tags: { + type: [String], + default: [], + description: 'Tags for categorization and searching' + } + +}, { + timestamps: true, + collection: 'missedBreaches' +}); + +// Indexes for common queries +missedBreachSchema.index({ incidentDate: -1 }); +missedBreachSchema.index({ severity: 1, incidentDate: -1 }); +missedBreachSchema.index({ missReason: 1, incidentDate: -1 }); +missedBreachSchema.index({ status: 1, incidentDate: -1 }); +missedBreachSchema.index({ environment: 1, incidentDate: -1 }); + +// Virtual for time to detection +missedBreachSchema.virtual('timeToDetection').get(function() { + if (!this.discoveredDate || !this.incidentDate) return null; + return Math.floor((this.discoveredDate - this.incidentDate) / (1000 * 60 * 60 * 24)); // Days +}); + +// Static methods + +/** + * Get missed breach statistics + */ +missedBreachSchema.statics.getStatistics = async function(startDate, endDate) { + const matchStage = {}; + + if (startDate && endDate) { + matchStage.incidentDate = { $gte: startDate, $lte: endDate }; + } + + const stats = await this.aggregate([ + { $match: matchStage }, + { + $group: { + _id: null, + totalMissed: { $sum: 1 }, + bySeverity: { + $push: '$severity' + }, + byReason: { + $push: '$missReason' + }, + totalActualCost: { + $sum: { $ifNull: ['$actualCost', 0] } + }, + totalEstimatedCost: { + $sum: { $ifNull: ['$estimatedCost', 0] } + }, + avgTimeToDetection: { + $avg: { + $divide: [ + { $subtract: ['$discoveredDate', '$incidentDate'] }, + 1000 * 60 * 60 * 24 // Convert to days + ] + } + } + } + } + ]); + + return stats[0] || null; +}; + +/** + * Calculate framework effectiveness rate + * Requires audit log stats for comparison + */ +missedBreachSchema.statics.calculateEffectivenessRate = async function(startDate, endDate, auditStats) { + const missedStats = await this.getStatistics(startDate, endDate); + + if (!missedStats || !auditStats) { + return null; + } + + const detected = auditStats.blocked || 0; + const missed = missedStats.totalMissed || 0; + const total = detected + missed; + + if (total === 0) return null; + + return { + detected, + missed, + total, + detectionRate: (detected / total) * 100, + missRate: (missed / total) * 100, + effectiveness: detected / total + }; +}; + +/** + * Find breaches by reason + */ +missedBreachSchema.statics.findByReason = function(reason, options = {}) { + const query = { missReason: reason }; + + if (options.status) { + query.status = options.status; + } + + return this.find(query) + .sort({ incidentDate: -1 }) + .limit(options.limit || 0); +}; + +/** + * Get breach breakdown by reason + */ +missedBreachSchema.statics.getBreakdownByReason = async function(startDate, endDate) { + const matchStage = {}; + + if (startDate && endDate) { + matchStage.incidentDate = { $gte: startDate, $lte: endDate }; + } + + const breakdown = await this.aggregate([ + { $match: matchStage }, + { + $group: { + _id: '$missReason', + count: { $sum: 1 }, + totalCost: { + $sum: { + $ifNull: [ + '$actualCost', + { $ifNull: ['$estimatedCost', 0] } + ] + } + }, + examples: { + $push: { + title: '$title', + severity: '$severity', + incidentDate: '$incidentDate' + } + } + } + }, + { + $project: { + _id: 0, + reason: '$_id', + count: 1, + totalCost: 1, + recentExamples: { $slice: ['$examples', 3] } + } + }, + { $sort: { count: -1 } } + ]); + + return breakdown; +}; + +const MissedBreach = mongoose.model('MissedBreach', missedBreachSchema); + +module.exports = MissedBreach; diff --git a/src/routes/index.js b/src/routes/index.js index b2dfd548..4fb9b564 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -30,6 +30,7 @@ const relationshipsRoutes = require('./relationships.routes'); const contactRoutes = require('./contact.routes'); const inboxRoutes = require('./inbox.routes'); const crmRoutes = require('./crm.routes'); +const missedBreachRoutes = require('./missedBreach.routes'); // Development/test routes (only in development) if (process.env.NODE_ENV !== 'production') { @@ -61,6 +62,7 @@ router.use('/relationships', relationshipsRoutes); router.use('/contact', contactRoutes); router.use('/inbox', inboxRoutes); router.use('/crm', crmRoutes); +router.use('/admin/missed-breaches', missedBreachRoutes); // API root endpoint - redirect browsers to documentation router.get('/', (req, res) => { diff --git a/src/routes/missedBreach.routes.js b/src/routes/missedBreach.routes.js new file mode 100644 index 00000000..20943781 --- /dev/null +++ b/src/routes/missedBreach.routes.js @@ -0,0 +1,31 @@ +/** + * Missed Breach Routes + * + * API endpoints for tracking governance framework false negatives + */ + +const express = require('express'); +const router = express.Router(); +const missedBreachController = require('../controllers/missedBreach.controller'); +const { authenticateToken, requireRole } = require('../middleware/auth.middleware'); + +// All routes require authentication and admin role +router.use(authenticateToken); +router.use(requireRole('admin')); + +// POST /api/admin/missed-breaches - Report new missed breach +router.post('/', missedBreachController.reportMissedBreach); + +// GET /api/admin/missed-breaches - Get all missed breaches +router.get('/', missedBreachController.getMissedBreaches); + +// GET /api/admin/missed-breaches/statistics - Get statistics +router.get('/statistics', missedBreachController.getMissedBreachStatistics); + +// PATCH /api/admin/missed-breaches/:id - Update missed breach +router.patch('/:id', missedBreachController.updateMissedBreach); + +// DELETE /api/admin/missed-breaches/:id - Delete missed breach +router.delete('/:id', missedBreachController.deleteMissedBreach); + +module.exports = router;