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

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

View File

@@ -2,6 +2,7 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { requireAuth, requireGroup } = require('../middleware/auth');
function createAuthRouter(db, logAudit) {
const router = express.Router();
@@ -220,12 +221,7 @@ function createAuthRouter(db, logAudit) {
});
// Clean up expired sessions (admin only)
router.post('/cleanup-sessions', 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' });
}
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
try {
await new Promise((resolve, reject) => {
db.run(

View File

@@ -520,7 +520,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
// Add a note to a (hostname, metric_id) pair.
// 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;
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
router.post('/sync', async (req, res) => {
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(db);
try {
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)
router.put('/:findingId/note', (req, res) => {
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findingId } = req.params;
const note = String(req.body.note || '').slice(0, 255);

View File

@@ -1,5 +1,6 @@
// routes/ivantiTodoQueue.js
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete'];
@@ -36,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// POST /api/ivanti/todo-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;
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
// 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 { vendor, workflow_type, status } = req.body;
@@ -162,7 +163,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user
// 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(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id],
@@ -178,7 +179,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
// DELETE /api/ivanti/todo-queue/:id
// 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;
db.get(

View File

@@ -5,6 +5,7 @@
const express = require('express');
const https = require('https');
const { requireGroup } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
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
router.post('/sync', async (req, res) => {
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncWorkflows(db);
try {
res.json(await readState(db));

View File

@@ -132,16 +132,15 @@ function createKnowledgeBaseRouter(db, upload) {
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'CREATE_KB_ARTICLE',
'knowledge_base',
this.lastID,
JSON.stringify({ title: title.trim(), filename: sanitizedName }),
req.ip
);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'CREATE_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(this.lastID),
details: { title: title.trim(), filename: sanitizedName },
ipAddress: req.ip
});
res.json({
success: true,
@@ -232,16 +231,15 @@ function createKnowledgeBaseRouter(db, upload) {
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'VIEW_KB_ARTICLE',
'knowledge_base',
id,
JSON.stringify({ filename: row.file_name }),
req.ip
);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'VIEW_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(id),
details: { filename: row.file_name },
ipAddress: req.ip
});
// Determine content type for inline display
let contentType = row.file_type || 'application/octet-stream';
@@ -284,16 +282,15 @@ function createKnowledgeBaseRouter(db, upload) {
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'DOWNLOAD_KB_ARTICLE',
'knowledge_base',
id,
JSON.stringify({ filename: row.file_name }),
req.ip
);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'DOWNLOAD_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(id),
details: { filename: row.file_name },
ipAddress: req.ip
});
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
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) => {
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) => {
if (err) {
@@ -317,6 +314,11 @@ function createKnowledgeBaseRouter(db, upload) {
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
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
if (err) {
@@ -330,16 +332,15 @@ function createKnowledgeBaseRouter(db, upload) {
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'DELETE_KB_ARTICLE',
'knowledge_base',
id,
JSON.stringify({ title: row.title }),
req.ip
);
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'DELETE_KB_ARTICLE',
entityType: 'knowledge_base',
entityId: String(id),
details: { title: row.title },
ipAddress: req.ip
});
res.json({ success: true });
});