fix: address all 11 review items for group-based access control

Bugs fixed:
- knowledgeBase.js: logAudit calls converted from positional args to object signature
- archerTickets.js: targetType/targetId renamed to entityType/entityId
- server.js: single CVE delete now has cascade/compliance check for Standard_User

Unprotected endpoints secured:
- ivantiTodoQueue.js: POST/PUT/DELETE now require Admin or Standard_User
- ivantiFindings.js: PUT note and POST sync now require Admin or Standard_User
- compliance.js: POST notes now requires Admin or Standard_User
- ivantiWorkflows.js: POST sync now requires Admin or Standard_User
- auth.js: cleanup-sessions now requires Admin via requireAuth + requireGroup

Additional fixes:
- ExportsPage.js: canExport() guard blocks Read_Only users
- knowledgeBase.js: Standard_User delete checks created_by ownership
- Migration: added INSERT/UPDATE triggers to enforce valid user_group values
This commit is contained in:
jramos
2026-04-07 09:52:26 -06:00
parent d910af847e
commit e9e2c0961d
10 changed files with 154 additions and 64 deletions

View File

@@ -78,8 +78,35 @@ function runMigration(db) {
(err) => { (err) => {
if (err) { reject(err); return; } if (err) { reject(err); return; }
console.log('✓ Created idx_users_user_group index'); console.log('✓ Created idx_users_user_group index');
console.log('Migration complete!');
resolve(); // Add CHECK constraint via trigger (SQLite can't ALTER TABLE ADD CONSTRAINT)
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_insert
BEFORE INSERT ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
db.run(
`CREATE TRIGGER IF NOT EXISTS check_user_group_update
BEFORE UPDATE OF user_group ON users
FOR EACH ROW
WHEN NEW.user_group NOT IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
BEGIN
SELECT RAISE(ABORT, 'Invalid user_group value. Must be Admin, Standard_User, Leadership, or Read_Only');
END`,
(err) => {
if (err) { reject(err); return; }
console.log('✓ Created user_group validation triggers');
console.log('Migration complete!');
resolve();
}
);
}
);
} }
); );
} }

View File

