# Security Audit Tracker — STEAM Security Dashboard

**Last scan:** 2026-04-20
**Scope:** Full repository — backend routes, middleware, helpers, scripts, frontend components
**Baseline:** `docs/security-audit-2026-04-01.md` (31 findings), `docs/security-remediation-plan.md` (17 prioritised items)

---

## Table of Contents

- [Remediation Status — April 1 Audit](#remediation-status--april-1-audit)
- [New Findings — April 20 Scan](#new-findings--april-20-scan)
- [Open Finding Summary](#open-finding-summary)
- [Positive Security Observations](#positive-security-observations)
- [Scan Metadata](#scan-metadata)

---

## Remediation Status — April 1 Audit

Cross-reference of the 31 original findings against the current codebase. Status: **Fixed**, **Partial**, or **Open**.

### Critical Findings

| ID | Title | Status | Evidence |
|---|---|---|---|
| C-1 | Missing auth on Ivanti findings endpoints | **Fixed** | `ivantiFindings.js` — router uses `requireAuth(db)` at router level, `requireGroup` on sync |
| C-2 | `requireRole(db)` bypasses role check in KB routes | **Fixed** | `knowledgeBase.js` — uses `requireGroup('Admin', 'Standard_User')` correctly |
| C-3 | Unauthenticated finding note writes | **Fixed** | `ivantiFindings.js` — note routes behind `requireAuth(db)` |
| C-4 | No brute force protection on login | **Fixed** | `auth.js` — `loginLimiter` (20 attempts / 15 min) applied to POST /login |
| C-5 | Default credentials displayed in login UI | **Fixed** | `LoginForm.js` — no hardcoded credentials in the component |
| C-6 | Missing sandbox on KB document iframe | **Fixed** | `KnowledgeBaseViewer.js:282` — `sandbox="allow-same-origin"` applied |

### High Findings

| ID | Title | Status | Evidence |
|---|---|---|---|
| H-1 | `/cleanup-sessions` missing role check | **Fixed** | `auth.js` — `requireAuth(db), requireGroup('Admin')` applied |
| H-2 | Hardcoded fallback SESSION_SECRET | **Fixed** | `server.js:34-37` — hard-fails with `process.exit(1)` if unset |
| H-3 | Audit log parameter mismatch — silent trail gaps | **Partial** | `knowledgeBase.js` — fixed. `archerTickets.js` — `logAudit` calls missing `username` field (see N-1 below) |
| H-4 | Viewers can write compliance notes | **Fixed** | `compliance.js` — `requireGroup('Admin', 'Standard_User')` on POST /notes |
| H-5 | Sync endpoints accessible to all authenticated users | **Fixed** | Both `ivantiFindings.js` and `ivantiWorkflows.js` — `requireGroup('Admin', 'Standard_User')` on POST /sync |
| H-6 | HTTP header injection via Content-Disposition filename | **Fixed** | `knowledgeBase.js` — filename sanitized with `.replace(/["\r\n\\]/g, '')` |
| H-7 | Race condition in KB file upload | **Fixed** | `knowledgeBase.js` — file moved after DB insert succeeds |
| H-8 | Hardcoded default admin password in setup.js | **Fixed** | `setup.js` — generates random password via `crypto.randomBytes(12)` |
| H-9 | ReactMarkdown renders HTML without sanitization | **Fixed** | `KnowledgeBaseViewer.js` — `rehypeSanitize` plugin applied |

### Medium Findings

| ID | Title | Status | Evidence |
|---|---|---|---|
| M-1 | No CSRF token protection | **Open** | Cookies use `SameSite: lax` — no CSRF token implemented |
| M-2 | CORS credentials with explicit origin list | **Open** | Acceptable for this deployment model — monitor |
| M-3 | No rate limiting on NVD API proxy | **Open** | No server-side cache or per-user rate limit on `/api/nvd/lookup` |
| M-4 | Admin self-demotion check uses loose equality | **Fixed** | `users.js` — uses `String(userId) === String(req.user.id)` |
| M-5 | Missing hostname format validation | **Fixed** | `compliance.js` POST /notes — regex validation `^[a-zA-Z0-9._-]+$` |
| M-6 | Vendor field validated before trim | **Open** | `ivantiTodoQueue.js:8` — `isValidVendor()` checks length before trim |
| M-7 | Unsanitized original filename in temp JSON | **Open** | `compliance.js:344` — `req.file.originalname` passed directly |
| M-8 | Hardcoded frontend IP in CSP header | **Fixed** | `knowledgeBase.js:302` — reads from `CORS_ORIGINS` env var |
| M-9 | API error messages forwarded to UI | **Open** | Frontend still uses `alert(err.message)` in several places |
| M-10 | User data in window.confirm dialogs | **Open** | Frontend still uses `window.confirm` with user-supplied data |

### Low / Info Findings

| ID | Title | Status | Evidence |
|---|---|---|---|
| L-1 | Silent ROLLBACK on transaction failure | **Open** | `compliance.js:167` — `.catch(() => {})` still swallows errors |
| L-2 | Fire-and-forget audit logging | **Partial** | `auditLog.js` — now logs to `console.error` on failure, but no alerting |
| L-3 | Async temp file cleanup with no error handling | **Open** | `compliance.js` — `fs.unlink(path, () => {})` still used |
| L-4 | IVANTI_SKIP_TLS with no startup warning | **Open** | No startup warning when `IVANTI_SKIP_TLS=true` |
| L-5 | console.error in production frontend | **Open** | No environment guard on console.error calls |
| L-6 | localStorage column config lacks structural validation | **Open** | No change observed |

### Remediation Plan Items (not in original 31)

| ID | Title | Status | Evidence |
|---|---|---|---|
| RP-1 | Authenticate /uploads static file access | **Open** | `server.js:127` — `express.static('uploads')` still unauthenticated |
| RP-2 | Sanitize Mermaid SVG output with DOMPurify | **Open** | `KnowledgeBaseViewer.js:38` — `innerHTML = svg` without DOMPurify |
| RP-3 | Strip server file paths from compliance preview response | **Open** | `compliance.js:342` — full `tempFilePath` returned to client |
| RP-4 | Add SESSION_SECRET to .env.example | **Open** | `.env.example` — no `SESSION_SECRET` entry |

---

## New Findings — April 20 Scan

Findings discovered in this scan that were not present in the April 1 audit.

---

### N-1 — Archer Ticket Audit Logs Missing `username` Field (Medium)

**File:** `backend/routes/archerTickets.js:89, 172, 195`

All three `logAudit` calls in the Archer tickets router omit the `username` field:

```js
logAudit(db, {
  userId: req.user.id,
  action: 'CREATE_ARCHER_TICKET',
  // username: req.user.username  ← missing
  ...
});
```

The `auditLog.js` helper defaults missing username to `'unknown'`, so all Archer ticket audit entries show `username = 'unknown'` instead of the actual user.

**Impact:** Audit trail for Archer ticket operations cannot identify which user performed the action. Compliance reviews and incident investigations are degraded.

**Fix:** Add `username: req.user.username` to all three `logAudit` calls.

---

### N-2 — `migrate-to-1.1.js` Contains Hardcoded Admin Password (Medium)

**File:** `backend/migrate-to-1.1.js:246`

```js
const passwordHash = await bcrypt.hash('admin123', 10);
```

While `setup.js` was fixed to generate random passwords (H-8), the migration script still hardcodes `admin123`. If this migration is run on an existing deployment, it resets the admin password to a known value.

**Impact:** Running the migration on a production system resets the admin account to a publicly known password.

**Fix:** Either generate a random password (matching `setup.js` pattern) or skip admin creation if the user already exists.

---

### N-3 — Compliance Preview Returns Full Server Filesystem Path (Medium)

**File:** `backend/routes/compliance.js:342`

```js
tempFile: tempFilePath,
```

The preview endpoint returns the full server-side path (e.g. `/home/cve-dashboard/backend/uploads/temp/compliance_preview_...json`) to the frontend. The commit endpoint then receives this path back and reads the file. This exposes the server's directory structure to any authenticated user.

**Impact:** Information disclosure — authenticated users learn the server's absolute filesystem layout, which aids further exploitation.

**Fix:** Return only the filename. Reconstruct the full path server-side in the commit handler:
```js
tempFile: tempFilename,  // just the basename
// In commit handler:
const tempFile = path.join(TEMP_DIR, path.basename(req.body.tempFile));
```

---

### N-4 — `/uploads` Static Directory Served Without Authentication (High)

**File:** `backend/server.js:127`

```js
app.use('/uploads', express.static('uploads', {
    dotfiles: 'deny',
    index: false
}));
```

All uploaded files (CVE documents, compliance data, knowledge base articles) are served as static files without any authentication check. Anyone who knows or guesses a file URL can access sensitive vulnerability documentation, compliance reports, and internal knowledge base content.

**Impact:** Unauthenticated access to all uploaded documents. File paths are predictable (CVE ID + vendor + timestamp-filename pattern).

**Fix:** Replace with an authenticated route handler:
```js
app.use('/uploads', requireAuth(db), express.static('uploads', { ... }));
```

---

### N-5 — Mermaid SVG Rendered via `innerHTML` Without Sanitization (Medium)

**File:** `frontend/src/components/KnowledgeBaseViewer.js:38`

```js
ref.current.innerHTML = svg;
```

Mermaid-generated SVG is injected directly into the DOM via `innerHTML`. While Mermaid itself sanitizes most input, a crafted diagram definition in a knowledge base article could potentially produce SVG with embedded event handlers or script elements.

**Impact:** Stored XSS vector if Mermaid's internal sanitization is bypassed. Any user viewing the article would execute the payload.

**Fix:** Sanitize the SVG string before injection:
```js
import DOMPurify from 'dompurify';
ref.current.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
```

---

### N-6 — `SESSION_SECRET` Not Documented in `.env.example` (Low)

**File:** `backend/.env.example`

The `SESSION_SECRET` environment variable is required for the server to start (hard-fail added per H-2 fix), but it is not listed in `.env.example`. Fresh deployments will fail with no guidance on what to set.

**Fix:** Add to `.env.example`:
```
# Session signing secret — generate with: openssl rand -hex 32
SESSION_SECRET=
```

---

### N-7 — `requireGroup` Error Response Leaks Current User Group (Low)

**File:** `backend/middleware/auth.js:55-60`

```js
return res.status(403).json({
    error: 'Insufficient permissions',
    required: allowedGroups,
    current: req.user.group
});
```

The 403 response includes both the required groups and the user's current group. This is minor information disclosure — an attacker probing endpoints learns the exact group membership of the compromised account and which groups are needed.

**Fix:** Remove `required` and `current` from the response:
```js
return res.status(403).json({ error: 'Insufficient permissions' });
```

---

### N-8 — No Content-Security-Policy Header on Main Application (Medium)

**File:** `backend/server.js:107-113`

Security headers include `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, and `Permissions-Policy`, but no `Content-Security-Policy` header. CSP is the primary browser-side defense against XSS.

**Impact:** No browser-enforced restriction on script sources. If an XSS vulnerability exists (e.g. N-5), there is no CSP to mitigate it.

**Fix:** Add a baseline CSP header:
```js
res.setHeader('Content-Security-Policy',
    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' data:; font-src 'self'; connect-src 'self'");
```
Start with `Content-Security-Policy-Report-Only` to avoid breaking existing functionality.

---

### N-9 — Expired Sessions Not Cleaned Up Automatically (Low)

**File:** `backend/server.js`, `backend/routes/auth.js`

The `sessions` table has no automatic cleanup. Expired sessions accumulate indefinitely. The `/cleanup-sessions` endpoint exists but must be triggered manually by an admin.

**Impact:** Performance degradation over time as the sessions table grows. Not directly exploitable, but expired session rows increase the surface for timing attacks on session lookups.

**Fix:** Add a cleanup interval on server startup:
```js
setInterval(() => {
    db.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
}, 6 * 60 * 60 * 1000); // every 6 hours
```

---

## Open Finding Summary

Prioritised list of all open findings requiring action.

### High Priority

| ID | Severity | Title | Source |
|---|---|---|---|
| N-4 | High | `/uploads` static directory served without authentication | New |

### Medium Priority

| ID | Severity | Title | Source |
|---|---|---|---|
| M-1 | Medium | No CSRF token protection | April 1 |
| M-3 | Medium | No rate limiting on NVD API proxy | April 1 |
| N-1 | Medium | Archer ticket audit logs missing `username` field | New |
| N-2 | Medium | `migrate-to-1.1.js` contains hardcoded admin password | New |
| N-3 | Medium | Compliance preview returns full server filesystem path | New |
| N-5 | Medium | Mermaid SVG rendered via `innerHTML` without sanitization | New |
| N-8 | Medium | No Content-Security-Policy header on main application | New |
| M-6 | Medium | Vendor field validated before trim | April 1 |
| M-7 | Medium | Unsanitized original filename in temp JSON | April 1 |
| M-9 | Medium | API error messages forwarded to UI | April 1 |
| M-10 | Medium | User data in `window.confirm` dialogs | April 1 |

### Low Priority

| ID | Severity | Title | Source |
|---|---|---|---|
| N-6 | Low | `SESSION_SECRET` not documented in `.env.example` | New |
| N-7 | Low | `requireGroup` error response leaks current user group | New |
| N-9 | Low | Expired sessions not cleaned up automatically | New |
| L-1 | Low | Silent ROLLBACK on transaction failure | April 1 |
| L-3 | Low | Async temp file cleanup with no error handling | April 1 |
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | April 1 |
| L-5 | Low | console.error in production frontend | April 1 |
| L-6 | Low | localStorage column config lacks structural validation | April 1 |

---

## Positive Security Observations

Verified secure patterns that should be preserved:

- **SQL injection prevention** — all queries use parameterized statements throughout the entire codebase
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` consistently applied in `server.js`, `compliance.js`, and `knowledgeBase.js`
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` with argument arrays — no shell injection
- **File upload security** — extension allowlist + MIME prefix validation + 10 MB size limit via multer
- **Password hashing** — bcrypt with cost factor 10 used for all password storage
- **Session management** — 32-byte random session IDs via `crypto.randomBytes`, httpOnly cookies, 24h expiry
- **Rate limiting** — login endpoint protected with 20 attempts per 15-minute window
- **Audit trail** — comprehensive audit logging on all state-changing operations (with noted exceptions above)
- **Self-modification prevention** — admin cannot demote or deactivate their own account
- **Ownership-scoped deletion** — Standard_User can only delete resources they created
- **Compliance linkage protection** — deletion blocked when tickets are linked to active compliance reports
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension and `uploads/temp/` directory
- **Static file serving** — `dotfiles: 'deny'` and `index: false` prevent directory listing

---

## Scan Metadata

| Field | Value |
|---|---|
| Scan date | 2026-04-20 |
| Scan type | Full repository static analysis |
| Scope | `backend/`, `frontend/src/`, config files |
| Baseline | `docs/security-audit-2026-04-01.md` |
| Previous findings | 31 (6 Critical, 9 High, 10 Medium, 6 Low/Info) |
| Remediated | 20 fully fixed, 2 partially fixed |
| Still open (from baseline) | 13 |
| New findings | 9 |
| Total open | 22 (1 High, 11 Medium, 10 Low) |
| Methodology | Static analysis — code review of all route handlers, middleware, helpers, and frontend components |
