diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js
index 8947ef5..13329da 100644
--- a/frontend/src/contexts/AuthContext.js
+++ b/frontend/src/contexts/AuthContext.js
@@ -72,16 +72,26 @@ export function AuthProvider({ children }) {
setUser(null);
};
- // Check if user has a specific role
- const hasRole = (...roles) => {
- return user && roles.includes(user.role);
+ // Check if user belongs to one of the specified groups
+ const isInGroup = (...groups) => user && groups.includes(user.group);
+
+ // Check if user can perform write operations (Admin or Standard_User)
+ const canWrite = () => isInGroup('Admin', 'Standard_User');
+
+ // Check if user can delete a resource
+ // Admin: always true; Standard_User: only if they own the resource; others: false
+ const canDelete = (resource) => {
+ if (!user) return false;
+ if (isInGroup('Admin')) return true;
+ if (!isInGroup('Standard_User')) return false;
+ return resource?.created_by === user.id;
};
- // Check if user can perform write operations (editor or admin)
- const canWrite = () => hasRole('editor', 'admin');
+ // Check if user can export data
+ const canExport = () => isInGroup('Admin', 'Standard_User', 'Leadership');
// Check if user is admin
- const isAdmin = () => hasRole('admin');
+ const isAdmin = () => isInGroup('Admin');
const value = {
user,
@@ -90,8 +100,10 @@ export function AuthProvider({ children }) {
login,
logout,
checkAuth,
- hasRole,
+ isInGroup,
canWrite,
+ canDelete,
+ canExport,
isAdmin,
isAuthenticated: !!user
};
From d910af847e07a0e779d0b5d3a8c516673fad71f3 Mon Sep 17 00:00:00 2001
From: jramos
Date: Mon, 6 Apr 2026 16:25:59 -0600
Subject: [PATCH 2/4] fix: wire up admin page route to render UserManagement
component
---
frontend/src/App.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 3e25b8d..fda9353 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1012,6 +1012,11 @@ export default function App() {
{currentPage === 'compliance' && }
{currentPage === 'knowledge-base' && }
{currentPage === 'exports' && }
+ {currentPage === 'admin' && isAdmin() && (
+
+ setCurrentPage('home')} />
+
+ )}
{/* User Management Modal */}
{showUserManagement && (
From e9e2c0961d8ea6eafe97215b613addc9f32553d0 Mon Sep 17 00:00:00 2001
From: jramos
Date: Tue, 7 Apr 2026 09:52:26 -0600
Subject: [PATCH 3/4] 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
---
backend/migrations/add_user_groups.js | 31 +++++++-
backend/routes/archerTickets.js | 12 +--
backend/routes/auth.js | 8 +-
backend/routes/compliance.js | 2 +-
backend/routes/ivantiFindings.js | 4 +-
backend/routes/ivantiTodoQueue.js | 9 ++-
backend/routes/ivantiWorkflows.js | 3 +-
backend/routes/knowledgeBase.js | 83 ++++++++++----------
backend/server.js | 55 ++++++++++++-
frontend/src/components/pages/ExportsPage.js | 11 +++
10 files changed, 154 insertions(+), 64 deletions(-)
diff --git a/backend/migrations/add_user_groups.js b/backend/migrations/add_user_groups.js
index 07d47a1..ada4a1b 100644
--- a/backend/migrations/add_user_groups.js
+++ b/backend/migrations/add_user_groups.js
@@ -78,8 +78,35 @@ function runMigration(db) {
(err) => {
if (err) { reject(err); return; }
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();
+ }
+ );
+ }
+ );
}
);
}
diff --git a/backend/routes/archerTickets.js b/backend/routes/archerTickets.js
index d185ec0..8474a8e 100644
--- a/backend/routes/archerTickets.js
+++ b/backend/routes/archerTickets.js
@@ -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
});
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 1b641c3..4f9a973 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -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(
diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js
index 032be7c..41a6432 100644
--- a/backend/routes/compliance.js
+++ b/backend/routes/compliance.js
@@ -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) {
diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js
index dc7ea2f..10a7029 100644
--- a/backend/routes/ivantiFindings.js
+++ b/backend/routes/ivantiFindings.js
@@ -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);
diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js
index 3d4adf1..148d924 100644
--- a/backend/routes/ivantiTodoQueue.js
+++ b/backend/routes/ivantiTodoQueue.js
@@ -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(
diff --git a/backend/routes/ivantiWorkflows.js b/backend/routes/ivantiWorkflows.js
index 3937947..f7489fe 100644
--- a/backend/routes/ivantiWorkflows.js
+++ b/backend/routes/ivantiWorkflows.js
@@ -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));
diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js
index e2f403a..2c27968 100644
--- a/backend/routes/knowledgeBase.js
+++ b/backend/routes/knowledgeBase.js
@@ -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 });
});
diff --git a/backend/server.js b/backend/server.js
index 9b7e36b..a265ac2 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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' });
}
+ // 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
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);
@@ -892,7 +945,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_Use
});
});
});
- });
+ }
});
// ========== DOCUMENT ENDPOINTS ==========
diff --git a/frontend/src/components/pages/ExportsPage.js b/frontend/src/components/pages/ExportsPage.js
index 42da1ea..08ea97a 100644
--- a/frontend/src/components/pages/ExportsPage.js
+++ b/frontend/src/components/pages/ExportsPage.js
@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx';
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 EXC_PATTERN = /EXC-\d+/i;
@@ -217,6 +218,7 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
// Main page
// ---------------------------------------------------------------------------
export default function ExportsPage() {
+ const { canExport } = useAuth();
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState('');
@@ -333,6 +335,15 @@ export default function ExportsPage() {
// ---- Render ----
+ if (!canExport()) {
+ return (
+
+
+
You do not have permission to export data.
+
+ );
+ }
+
return (
From c50fc5d8a83391d447062c814a965c71a9c62dea Mon Sep 17 00:00:00 2001
From: jramos
Date: Tue, 7 Apr 2026 10:09:18 -0600
Subject: [PATCH 4/4] 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
---
backend/routes/knowledgeBase.js | 69 +++-
docs/security-audit-2026-04-01.md | 617 ++++++++++++++++++++++++++++++
2 files changed, 680 insertions(+), 6 deletions(-)
create mode 100644 docs/security-audit-2026-04-01.md
diff --git a/backend/routes/knowledgeBase.js b/backend/routes/knowledgeBase.js
index 2c27968..9f29900 100644
--- a/backend/routes/knowledgeBase.js
+++ b/backend/routes/knowledgeBase.js
@@ -39,7 +39,19 @@ function createKnowledgeBaseRouter(db, upload) {
return ALLOWED_EXTENSIONS.has(ext);
}
- // POST /api/knowledge-base/upload - Upload new document
+ /**
+ * POST /api/knowledge-base/upload
+ * Upload a new knowledge base document.
+ *
+ * @body {string} title - Article title (required)
+ * @body {string} [description] - Article description
+ * @body {string} [category] - Article category (defaults to 'General')
+ * @body {File} file - The document file to upload (multipart/form-data)
+ *
+ * @response 200 - { success: true, id: number, title: string, slug: string, category: string }
+ * @response 400 - { error: string } - Missing title, no file, or invalid file type
+ * @response 500 - { error: string } - Database or filesystem error
+ */
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
@@ -160,7 +172,13 @@ function createKnowledgeBaseRouter(db, upload) {
}
});
- // GET /api/knowledge-base - List all articles
+ /**
+ * GET /api/knowledge-base
+ * List all knowledge base articles.
+ *
+ * @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
+ * @response 500 - { error: string }
+ */
router.get('/', requireAuth(db), (req, res) => {
const sql = `
SELECT
@@ -182,7 +200,16 @@ function createKnowledgeBaseRouter(db, upload) {
});
});
- // GET /api/knowledge-base/:id - Get single article details
+ /**
+ * GET /api/knowledge-base/:id
+ * Get a single article's details by ID.
+ *
+ * @param {string} id - Article ID (route parameter)
+ *
+ * @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
+ * @response 404 - { error: 'Article not found' }
+ * @response 500 - { error: string }
+ */
router.get('/:id', requireAuth(db), (req, res) => {
const { id } = req.params;
@@ -210,7 +237,17 @@ function createKnowledgeBaseRouter(db, upload) {
});
});
- // GET /api/knowledge-base/:id/content - Get document content for display
+ /**
+ * GET /api/knowledge-base/:id/content
+ * Get document content for inline display. Returns the raw file with appropriate
+ * Content-Type headers. Markdown and text files are served as text/plain.
+ *
+ * @param {string} id - Article ID (route parameter)
+ *
+ * @response 200 - Raw file content with Content-Type and Content-Disposition headers
+ * @response 404 - { error: string } - Article or file not found
+ * @response 500 - { error: string }
+ */
router.get('/:id/content', requireAuth(db), (req, res) => {
const { id } = req.params;
@@ -261,7 +298,16 @@ function createKnowledgeBaseRouter(db, upload) {
});
});
- // GET /api/knowledge-base/:id/download - Download document
+ /**
+ * GET /api/knowledge-base/:id/download
+ * Download a knowledge base document as an attachment.
+ *
+ * @param {string} id - Article ID (route parameter)
+ *
+ * @response 200 - File download with Content-Disposition: attachment header
+ * @response 404 - { error: string } - Article or file not found
+ * @response 500 - { error: string }
+ */
router.get('/:id/download', requireAuth(db), (req, res) => {
const { id } = req.params;
@@ -298,7 +344,18 @@ function createKnowledgeBaseRouter(db, upload) {
});
});
- // DELETE /api/knowledge-base/:id - Delete article
+ /**
+ * DELETE /api/knowledge-base/:id
+ * Delete a knowledge base article and its associated file.
+ * Standard_User can only delete articles they created. Admin can delete any article.
+ *
+ * @param {string} id - Article ID (route parameter)
+ *
+ * @response 200 - { success: true }
+ * @response 403 - { error: string } - Ownership check failed for Standard_User
+ * @response 404 - { error: 'Article not found' }
+ * @response 500 - { error: string }
+ */
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
diff --git a/docs/security-audit-2026-04-01.md b/docs/security-audit-2026-04-01.md
new file mode 100644
index 0000000..174401b
--- /dev/null
+++ b/docs/security-audit-2026-04-01.md
@@ -0,0 +1,617 @@
+# Security Audit Report — STEAM Security Dashboard
+
+**Date:** 2026-04-01
+**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend
+**Methodology:** Static analysis across four parallel audit tracks
+
+---
+
+## Executive Summary
+
+The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.
+
+| Severity | Count |
+|----------|-------|
+| Critical | 6 |
+| High | 9 |
+| Medium | 10 |
+| Low / Info | 6 |
+| **Total** | **31** |
+
+The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.
+
+---
+
+## Critical Findings
+
+---
+
+### C-1 — Missing Authentication on Ivanti Findings Endpoints
+
+**File:** `backend/routes/ivantiFindings.js:552–600`
+
+The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:
+
+```js
+const { requireRole } = require('../middleware/auth'); // requireAuth never imported
+
+router.get('/', async (req, res) => { // line 552 — no auth
+router.post('/sync', async (req, res) => { // line 561 — no auth
+router.get('/counts', async (req, res) => { // line 571 — no auth
+router.get('/fp-workflow-counts', ...) // line 580 — no auth
+```
+
+**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.
+
+**Fix:** Import `requireAuth` and apply it to the router or each route:
+```js
+const { requireAuth, requireRole } = require('../middleware/auth');
+router.use(requireAuth(db));
+```
+
+---
+
+### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base
+
+**File:** `backend/routes/knowledgeBase.js:43, 305`
+
+`requireRole` is called with `db` as the first argument:
+
+```js
+router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
+router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
+```
+
+The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents.
+
+**Fix:** Remove `db` from all `requireRole` calls:
+```js
+requireRole('editor', 'admin')
+```
+
+---
+
+### C-3 — Unauthenticated Ivanti Finding Note Writes
+
+**File:** `backend/routes/ivantiFindings.js:639`
+
+The PUT endpoint for saving finding notes has no authentication middleware:
+
+```js
+router.put('/:findingId/note', (req, res) => {
+ const note = String(req.body.note || '').slice(0, 255);
+ db.run(`INSERT INTO ivanti_finding_notes ...`);
+});
+```
+
+**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.
+
+**Fix:** Add `requireAuth(db)` to this route.
+
+---
+
+### C-4 — No Brute Force Protection on Login Endpoint
+
+**File:** `backend/routes/auth.js:10`
+
+The login endpoint has no rate limiting, attempt counting, or lockout:
+
+```js
+router.post('/login', async (req, res) => {
+ const { username, password } = req.body;
+ // Direct DB lookup, unlimited attempts
+```
+
+**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.
+
+**Fix:** Apply `express-rate-limit` to the login route:
+```js
+const rateLimit = require('express-rate-limit');
+const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
+router.post('/login', loginLimiter, async (req, res) => { ... });
+```
+
+---
+
+### C-5 — Default Credentials Displayed in Login UI
+
+**File:** `frontend/src/components/LoginForm.js:104`
+
+The login form renders hardcoded credentials in plain text:
+
+```jsx
+
+ Default: admin /
+ admin123
+
+```
+
+**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.
+
+**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.
+
+---
+
+### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe
+
+**File:** `frontend/src/components/KnowledgeBaseViewer.js:195`
+
+The inline document viewer renders uploaded files in an unsandboxed iframe:
+
+```jsx
+