@@ -89,8 +89,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'CREATE_ARCHER_TICKET', action: 'CREATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: this.lastID, entityId: String(this.lastID),
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor }, details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -172,8 +172,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'UPDATE_ARCHER_TICKET', action: 'UPDATE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: id, entityId: String(id),
details: { before: existing, changes: req.body }, details: { before: existing, changes: req.body },
ipAddress: req.ip ipAddress: req.ip
}); });
@@ -195,8 +195,8 @@ function createArcherTicketsRouter(db) {
logAudit(db, { logAudit(db, {
userId: req.user.id, userId: req.user.id,
action: 'DELETE_ARCHER_TICKET', action: 'DELETE_ARCHER_TICKET',
targetType: 'archer_ticket', entityType: 'archer_ticket',
targetId: id, entityId: String(id),
details: { deleted: ticket }, details: { deleted: ticket },
ipAddress: req.ip ipAddress: req.ip
}); });

View File

@@ -2,6 +2,7 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const { requireAuth, requireGroup } = require('../middleware/auth');
function createAuthRouter(db, logAudit) { function createAuthRouter(db, logAudit) {
const router = express.Router(); const router = express.Router();
@@ -220,12 +221,7 @@ function createAuthRouter(db, logAudit) {
}); });
// Clean up expired sessions (admin only) // Clean up expired sessions (admin only)
router.post('/cleanup-sessions', async (req, res) => { router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
// Basic auth check - require a valid session to call this
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Authentication required' });
}
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
db.run( db.run(

View File

@@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// Add a note to a (hostname, metric_id) pair. // Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note } // Body: { hostname, metric_id, note }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/notes', async (req, res) => { router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { hostname, metric_id, note } = req.body; const { hostname, metric_id, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) { if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {

View File

@@ -829,7 +829,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// POST /sync — trigger immediate sync, return fresh state // POST /sync — trigger immediate sync, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(db); await syncFindings(db);
try { try {
res.json(await readStateWithNotes(db)); res.json(await readStateWithNotes(db));
@@ -934,7 +934,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
}); });
// PUT /:findingId/note — save or update a note (max 255 chars enforced here) // PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params; const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255); const note = String(req.body.note || '').slice(0, 255);

View File

@@ -1,5 +1,6 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete']; const VALID_STATUSES = ['pending', 'complete'];
@@ -36,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// POST /api/ivanti/todo-queue // POST /api/ivanti/todo-queue
// Add a finding to the queue // Add a finding to the queue
router.post('/', requireAuth(db), (req, res) => { router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
@@ -86,7 +87,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// PUT /api/ivanti/todo-queue/:id // PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user // Update vendor, workflow_type, or status — scoped to current user
router.put('/:id', requireAuth(db), (req, res) => { router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const { vendor, workflow_type, status } = req.body; const { vendor, workflow_type, status } = req.body;
@@ -162,7 +163,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/completed // DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user // Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id // IMPORTANT: This route must be registered BEFORE DELETE /:id
router.delete('/completed', requireAuth(db), (req, res) => { router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
db.run( db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id], [req.user.id],
@@ -178,7 +179,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/:id // DELETE /api/ivanti/todo-queue/:id
// Delete a single item — scoped to current user // Delete a single item — scoped to current user
router.delete('/:id', requireAuth(db), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
db.get( db.get(

View File

@@ -5,6 +5,7 @@
const express = require('express'); const express = require('express');
const https = require('https'); const https = require('https');
const { requireGroup } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -259,7 +260,7 @@ function createIvantiWorkflowsRouter(db, requireAuth) {
}); });
// POST /sync — trigger an immediate sync, await completion, return fresh state // POST /sync — trigger an immediate sync, await completion, return fresh state
router.post('/sync', async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db); await syncWorkflows(db);
try { try {
res.json(await readState(db)); res.json(await readState(db));

View File

@@ -132,16 +132,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'CREATE_KB_ARTICLE',
'CREATE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(this.lastID),
this.lastID, details: { title: title.trim(), filename: sanitizedName },
JSON.stringify({ title: title.trim(), filename: sanitizedName }), ipAddress: req.ip
req.ip });
);
res.json({ res.json({
success: true, success: true,
@@ -232,16 +231,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'VIEW_KB_ARTICLE',
'VIEW_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
// Determine content type for inline display // Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream'; let contentType = row.file_type || 'application/octet-stream';
@@ -284,16 +282,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DOWNLOAD_KB_ARTICLE',
'DOWNLOAD_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { filename: row.file_name },
JSON.stringify({ filename: row.file_name }), ipAddress: req.ip
req.ip });
);
res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
@@ -305,7 +302,7 @@ function createKnowledgeBaseRouter(db, upload) {
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params; const { id } = req.params;
const sql = 'SELECT file_path, title FROM knowledge_base WHERE id = ?'; const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
db.get(sql, [id], (err, row) => { db.get(sql, [id], (err, row) => {
if (err) { if (err) {
@@ -317,6 +314,11 @@ function createKnowledgeBaseRouter(db, upload) {
return res.status(404).json({ error: 'Article not found' }); return res.status(404).json({ error: 'Article not found' });
} }
// Ownership check: Standard_User can only delete articles they created
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
return res.status(403).json({ error: 'You can only delete resources you created' });
}
// Delete database record // Delete database record
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
if (err) { if (err) {
@@ -330,16 +332,15 @@ function createKnowledgeBaseRouter(db, upload) {
} }
// Log audit entry // Log audit entry
logAudit( logAudit(db, {
db, userId: req.user.id,
req.user.id, username: req.user.username,
req.user.username, action: 'DELETE_KB_ARTICLE',
'DELETE_KB_ARTICLE', entityType: 'knowledge_base',
'knowledge_base', entityId: String(id),
id, details: { title: row.title },
JSON.stringify({ title: row.title }), ipAddress: req.ip
req.ip });
);
res.json({ success: true }); res.json({ success: true });
}); });

View File

@@ -846,6 +846,59 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use
return res.status(403).json({ error: 'You can only delete resources you created' }); return res.status(403).json({ error: 'You can only delete resources you created' });
} }
// Cascade/compliance check for Standard_User
if (req.user.group === 'Standard_User') {
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
const allTickets = [
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
];
if (allTickets.length === 0) {
return doSingleCveDelete(req, res, id, cve);
}
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
const likeParams = allTickets.map(t => `%${t.key}%`);
db.all(
`SELECT ci.id, ci.extra_json FROM compliance_items ci
JOIN compliance_uploads cu ON ci.upload_id = cu.id
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
likeParams,
(compErr, compLinks) => {
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
const hasLink = (compLinks || []).some(cl => {
const json = cl.extra_json || '';
return allTickets.some(t => json.includes(t.key));
});
if (hasLink) {
return res.status(403).json({
error: 'CVE deletion blocked: associated ticket linked to compliance report',
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
});
}
return doSingleCveDelete(req, res, id, cve);
}
);
});
});
}
doSingleCveDelete(req, res, id, cve);
});
function doSingleCveDelete(req, res, id, cve) {
// Delete associated documents from DB // Delete associated documents from DB
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => { db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
if (docErr) console.error('Error fetching documents:', docErr); if (docErr) console.error('Error fetching documents:', docErr);
@@ -892,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use
}); });
}); });
}); });
}); }
}); });
// ========== DOCUMENT ENDPOINTS ========== // ========== DOCUMENT ENDPOINTS ==========

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react'; import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i; const EXC_PATTERN = /EXC-\d+/i;
@@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page // Main page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ExportsPage() { export default function ExportsPage() {
const { canExport } = useAuth();
const [loading, setLoading] = useState(null); const [loading, setLoading] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState(''); const [cveStatus, setCveStatus] = useState('');
@@ -333,6 +335,15 @@ export default function ExportsPage() {
// ---- Render ---- // ---- Render ----
if (!canExport()) {
return (
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
</div>
);
}
return ( return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>