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
24 KiB
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:
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:
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:
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:
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:
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:
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:
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:
<p className="text-sm text-gray-500 text-center font-mono">
Default: <span className="text-intel-accent">admin</span> /
<span className="text-intel-accent">admin123</span>
</p>
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:
<iframe
src={`${API_BASE}/knowledge-base/${article.id}/content`}
title={article.title}
className="w-full h-full rounded"
>
Impact: A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing localStorage, sessionStorage, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.
Fix: Add a restrictive sandbox attribute:
<iframe
sandbox="allow-same-origin allow-scripts"
src={...}
title={article.title}
/>
High Findings
H-1 — /cleanup-sessions Missing Role Check
File: backend/routes/auth.js:223
The comment says "admin only" but the endpoint only checks for any valid session:
router.post('/cleanup-sessions', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) return res.status(401).json({ error: '...' });
// No role check
Fix: Apply requireAuth(db) and requireRole('admin').
H-2 — Hardcoded Fallback SESSION_SECRET
File: backend/server.js:31
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
If the .env file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.
Fix: Fail hard on startup if the secret is not set:
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');
H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps
Files: backend/routes/archerTickets.js:89–95, 172, 206 and backend/routes/knowledgeBase.js:235–244, 287–296
The logAudit helper expects an object with entityType and entityId. These callers use the wrong keys (targetType, targetId) or pass positional arguments instead of an object:
// archerTickets.js — wrong keys
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });
// knowledgeBase.js — positional (wrong pattern)
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);
Impact: All Archer ticket and Knowledge Base operations produce audit log rows with NULL entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.
Fix: Align all callers to the object format expected by auditLog.js:
logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });
H-4 — Viewers Can Write Compliance Notes
Files: backend/routes/compliance.js:522 (also flagged by file-upload audit)
The POST /notes endpoint is protected by authentication but not by role:
router.post('/notes', async (req, res) => { // no requireRole()
Impact: Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.
Fix: requireRole('editor', 'admin') on this route.
H-5 — Sync Endpoints Accessible to All Authenticated Users
Files: backend/routes/ivantiFindings.js:561, backend/routes/ivantiWorkflows.js:262
POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.
Impact: Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.
Fix: Add requireRole('editor', 'admin') to both POST /sync routes.
H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition
File: backend/routes/knowledgeBase.js:258, 299
The original uploaded filename (user-controlled) is written directly into the Content-Disposition response header:
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
row.file_name stores uploadedFile.originalname which is not sanitized for use in HTTP headers. A filename containing "\r\n characters can split the response and inject arbitrary headers.
Fix:
const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
H-7 — Race Condition in Knowledge Base File Upload
File: backend/routes/knowledgeBase.js:91–155
The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.
Fix: Keep the file in the temp directory until the DB insert succeeds, then move it:
db.run(insertSql, [...], function(err) {
if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
fs.renameSync(uploadedFile.path, filePath);
res.json({ success: true });
});
H-8 — Hardcoded Default Admin Password in setup.js
File: backend/setup.js:175
const passwordHash = await bcrypt.hash('admin123', 10);
If setup.js is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).
Fix: Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.
H-9 — ReactMarkdown Renders HTML Without Sanitization
File: frontend/src/components/KnowledgeBaseViewer.js:169–171
<ReactMarkdown>{content}</ReactMarkdown>
ReactMarkdown by default allows raw HTML in markdown (via rehype-raw). A knowledge base article containing <img src=x onerror="..."> or <script> tags would execute JavaScript in the viewer's browser.
Fix: Add rehype-sanitize:
import rehypeSanitize from 'rehype-sanitize';
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>
Medium Findings
M-1 — No CSRF Token Protection on State-Changing Requests
Files: All POST / PUT / DELETE routes
Cookies are SameSite: lax which provides partial protection, but lax still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.
Fix: Either upgrade session cookie to SameSite: strict, or implement a CSRF token (double-submit cookie pattern or csurf middleware).
M-2 — CORS Allows Credentials with Explicit Origin List
File: backend/server.js:111–114
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
credentials: true with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.
Fix: Ensure CORS_ORIGINS is reviewed whenever the deployment changes. Consider SameSite: strict on cookies to reduce reliance on CORS for CSRF protection.
M-3 — No Rate Limiting on NVD API Proxy
File: backend/routes/nvdLookup.js:13
Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.
Fix: Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.
M-4 — Admin Self-Demotion Check Uses Loose Equality
File: backend/routes/users.js:118
if (userId == req.user.id && role && role !== 'admin') {
Using == allows type coercion. If userId is passed as a different type than req.user.id, the comparison may not match correctly.
Fix: String(userId) === String(req.user.id).
M-5 — Missing Hostname Format Validation
File: backend/routes/compliance.js:451
The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.
Fix:
if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
return res.status(400).json({ error: 'Invalid hostname format' });
}
M-6 — Vendor Field Validated Before Trim
File: backend/routes/ivantiTodoQueue.js:8, 56
Vendor length is validated before .trim() is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.
Fix: Trim first, then validate length and presence.
M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON
File: backend/routes/compliance.js:262
filename: req.file.originalname, // user-controlled, unsanitized
The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.
Fix: filename: sanitizePathSegment(req.file.originalname).
M-8 — Hardcoded Frontend Origin in CSP Header
File: backend/routes/knowledgeBase.js:261
res.setHeader('Content-Security-Policy',
"frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.
Fix: Use CORS_ORIGINS from the environment variable.
M-9 — Sensitive API Error Messages Forwarded to UI
Files: frontend/src/App.js:801, 816, 847, 886
} catch (err) {
alert(`Error: ${err.message}`);
}
Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.
Fix: Show generic user-facing messages; log details to the console in development only.
M-10 — User-Supplied Data in window.confirm Dialogs
File: frontend/src/App.js:806, 891
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
A ticket with a crafted ticket_key value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.
Fix: Use a React modal component with escaped, controlled text instead of window.confirm.
Low / Info Findings
L-1 — Silent ROLLBACK on Compliance Transaction Failure
File: backend/routes/compliance.js:167
await dbRun(db, 'ROLLBACK').catch(() => {});
If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.
Fix: Log rollback failures even if execution continues.
L-2 — Fire-and-Forget Audit Logging
File: backend/helpers/auditLog.js:9
Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.
Fix: Log audit write failures to stderr so they surface in server logs.
L-3 — Async Temp File Cleanup With No Error Handling
File: backend/routes/compliance.js:239, 247, 266, 281, 322
fs.unlink(req.file.path, () => {});
Cleanup failures accumulate silently, potentially causing disk exhaustion over time.
Fix: Log errors on unlink failure (excluding ENOENT which is expected).
L-4 — IVANTI_SKIP_TLS Disables Certificate Validation
File: backend/routes/ivantiFindings.js:385
IVANTI_SKIP_TLS=true disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.
Fix: Log a prominent warning on startup when this flag is active, and ensure it is never set in production.
L-5 — console.error in Production Frontend Code
Files: frontend/src/contexts/AuthContext.js:26, KnowledgeBaseViewer.js:31, 56
Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.
Fix: Guard with if (process.env.NODE_ENV === 'development') or use a structured logging library.
L-6 — localStorage Column Config Lacks Structural Validation
File: frontend/src/components/pages/ReportingPage.js:51–68
Column order/visibility is loaded from localStorage and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.
Fix: Validate each loaded item against the known COLUMN_DEFS whitelist before use (a hasOwnProperty check is already present; ensure it runs on every item before the merge).
Summary Table
| ID | Severity | Title | File |
|---|---|---|---|
| C-1 | Critical | Missing auth on Ivanti findings endpoints | ivantiFindings.js:552 |
| C-2 | Critical | requireRole(db) call bypasses role check in KB routes | knowledgeBase.js:43,305 |
| C-3 | Critical | Unauthenticated finding note writes | ivantiFindings.js:639 |
| C-4 | Critical | No brute force protection on login | auth.js:10 |
| C-5 | Critical | Default credentials displayed in login UI | LoginForm.js:104 |
| C-6 | Critical | Missing sandbox on PDF/document iframe | KnowledgeBaseViewer.js:195 |
| H-1 | High | /cleanup-sessions missing role check | auth.js:223 |
| H-2 | High | Hardcoded fallback SESSION_SECRET | server.js:31 |
| H-3 | High | Audit log parameter mismatch — silent trail gaps | archerTickets.js, knowledgeBase.js |
| H-4 | High | Viewers can write compliance notes | compliance.js:522 |
| H-5 | High | Sync endpoints accessible to all authenticated users | ivantiFindings.js:561, ivantiWorkflows.js:262 |
| H-6 | High | HTTP header injection via Content-Disposition filename | knowledgeBase.js:258,299 |
| H-7 | High | Race condition in KB file upload | knowledgeBase.js:91 |
| H-8 | High | Hardcoded default admin password in setup.js | setup.js:175 |
| H-9 | High | ReactMarkdown renders HTML without sanitization | KnowledgeBaseViewer.js:169 |
| M-1 | Medium | No CSRF token protection | All state-changing routes |
| M-2 | Medium | CORS credentials with explicit origin list | server.js:111 |
| M-3 | Medium | No rate limiting on NVD API proxy | nvdLookup.js:13 |
| M-4 | Medium | Admin self-demotion check uses loose equality | users.js:118 |
| M-5 | Medium | Missing hostname format validation | compliance.js:451 |
| M-6 | Medium | Vendor field validated before trim | ivantiTodoQueue.js:8,56 |
| M-7 | Medium | Unsanitized original filename in temp JSON | compliance.js:262 |
| M-8 | Medium | Hardcoded frontend IP in CSP header | knowledgeBase.js:261 |
| M-9 | Medium | API error messages forwarded to UI | App.js:801,816,847,886 |
| M-10 | Medium | User data in window.confirm dialogs | App.js:806,891 |
| L-1 | Low | Silent ROLLBACK on transaction failure | compliance.js:167 |
| L-2 | Low | Fire-and-forget audit logging | auditLog.js:9 |
| L-3 | Low | Async temp file cleanup with no error handling | compliance.js:239+ |
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | ivantiFindings.js:385 |
| L-5 | Low | console.error exposed in production frontend | AuthContext.js, KnowledgeBaseViewer.js |
| L-6 | Low | localStorage column config lacks structural validation | ReportingPage.js:51 |
Remediation Priority
Immediate — fix before adding users
- C-1 — Add
requireAuthimport and router-level middleware toivantiFindings.js - C-2 — Remove
dbfrom allrequireRole(db, ...)calls inknowledgeBase.js - C-3 — Add
requireAuth(db)to the finding note PUT route - C-4 — Add
express-rate-limitto the login route (20 attempts / 15 min) - C-5 — Remove default credentials from
LoginForm.js - H-2 — Hard-fail on startup if
SESSION_SECRETis not set in env
Short-term — next maintenance window
- C-6 — Add
sandboxattribute to the KB iframe - H-3 — Fix
logAuditcall signatures inarcherTickets.jsandknowledgeBase.js - H-4 — Add
requireRole('editor', 'admin')to POST /compliance/notes - H-5 — Add
requireRole('editor', 'admin')to both POST /sync routes - H-6 — Sanitize filename for
Content-Dispositionheader - H-7 — Move file after DB insert succeeds in KB upload
- H-8 — Remove hardcoded password from
setup.js; generate random on first run - H-9 — Add
rehype-sanitizetoReactMarkdownusage
Medium-term
- M-1 — Implement CSRF token or upgrade cookie to
SameSite: strict - M-3 — Add server-side CVE lookup cache
- M-5 — Add hostname format regex validation
- M-8 — Pull frontend origin from
CORS_ORIGINSenv var for CSP header - M-9 — Replace
alert(err.message)with user-friendly error messages - Remaining medium and low findings
Positive Security Observations
The following were explicitly verified as secure and should be preserved:
- SQL injection prevention — all queries use SQLite3 parameterized statements throughout
- Path traversal prevention —
sanitizePathSegment()andisPathWithinUploads()are comprehensive and consistently applied - Python script execution —
spawn('python3', [SCRIPT, filePath])passes arguments as an array, not a shell string — no command injection possible - Python scripts — no
eval(),exec(),pickle.load(), or shell calls in any script - File size enforcement — 10 MB limit applied via multer before route handlers execute
- File type allowlisting — extension + MIME prefix validation applied at upload
- Static file serving —
express.staticwith{ dotfiles: 'deny', index: false }prevents directory listing - Temp file path validation —
isSafeTempPath()enforces.jsonextension on compliance temp files - Password hashing — bcrypt with cost factor 10 used throughout
Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.