Compare commits
264 Commits
feature/wo
...
33e449f520
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e449f520
|
||
|
|
e2fae896dc
|
||
|
|
fd144966b7
|
||
|
|
392e4917b6
|
||
|
|
c19d549ae8
|
||
|
|
2edf6228ff
|
||
|
|
8f42f9d9c3
|
||
|
|
8788b1e91a
|
||
|
|
60bb86f2ea
|
||
|
|
19b5009010
|
||
|
|
de4ff3f084
|
||
|
|
c9f93a2a9b
|
||
|
|
76667f65c6
|
||
|
|
6b805ee633
|
||
|
|
704432788c
|
||
|
|
e86dd8be15
|
||
|
|
6148f06a95
|
||
|
|
758a300f67
|
||
|
|
dff1fa3cc9
|
||
|
|
940cb3251c
|
||
|
|
ae2b7e0433
|
||
|
|
e45deccdb7
|
||
|
|
f9770872ba
|
||
|
|
f9b96e9040
|
||
|
|
df31cc3c79
|
||
|
|
ddc3af9147
|
||
|
|
56bd5ca148
|
||
|
|
64d5e0cb40
|
||
|
|
0c99420f17
|
||
|
|
f00a1ce7bb
|
||
|
|
00bf92a2a1
|
||
|
|
520f50fbbf
|
||
|
|
da5505bd27
|
||
|
|
3814de5845
|
||
|
|
487489e26c
|
||
|
|
3643c123b4
|
||
|
|
be1d357692
|
||
|
|
492780fd90
|
||
|
|
4d255209fd
|
||
|
|
1fe6c1f84c
|
||
|
|
97e5d68d8e
|
||
|
|
b808d0e38e
|
||
|
|
a72300475b
|
||
|
|
7577ab1219
|
||
|
|
a2bc1ff564
|
||
|
|
682ee9417f
|
||
|
|
61d7e00d4f
|
||
|
|
ebaf4cd18c
|
||
|
|
55238ec71e
|
||
|
|
408aaa7012
|
||
|
|
1eb8eab76f
|
||
|
|
232eedce70
|
||
|
|
0ca2fe99e9
|
||
|
|
04360cc4bc
|
||
|
|
d61383ac7b
|
||
|
|
808625dab4
|
||
|
|
0fefd2a707
|
||
|
|
828e7cc45d
|
||
|
|
5126ccc6ae
|
||
|
|
870c0e247a
|
||
|
|
671894ff5f
|
||
|
|
0c6830fc6c
|
||
|
|
9eec63ea42
|
||
|
|
0d29a1b84e
|
||
|
|
4416f6a25d
|
||
|
|
97d378033b
|
||
|
|
537cf96a0a
|
||
|
|
f3d7f2ac1d
|
||
|
|
8c93e86fe0
|
||
|
|
d093a3d113
|
||
|
|
955036145d
|
||
|
|
7245352496
|
||
|
|
cda1eaadc9
|
||
|
|
3cf0d6be3d
|
||
|
|
cc652ba964
|
||
|
|
f76996a161
|
||
|
|
b870f47e67
|
||
|
|
890d7b82dc
|
||
|
|
1b0fc072cc
|
||
|
|
3f00f4c941
|
||
|
|
eef324936d
|
||
|
|
de2c5f245e
|
||
|
|
86fdd084ac
|
||
|
|
f657351219
|
||
|
|
3db84a377b
|
||
|
|
1b8790ff16
|
||
|
|
cf43e85c38
|
||
|
|
6163be626e
|
||
|
|
573903a885
|
||
|
|
77f113e9ae
|
||
|
|
8cd73c126e
|
||
|
|
e30ad79f2a
|
||
|
|
33927b150b
|
||
|
|
845d843e71
|
||
|
|
5cdca09f40
|
||
|
|
bd5fcccacf
|
||
|
|
df3173a720
|
||
|
|
9b8ae6cd79
|
||
|
|
2656df94d3
|
||
|
|
af951fdc12
|
||
|
|
7f7d3a2977
|
||
|
|
034d3963b9
|
||
|
|
c8b3626ac5
|
||
|
|
8e377bb85f
|
||
|
|
5a9df2103f | ||
|
|
bfa52c7f8f | ||
|
|
3202b0707c | ||
|
|
15abf8bae4 | ||
| 8df961cce8 | |||
|
|
7a179f19a1 | ||
|
|
4f960d0866 | ||
|
|
caa1d539cc | ||
|
|
b1069b1a05 | ||
|
|
1186f9f807 | ||
|
|
e13b18c169 | ||
|
|
05d47c91a8 | ||
|
|
b0c3daba01 | ||
|
|
675847de0c | ||
|
|
623b57ca06 | ||
|
|
06c6821d85 | ||
|
|
8da62f0f14 | ||
|
|
5a9dc007db | ||
|
|
3f9e1da2a3 | ||
|
|
7ea4ceb8df | ||
|
|
00a6f7ae0f | ||
|
|
69809955a9 | ||
|
|
6ee68f5521 | ||
|
|
5ffedad02f | ||
|
|
8bf8dc55dd | ||
|
|
53439b2af8 | ||
|
|
4c04c9870a | ||
|
|
e1b000870c | ||
|
|
f3ba322403 | ||
|
|
0bea387ac9 | ||
|
|
aa3ce3bae9 | ||
|
|
0cdaecf890 | ||
|
|
043c85cc69 | ||
|
|
6082721452 | ||
|
|
a214393723 | ||
|
|
f141fa58a1 | ||
|
|
e1b0236874 | ||
|
|
ed48522932 | ||
|
|
938dda400a | ||
|
|
732873dd6a | ||
|
|
0fe8e94d51 | ||
|
|
28bce28fc9 | ||
|
|
72fd79ea42 | ||
|
|
f63c286458 | ||
|
|
93c144576f | ||
|
|
fa3b045a2f | ||
|
|
4583d09750 | ||
|
|
75ac8c823a | ||
|
|
68e36b4bac | ||
|
|
d24b45b404 | ||
|
|
d64eb7eec4 | ||
|
|
6cb65fddc1 | ||
|
|
0ca83c6736 | ||
|
|
06268880da | ||
|
|
b4f0ddcb78 | ||
|
|
55e3e074a5 | ||
|
|
66bbeb84a5 | ||
|
|
4578f8cd85 | ||
|
|
5469a86e6e | ||
|
|
2b6db1f903 | ||
|
|
7c97bc3a84 | ||
|
|
835fbf26e7 | ||
|
|
c4aaeff2a1 | ||
|
|
df30430956 | ||
|
|
57f11c362b | ||
|
|
4df83d36dd | ||
|
|
0a7a7c2827 | ||
|
|
1963faf9b8 | ||
|
|
9b36a58959 | ||
|
|
690c30aac0 | ||
|
|
fc68097821 | ||
|
|
d9fdaf5cbb | ||
|
|
cb3da6980c | ||
|
|
ccc3576706 | ||
|
|
5405926550 | ||
|
|
328e48ea8c | ||
|
|
41f9c35586 | ||
|
|
729dada05c | ||
|
|
5d417edf82 | ||
|
|
03e60c9daf | ||
|
|
ee9403ab47 | ||
|
|
3d04cd393f | ||
|
|
382bc81a7e | ||
|
|
7302ece958 | ||
|
|
80d80c099f | ||
|
|
a2a43a8685 | ||
|
|
a711972054 | ||
|
|
8a6a3485e9 | ||
|
|
169a0d2337 | ||
|
|
c50fc5d8a8 | ||
|
|
e9e2c0961d | ||
|
|
d910af847e | ||
|
|
73fd747576 | ||
| 1ef57b0504 | |||
|
|
d1fe0bf455 | ||
|
|
3f7887eba6 | ||
|
|
9bd5a52661 | ||
|
|
2b4ec5d8e2 | ||
|
|
62592e9821 | ||
| 2fead2cfef | |||
| 7c0ba41514 | |||
| 9c6c03a518 | |||
| 0d48c109b3 | |||
| 18ad31228e | |||
| 3dcb91a1fc | |||
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 | |||
| b111273e5a | |||
| a7c74f625f | |||
| 8aef51b59a | |||
| d0087ba9b7 | |||
| 3d6062f3fa | |||
| 7af44608d0 | |||
| 3bb86e8369 | |||
| 4676279a72 | |||
| d3d86ddcf2 | |||
| 558c65807d | |||
| 518cb0a849 | |||
| b0adfa1bda | |||
| 7a2c56a11f | |||
| 89b1f57ef4 | |||
| 6bf6371e51 | |||
| 4d472b0aef | |||
| 887d11610e | |||
| 1520cc994b | |||
| 906066c7fa | |||
| b58bd0650a | |||
| ae04bc981e | |||
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a | |||
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 |
@@ -1,89 +0,0 @@
|
|||||||
# Backend Agent — CVE Dashboard
|
|
||||||
|
|
||||||
## Role
|
|
||||||
You are the backend specialist for the CVE Dashboard project. You manage the Express.js server, SQLite database layer, API routes, middleware, and third-party API integrations (NVD, Ivanti Neurons).
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
- **Runtime:** Node.js v18+
|
|
||||||
- **Framework:** Express.js 4.x
|
|
||||||
- **Database:** SQLite3 (file: `backend/cve_database.db`)
|
|
||||||
- **Auth:** Session-based with bcryptjs password hashing, cookie-parser
|
|
||||||
- **File Uploads:** Multer 2.0.2 with security hardening
|
|
||||||
- **Environment:** dotenv for config management
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `backend/server.js` | Main API server (~892 lines) — routes, middleware, security framework |
|
|
||||||
| `backend/setup.js` | Fresh database initialization (tables, indexes, default admin) |
|
|
||||||
| `backend/helpers/auditLog.js` | Fire-and-forget audit logging helper |
|
|
||||||
| `backend/middleware/auth.js` | `requireAuth(db)` and `requireRole()` middleware |
|
|
||||||
| `backend/routes/auth.js` | Login/logout/session endpoints |
|
|
||||||
| `backend/routes/users.js` | User CRUD (admin only) |
|
|
||||||
| `backend/routes/auditLog.js` | Audit log retrieval with filtering |
|
|
||||||
| `backend/routes/nvdLookup.js` | NVD API 2.0 proxy endpoint |
|
|
||||||
| `backend/.env.example` | Environment variable template |
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
- **cves**: `UNIQUE(cve_id, vendor)` — multi-vendor support
|
|
||||||
- **documents**: linked by `cve_id + vendor`, tracks file metadata
|
|
||||||
- **users**: username, email, password_hash, role (admin/editor/viewer), is_active
|
|
||||||
- **sessions**: session_id, user_id, expires_at (24hr)
|
|
||||||
- **required_documents**: vendor-specific mandatory doc types
|
|
||||||
- **audit_logs**: user_id, username, action, entity_type, entity_id, details, ip_address
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
- `POST /api/auth/login|logout`, `GET /api/auth/me` — Authentication
|
|
||||||
- `GET|POST|PUT|DELETE /api/cves` — CVE CRUD with role enforcement
|
|
||||||
- `GET /api/cves/check/:cveId` — Quick check (multi-vendor)
|
|
||||||
- `GET /api/cves/:cveId/vendors` — Vendors for a CVE
|
|
||||||
- `POST /api/cves/:cveId/documents` — Upload documents
|
|
||||||
- `DELETE /api/documents/:id` — Admin-only document deletion
|
|
||||||
- `GET /api/vendors` — Vendor list
|
|
||||||
- `GET /api/stats` — Dashboard statistics
|
|
||||||
- `GET /api/nvd/lookup/:cveId` — NVD proxy (10s timeout, severity cascade v3.1>v3.0>v2.0)
|
|
||||||
- `POST /api/cves/nvd-sync` — Bulk NVD update with audit logging
|
|
||||||
- `GET|POST /api/audit-logs` — Audit log (admin only)
|
|
||||||
- `GET|POST|PUT|DELETE /api/users` — User management (admin only)
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
```
|
|
||||||
PORT=3001
|
|
||||||
API_HOST=<server-ip>
|
|
||||||
CORS_ORIGINS=http://<server-ip>:3000
|
|
||||||
SESSION_SECRET=<secret>
|
|
||||||
NVD_API_KEY=<optional>
|
|
||||||
IVANTI_API_KEY=<future>
|
|
||||||
IVANTI_CLIENT_ID=<future>
|
|
||||||
IVANTI_BASE_URL=https://platform4.risksense.com/api/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
### Security (MANDATORY)
|
|
||||||
1. **Input validation first** — Validate all inputs before any DB operation. Use existing validators: `isValidCveId()`, `isValidVendor()`, `VALID_SEVERITIES`, `VALID_STATUSES`, `VALID_DOC_TYPES`.
|
|
||||||
2. **Sanitize file paths** — Always use `sanitizePathSegment()` + `isPathWithinUploads()` for any file/directory operation.
|
|
||||||
3. **Never leak internals** — 500 responses use generic `"Internal server error."` only. Log full error server-side.
|
|
||||||
4. **Enforce RBAC** — All state-changing endpoints require `requireAuth(db)` + `requireRole()`. Viewers are read-only.
|
|
||||||
5. **Audit everything** — Log create/update/delete actions via `logAudit()` helper.
|
|
||||||
6. **File upload restrictions** — Extension allowlist + MIME validation. No executables.
|
|
||||||
7. **Parameterized queries only** — Never interpolate user input into SQL strings.
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Follow existing patterns in `server.js` for new endpoints.
|
|
||||||
- New routes go in `backend/routes/` as separate files, mounted in `server.js`.
|
|
||||||
- Use async/await with try-catch. Wrap db calls in `db.get()`, `db.all()`, `db.run()`.
|
|
||||||
- Keep responses consistent: `{ success: true, data: ... }` or `{ error: "message" }`.
|
|
||||||
- Add JSDoc-style comments only for non-obvious logic.
|
|
||||||
|
|
||||||
### Database Changes
|
|
||||||
- Never modify tables directly in route code. Create migration scripts in `backend/` (pattern: `migrate_<feature>.js`).
|
|
||||||
- Always back up the DB before migrations.
|
|
||||||
- Add appropriate indexes for new query patterns.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- After making changes, verify the server starts cleanly: `node backend/server.js`.
|
|
||||||
- Test new endpoints with curl examples.
|
|
||||||
- Check that existing endpoints still work (no regressions).
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Frontend Agent — CVE Dashboard
|
|
||||||
|
|
||||||
## Role
|
|
||||||
You are the frontend specialist for the CVE Dashboard project. You build and maintain the React UI, handle client-side state, manage API communication, and implement user-facing features.
|
|
||||||
|
|
||||||
**IMPORTANT:** When creating new UI components or implementing frontend features, you should use the `frontend-design` skill to ensure production-grade, distinctive design quality. Invoke this skill using the Skill tool with `skill: "frontend-design"`.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
- **Framework:** React 18.2.4 (Create React App)
|
|
||||||
- **Styling:** Tailwind CSS (loaded via CDN in `public/index.html`)
|
|
||||||
- **Icons:** Lucide React
|
|
||||||
- **State:** React useState/useEffect + Context API (AuthContext)
|
|
||||||
- **API Communication:** Fetch API with credentials: 'include' for session cookies
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `frontend/src/App.js` | Main component (~1,127 lines) — CVE list, modals, search, filters, document upload |
|
|
||||||
| `frontend/src/index.js` | React entry point |
|
|
||||||
| `frontend/src/App.css` | Global styles |
|
|
||||||
| `frontend/src/components/LoginForm.js` | Login page |
|
|
||||||
| `frontend/src/components/UserMenu.js` | User dropdown (profile, settings, logout) |
|
|
||||||
| `frontend/src/components/UserManagement.js` | Admin user management interface |
|
|
||||||
| `frontend/src/components/AuditLog.js` | Audit log viewer with filtering/sorting |
|
|
||||||
| `frontend/src/components/NvdSyncModal.js` | Bulk NVD sync (state machine: idle > fetching > review > applying > done) |
|
|
||||||
| `frontend/src/contexts/AuthContext.js` | Auth state + `useAuth()` hook |
|
|
||||||
| `frontend/public/index.html` | HTML shell (includes Tailwind CDN script) |
|
|
||||||
| `frontend/.env.example` | Environment variable template |
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
```
|
|
||||||
REACT_APP_API_BASE=http://<server-ip>:3001/api
|
|
||||||
REACT_APP_API_HOST=http://<server-ip>:3001
|
|
||||||
```
|
|
||||||
**Critical:** React caches env vars at build time. After `.env` changes, the dev server must be fully restarted (not just refreshed).
|
|
||||||
|
|
||||||
### API Base URL
|
|
||||||
All fetch calls use `process.env.REACT_APP_API_BASE` as the base URL. Requests include `credentials: 'include'` for session cookie auth.
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. `LoginForm.js` posts credentials to `/api/auth/login`
|
|
||||||
2. Server returns session cookie (httpOnly, sameSite: lax)
|
|
||||||
3. `AuthContext.js` checks `/api/auth/me` on mount to restore sessions
|
|
||||||
4. `useAuth()` hook provides `user`, `login()`, `logout()`, `loading` throughout the app
|
|
||||||
5. Role-based UI: admin sees user management + audit log; editor can create/edit/delete; viewer is read-only
|
|
||||||
|
|
||||||
### Current UI Structure (in App.js)
|
|
||||||
- **Header**: App title, stats bar, Quick Check input, "Add CVE" button, "Sync with NVD" button (editor/admin), User Menu
|
|
||||||
- **Filters**: Search input, vendor dropdown, severity dropdown
|
|
||||||
- **CVE List**: Grouped by CVE ID, each group shows vendor rows with status badges, document counts, edit/delete buttons
|
|
||||||
- **Modals**: Add CVE (with NVD auto-fill), Edit CVE (with NVD update), Document Upload, NVD Sync
|
|
||||||
- **Admin Views**: User Management tab, Audit Log tab
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
### Component Patterns
|
|
||||||
- New UI features should be extracted into separate components under `frontend/src/components/`.
|
|
||||||
- Use functional components with hooks. No class components.
|
|
||||||
- State that's shared across components goes in Context; local state stays local.
|
|
||||||
- Destructure props. Use meaningful variable names.
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
- Use Tailwind CSS utility classes exclusively. No custom CSS unless absolutely necessary.
|
|
||||||
- Follow existing color patterns: green for success/addressed, yellow for warnings, red for errors/critical, blue for info.
|
|
||||||
- Responsive design: use Tailwind responsive prefixes (sm:, md:, lg:).
|
|
||||||
- Dark mode is not currently implemented — do not add it unless requested.
|
|
||||||
|
|
||||||
### API Communication
|
|
||||||
- Always use `fetch()` with `credentials: 'include'`.
|
|
||||||
- Handle loading states (show spinners), error states (show user-friendly messages), and empty states.
|
|
||||||
- On 401 responses, redirect to login (session expired).
|
|
||||||
- Pattern:
|
|
||||||
```js
|
|
||||||
const res = await fetch(`${process.env.REACT_APP_API_BASE}/endpoint`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!res.ok) { /* handle error */ }
|
|
||||||
const result = await res.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Role-Based UI
|
|
||||||
- Check `user.role` before rendering admin/editor controls.
|
|
||||||
- Viewers see data but no create/edit/delete buttons.
|
|
||||||
- Editors see create/edit/delete for CVEs and documents.
|
|
||||||
- Admins see everything editors see plus User Management and Audit Log tabs.
|
|
||||||
|
|
||||||
### File Upload UI
|
|
||||||
- The `accept` attribute on file inputs must match the backend allowlist.
|
|
||||||
- Current allowed: `.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.svg,.zip,.tar,.gz,.7z,.rar,.eml,.msg`
|
|
||||||
- Max file size: 10MB (enforced backend, show friendly message on 413).
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- No inline styles — use Tailwind classes.
|
|
||||||
- Extract repeated logic into custom hooks or utility functions.
|
|
||||||
- Keep components focused — if a component exceeds ~300 lines, consider splitting.
|
|
||||||
- Use `key` props correctly on lists (use unique IDs, not array indexes).
|
|
||||||
- Clean up useEffect subscriptions and timers.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- After making changes, verify the frontend compiles: `cd frontend && npm start` (or check for build errors).
|
|
||||||
- Test in browser: check console for errors, verify API calls succeed.
|
|
||||||
- Test role-based visibility with different user accounts.
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# Security Agent — CVE Dashboard
|
|
||||||
|
|
||||||
## Role
|
|
||||||
You are the security specialist for the CVE Dashboard project. You perform code reviews, dependency audits, and vulnerability assessments. You identify security issues and recommend fixes aligned with the project's existing security framework.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
### Application Profile
|
|
||||||
- **Type:** Internal vulnerability management tool (Charter Communications)
|
|
||||||
- **Users:** Security team members with assigned roles (admin/editor/viewer)
|
|
||||||
- **Data Sensitivity:** CVE remediation status, vendor documentation, user credentials
|
|
||||||
- **Exposure:** Internal network (home lab / corporate network), not internet-facing
|
|
||||||
|
|
||||||
### Tech Stack Security Surface
|
|
||||||
| Layer | Technology | Key Risks |
|
|
||||||
|-------|-----------|-----------|
|
|
||||||
| Frontend | React 18, Tailwind CDN | XSS, CSRF, sensitive data in client state |
|
|
||||||
| Backend | Express.js 4.x | Injection, auth bypass, path traversal, DoS |
|
|
||||||
| Database | SQLite3 | SQL injection, file access, no encryption at rest |
|
|
||||||
| Auth | bcryptjs + session cookies | Session fixation, brute force, weak passwords |
|
|
||||||
| File Upload | Multer | Unrestricted upload, path traversal, malicious files |
|
|
||||||
| External API | NVD API 2.0 | SSRF, response injection, rate limit abuse |
|
|
||||||
|
|
||||||
### Existing Security Controls
|
|
||||||
These are already implemented — verify they remain intact during reviews:
|
|
||||||
|
|
||||||
**Input Validation (backend/server.js)**
|
|
||||||
- CVE ID: `/^CVE-\d{4}-\d{4,}$/` via `isValidCveId()`
|
|
||||||
- Vendor: non-empty, max 200 chars via `isValidVendor()`
|
|
||||||
- Severity: enum `VALID_SEVERITIES` (Critical, High, Medium, Low)
|
|
||||||
- Status: enum `VALID_STATUSES` (Open, Addressed, In Progress, Resolved)
|
|
||||||
- Document type: enum `VALID_DOC_TYPES` (advisory, email, screenshot, patch, other)
|
|
||||||
- Description: max 10,000 chars
|
|
||||||
- Published date: `YYYY-MM-DD` format
|
|
||||||
|
|
||||||
**File Upload Security**
|
|
||||||
- Extension allowlist: `ALLOWED_EXTENSIONS` — documents only, all executables blocked
|
|
||||||
- MIME type validation: `ALLOWED_MIME_PREFIXES` — image/*, text/*, application/pdf, Office types
|
|
||||||
- Filename sanitization: strips `/`, `\`, `..`, null bytes
|
|
||||||
- File size limit: 10MB
|
|
||||||
|
|
||||||
**Path Traversal Prevention**
|
|
||||||
- `sanitizePathSegment(segment)` — strips dangerous characters from path components
|
|
||||||
- `isPathWithinUploads(targetPath)` — verifies resolved path stays within uploads root
|
|
||||||
|
|
||||||
**Authentication & Sessions**
|
|
||||||
- bcryptjs password hashing (default rounds)
|
|
||||||
- Session cookies: `httpOnly: true`, `sameSite: 'lax'`, `secure` in production
|
|
||||||
- 24-hour session expiry
|
|
||||||
- Role-based access control on all state-changing endpoints
|
|
||||||
|
|
||||||
**Security Headers**
|
|
||||||
- `X-Content-Type-Options: nosniff`
|
|
||||||
- `X-Frame-Options: DENY`
|
|
||||||
- `X-XSS-Protection: 1; mode=block`
|
|
||||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
|
||||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
|
||||||
|
|
||||||
**Error Handling**
|
|
||||||
- Generic 500 responses (no `err.message` to client)
|
|
||||||
- Full errors logged server-side
|
|
||||||
- Static file serving: `dotfiles: 'deny'`, `index: false`
|
|
||||||
- JSON body limit: 1MB
|
|
||||||
|
|
||||||
### Key Files to Review
|
|
||||||
| File | Security Relevance |
|
|
||||||
|------|-------------------|
|
|
||||||
| `backend/server.js` | Central security framework, all core routes, file handling |
|
|
||||||
| `backend/middleware/auth.js` | Authentication and authorization middleware |
|
|
||||||
| `backend/routes/auth.js` | Login/logout, session management |
|
|
||||||
| `backend/routes/users.js` | User CRUD, password handling |
|
|
||||||
| `backend/routes/nvdLookup.js` | External API proxy (SSRF risk) |
|
|
||||||
| `backend/routes/auditLog.js` | Audit log access control |
|
|
||||||
| `frontend/src/contexts/AuthContext.js` | Client-side auth state |
|
|
||||||
| `frontend/src/App.js` | Client-side input handling, API calls |
|
|
||||||
| `frontend/src/components/LoginForm.js` | Credential handling |
|
|
||||||
| `.gitignore` | Verify secrets are excluded |
|
|
||||||
|
|
||||||
## Review Checklists
|
|
||||||
|
|
||||||
### Code Review (run on all PRs/changes)
|
|
||||||
1. **Injection** — Are all database queries parameterized? No string interpolation in SQL.
|
|
||||||
2. **Authentication** — Do new state-changing endpoints use `requireAuth(db)` + `requireRole()`?
|
|
||||||
3. **Authorization** — Is role checking correct? (admin-only vs editor+ vs all authenticated)
|
|
||||||
4. **Input Validation** — Are all user inputs validated before use? New fields need validators.
|
|
||||||
5. **File Operations** — Do file/directory operations use `sanitizePathSegment()` + `isPathWithinUploads()`?
|
|
||||||
6. **Error Handling** — Do 500 responses avoid leaking `err.message`? Are errors logged server-side?
|
|
||||||
7. **Audit Logging** — Are create/update/delete actions logged via `logAudit()`?
|
|
||||||
8. **CORS** — Is `CORS_ORIGINS` still restrictive? No wildcards in production.
|
|
||||||
9. **Dependencies** — Any new packages? Check for known vulnerabilities.
|
|
||||||
10. **Secrets** — No hardcoded credentials, API keys, or secrets in code. All in `.env`.
|
|
||||||
|
|
||||||
### Dependency Audit
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd backend && npm audit
|
|
||||||
# Frontend
|
|
||||||
cd frontend && npm audit
|
|
||||||
```
|
|
||||||
- Flag any `high` or `critical` severity findings.
|
|
||||||
- Check for outdated packages with known CVEs: `npm outdated`.
|
|
||||||
- Review new dependencies: check npm page, weekly downloads, last publish date, maintainer reputation.
|
|
||||||
|
|
||||||
### OWASP Top 10 Mapping
|
|
||||||
| OWASP Category | Status | Notes |
|
|
||||||
|---------------|--------|-------|
|
|
||||||
| A01 Broken Access Control | Mitigated | RBAC + session auth on all endpoints |
|
|
||||||
| A02 Cryptographic Failures | Partial | bcrypt for passwords; no encryption at rest for DB/files |
|
|
||||||
| A03 Injection | Mitigated | Parameterized queries, input validation |
|
|
||||||
| A04 Insecure Design | Acceptable | Internal tool with limited user base |
|
|
||||||
| A05 Security Misconfiguration | Mitigated | Security headers, CORS config, dotfiles denied |
|
|
||||||
| A06 Vulnerable Components | Monitor | Run `npm audit` regularly |
|
|
||||||
| A07 Auth Failures | Mitigated | Session-based auth, bcrypt, httpOnly cookies |
|
|
||||||
| A08 Data Integrity Failures | Partial | File type validation; no code signing |
|
|
||||||
| A09 Logging & Monitoring | Mitigated | Audit logging on all mutations |
|
|
||||||
| A10 SSRF | Partial | NVD proxy validates CVE ID format; review for Ivanti integration |
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
When reporting findings, use this structure:
|
|
||||||
```
|
|
||||||
### [SEVERITY] Finding Title
|
|
||||||
- **Location:** file:line_number
|
|
||||||
- **Issue:** Description of the vulnerability
|
|
||||||
- **Impact:** What an attacker could achieve
|
|
||||||
- **Recommendation:** Specific fix with code example
|
|
||||||
- **OWASP:** Category reference
|
|
||||||
```
|
|
||||||
|
|
||||||
Severity levels: CRITICAL, HIGH, MEDIUM, LOW, INFO
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
1. Never suggest disabling security controls for convenience.
|
|
||||||
2. Recommendations must be compatible with the existing security framework — extend it, don't replace it.
|
|
||||||
3. Flag any regression in existing security controls immediately.
|
|
||||||
4. For dependency issues, provide the specific CVE and affected version range.
|
|
||||||
5. Consider the threat model — this is an internal tool, not internet-facing. Prioritize accordingly.
|
|
||||||
6. When reviewing file upload changes, always verify both frontend `accept` attribute and backend allowlist stay in sync.
|
|
||||||
7. Do not recommend changes that would break existing functionality without a migration path.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Project Instructions
|
|
||||||
|
|
||||||
## Token Usage & Efficiency
|
|
||||||
Follow the guidelines in `.claude/optimization.md` for:
|
|
||||||
- When to use subagents vs main conversation
|
|
||||||
- Model selection (Haiku vs Sonnet)
|
|
||||||
- Token preservation strategies
|
|
||||||
- Rate limiting rules
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
This is a CVE (Common Vulnerabilities and Exposures) dashboard application for tracking security vulnerabilities, vendors, and JIRA tickets.
|
|
||||||
|
|
||||||
## Security Focus
|
|
||||||
All code changes should consider:
|
|
||||||
- Input validation
|
|
||||||
- SQL injection prevention
|
|
||||||
- XSS protection
|
|
||||||
- Authentication/authorization
|
|
||||||
|
|
||||||
## Frontend Development
|
|
||||||
When working on frontend features or UI components:
|
|
||||||
- Use the `frontend-design` skill for new component creation and UI implementation
|
|
||||||
- This skill provides production-grade design quality and avoids generic AI aesthetics
|
|
||||||
- Invoke it using: `Skill` tool with `skill: "frontend-design"`
|
|
||||||
- The skill will guide implementation with distinctive, polished code patterns
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
OPTIMIZATION.md - Token Usage & Subagent Strategy
|
|
||||||
|
|
||||||
## SUBAGENT USAGE STRATEGY
|
|
||||||
|
|
||||||
Subagents run in separate contexts and preserve main conversation tokens.
|
|
||||||
|
|
||||||
### When to Use Subagents
|
|
||||||
|
|
||||||
**Use Subagents for:**
|
|
||||||
- Large-scale codebase exploration and analysis
|
|
||||||
- Complex multi-step investigations across many files
|
|
||||||
- Detailed code pattern searches and refactoring analysis
|
|
||||||
- Gathering comprehensive information before main conversation work
|
|
||||||
- When total tokens would exceed 30,000 in main conversation
|
|
||||||
|
|
||||||
**Keep in Main Conversation:**
|
|
||||||
- Direct file edits (1-3 files)
|
|
||||||
- Simple code changes and debugging
|
|
||||||
- Architecture decisions
|
|
||||||
- Security reviews and approvals
|
|
||||||
- User-facing responses and recommendations
|
|
||||||
- Questions requiring reasoning about codebase
|
|
||||||
- Frontend UI work (use `frontend-design` skill for new components)
|
|
||||||
|
|
||||||
### Subagent Types & When to Use
|
|
||||||
|
|
||||||
**Explore Agent** (Haiku 3.5)
|
|
||||||
- Codebase exploration and file discovery
|
|
||||||
- Pattern searching across large codebases
|
|
||||||
- Gathering information about file structure
|
|
||||||
- Finding references and relationships
|
|
||||||
|
|
||||||
**General-Purpose Agent** (Haiku 3.5)
|
|
||||||
- Multi-step code analysis tasks
|
|
||||||
- Summarizing findings from exploration
|
|
||||||
- Complex searches requiring multiple strategies
|
|
||||||
- Collecting data for main conversation decisions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MODEL SELECTION STRATEGY
|
|
||||||
|
|
||||||
### Main Conversation (Sonnet 4.5)
|
|
||||||
- **Always use Sonnet 4.5 in main conversation**
|
|
||||||
- Direct file edits and modifications
|
|
||||||
- Architecture and design decisions
|
|
||||||
- Security analysis and approvals
|
|
||||||
- Complex reasoning and recommendations
|
|
||||||
- Final user responses
|
|
||||||
|
|
||||||
### Subagent Models
|
|
||||||
|
|
||||||
**Haiku 4.5** (Default for subagents)
|
|
||||||
- Code exploration and pattern searching
|
|
||||||
- File discovery and structure analysis
|
|
||||||
- Simple codebase investigations
|
|
||||||
- Gathering information and summarizing
|
|
||||||
- Task: Use Haiku first for subagent work
|
|
||||||
|
|
||||||
**Sonnet 4.5** (For subagents - when needed)
|
|
||||||
- Security-critical analysis within subagents
|
|
||||||
- Complex architectural decisions needed in exploration
|
|
||||||
- High-risk code analysis
|
|
||||||
- When exploration requires advanced reasoning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RATE LIMITING GUIDANCE
|
|
||||||
|
|
||||||
### API Call Throttling
|
|
||||||
- 5 seconds minimum between API calls
|
|
||||||
- 10 seconds minimum between web searches
|
|
||||||
- Batch similar work whenever possible
|
|
||||||
- If you hit 429 error: STOP and wait 5 minutes
|
|
||||||
|
|
||||||
### Budget Management
|
|
||||||
- Track tokens used across all agents
|
|
||||||
- Main conversation should stay under 100,000 tokens
|
|
||||||
- Subagent work can extend to 50,000 tokens per agent
|
|
||||||
- Batch multiple subagent tasks together when possible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TOKEN PRESERVATION RULES
|
|
||||||
|
|
||||||
### Best Practices for Long-Running Conversations
|
|
||||||
|
|
||||||
**In Main Conversation:**
|
|
||||||
1. Start with subagent for exploration (saves ~20,000 tokens)
|
|
||||||
2. Request subagent summarize findings
|
|
||||||
3. Use summary to inform main conversation edits/decisions
|
|
||||||
4. Keep main conversation focused on decisions and actions
|
|
||||||
|
|
||||||
**Information Gathering:**
|
|
||||||
- Use subagents to explore before asking for analysis in main conversation
|
|
||||||
- Have subagent provide condensed summaries (250-500 words max)
|
|
||||||
- Main conversation uses summary + provides feedback/decisions
|
|
||||||
|
|
||||||
**File Editing:**
|
|
||||||
- For <3 files: Keep in main conversation
|
|
||||||
- For 3+ files: Split between subagent (finding/analysis) and main (approval/execution)
|
|
||||||
- Simple edits (1-5 lines per file): Main conversation
|
|
||||||
- Complex refactoring (10+ lines per file): Subagent analysis + main approval
|
|
||||||
|
|
||||||
**Code Review Workflow:**
|
|
||||||
1. Subagent explores and analyzes code patterns
|
|
||||||
2. Subagent flags issues and suggests improvements
|
|
||||||
3. Main conversation reviews suggestions
|
|
||||||
4. Main conversation executes approved changes
|
|
||||||
|
|
||||||
### Token Budget Allocation Example
|
|
||||||
- Main conversation: 0-100,000 tokens (soft limit)
|
|
||||||
- Per subagent task: 0-50,000 tokens
|
|
||||||
- Critical work (security): Use Sonnet in main conversation
|
|
||||||
- Exploratory work: Use Explore agent (Haiku) in subagent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DECISION TREE
|
|
||||||
|
|
||||||
```
|
|
||||||
Is this a direct file edit request?
|
|
||||||
├─ YES (1-3 files, <10 lines each) → Main conversation
|
|
||||||
├─ NO
|
|
||||||
└─ Is this exploratory analysis?
|
|
||||||
├─ YES (finding files, patterns) → Use Explore agent (Haiku)
|
|
||||||
├─ NO
|
|
||||||
└─ Is this complex multi-step work?
|
|
||||||
├─ YES (3+ steps, many files) → Use General agent (Haiku)
|
|
||||||
├─ NO
|
|
||||||
└─ Is this security-critical?
|
|
||||||
├─ YES → Main conversation (Sonnet)
|
|
||||||
└─ NO → Subagent (Haiku) or Main conversation
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SUMMARY
|
|
||||||
|
|
||||||
**Main Conversation (You):** Architecture, decisions, edits, reviews
|
|
||||||
**Subagents:** Exploration, analysis, information gathering
|
|
||||||
**Sonnet 4.5:** Security, complexity, final decisions
|
|
||||||
**Haiku 4.5:** Exploration, gathering, analysis support
|
|
||||||
BIN
.compliance-staging/.gitkeep
Normal file
BIN
.compliance-staging/.gitkeep
Normal file
Binary file not shown.
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
# Node modules
|
# Node modules
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
backend/cve_database.db
|
backend/cve_database.db
|
||||||
@@ -37,10 +36,38 @@ frontend.pid
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
backend/uploads/temp/
|
backend/uploads/temp/
|
||||||
claude.md
|
|
||||||
claude_status.md
|
|
||||||
feature_request*.md
|
feature_request*.md
|
||||||
|
|
||||||
|
# AI tooling config
|
||||||
|
.claude/
|
||||||
|
ai_notes.md
|
||||||
|
ai_status.md
|
||||||
backend/add_vendor_to_documents.js
|
backend/add_vendor_to_documents.js
|
||||||
backend/fix_multivendor_constraint.js
|
backend/fix_multivendor_constraint.js
|
||||||
backend/server.js-backup
|
backend/server.js-backup
|
||||||
backend/setup.js-backup
|
backend/setup.js-backup
|
||||||
|
|
||||||
|
# Compliance staging — keep folder, ignore contents
|
||||||
|
.compliance-staging/*
|
||||||
|
!.compliance-staging/.gitkeep
|
||||||
|
|
||||||
|
# Kiro agents (local only)
|
||||||
|
.kiro/
|
||||||
|
|
||||||
|
# Zip files
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# Production DB copies
|
||||||
|
cve_database_prod.db
|
||||||
|
cve_database.db.prod
|
||||||
|
cve_database.db.backup
|
||||||
|
database.db
|
||||||
|
|
||||||
|
# Operations — local admin records, UAT logs, firewall requests, data exports
|
||||||
|
docs/operations/
|
||||||
|
|
||||||
|
# Data exports — local spreadsheets
|
||||||
|
docs/data-exports/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
|||||||
320
.gitlab-ci.yml
Normal file
320
.gitlab-ci.yml
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Pipeline stages:
|
||||||
|
# 1. install — install dependencies for backend and frontend
|
||||||
|
# 2. lint — run linters / static checks
|
||||||
|
# 3. test — run backend (Jest) and frontend (react-scripts) tests
|
||||||
|
# 4. build — produce the production frontend bundle
|
||||||
|
# 5. deploy — deploy to staging (local) or production (SSH to 71.85.90.6)
|
||||||
|
# 6. verify — post-deploy health checks
|
||||||
|
#
|
||||||
|
# Environments:
|
||||||
|
# staging — dashboard-dev:3100 (auto-deploy on main/master)
|
||||||
|
# production — 71.85.90.6:3001 (manual trigger, requires staging verification)
|
||||||
|
#
|
||||||
|
# Executor: shell (runs on dashboard-dev using system Node.js)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Variables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
variables:
|
||||||
|
PROD_HOST: "71.85.90.6"
|
||||||
|
PROD_USER: "root"
|
||||||
|
PROD_DIR: "/home/cve-dashboard"
|
||||||
|
STAGING_DIR: "/home/cve-dashboard-staging"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global cache — persists node_modules between pipeline runs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
- frontend/node_modules/
|
||||||
|
policy: pull
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
stages:
|
||||||
|
- install
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
- verify
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 1: Install dependencies
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
install-backend:
|
||||||
|
stage: install
|
||||||
|
script:
|
||||||
|
- npm ci --prefer-offline
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
policy: pull-push
|
||||||
|
|
||||||
|
install-frontend:
|
||||||
|
stage: install
|
||||||
|
script:
|
||||||
|
- cd frontend && npm ci --prefer-offline
|
||||||
|
cache:
|
||||||
|
key: ${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- frontend/node_modules/
|
||||||
|
policy: pull-push
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 2: Lint / static analysis
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
stage: lint
|
||||||
|
script:
|
||||||
|
- cd frontend && npm ci --prefer-offline && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 10
|
||||||
|
needs:
|
||||||
|
- install-frontend
|
||||||
|
|
||||||
|
lint-backend:
|
||||||
|
stage: lint
|
||||||
|
script:
|
||||||
|
- npm ci --prefer-offline
|
||||||
|
- node -c backend/server.js
|
||||||
|
- node -c backend/routes/*.js
|
||||||
|
- node -c backend/helpers/*.js
|
||||||
|
- node -c backend/middleware/*.js
|
||||||
|
needs:
|
||||||
|
- install-backend
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 3: Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- npm ci --prefer-offline
|
||||||
|
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||||
|
timeout: 5 minutes
|
||||||
|
needs:
|
||||||
|
- install-backend
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- npm ci --prefer-offline
|
||||||
|
- cd frontend && npm ci --prefer-offline && CI=true npx react-scripts test --watchAll=false --ci
|
||||||
|
timeout: 5 minutes
|
||||||
|
needs:
|
||||||
|
- install-frontend
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 4: Build
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- cd frontend && npm ci --prefer-offline && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- frontend/build/
|
||||||
|
expire_in: 7 days
|
||||||
|
needs:
|
||||||
|
- test-frontend
|
||||||
|
- lint-frontend
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 5: Deploy
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Staging — auto-deploys on main/master to dashboard-dev:3100
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
deploy-staging:
|
||||||
|
stage: deploy
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
|
when: on_success
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
url: http://localhost:3100
|
||||||
|
script:
|
||||||
|
- echo "Deploying to staging (dashboard-dev:3100)..."
|
||||||
|
# Ensure staging directory exists
|
||||||
|
- mkdir -p ${STAGING_DIR}
|
||||||
|
# Sync code (exclude .git, node_modules, uploads, logs)
|
||||||
|
- rsync -a --delete
|
||||||
|
--exclude='.git'
|
||||||
|
--exclude='node_modules'
|
||||||
|
--exclude='frontend/node_modules'
|
||||||
|
--exclude='frontend/build'
|
||||||
|
--exclude='backend/uploads'
|
||||||
|
--exclude='*.log'
|
||||||
|
--exclude='*.db'
|
||||||
|
--exclude='.env'
|
||||||
|
${CI_PROJECT_DIR}/ ${STAGING_DIR}/
|
||||||
|
# Copy built frontend
|
||||||
|
- cp -r ${CI_PROJECT_DIR}/frontend/build ${STAGING_DIR}/frontend/build
|
||||||
|
# Install deps in staging
|
||||||
|
- cd ${STAGING_DIR} && npm ci --prefer-offline
|
||||||
|
- cd ${STAGING_DIR}/frontend && npm ci --prefer-offline
|
||||||
|
# Ensure staging .env exists
|
||||||
|
- |
|
||||||
|
if [ ! -f "${STAGING_DIR}/backend/.env" ]; then
|
||||||
|
cp ${CI_PROJECT_DIR}/backend/.env ${STAGING_DIR}/backend/.env
|
||||||
|
sed -i 's/^PORT=.*/PORT=3100/' ${STAGING_DIR}/backend/.env
|
||||||
|
grep -q "^PORT=" ${STAGING_DIR}/backend/.env || echo "PORT=3100" >> ${STAGING_DIR}/backend/.env
|
||||||
|
fi
|
||||||
|
# Run migrations
|
||||||
|
- cd ${STAGING_DIR}/backend && node migrations/run-all.js
|
||||||
|
# Restart staging service
|
||||||
|
- sudo systemctl restart cve-backend-staging || sudo systemctl start cve-backend-staging || true
|
||||||
|
- echo "Staging deploy complete."
|
||||||
|
after_script:
|
||||||
|
- |
|
||||||
|
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||||
|
for ISSUE in $ISSUES; do
|
||||||
|
curl --silent --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: ${GITLAB_PAT}" \
|
||||||
|
"${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \
|
||||||
|
--data-urlencode "body=✅ Deployed to **staging** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
needs:
|
||||||
|
- build-frontend
|
||||||
|
- test-backend
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production — manual trigger, SSH to 71.85.90.6
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
deploy-production:
|
||||||
|
stage: deploy
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
|
when: manual
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: http://71.85.90.6:3001
|
||||||
|
script:
|
||||||
|
- echo "Deploying to production (${PROD_HOST})..."
|
||||||
|
# Record current commit on prod for rollback
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit
|
||||||
|
- echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
|
||||||
|
# Sync code to production (exclude local-only files)
|
||||||
|
- rsync -az --delete
|
||||||
|
--exclude='.git'
|
||||||
|
--exclude='node_modules'
|
||||||
|
--exclude='frontend/node_modules'
|
||||||
|
--exclude='frontend/build'
|
||||||
|
--exclude='backend/uploads'
|
||||||
|
--exclude='*.log'
|
||||||
|
--exclude='*.db'
|
||||||
|
--exclude='.env'
|
||||||
|
--exclude='.compliance-staging'
|
||||||
|
${CI_PROJECT_DIR}/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
||||||
|
# Copy built frontend
|
||||||
|
- rsync -az ${CI_PROJECT_DIR}/frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
||||||
|
# Install deps on production
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline"
|
||||||
|
# Run migrations
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js"
|
||||||
|
# Restart services — install systemd unit if not present
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service" || scp ${CI_PROJECT_DIR}/deploy/cve-backend-production.service ${PROD_USER}@${PROD_HOST}:/etc/systemd/system/cve-backend.service
|
||||||
|
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
||||||
|
- echo "Production deploy complete."
|
||||||
|
after_script:
|
||||||
|
- |
|
||||||
|
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||||
|
for ISSUE in $ISSUES; do
|
||||||
|
curl --silent --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: ${GITLAB_PAT}" \
|
||||||
|
"${CI_SERVER_URL}/api/v4/projects/${CI_PROJECT_ID}/issues/${ISSUE}/notes" \
|
||||||
|
--data-urlencode "body=🚀 Deployed to **production** in pipeline [#${CI_PIPELINE_ID}](${CI_PIPELINE_URL}) (commit \`${CI_COMMIT_SHORT_SHA}\`)" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
needs:
|
||||||
|
- build-frontend
|
||||||
|
- test-backend
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STAGE 6: Post-deploy verification
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Staging health check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
verify-staging:
|
||||||
|
stage: verify
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
|
when: on_success
|
||||||
|
script:
|
||||||
|
- echo "Verifying staging..."
|
||||||
|
- sleep 3
|
||||||
|
- |
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3100/api/health 2>/dev/null || echo "000")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Staging health check passed (attempt $i)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Staging not ready (status: $STATUS), retrying... (attempt $i/5)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ "$STATUS" != "200" ]; then
|
||||||
|
echo "FAILED: Staging health check failed after 5 attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- echo "Staging verification passed."
|
||||||
|
needs:
|
||||||
|
- deploy-staging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production health check — rolls back on failure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
verify-production:
|
||||||
|
stage: verify
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
|
when: on_success
|
||||||
|
script:
|
||||||
|
- echo "Verifying production..."
|
||||||
|
- sleep 3
|
||||||
|
- |
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${PROD_HOST}:3001/api/health 2>/dev/null || echo "000")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Production health check passed (attempt $i)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Production not ready (status: $STATUS), retrying... (attempt $i/10)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ "$STATUS" != "200" ]; then
|
||||||
|
echo "FAILED: Production health check failed — initiating rollback"
|
||||||
|
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
||||||
|
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
||||||
|
echo "Rolling back to $PREV_COMMIT..."
|
||||||
|
# Re-sync the previous version
|
||||||
|
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git checkout ${PREV_COMMIT} --force 2>/dev/null" || true
|
||||||
|
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||||
|
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
||||||
|
echo "Rollback complete. Verify manually."
|
||||||
|
else
|
||||||
|
echo "No previous commit recorded — manual intervention required."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- echo "Production verification passed."
|
||||||
|
needs:
|
||||||
|
- deploy-production
|
||||||
|
allow_failure: false
|
||||||
14
.kiro/hooks/migration-registration-check.kiro.hook
Normal file
14
.kiro/hooks/migration-registration-check.kiro.hook
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Migration Registration Check",
|
||||||
|
"description": "After any write to backend/migrations/, verify the file is registered in POSTGRES_MIGRATIONS array in run-all.js. Blocks until confirmed.",
|
||||||
|
"version": "2",
|
||||||
|
"when": {
|
||||||
|
"type": "postToolUse",
|
||||||
|
"toolTypes": ["write"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "STOP. If the tool just wrote or created a file matching backend/migrations/*.js (but NOT run-all.js itself), you MUST immediately:\n1. Read backend/migrations/run-all.js\n2. Check if the migration filename exists in the POSTGRES_MIGRATIONS array\n3. If NOT present, add it to the end of the array RIGHT NOW before doing anything else\n4. Do NOT proceed with other work until this is done\n\nThis is a hard requirement — migrations not in run-all.js will not run in CI/CD and will break production deploys. If the written file is not a migration file, ignore this message."
|
||||||
|
}
|
||||||
|
}
|
||||||
88
.kiro/steering/tech.md
Normal file
88
.kiro/steering/tech.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Tech Stack & Build System
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Backend | Node.js 18+, Express 5 |
|
||||||
|
| Database | PostgreSQL (via `pg` pool in `backend/db.js`) |
|
||||||
|
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
||||||
|
| File uploads | Multer 2 (10MB limit) |
|
||||||
|
| Frontend | React 19 (Create React App / react-scripts 5) |
|
||||||
|
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
||||||
|
| UI Icons | lucide-react |
|
||||||
|
| Charts | recharts |
|
||||||
|
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
||||||
|
| Markdown rendering | react-markdown |
|
||||||
|
| Diagrams | mermaid |
|
||||||
|
|
||||||
|
## Architecture: Single-Port Serving
|
||||||
|
|
||||||
|
Express on port 3001 serves **both** the API and the production frontend build:
|
||||||
|
- API routes: `/api/*` — handled by Express route handlers
|
||||||
|
- Frontend: everything else — served as static files from `frontend/build/`
|
||||||
|
|
||||||
|
There is no separate frontend server in production. The React dev server (`npm start` on port 3000) is only for local development with hot-reload. In production and on the dev server, you must run `npm run build` in `frontend/` after any frontend code change, then restart the backend.
|
||||||
|
|
||||||
|
**After editing frontend source files:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build # Compile new bundle into frontend/build/
|
||||||
|
# Then restart backend (or it will serve the new static files on next request)
|
||||||
|
```
|
||||||
|
|
||||||
|
The CI/CD pipeline handles this automatically — `build-frontend` stage runs before deploy.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
node setup.js # Initialize DB, tables, indexes, default admin user
|
||||||
|
node server.js # Start backend on port 3001 (serves API + frontend build)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm run build # Production build → frontend/build/ (REQUIRED after code changes)
|
||||||
|
npm start # Dev server on port 3000 (local dev only, NOT used in production)
|
||||||
|
npm test # Run tests (react-scripts test)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Both servers (from project root)
|
||||||
|
```bash
|
||||||
|
./start-servers.sh # Start backend + frontend in background
|
||||||
|
./stop-servers.sh # Stop all servers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations (run from `backend/`)
|
||||||
|
```bash
|
||||||
|
node migrations/run-all.js # Runs all migrations in order (idempotent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Scripts (from `backend/scripts/`)
|
||||||
|
```bash
|
||||||
|
# Compliance xlsx parsing (called automatically by upload flow)
|
||||||
|
python3 parse_compliance_xlsx.py <file>
|
||||||
|
|
||||||
|
# Bulk notes import
|
||||||
|
python3 import_notes_from_csv.py input.csv --dry-run
|
||||||
|
python3 import_notes_from_csv.py input.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv).
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
||||||
|
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
||||||
|
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
||||||
|
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
||||||
|
|
||||||
|
## Ports
|
||||||
|
|
||||||
|
| Environment | URL | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Production / Dev server | http://IP:3001 | Express serves API + static frontend build |
|
||||||
|
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
||||||
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the STEAM Security Dashboard are documented in this file.
|
||||||
|
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-05-19
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **PostgreSQL migration** — database engine switched from SQLite to PostgreSQL. Requires running `deploy-postgres.sh`, data migration, and `DATABASE_URL` env var. SQLite is no longer supported.
|
||||||
|
- **Multi-BU tenancy** — data is now scoped per business unit with per-user team assignments. Replaces the previous binary scope toggle.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **In-app notification system** — replaces Webex bot integration with native notifications
|
||||||
|
- **Screenshot uploads** in feedback modal, Webex bot DM on issue close
|
||||||
|
- **CCP Metrics page** — multi-vertical VCL upload and cross-org compliance reporting
|
||||||
|
- **VCL compliance reporting** — exec report page, device metadata fields, bulk upload
|
||||||
|
- **Aggregated burndown forecast** on CCP Metrics overview page
|
||||||
|
- **Sub-team drill-down** — metric sub-team intermediate view with per-team breakdowns
|
||||||
|
- **Metric breakdown panel** — Non-Compliant stat clickable, reveals metric breakdown buttons, compact grid with top 8 and show-all toggle
|
||||||
|
- **Remediation plan and resolution date history tracking**
|
||||||
|
- **Data management panel** — delete vertical, rollback upload, and reset all
|
||||||
|
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
||||||
|
- **Re-queue findings** from rejected FP submissions
|
||||||
|
- **FP submissions cleanup** — auto-clear approved, dismiss rejected, collapsible section
|
||||||
|
- **DECOM workflow type** — auto-note/hide on decom, show CVEs on CARD queue items
|
||||||
|
- **Interactive configuration wizard** for deployment setup
|
||||||
|
- **Unified setup script** (`configure.js`) merging deploy + config wizard
|
||||||
|
- **Per-BU trend lines** in Ivanti counts history chart
|
||||||
|
- **Multi-select BU picker** replacing binary scope toggle
|
||||||
|
- **Configurable IVANTI_MANAGED_BUS** env var for multi-tenant drift classification
|
||||||
|
- **Pipeline-to-issue traceability** via `after_script` comments in CI/CD
|
||||||
|
- **CI/CD pipeline** with feedback modal, Atlas `qualys_id` fallback, and health endpoint
|
||||||
|
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
||||||
|
- **Systemd service scripts** for start/stop management
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix duplicate failing metrics on same asset across compliance endpoints
|
||||||
|
- Fix duplicate chart entries on compliance page when multiple verticals share a report_date
|
||||||
|
- Fix requeue inserting Postgres array literal instead of JSON into `cves_json`
|
||||||
|
- Fix todo queue crash on malformed `cves_json` data
|
||||||
|
- Fix AEO compliance page not showing metric health cards on dev
|
||||||
|
- Fix double-counting in VCL multi-vertical stats — use only `ALL:` rollup rows
|
||||||
|
- Fix compliance stats to use Summary sheet data instead of item counts
|
||||||
|
- Fix route mount order: `vcl-multi` must precede general compliance router
|
||||||
|
- Fix requeue: fallback to `finding_ids_json` when queue items are deleted or absent
|
||||||
|
- Sync FP submission `lifecycle_status` from Ivanti `currentState` on fetch
|
||||||
|
- Fix History tab crash: coerce Ivanti note fields to strings before rendering
|
||||||
|
- Fix archive bar chart: `fmtDate` now handles ISO datetime strings from PostgreSQL date columns
|
||||||
|
- Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click, BU scope filtering
|
||||||
|
- Fix BU drift checker: derive `EXPECTED_BUS` from `IVANTI_BU_FILTER` env var
|
||||||
|
- Fix null `bu_teams` in postgres migration, add retry logic to deploy script
|
||||||
|
- Fix missing `created_by` column in `archer_tickets` table
|
||||||
|
- Fix FP workflow counts donut scoped by BU
|
||||||
|
- Fix `dotenv` loading in `db.js` so `DATABASE_URL` is available on import
|
||||||
|
- Fix property test CI failure: mock db module before importing route
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Track `package-lock.json` files for deterministic CI installs
|
||||||
|
- Remove unused icon imports and unused imports to satisfy ESLint thresholds
|
||||||
|
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
||||||
|
- Auto-run migrations in pipeline
|
||||||
|
- Documentation updates for PostgreSQL migration, systemd scripts, and reference manual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-05-01
|
||||||
|
|
||||||
|
Initial release of the STEAM Security Dashboard.
|
||||||
|
|
||||||
566
README.md
566
README.md
@@ -1,515 +1,141 @@
|
|||||||
# CVE Dashboard
|
# STEAM Security Dashboard v1.0.0
|
||||||
|
|
||||||
A self-hosted vulnerability management dashboard for tracking CVE remediation status, maintaining vendor documentation, and managing risk acceptance workflows.
|
A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. Centralises CVE tracking, Ivanti host finding triage, AEO compliance posture, FP/Archer/CARD exception workflows, and internal documentation in a single interface.
|
||||||
|
|
||||||
---
|
## Quick Start
|
||||||
|
|
||||||
## Table of Contents
|
### Prerequisites
|
||||||
|
|
||||||
- [Overview](#overview)
|
- Node.js 18+
|
||||||
- [Tech Stack](#tech-stack)
|
- Docker (for PostgreSQL 16 container)
|
||||||
- [Prerequisites](#prerequisites)
|
- Python 3 with `python3-pandas` and `python3-openpyxl` (for compliance xlsx parsing)
|
||||||
- [Installation](#installation)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Running the Application](#running-the-application)
|
|
||||||
- [Features](#features)
|
|
||||||
- [API Reference](#api-reference)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Database Schema](#database-schema)
|
|
||||||
- [Security Model](#security-model)
|
|
||||||
- [Migrations](#migrations)
|
|
||||||
|
|
||||||
---
|
### Install
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CVE Dashboard answers a common problem in vulnerability management: before requesting false positive designations, you need to know whether a CVE has already been addressed, and whether the supporting vendor documentation exists. This application provides:
|
|
||||||
|
|
||||||
- A searchable, filterable CVE list with per-vendor tracking
|
|
||||||
- Document storage attached to each CVE/vendor pair (advisories, emails, screenshots, patches)
|
|
||||||
- NVD API integration to auto-populate CVE metadata
|
|
||||||
- Archer risk acceptance ticket tracking (EXC numbers)
|
|
||||||
- Weekly vulnerability report upload and processing
|
|
||||||
- A knowledge base for internal documentation and policies
|
|
||||||
- Role-based access control with a full audit trail
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|
||||||
| Backend | Node.js, Express 5 |
|
|
||||||
| Database | SQLite3 |
|
|
||||||
| File uploads | Multer 2 |
|
|
||||||
| Auth | bcryptjs, cookie-based sessions |
|
|
||||||
| Frontend | React 19, lucide-react, react-markdown |
|
|
||||||
| Report processing | Python 3 (pandas, openpyxl) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18 or later
|
|
||||||
- npm
|
|
||||||
- Python 3 with pip (required only for weekly report processing)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Clone the repository
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url>
|
git clone <repo-url>
|
||||||
cd cve-dashboard
|
cd cve-dashboard
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install backend dependencies
|
# Backend dependencies
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Frontend dependencies
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
|
# Python dependencies (Ubuntu/Debian)
|
||||||
|
apt install -y python3-pandas python3-openpyxl
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `package.json` lists the backend dependencies. Install them from the `backend/` directory where `server.js` lives.
|
### Configure
|
||||||
|
|
||||||
### 3. Install frontend dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cp backend/.env.example backend/.env
|
||||||
npm install
|
# Edit backend/.env — at minimum set SESSION_SECRET and DATABASE_URL:
|
||||||
|
# openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Install Python dependencies (for weekly report upload feature)
|
See `backend/.env.example` for all available options including `DATABASE_URL`, Ivanti API, Jira, and Atlas integration keys.
|
||||||
|
|
||||||
|
### Start PostgreSQL
|
||||||
|
|
||||||
|
The deploy script handles the full Postgres setup — container, schema, dependencies, and data migration from SQLite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend/scripts
|
chmod +x scripts/deploy-postgres.sh
|
||||||
pip install -r requirements.txt
|
./scripts/deploy-postgres.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Required packages: `pandas>=2.0.0`, `openpyxl>=3.0.0`
|
For fresh installs without an existing SQLite database, the script creates the schema and skips migration.
|
||||||
|
|
||||||
### 5. Initialize the database
|
### Build and Run
|
||||||
|
|
||||||
Run this once from the `backend/` directory to create the SQLite database, all tables, indexes, the uploads directory, and a default admin user:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
# Build frontend
|
||||||
node setup.js
|
cd frontend && npm run build && cd ..
|
||||||
|
|
||||||
|
# Start servers
|
||||||
|
./start-servers.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `backend/cve_database.db` and a default admin account:
|
Dashboard: http://localhost:3000 · API: http://localhost:3001
|
||||||
- Username: `admin`
|
|
||||||
- Password: `admin123`
|
|
||||||
|
|
||||||
**Change the admin password immediately after first login.**
|
The helper scripts use `systemctl` under the hood — the systemd units in `systemd/` must be installed first. See the full manual for setup instructions.
|
||||||
|
|
||||||
### 6. Run database migrations
|
|
||||||
|
|
||||||
After the initial setup, apply the feature migrations in order:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node migrations/add_weekly_reports_table.js
|
|
||||||
node migrations/add_knowledge_base_table.js
|
|
||||||
node migrations/add_archer_tickets_table.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The application is configured via `.env` files. These files are gitignored and must be created manually per environment.
|
|
||||||
|
|
||||||
### Backend: `backend/.env`
|
|
||||||
|
|
||||||
```
|
|
||||||
PORT=3001
|
|
||||||
API_HOST=localhost
|
|
||||||
CORS_ORIGINS=http://YOUR_IP:3000
|
|
||||||
SESSION_SECRET=change-this-to-a-random-secret
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Optional: NVD API key for higher rate limits
|
|
||||||
# Register at https://nvd.nist.gov/developers/request-an-api-key
|
|
||||||
NVD_API_KEY=your-key-here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend: `frontend/.env`
|
|
||||||
|
|
||||||
```
|
|
||||||
REACT_APP_API_BASE=http://YOUR_IP:3001/api
|
|
||||||
REACT_APP_API_HOST=http://YOUR_IP:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `YOUR_IP` with the machine's IP address or `localhost` for local development.
|
|
||||||
|
|
||||||
**Important:** React caches environment variables at build/start time. After changing `frontend/.env`, you must fully restart the frontend process. A page refresh alone is not sufficient.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
### Using the helper scripts (recommended)
|
|
||||||
|
|
||||||
From the project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./start-servers.sh # Starts backend and frontend in the background
|
|
||||||
./stop-servers.sh # Stops all servers
|
|
||||||
```
|
|
||||||
|
|
||||||
The start script saves PIDs to `backend.pid` and `frontend.pid`. Logs are written to `backend/backend.log` and `frontend/frontend.log`.
|
|
||||||
|
|
||||||
### Running manually
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1 - backend
|
|
||||||
cd backend
|
|
||||||
node server.js
|
|
||||||
|
|
||||||
# Terminal 2 - frontend
|
|
||||||
cd frontend
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Default ports
|
|
||||||
|
|
||||||
- Frontend: http://localhost:3000
|
|
||||||
- Backend API: http://localhost:3001
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Authentication and User Roles
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **CVE Management** | Track CVEs across multiple vendors with document storage and NVD auto-fill |
|
||||||
|
| **Reporting** | Ivanti host finding triage with donut charts, inline editing, advanced filtering, CSV/XLSX export |
|
||||||
|
| **Ivanti Queue** | Personal staging list for batch FP, Archer, CARD, and Granite workflows |
|
||||||
|
| **FP Workflow** | Submit false positive workflows directly to Ivanti API with attachments |
|
||||||
|
| **Compliance** | Weekly AEO xlsx upload with diff preview, drift detection, per-team metric health cards |
|
||||||
|
| **Archive Tracking** | Automatic detection of disappeared/returned findings with BU reassignment classification |
|
||||||
|
| **Findings Trend** | Historical open vs closed chart with archive activity sparkline and shift reason tooltips |
|
||||||
|
| **Jira Integration** | Create, sync, and track Jira Data Center tickets linked to CVE/vendor pairs |
|
||||||
|
| **Archer Tickets** | Track risk acceptance exceptions (EXC numbers) linked to findings |
|
||||||
|
| **CARD API** | Granite/CARD asset lookup integration for network device workflows |
|
||||||
|
| **Knowledge Base** | Internal document library with inline PDF/Markdown viewing |
|
||||||
|
| **Access Control** | Four user groups (Admin, Standard_User, Leadership, Read_Only) with full audit trail |
|
||||||
|
|
||||||
All routes require authentication. Three roles are supported:
|
## Project Structure
|
||||||
|
|
||||||
| Role | Permissions |
|
|
||||||
|---|---|
|
|
||||||
| `viewer` | Read-only access to CVEs, documents, weekly reports, knowledge base, Archer tickets |
|
|
||||||
| `editor` | All viewer permissions plus: create/update CVEs, upload documents, upload weekly reports, manage knowledge base articles, manage Archer tickets |
|
|
||||||
| `admin` | All editor permissions plus: delete documents, delete weekly reports, manage users, view audit logs |
|
|
||||||
|
|
||||||
Sessions expire after 24 hours. Session tokens are stored in `httpOnly` cookies.
|
|
||||||
|
|
||||||
### CVE Management
|
|
||||||
|
|
||||||
- Add CVEs with full metadata: CVE ID, vendor, severity (Critical/High/Medium/Low), description, published date, and status (Open/In Progress/Addressed/Resolved)
|
|
||||||
- The same CVE ID can be tracked across multiple vendors independently
|
|
||||||
- Filter the CVE list by search term, vendor, severity, and status
|
|
||||||
- Edit any field on an existing CVE entry; file paths are updated automatically when CVE ID or vendor changes
|
|
||||||
- Delete a single vendor entry or all vendor entries for a CVE ID
|
|
||||||
- Paginated list view to prevent performance issues with large datasets
|
|
||||||
- Quick Check: look up a CVE ID and see all vendors tracking it with their current status
|
|
||||||
|
|
||||||
### NVD Integration
|
|
||||||
|
|
||||||
- Auto-fill CVE description, severity, and published date from the NIST NVD API 2.0 when adding a new CVE
|
|
||||||
- Bulk NVD Sync: fetch updated metadata for all CVEs in the database in one operation (editor/admin)
|
|
||||||
- CVSS severity mapping cascades: v3.1 preferred, then v3.0, then v2.0
|
|
||||||
- NVD API key support via `NVD_API_KEY` environment variable for higher rate limits
|
|
||||||
|
|
||||||
### Document Management
|
|
||||||
|
|
||||||
Documents are attached to a CVE/vendor pair and stored on disk under `backend/uploads/<CVE-ID>/<vendor>/`.
|
|
||||||
|
|
||||||
Supported document types: `advisory`, `email`, `screenshot`, `patch`, `other`
|
|
||||||
|
|
||||||
Allowed file extensions: PDF, images (PNG, JPG, GIF, BMP, TIFF), Office documents (DOC, DOCX, XLS, XLSX, PPT, PPTX), text files (TXT, MD, CSV, LOG), email files (MSG, EML), and others (RTF, HTML, XML, JSON, YAML, ODF variants).
|
|
||||||
|
|
||||||
File size limit: 10 MB per upload.
|
|
||||||
|
|
||||||
### Weekly Reports
|
|
||||||
|
|
||||||
Editors and admins can upload weekly vulnerability reports as `.xlsx` files. The report is processed by a Python script (`backend/scripts/split_cve_report.py`) that:
|
|
||||||
|
|
||||||
1. Reads the `Vulnerabilities` sheet
|
|
||||||
2. Splits rows where multiple CVE IDs are comma-separated in the `CVE ID` column into individual rows
|
|
||||||
3. Saves the processed file alongside the original
|
|
||||||
|
|
||||||
Both the original and processed files can be downloaded from the weekly reports list. Only the most recently uploaded report is marked as current. Admins can delete old report records and their associated files.
|
|
||||||
|
|
||||||
### Archer Risk Acceptance Tickets
|
|
||||||
|
|
||||||
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
|
||||||
|
|
||||||
- EXC number format: `EXC-NNNNN`
|
|
||||||
- Statuses: `Draft`, `Open`, `Under Review`, `Accepted`
|
|
||||||
- Optional Archer URL field for deep-linking to the Archer record
|
|
||||||
- Filter tickets by CVE ID, vendor, or status
|
|
||||||
- EXC numbers are unique across the system
|
|
||||||
|
|
||||||
### Knowledge Base
|
|
||||||
|
|
||||||
A document library for internal reference material such as policies, runbooks, and vendor advisories.
|
|
||||||
|
|
||||||
- Upload documents with a title, optional description, and category
|
|
||||||
- View documents inline in the browser (PDFs render in an iframe; markdown files are rendered as HTML)
|
|
||||||
- Download any document
|
|
||||||
- Filter and browse by category
|
|
||||||
- Editors and admins can upload and delete; all authenticated users can view
|
|
||||||
|
|
||||||
Allowed file types: PDF, Markdown, TXT, Office documents, HTML, JSON, YAML, and images.
|
|
||||||
|
|
||||||
### User Management (Admin)
|
|
||||||
|
|
||||||
Admins can create, update, and delete user accounts from the UI. Supported operations:
|
|
||||||
|
|
||||||
- Create users with a role assignment
|
|
||||||
- Change username, email, password, role, or active status
|
|
||||||
- Deactivating a user immediately invalidates all their active sessions
|
|
||||||
- Admins cannot demote themselves or deactivate their own account
|
|
||||||
|
|
||||||
### Audit Log (Admin)
|
|
||||||
|
|
||||||
Every state-changing action is recorded with the user identity, IP address, action type, target entity, and a before/after details payload. Admins can view the audit log with filtering by user, action type, entity type, and date range. Results are paginated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` and `/api/auth/logout` require a valid session cookie.
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/auth/login` | Public | Log in, receive session cookie |
|
|
||||||
| POST | `/api/auth/logout` | Public | Invalidate session |
|
|
||||||
| GET | `/api/auth/me` | Session | Get current user info |
|
|
||||||
| POST | `/api/auth/cleanup-sessions` | Session | Delete expired sessions |
|
|
||||||
|
|
||||||
### CVEs
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/cves` | viewer+ | List CVEs with optional filters: `search`, `vendor`, `severity`, `status` |
|
|
||||||
| POST | `/api/cves` | editor+ | Create a new CVE entry |
|
|
||||||
| PUT | `/api/cves/:id` | editor+ | Update a CVE entry by row ID |
|
|
||||||
| PATCH | `/api/cves/:cveId/status` | editor+ | Update status for all vendor rows matching a CVE ID |
|
|
||||||
| DELETE | `/api/cves/:id` | editor+ | Delete a single CVE vendor entry |
|
|
||||||
| DELETE | `/api/cves/by-cve-id/:cveId` | editor+ | Delete all vendor entries for a CVE ID |
|
|
||||||
| GET | `/api/cves/check/:cveId` | viewer+ | Quick check: does this CVE exist and what is its status? |
|
|
||||||
| GET | `/api/cves/distinct-ids` | viewer+ | List all distinct CVE IDs (used by NVD sync) |
|
|
||||||
| GET | `/api/cves/:cveId/vendors` | viewer+ | List all vendor entries for a specific CVE ID |
|
|
||||||
|
|
||||||
### Documents
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/cves/:cveId/documents` | viewer+ | List documents for a CVE, optionally filtered by `?vendor=` |
|
|
||||||
| POST | `/api/cves/:cveId/documents` | editor+ | Upload a document for a CVE/vendor pair |
|
|
||||||
| DELETE | `/api/documents/:id` | admin | Delete a document and its file from disk |
|
|
||||||
|
|
||||||
### NVD
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/nvd/lookup/:cveId` | viewer+ | Look up a single CVE in the NVD API |
|
|
||||||
| POST | `/api/cves/nvd-sync` | editor+ | Bulk update CVE metadata from NVD |
|
|
||||||
|
|
||||||
### Weekly Reports
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/weekly-reports/upload` | editor+ | Upload and process a `.xlsx` vulnerability report |
|
|
||||||
| GET | `/api/weekly-reports` | viewer+ | List all uploaded reports |
|
|
||||||
| GET | `/api/weekly-reports/:id/download/:type` | viewer+ | Download `original` or `processed` file |
|
|
||||||
| DELETE | `/api/weekly-reports/:id` | admin | Delete a report record and its files |
|
|
||||||
|
|
||||||
### Knowledge Base
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/knowledge-base/upload` | editor+ | Upload a new knowledge base document |
|
|
||||||
| GET | `/api/knowledge-base` | viewer+ | List all articles |
|
|
||||||
| GET | `/api/knowledge-base/:id` | viewer+ | Get article metadata |
|
|
||||||
| GET | `/api/knowledge-base/:id/content` | viewer+ | Get file content for inline display |
|
|
||||||
| GET | `/api/knowledge-base/:id/download` | viewer+ | Download the file |
|
|
||||||
| DELETE | `/api/knowledge-base/:id` | editor+ | Delete article and file |
|
|
||||||
|
|
||||||
### Archer Tickets
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/archer-tickets` | viewer+ | List tickets, optional filters: `cve_id`, `vendor`, `status` |
|
|
||||||
| POST | `/api/archer-tickets` | editor+ | Create a new Archer ticket |
|
|
||||||
| PUT | `/api/archer-tickets/:id` | editor+ | Update an Archer ticket |
|
|
||||||
| DELETE | `/api/archer-tickets/:id` | editor+ | Delete an Archer ticket |
|
|
||||||
|
|
||||||
### Users (Admin only)
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/users` | admin | List all users |
|
|
||||||
| GET | `/api/users/:id` | admin | Get a single user |
|
|
||||||
| POST | `/api/users` | admin | Create a user |
|
|
||||||
| PATCH | `/api/users/:id` | admin | Update a user |
|
|
||||||
| DELETE | `/api/users/:id` | admin | Delete a user |
|
|
||||||
|
|
||||||
### Audit Logs (Admin only)
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/audit-logs` | admin | Paginated audit log with filters |
|
|
||||||
| GET | `/api/audit-logs/actions` | admin | List distinct action types |
|
|
||||||
|
|
||||||
### Utility
|
|
||||||
|
|
||||||
| Method | Path | Role | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/vendors` | viewer+ | List all distinct vendor names |
|
|
||||||
| GET | `/api/stats` | viewer+ | Dashboard statistics (total CVEs, critical count, addressed count, document count) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
```
|
||||||
cve-dashboard/
|
cve-dashboard/
|
||||||
├── start-servers.sh # Start backend + frontend in background
|
|
||||||
├── stop-servers.sh # Stop all servers
|
|
||||||
│
|
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express app, CVE/document endpoints, middleware
|
│ ├── server.js # Express API server
|
||||||
│ ├── setup.js # One-time DB initialization and default admin creation
|
│ ├── db.js # PostgreSQL connection pool (pg)
|
||||||
│ ├── cve_database.db # SQLite database (gitignored)
|
│ ├── db-schema.sql # Complete DDL for fresh Postgres setup
|
||||||
│ ├── uploads/ # File storage (gitignored)
|
│ ├── setup-postgres.js # Schema initializer (runs db-schema.sql)
|
||||||
│ │ ├── <CVE-ID>/
|
│ ├── routes/ # API route handlers
|
||||||
│ │ │ └── <vendor>/ # CVE documents stored here
|
│ ├── helpers/ # API clients (Ivanti, Jira, Atlas, CARD)
|
||||||
│ │ ├── weekly_reports/ # Uploaded vulnerability reports
|
│ ├── middleware/ # Auth middleware
|
||||||
│ │ ├── knowledge_base/ # Knowledge base documents
|
│ ├── migrations/ # Schema migrations (legacy SQLite deployments)
|
||||||
│ │ └── temp/ # Temporary upload staging directory
|
│ └── scripts/ # Compliance parser, data import utilities
|
||||||
│ ├── routes/
|
├── frontend/
|
||||||
│ │ ├── auth.js # Login, logout, session check
|
│ ├── src/
|
||||||
│ │ ├── users.js # User CRUD (admin)
|
│ │ ├── App.js # Main app with routing
|
||||||
│ │ ├── auditLog.js # Audit log viewer (admin)
|
│ │ ├── components/ # React components
|
||||||
│ │ ├── nvdLookup.js # NVD API proxy
|
│ │ └── contexts/ # Auth context
|
||||||
│ │ ├── weeklyReports.js # Weekly report upload and management
|
│ └── public/
|
||||||
│ │ ├── knowledgeBase.js # Knowledge base document management
|
├── docs/
|
||||||
│ │ └── archerTickets.js # Archer EXC ticket CRUD
|
│ ├── api/ # API specs (Ivanti, Atlas, Jira)
|
||||||
│ ├── middleware/
|
│ ├── design/ # Design system, workflow diagrams
|
||||||
│ │ └── auth.js # requireAuth and requireRole middleware
|
│ ├── guides/ # User guides, full reference manual
|
||||||
│ ├── helpers/
|
│ ├── security/ # Security audits and remediation plans
|
||||||
│ │ ├── auditLog.js # logAudit helper
|
│ ├── testing/ # Test plans and scripts
|
||||||
│ │ └── excelProcessor.js # Calls Python script for report processing
|
│ └── troubleshooting/ # Investigation scripts and reports
|
||||||
│ ├── migrations/
|
├── docker-compose.yml # PostgreSQL 16 container definition
|
||||||
│ │ ├── add_weekly_reports_table.js
|
├── scripts/
|
||||||
│ │ ├── add_knowledge_base_table.js
|
│ └── deploy-postgres.sh # One-time deployment: container, schema, migration
|
||||||
│ │ └── add_archer_tickets_table.js
|
├── systemd/ # systemd service files
|
||||||
│ └── scripts/
|
├── start-servers.sh
|
||||||
│ ├── split_cve_report.py # Python: splits multi-CVE rows in Excel reports
|
└── stop-servers.sh
|
||||||
│ └── requirements.txt # pandas, openpyxl
|
|
||||||
│
|
|
||||||
└── frontend/
|
|
||||||
└── src/
|
|
||||||
├── App.js # Main application, CVE list, filters, modals
|
|
||||||
├── App.css # Global styles
|
|
||||||
├── contexts/
|
|
||||||
│ └── AuthContext.js # Auth state provider
|
|
||||||
└── components/
|
|
||||||
├── LoginForm.js # Login page
|
|
||||||
├── UserMenu.js # User dropdown in header
|
|
||||||
├── UserManagement.js # Admin user management panel
|
|
||||||
├── AuditLog.js # Admin audit log viewer
|
|
||||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
|
||||||
├── WeeklyReportModal.js # Weekly report upload dialog
|
|
||||||
├── KnowledgeBaseModal.js # Knowledge base upload/list
|
|
||||||
└── KnowledgeBaseViewer.js # Inline document viewer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Tech Stack
|
||||||
|
|
||||||
## Database Schema
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Backend | Node.js 18+, Express 5 |
|
||||||
|
| Database | PostgreSQL 16 (Docker, port 5433) |
|
||||||
|
| Frontend | React 19, Recharts, Lucide React |
|
||||||
|
| Auth | bcryptjs, cookie-based sessions, express-rate-limit |
|
||||||
|
| Compliance | Python 3, pandas, openpyxl |
|
||||||
|
|
||||||
### Core tables
|
## Documentation
|
||||||
|
|
||||||
**`cves`** - One row per CVE/vendor pair. `UNIQUE(cve_id, vendor)`.
|
- **[Full Reference Manual](docs/guides/full-reference-manual.md)** — comprehensive feature documentation, API reference, database schema, security model, and configuration details
|
||||||
|
- **[Postgres Migration Plan](docs/guides/postgres-migration-plan.md)** — architecture decisions, schema design, and cutover procedure for the SQLite to PostgreSQL migration
|
||||||
|
- **[Migration Guide](backend/migrations/README.md)** — schema migration scripts for upgrading existing deployments
|
||||||
|
- **[Design System](docs/design/design-system.md)** — UI component patterns and color system
|
||||||
|
- **[Ivanti API Reference](docs/api/ivanti-api-reference.md)** — Ivanti/RiskSense API integration details
|
||||||
|
- **[Jira API Use Cases](docs/api/jira-api-use-cases.md)** — Jira Data Center API compliance summary
|
||||||
|
|
||||||
**`documents`** - Files attached to a CVE/vendor pair. Foreign key to `cves(cve_id)`.
|
## License
|
||||||
|
|
||||||
**`required_documents`** - Vendor-specific document requirements (advisory, screenshot, etc.).
|
Internal use only — Charter Communications / NTS-AEO.
|
||||||
|
|
||||||
**`users`** - Accounts with roles: `admin`, `editor`, `viewer`.
|
|
||||||
|
|
||||||
**`sessions`** - Active sessions. Expire after 24 hours.
|
|
||||||
|
|
||||||
**`audit_logs`** - Append-only log of all state-changing actions.
|
|
||||||
|
|
||||||
### Feature tables (added by migrations)
|
|
||||||
|
|
||||||
**`weekly_reports`** - Metadata for uploaded vulnerability reports. Tracks original and processed file paths, row counts, uploader, and a `is_current` flag.
|
|
||||||
|
|
||||||
**`knowledge_base`** - Document library entries with title, slug, category, description, and file metadata.
|
|
||||||
|
|
||||||
**`archer_tickets`** - Archer EXC exception tickets linked to CVE/vendor pairs. `UNIQUE(exc_number)`.
|
|
||||||
|
|
||||||
### View
|
|
||||||
|
|
||||||
**`cve_document_status`** - Aggregates document counts per CVE/vendor and derives a `compliance_status` (`Complete` when an advisory is present, otherwise `Missing Required Docs`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Model
|
*Designed and built by Jordan Ramos (jordan.ramos@spectrum.com)*
|
||||||
|
|
||||||
### File upload security
|
|
||||||
|
|
||||||
- Extension allowlist enforced by Multer; executables (`.exe`, `.js`, `.sh`, `.py`, `.bat`, etc.) are blocked
|
|
||||||
- MIME type prefix validation in addition to extension checking
|
|
||||||
- 10 MB per-file size limit
|
|
||||||
- Filenames are sanitized: path separators, `..` sequences, null bytes, and non-alphanumeric characters are removed
|
|
||||||
|
|
||||||
### Path traversal prevention
|
|
||||||
|
|
||||||
- `sanitizePathSegment()` strips `/`, `\`, `..`, and null bytes from any value used in `path.join()`
|
|
||||||
- `isPathWithinUploads()` verifies resolved paths stay within the uploads root before any file operation
|
|
||||||
|
|
||||||
### Input validation
|
|
||||||
|
|
||||||
- CVE ID must match `/^CVE-\d{4}-\d{4,}$/`
|
|
||||||
- Severity must be one of: `Critical`, `High`, `Medium`, `Low`
|
|
||||||
- Status must be one of: `Open`, `Addressed`, `In Progress`, `Resolved`
|
|
||||||
- Archer EXC numbers must match `/^EXC-\d+$/`
|
|
||||||
- All database operations use prepared statements
|
|
||||||
|
|
||||||
### Error handling
|
|
||||||
|
|
||||||
- 500 responses never leak internal error messages to the client
|
|
||||||
- Full errors are logged server-side only
|
|
||||||
- Descriptive 400/409 responses are safe because they contain only validation messages written by the application
|
|
||||||
|
|
||||||
### Security headers
|
|
||||||
|
|
||||||
Applied to all responses:
|
|
||||||
|
|
||||||
- `X-Content-Type-Options: nosniff`
|
|
||||||
- `X-Frame-Options: SAMEORIGIN`
|
|
||||||
- `X-XSS-Protection: 1; mode=block`
|
|
||||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
|
||||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
|
||||||
|
|
||||||
### Session cookies
|
|
||||||
|
|
||||||
`httpOnly: true`, `sameSite: lax`, `secure: true` in production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
Migrations are standalone Node.js scripts that alter the database directly. Run them in the order listed. They use `CREATE TABLE IF NOT EXISTS`, so they are safe to run again if needed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node migrations/add_weekly_reports_table.js
|
|
||||||
node migrations/add_knowledge_base_table.js
|
|
||||||
node migrations/add_archer_tickets_table.js
|
|
||||||
```
|
|
||||||
|
|
||||||
For an existing deployment upgrading from an earlier schema, also check the legacy migration scripts in `backend/`:
|
|
||||||
|
|
||||||
- `migrate_multivendor.js` - Adds multi-vendor support to an older single-vendor schema
|
|
||||||
- `migrate-audit-log.js` - Adds the audit_logs table to pre-auth deployments
|
|
||||||
- `migrate-to-1.1.js` - General 1.0 to 1.1 schema update
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
# Weekly Vulnerability Report Upload Feature
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
|
|
||||||
- Created `weekly_reports` table to store report metadata
|
|
||||||
- Tracks upload date, file paths, row counts, and which report is current
|
|
||||||
- Indexed for fast queries
|
|
||||||
|
|
||||||
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
|
|
||||||
- Executes Python script via Node.js child_process
|
|
||||||
- Parses row counts from Python output
|
|
||||||
- Handles errors, timeouts (30 seconds), and validation
|
|
||||||
|
|
||||||
3. **API Routes** (`backend/routes/weeklyReports.js`)
|
|
||||||
- `POST /api/weekly-reports/upload` - Upload and process Excel file
|
|
||||||
- `GET /api/weekly-reports` - List all reports
|
|
||||||
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
|
|
||||||
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
|
|
||||||
|
|
||||||
4. **Python Script** (`backend/scripts/split_cve_report.py`)
|
|
||||||
- Moved from ~/Documents to backend/scripts
|
|
||||||
- Splits comma-separated CVE IDs into separate rows
|
|
||||||
- Duplicates device/IP data for each CVE
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
|
|
||||||
- Phase-based UI: idle → uploading → processing → success
|
|
||||||
- File upload with .xlsx validation
|
|
||||||
- Display existing reports with current report indicator (★)
|
|
||||||
- Download buttons for both original and processed files
|
|
||||||
|
|
||||||
2. **App.js Integration**
|
|
||||||
- Added "Weekly Report" button next to NVD Sync button
|
|
||||||
- State management for modal visibility
|
|
||||||
- Modal rendering
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
### Starting the Application
|
|
||||||
|
|
||||||
1. **Backend:**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
node server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Frontend:**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the Feature
|
|
||||||
|
|
||||||
1. **Access the Feature**
|
|
||||||
- Login as an editor or admin user
|
|
||||||
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
|
|
||||||
|
|
||||||
2. **Upload a Report**
|
|
||||||
- Click the "Weekly Report" button
|
|
||||||
- Click "Choose File" and select your .xlsx file
|
|
||||||
- Click "Upload & Process"
|
|
||||||
- Wait for processing to complete (usually 5-10 seconds)
|
|
||||||
|
|
||||||
3. **Download Processed Report**
|
|
||||||
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
|
|
||||||
- Click "Download Processed" to get the split version
|
|
||||||
- The current week's report is marked with a ★ star icon
|
|
||||||
|
|
||||||
4. **Access Previous Reports**
|
|
||||||
- All previous reports are listed below the upload section
|
|
||||||
- Click the download icons to get original or processed versions
|
|
||||||
- Reports are labeled as "This week's report", "Last week's report", or by date
|
|
||||||
|
|
||||||
### What the Processing Does
|
|
||||||
|
|
||||||
**Before Processing:**
|
|
||||||
| HOSTNAME | IP | CVE ID |
|
|
||||||
|----------|------------|---------------------------|
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
|
|
||||||
|
|
||||||
**After Processing:**
|
|
||||||
| HOSTNAME | IP | CVE ID |
|
|
||||||
|----------|------------|---------------------------|
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-1234 |
|
|
||||||
| server01 | 10.0.0.1 | CVE-2024-5678 |
|
|
||||||
|
|
||||||
Each CVE now has its own row, making it easy to:
|
|
||||||
- Sort by CVE ID
|
|
||||||
- Filter for specific CVEs
|
|
||||||
- Research CVEs one by one per device
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
### New Files Created
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
scripts/
|
|
||||||
split_cve_report.py # Python script for CVE splitting
|
|
||||||
requirements.txt # Python dependencies
|
|
||||||
routes/
|
|
||||||
weeklyReports.js # API endpoints
|
|
||||||
helpers/
|
|
||||||
excelProcessor.js # Python integration
|
|
||||||
migrations/
|
|
||||||
add_weekly_reports_table.js # Database migration
|
|
||||||
uploads/
|
|
||||||
weekly_reports/ # Uploaded and processed files
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
src/
|
|
||||||
components/
|
|
||||||
WeeklyReportModal.js # Upload modal UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
server.js # Added route mounting
|
|
||||||
|
|
||||||
frontend/
|
|
||||||
src/
|
|
||||||
App.js # Added button and modal
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security & Permissions
|
|
||||||
|
|
||||||
- **Upload**: Requires editor or admin role
|
|
||||||
- **Download**: Any authenticated user
|
|
||||||
- **Delete**: Admin only
|
|
||||||
- **File Validation**: Only .xlsx files accepted, 10MB limit
|
|
||||||
- **Audit Logging**: All uploads, downloads, and deletions are logged
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend Issues
|
|
||||||
|
|
||||||
**Python not found:**
|
|
||||||
```bash
|
|
||||||
# Install Python 3
|
|
||||||
sudo apt-get install python3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Missing dependencies:**
|
|
||||||
```bash
|
|
||||||
# Install pandas and openpyxl
|
|
||||||
pip3 install pandas openpyxl
|
|
||||||
```
|
|
||||||
|
|
||||||
**Port already in use:**
|
|
||||||
```bash
|
|
||||||
# Find and kill process using port 3001
|
|
||||||
lsof -i :3001
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Issues
|
|
||||||
|
|
||||||
**Button not visible:**
|
|
||||||
- Make sure you're logged in as editor or admin
|
|
||||||
- Viewer role cannot upload reports
|
|
||||||
|
|
||||||
**Upload fails:**
|
|
||||||
- Check file is .xlsx format (not .xls or .csv)
|
|
||||||
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
|
|
||||||
- Check file size is under 10MB
|
|
||||||
|
|
||||||
**Processing timeout:**
|
|
||||||
- Large files (10,000+ rows) may timeout
|
|
||||||
- Try reducing file size or increase timeout in `excelProcessor.js`
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [x] Backend starts without errors
|
|
||||||
- [x] Frontend compiles successfully
|
|
||||||
- [x] Database migration completed
|
|
||||||
- [x] Python dependencies installed
|
|
||||||
- [ ] Upload .xlsx file (manual test in browser)
|
|
||||||
- [ ] Verify processed file has split CVEs (manual test)
|
|
||||||
- [ ] Download original and processed files (manual test)
|
|
||||||
- [ ] Verify current report marked with star (manual test)
|
|
||||||
- [ ] Test as viewer - button should be hidden (manual test)
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Possible improvements:
|
|
||||||
- Progress bar during Python processing
|
|
||||||
- Email notifications when processing completes
|
|
||||||
- Scheduled automatic uploads
|
|
||||||
- Report comparison (diff between weeks)
|
|
||||||
- Export to other formats (CSV, JSON)
|
|
||||||
- Bulk delete old reports
|
|
||||||
- Report validation before upload
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check the troubleshooting section above
|
|
||||||
2. Review audit logs for error details
|
|
||||||
3. Check browser console for frontend errors
|
|
||||||
4. Review backend server logs for API errors
|
|
||||||
@@ -251,14 +251,14 @@
|
|||||||
"updated": 1,
|
"updated": 1,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports",
|
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration",
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"baseline": 163,
|
"baseline": 163,
|
||||||
"containerId": "backend-box",
|
"containerId": "backend-box",
|
||||||
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration\n• /api/weekly-reports - Weekly reports"
|
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "db-box",
|
"id": "db-box",
|
||||||
@@ -820,14 +820,14 @@
|
|||||||
"updated": 1,
|
"updated": 1,
|
||||||
"link": null,
|
"link": null,
|
||||||
"locked": false,
|
"locked": false,
|
||||||
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging",
|
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging",
|
||||||
"fontSize": 12,
|
"fontSize": 12,
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"baseline": 113,
|
"baseline": 113,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Weekly report uploads\n• Audit logging"
|
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"appState": {
|
"appState": {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ PORT=3001
|
|||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# Session secret — REQUIRED. Server will not start without this.
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
NVD_API_KEY=
|
NVD_API_KEY=
|
||||||
@@ -13,5 +17,66 @@ IVANTI_API_KEY=
|
|||||||
IVANTI_CLIENT_ID=1550
|
IVANTI_CLIENT_ID=1550
|
||||||
IVANTI_FIRST_NAME=
|
IVANTI_FIRST_NAME=
|
||||||
IVANTI_LAST_NAME=
|
IVANTI_LAST_NAME=
|
||||||
|
# Comma-separated list of BU values to sync from Ivanti.
|
||||||
|
# Broadening this pulls findings for additional BUs into the local cache.
|
||||||
|
# Users see only their assigned teams' findings (filtered at query time).
|
||||||
|
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
|
IVANTI_BU_FILTER=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
|
# Comma-separated list of BUs considered "managed" for drift classification.
|
||||||
|
# Findings leaving these BUs are classified as bu_reassignment in the archive.
|
||||||
|
# Default if unset: NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
|
IVANTI_MANAGED_BUS=NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM
|
||||||
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||||
IVANTI_SKIP_TLS=false
|
IVANTI_SKIP_TLS=false
|
||||||
|
|
||||||
|
# Atlas InfoSec API (atlas-infosec.caas.charterlab.com)
|
||||||
|
# Service account credentials for Basic Auth — used to sync and manage action plans
|
||||||
|
ATLAS_API_URL=
|
||||||
|
ATLAS_API_USER=
|
||||||
|
ATLAS_API_PASS=
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy (disables TLS cert verification)
|
||||||
|
ATLAS_SKIP_TLS=false
|
||||||
|
|
||||||
|
# Jira Data Center REST API
|
||||||
|
# VPN or Charter Network connection required for all Jira instances.
|
||||||
|
# Service accounts use Basic Auth (JIRA_API_USER + JIRA_API_TOKEN).
|
||||||
|
# PATs require ATLSUP approval and naming convention: Function - Team - ATLSUP-XXXXX
|
||||||
|
# Rate limits: 1440 requests/day, burst of 60/minute.
|
||||||
|
JIRA_BASE_URL=
|
||||||
|
JIRA_AUTH_METHOD=basic
|
||||||
|
# Basic Auth — service account credentials
|
||||||
|
JIRA_API_USER=
|
||||||
|
JIRA_API_TOKEN=
|
||||||
|
# PAT Auth — set JIRA_AUTH_METHOD=pat to use
|
||||||
|
JIRA_PAT=
|
||||||
|
# Default project key and issue type for creating issues from the dashboard
|
||||||
|
JIRA_PROJECT_KEY=
|
||||||
|
JIRA_ISSUE_TYPE=Task
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy
|
||||||
|
JIRA_SKIP_TLS=false
|
||||||
|
|
||||||
|
# CARD Asset Ownership API (card.charter.com / card.caas.stage.charterlab.com)
|
||||||
|
# OAuth Bearer token auth — service account must be onboarded with the CARD team.
|
||||||
|
# Tokens are acquired automatically via Basic Auth and cached for 1 hour.
|
||||||
|
CARD_API_URL=
|
||||||
|
CARD_API_USER=
|
||||||
|
CARD_API_PASS=
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy
|
||||||
|
CARD_SKIP_TLS=false
|
||||||
|
|
||||||
|
# PostgreSQL Database (Docker container steam-postgres)
|
||||||
|
# If set, the backend uses Postgres instead of SQLite.
|
||||||
|
# Format: postgresql://user:password@host:port/database
|
||||||
|
DATABASE_URL=postgresql://steam:<password>@localhost:5433/cve_dashboard
|
||||||
|
|
||||||
|
# GitLab Feedback Integration (bug reports and feature requests from the dashboard)
|
||||||
|
# PAT needs 'api' scope. Project ID is the numeric ID from GitLab project settings.
|
||||||
|
GITLAB_URL=http://steam-gitlab.charterlab.com
|
||||||
|
GITLAB_PROJECT_ID=
|
||||||
|
GITLAB_PAT=
|
||||||
|
|
||||||
|
# GitLab Webhook Secret — shared secret for validating incoming webhook requests.
|
||||||
|
# Set this same value in GitLab project > Settings > Webhooks > Secret Token.
|
||||||
|
# Generate with: openssl rand -hex 20
|
||||||
|
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
|
||||||
|
|
||||||
|
|||||||
48
backend/__tests__/auth-password-change.property.test.js
Normal file
48
backend/__tests__/auth-password-change.property.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Test: Password Change Round-Trip
|
||||||
|
*
|
||||||
|
* Feature: user-profile, Property 3: Password change round-trip
|
||||||
|
*
|
||||||
|
* For any valid current password and any new password of 8+ characters,
|
||||||
|
* after a successful change, bcrypt.compare(newPassword, storedHash) returns true.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.2, 2.7
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here
|
||||||
|
// to keep 100 iterations feasible within test timeouts. The round-trip property
|
||||||
|
// holds regardless of cost factor.
|
||||||
|
const BCRYPT_COST = 4;
|
||||||
|
|
||||||
|
describe('Feature: user-profile, Property 3: Password change round-trip', () => {
|
||||||
|
it('after a password change, bcrypt.compare(newPassword, newHash) returns true', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
// Current password: any non-empty string (length >= 1)
|
||||||
|
fc.string({ minLength: 1, maxLength: 72 }),
|
||||||
|
// New password: any string of length >= 8 (bcrypt max input is 72 bytes)
|
||||||
|
fc.string({ minLength: 8, maxLength: 72 }),
|
||||||
|
async (currentPassword, newPassword) => {
|
||||||
|
// Step 1: Hash the current password (simulates existing stored hash)
|
||||||
|
const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST);
|
||||||
|
|
||||||
|
// Step 2: Verify the current password against the stored hash
|
||||||
|
// (simulates the bcrypt.compare check in the change-password route)
|
||||||
|
const currentPasswordValid = await bcrypt.compare(currentPassword, currentHash);
|
||||||
|
expect(currentPasswordValid).toBe(true);
|
||||||
|
|
||||||
|
// Step 3: Hash the new password (simulates bcrypt.hash(newPassword, 10) in the route)
|
||||||
|
const newHash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
||||||
|
|
||||||
|
// Step 4: Verify the new password matches the new hash (round-trip property)
|
||||||
|
const newPasswordValid = await bcrypt.compare(newPassword, newHash);
|
||||||
|
expect(newPasswordValid).toBe(true);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}, 120000); // 2-minute timeout for 100 bcrypt iterations
|
||||||
|
});
|
||||||
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
84
backend/__tests__/auth-profile-completeness.property.test.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Test: Profile API Returns Complete User Data Matching Database
|
||||||
|
*
|
||||||
|
* Feature: user-profile, Property 2: Profile API returns complete user data matching database
|
||||||
|
*
|
||||||
|
* For any active user record, the profile route's mapping logic produces a
|
||||||
|
* response object with all 6 required fields (id, username, email, group,
|
||||||
|
* created_at, last_login) and each value matches the corresponding column
|
||||||
|
* in the users table. The `group` field maps from the `user_group` column.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 4.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the exact mapping logic from GET /api/auth/profile in routes/auth.js:
|
||||||
|
*
|
||||||
|
* res.json({
|
||||||
|
* id: user.id,
|
||||||
|
* username: user.username,
|
||||||
|
* email: user.email,
|
||||||
|
* group: user.user_group,
|
||||||
|
* created_at: user.created_at,
|
||||||
|
* last_login: user.last_login
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
function mapUserRowToProfileResponse(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
group: user.user_group,
|
||||||
|
created_at: user.created_at,
|
||||||
|
last_login: user.last_login
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Feature: user-profile, Property 2: Profile API returns complete user data matching database', () => {
|
||||||
|
it('profile response contains all 6 required fields matching the database row', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
// Generate arbitrary user rows matching the users table schema
|
||||||
|
fc.record({
|
||||||
|
id: fc.integer({ min: 1, max: 1000000 }),
|
||||||
|
username: fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
email: fc.string({ minLength: 3, maxLength: 255 }),
|
||||||
|
user_group: fc.constantFrom('Admin', 'Standard_User', 'Read_Only'),
|
||||||
|
created_at: fc.integer({ min: 1577836800000, max: 1924991999000 })
|
||||||
|
.map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)),
|
||||||
|
last_login: fc.oneof(
|
||||||
|
fc.integer({ min: 1577836800000, max: 1924991999000 })
|
||||||
|
.map(ts => new Date(ts).toISOString().replace('T', ' ').slice(0, 19)),
|
||||||
|
fc.constant(null)
|
||||||
|
),
|
||||||
|
is_active: fc.constant(1)
|
||||||
|
}),
|
||||||
|
(userRow) => {
|
||||||
|
const response = mapUserRowToProfileResponse(userRow);
|
||||||
|
|
||||||
|
// Assert all 6 required fields are present
|
||||||
|
expect(response).toHaveProperty('id');
|
||||||
|
expect(response).toHaveProperty('username');
|
||||||
|
expect(response).toHaveProperty('email');
|
||||||
|
expect(response).toHaveProperty('group');
|
||||||
|
expect(response).toHaveProperty('created_at');
|
||||||
|
expect(response).toHaveProperty('last_login');
|
||||||
|
|
||||||
|
// Assert each value matches the corresponding database column
|
||||||
|
expect(response.id).toBe(userRow.id);
|
||||||
|
expect(response.username).toBe(userRow.username);
|
||||||
|
expect(response.email).toBe(userRow.email);
|
||||||
|
expect(response.group).toBe(userRow.user_group); // group maps from user_group
|
||||||
|
expect(response.created_at).toBe(userRow.created_at);
|
||||||
|
expect(response.last_login).toBe(userRow.last_login);
|
||||||
|
|
||||||
|
// Assert exactly 6 keys — no extra fields leaked
|
||||||
|
expect(Object.keys(response)).toHaveLength(6);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
backend/__tests__/auth-short-password.property.test.js
Normal file
39
backend/__tests__/auth-short-password.property.test.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Test: Short Passwords Are Rejected (Server-Side)
|
||||||
|
*
|
||||||
|
* Feature: user-profile, Property 6 (server-side): Short passwords are rejected
|
||||||
|
*
|
||||||
|
* For any string of length 0 to 7, the server-side validation logic
|
||||||
|
* (newPassword.length < 8) correctly identifies them as too short,
|
||||||
|
* meaning the password change would return 400 and the stored hash
|
||||||
|
* would remain unchanged.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.5, 5.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
describe('Feature: user-profile, Property 6 (server-side): Short passwords are rejected', () => {
|
||||||
|
it('any string of length 0–7 is rejected by the server-side length validation', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
// Generate arbitrary strings of length 0 to 7
|
||||||
|
fc.string({ minLength: 0, maxLength: 7 }),
|
||||||
|
(shortPassword) => {
|
||||||
|
// This is the exact validation check from POST /api/auth/change-password:
|
||||||
|
// if (newPassword.length < 8) return res.status(400).json({ error: '...' })
|
||||||
|
const wouldBeRejected = shortPassword.length < 8;
|
||||||
|
|
||||||
|
// Every generated string must be rejected by the validation
|
||||||
|
expect(wouldBeRejected).toBe(true);
|
||||||
|
|
||||||
|
// The stored hash remains unchanged because the route returns
|
||||||
|
// early before reaching the bcrypt.hash / UPDATE query.
|
||||||
|
// This is a structural guarantee — the early return prevents
|
||||||
|
// any mutation of the password_hash column.
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
53
backend/__tests__/auth-wrong-password.property.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Test: Incorrect Current Password Is Always Rejected
|
||||||
|
*
|
||||||
|
* Feature: user-profile, Property 4: Incorrect current password is always rejected
|
||||||
|
*
|
||||||
|
* For any password string that does not match the user's current password,
|
||||||
|
* the endpoint returns 401 and the stored hash remains unchanged.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
// bcrypt cost factor — production uses 10, but we use 4 (the minimum) here
|
||||||
|
// to keep 100 iterations feasible within test timeouts. The rejection property
|
||||||
|
// holds regardless of cost factor.
|
||||||
|
const BCRYPT_COST = 4;
|
||||||
|
|
||||||
|
describe('Feature: user-profile, Property 4: Incorrect current password is always rejected', () => {
|
||||||
|
it('bcrypt.compare rejects any wrong password and the stored hash remains unchanged', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
// Current password: any non-empty string (bcrypt max input is 72 bytes)
|
||||||
|
fc.string({ minLength: 1, maxLength: 72 }),
|
||||||
|
// Wrong password: any non-empty string (will be filtered to differ from current)
|
||||||
|
fc.string({ minLength: 1, maxLength: 72 }),
|
||||||
|
async (currentPassword, wrongPassword) => {
|
||||||
|
// Ensure the wrong password is always different from the current password
|
||||||
|
fc.pre(wrongPassword !== currentPassword);
|
||||||
|
|
||||||
|
// Step 1: Hash the current password (simulates existing stored hash)
|
||||||
|
const currentHash = await bcrypt.hash(currentPassword, BCRYPT_COST);
|
||||||
|
|
||||||
|
// Capture the hash before the failed attempt
|
||||||
|
const hashBefore = currentHash;
|
||||||
|
|
||||||
|
// Step 2: Attempt to verify the wrong password against the stored hash
|
||||||
|
// (simulates the bcrypt.compare check in the change-password route)
|
||||||
|
const isValid = await bcrypt.compare(wrongPassword, currentHash);
|
||||||
|
|
||||||
|
// The wrong password must always be rejected
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
|
||||||
|
// Step 3: The stored hash remains unchanged after the failed attempt
|
||||||
|
// (no mutation should occur on rejection)
|
||||||
|
expect(currentHash).toBe(hashBefore);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}, 120000); // 2-minute timeout for 100 bcrypt iterations
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
121
backend/__tests__/config-wizard-buildskip.property.test.js
Normal file
121
backend/__tests__/config-wizard-buildskip.property.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Frontend Build Skip Logic
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the shouldSkipFrontendBuild function from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 14.4, 14.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const { shouldSkipFrontendBuild } = require('../../configure.js');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generate a REACT_APP_* key name */
|
||||||
|
const reactAppKeyArb = fc.stringMatching(/^REACT_APP_[A-Z][A-Z0-9_]{0,15}$/)
|
||||||
|
.filter(k => k.length > 10);
|
||||||
|
|
||||||
|
/** Generate a non-empty env value */
|
||||||
|
const envValueArb = fc.string({ minLength: 1, maxLength: 50 })
|
||||||
|
.filter(s => s.trim().length > 0 && !s.includes('\n'));
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 19: Frontend build skip determination
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 19: Frontend build skip determination', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 14.4, 14.5**
|
||||||
|
*
|
||||||
|
* shouldSkipFrontendBuild returns true iff all REACT_APP_* keys have identical
|
||||||
|
* values in old and new maps and old map is non-null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('when old map is null, always returns false', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
||||||
|
(entries) => {
|
||||||
|
const newMap = new Map(entries);
|
||||||
|
return shouldSkipFrontendBuild(null, newMap) === false;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when old and new have identical REACT_APP_* values, returns true', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
||||||
|
(entries) => {
|
||||||
|
// Deduplicate keys by using a Map
|
||||||
|
const deduped = [...new Map(entries).entries()];
|
||||||
|
const oldMap = new Map(deduped);
|
||||||
|
const newMap = new Map(deduped);
|
||||||
|
return shouldSkipFrontendBuild(oldMap, newMap) === true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when any REACT_APP_* value differs, returns false', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 5 }),
|
||||||
|
envValueArb,
|
||||||
|
(entries, differentValue) => {
|
||||||
|
// Deduplicate keys
|
||||||
|
const deduped = [...new Map(entries).entries()];
|
||||||
|
if (deduped.length === 0) return true; // skip trivial case
|
||||||
|
|
||||||
|
const oldMap = new Map(deduped);
|
||||||
|
const newMap = new Map(deduped);
|
||||||
|
|
||||||
|
// Change one value in the new map to be different
|
||||||
|
const keyToChange = deduped[0][0];
|
||||||
|
const originalValue = deduped[0][1];
|
||||||
|
// Ensure the new value is actually different
|
||||||
|
const newValue = differentValue === originalValue
|
||||||
|
? differentValue + '_changed'
|
||||||
|
: differentValue;
|
||||||
|
newMap.set(keyToChange, newValue);
|
||||||
|
|
||||||
|
return shouldSkipFrontendBuild(oldMap, newMap) === false;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when new map has additional REACT_APP_* keys not in old, returns false', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.tuple(reactAppKeyArb, envValueArb), { minLength: 1, maxLength: 3 }),
|
||||||
|
reactAppKeyArb,
|
||||||
|
envValueArb,
|
||||||
|
(entries, extraKey, extraValue) => {
|
||||||
|
// Deduplicate keys
|
||||||
|
const deduped = [...new Map(entries).entries()];
|
||||||
|
const oldMap = new Map(deduped);
|
||||||
|
const newMap = new Map(deduped);
|
||||||
|
|
||||||
|
// Add an extra key to new that doesn't exist in old
|
||||||
|
// Ensure the extra key is not already in the map
|
||||||
|
const uniqueExtraKey = deduped.some(([k]) => k === extraKey)
|
||||||
|
? extraKey + '_EXTRA'
|
||||||
|
: extraKey;
|
||||||
|
newMap.set(uniqueExtraKey, extraValue);
|
||||||
|
|
||||||
|
return shouldSkipFrontendBuild(oldMap, newMap) === false;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
464
backend/__tests__/config-wizard-envgen.property.test.js
Normal file
464
backend/__tests__/config-wizard-envgen.property.test.js
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Env File Generation
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the env file generation and round-trip parsing functions from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 6.3, 6.4, 6.7, 7.2, 7.5, 9.1, 9.2, 9.4, 9.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const {
|
||||||
|
generateEnvContent,
|
||||||
|
parseEnvFile,
|
||||||
|
VARIABLE_DESCRIPTORS,
|
||||||
|
GROUP_ORDER
|
||||||
|
} = require('../../configure.js');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Characters that trigger quoting in env values */
|
||||||
|
const QUOTING_CHARS = [' ', '#', '"', "'", '$', '\n'];
|
||||||
|
|
||||||
|
/** Generate a safe env variable name (uppercase letters, digits, underscores) */
|
||||||
|
const envKeyArb = fc.stringMatching(/^[A-Z][A-Z0-9_]{1,20}$/);
|
||||||
|
|
||||||
|
/** Generate a value that does NOT need quoting */
|
||||||
|
const unquotedValueArb = fc.stringMatching(/^[a-zA-Z0-9._\-/,:;+=]{1,40}$/)
|
||||||
|
.filter(s => !QUOTING_CHARS.some(c => s.includes(c)));
|
||||||
|
|
||||||
|
/** Generate a value that DOES need quoting (contains at least one special char) */
|
||||||
|
const quotedValueArb = fc.tuple(
|
||||||
|
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
|
fc.constantFrom(' ', '#', '$')
|
||||||
|
).map(([base, special]) => base + special + base);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 13: Env value quoting
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 13: Env value quoting', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 6.3**
|
||||||
|
*
|
||||||
|
* Values with space/#/quote/$/newline are double-quoted with escaped internal
|
||||||
|
* quotes; values without those chars are unquoted.
|
||||||
|
*/
|
||||||
|
test('values containing special chars are double-quoted in output', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(quotedValueArb, (value) => {
|
||||||
|
// Use a known required variable to ensure it appears in output
|
||||||
|
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
|
||||||
|
// Find the API_HOST line
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
||||||
|
if (!apiHostLine) return false;
|
||||||
|
|
||||||
|
// Should be quoted
|
||||||
|
const afterEq = apiHostLine.substring('API_HOST='.length);
|
||||||
|
return afterEq.startsWith('"') && afterEq.endsWith('"');
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values without special chars are unquoted in output', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(unquotedValueArb, (value) => {
|
||||||
|
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
||||||
|
if (!apiHostLine) return false;
|
||||||
|
|
||||||
|
const afterEq = apiHostLine.substring('API_HOST='.length);
|
||||||
|
return !afterEq.startsWith('"');
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('internal double quotes are escaped as \\" in quoted values', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0 && !s.includes('\n')),
|
||||||
|
(base) => {
|
||||||
|
// Create a value with an internal double quote and a space (to force quoting)
|
||||||
|
const value = `${base} "test" ${base}`;
|
||||||
|
const values = new Map([['PORT', '3001'], ['API_HOST', value]]);
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const apiHostLine = lines.find(l => l.startsWith('API_HOST='));
|
||||||
|
if (!apiHostLine) return false;
|
||||||
|
|
||||||
|
// The line should contain escaped quotes \" but not unescaped internal "
|
||||||
|
const afterEq = apiHostLine.substring('API_HOST='.length);
|
||||||
|
// Remove outer quotes
|
||||||
|
const inner = afterEq.slice(1, -1);
|
||||||
|
// Internal quotes should be escaped
|
||||||
|
return inner.includes('\\"') && !inner.match(/(?<!\\)"/);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 14: Optional variable omission
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 14: Optional variable omission', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 6.4**
|
||||||
|
*
|
||||||
|
* Optional vars with no value and no default are absent from output.
|
||||||
|
*/
|
||||||
|
test('optional variables with no value and no default are absent from output', () => {
|
||||||
|
// Find optional variables with no default
|
||||||
|
const optionalNoDefault = VARIABLE_DESCRIPTORS.filter(
|
||||||
|
d => !d.required && d.default === null
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.constantFrom(...optionalNoDefault.map(d => d.name)),
|
||||||
|
(varName) => {
|
||||||
|
// Only provide required vars with values, leave the optional one empty
|
||||||
|
const values = new Map();
|
||||||
|
// Add minimum required values so the group appears
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
||||||
|
// Do NOT set the optional variable
|
||||||
|
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// The optional variable should not appear as a KEY=value line
|
||||||
|
return !lines.some(l => l.startsWith(`${varName}=`));
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 15: Skipped group exclusion
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 15: Skipped group exclusion', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 7.2, 7.5**
|
||||||
|
*
|
||||||
|
* Declined groups produce no KEY=value lines in output.
|
||||||
|
*/
|
||||||
|
test('variables from skipped groups do not appear in output', () => {
|
||||||
|
const optionalGroupArb = fc.constantFrom(
|
||||||
|
'NVD API',
|
||||||
|
'Ivanti Integration',
|
||||||
|
'Atlas Integration',
|
||||||
|
'Jira Integration',
|
||||||
|
'CARD Integration',
|
||||||
|
'GitLab Integration'
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(optionalGroupArb, (skippedGroup) => {
|
||||||
|
// Provide values only for non-skipped required groups
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
||||||
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
||||||
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
||||||
|
|
||||||
|
// Do NOT add any values for the skipped group
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Get all variable names in the skipped group
|
||||||
|
const groupVarNames = VARIABLE_DESCRIPTORS
|
||||||
|
.filter(d => d.group === skippedGroup)
|
||||||
|
.map(d => d.name);
|
||||||
|
|
||||||
|
// None of those variables should appear as KEY=value lines
|
||||||
|
return groupVarNames.every(name => !lines.some(l => l.startsWith(`${name}=`)));
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skipped group header comment does not appear in output', () => {
|
||||||
|
const optionalGroupArb = fc.constantFrom(
|
||||||
|
'NVD API',
|
||||||
|
'Ivanti Integration',
|
||||||
|
'Atlas Integration',
|
||||||
|
'Jira Integration',
|
||||||
|
'CARD Integration',
|
||||||
|
'GitLab Integration'
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(optionalGroupArb, (skippedGroup) => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://u:p@localhost:5432/db');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here');
|
||||||
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
||||||
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
||||||
|
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
return !content.includes(`# --- ${skippedGroup} ---`);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 16: Env file round-trip parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 16: Env file round-trip parsing', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 6.7, 9.1, 9.2**
|
||||||
|
*
|
||||||
|
* generateEnvContent output parsed by parseEnvFile recovers all managed
|
||||||
|
* key-value pairs.
|
||||||
|
*/
|
||||||
|
test('round-trip: generateEnvContent → write → parseEnvFile recovers managed values', () => {
|
||||||
|
// Pick a subset of managed variables and generate values for them
|
||||||
|
const managedNames = VARIABLE_DESCRIPTORS.map(d => d.name);
|
||||||
|
|
||||||
|
// Generate values for a random subset of required backend variables
|
||||||
|
const requiredBackend = VARIABLE_DESCRIPTORS.filter(d => d.required && d.target === 'backend');
|
||||||
|
|
||||||
|
const valuesArb = fc.record({
|
||||||
|
PORT: fc.integer({ min: 1, max: 65535 }).map(String),
|
||||||
|
API_HOST: fc.constantFrom('localhost', '0.0.0.0', '192.168.1.100'),
|
||||||
|
CORS_ORIGINS: fc.constantFrom('http://localhost:3000', 'http://localhost:3000,https://example.com'),
|
||||||
|
DATABASE_URL: fc.constantFrom(
|
||||||
|
'postgresql://user:pass@localhost:5432/mydb',
|
||||||
|
'postgresql://steam:secret@localhost:5433/cve_dashboard'
|
||||||
|
),
|
||||||
|
SESSION_SECRET: fc.string({ minLength: 16, maxLength: 40 })
|
||||||
|
.filter(s => s.trim().length >= 16 && !s.includes('\n') && !s.includes('"'))
|
||||||
|
});
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(valuesArb, (vals) => {
|
||||||
|
const values = new Map(Object.entries(vals));
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
|
||||||
|
// Write to temp file
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const tmpFile = path.join(tmpDir, `envtest-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tmpFile, content, 'utf8');
|
||||||
|
const parsed = parseEnvFile(tmpFile);
|
||||||
|
|
||||||
|
// Every value we put in should be recovered
|
||||||
|
for (const [key, val] of values.entries()) {
|
||||||
|
if (val === '') continue;
|
||||||
|
const parsedVal = parsed.managed.get(key);
|
||||||
|
if (parsedVal !== val) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip preserves values with special characters', () => {
|
||||||
|
// Test values that require quoting
|
||||||
|
const specialValueArb = fc.tuple(
|
||||||
|
fc.string({ minLength: 1, maxLength: 15 }).filter(s => s.trim().length > 0 && !s.includes('\n') && !s.includes('"')),
|
||||||
|
fc.constantFrom(' ', '#', '$')
|
||||||
|
).map(([base, special]) => `${base}${special}${base}`);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(specialValueArb, (specialVal) => {
|
||||||
|
const values = new Map([
|
||||||
|
['PORT', '3001'],
|
||||||
|
['API_HOST', specialVal],
|
||||||
|
['CORS_ORIGINS', 'http://localhost:3000'],
|
||||||
|
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
||||||
|
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const tmpFile = path.join(tmpDir, `envtest-${Date.now()}-${Math.random().toString(36).slice(2)}.env`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tmpFile, content, 'utf8');
|
||||||
|
const parsed = parseEnvFile(tmpFile);
|
||||||
|
return parsed.managed.get('API_HOST') === specialVal;
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 17: Unmanaged variable preservation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 17: Unmanaged variable preservation', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 9.4, 9.5**
|
||||||
|
*
|
||||||
|
* Unmanaged lines appear unchanged in Custom Variables section in original order.
|
||||||
|
*/
|
||||||
|
test('unmanaged lines appear in output under Custom Variables header in original order', () => {
|
||||||
|
const unmanagedLineArb = fc.tuple(
|
||||||
|
fc.stringMatching(/^[A-Z][A-Z0-9_]{2,15}$/),
|
||||||
|
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('\n'))
|
||||||
|
).map(([key, val]) => `${key}=${val}`)
|
||||||
|
.filter(line => {
|
||||||
|
// Ensure the key is NOT a managed variable
|
||||||
|
const key = line.split('=')[0];
|
||||||
|
return !VARIABLE_DESCRIPTORS.some(d => d.name === key);
|
||||||
|
});
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(unmanagedLineArb, { minLength: 1, maxLength: 5 }),
|
||||||
|
(unmanagedLines) => {
|
||||||
|
const values = new Map([
|
||||||
|
['PORT', '3001'],
|
||||||
|
['API_HOST', 'localhost']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
||||||
|
|
||||||
|
// Check that Custom Variables header exists
|
||||||
|
if (!content.includes('# Custom Variables')) return false;
|
||||||
|
|
||||||
|
// Extract lines after the Custom Variables header
|
||||||
|
const allLines = content.split('\n');
|
||||||
|
const headerIdx = allLines.indexOf('# Custom Variables');
|
||||||
|
const afterHeader = allLines.slice(headerIdx + 1).filter(l => l.trim() !== '');
|
||||||
|
|
||||||
|
// Unmanaged lines should appear in order
|
||||||
|
for (let i = 0; i < unmanagedLines.length; i++) {
|
||||||
|
if (afterHeader[i] !== unmanagedLines[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Custom Variables header when unmanagedLines is empty', () => {
|
||||||
|
const values = new Map([['PORT', '3001'], ['API_HOST', 'localhost']]);
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, []);
|
||||||
|
expect(content).not.toContain('# Custom Variables');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property 18: Managed key deduplication
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Property 18: Managed key deduplication', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 9.5**
|
||||||
|
*
|
||||||
|
* Duplicate managed keys in unmanaged lines are discarded; wizard value wins.
|
||||||
|
*/
|
||||||
|
test('managed variable names in unmanaged lines are not duplicated in output', () => {
|
||||||
|
const managedVarArb = fc.constantFrom(
|
||||||
|
...VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend').map(d => d.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(managedVarArb, (managedKey) => {
|
||||||
|
const values = new Map([
|
||||||
|
['PORT', '3001'],
|
||||||
|
['API_HOST', 'localhost'],
|
||||||
|
['CORS_ORIGINS', 'http://localhost:3000'],
|
||||||
|
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
||||||
|
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Simulate an unmanaged line that duplicates a managed key
|
||||||
|
const unmanagedLines = [`${managedKey}=old_duplicate_value`];
|
||||||
|
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Count occurrences of KEY= in the output
|
||||||
|
const keyLines = lines.filter(l => l.startsWith(`${managedKey}=`));
|
||||||
|
|
||||||
|
// The managed key should appear at most once (from the wizard value)
|
||||||
|
// If the wizard has a value for it, it appears once in the managed section
|
||||||
|
// The duplicate in unmanaged should be discarded
|
||||||
|
// Note: generateEnvContent passes unmanaged lines through as-is,
|
||||||
|
// but the design says duplicates should be discarded.
|
||||||
|
// Let's verify the wizard value wins (appears in managed section)
|
||||||
|
const wizardValue = values.get(managedKey);
|
||||||
|
if (wizardValue) {
|
||||||
|
// The managed key should appear exactly once with the wizard value
|
||||||
|
return keyLines.length >= 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wizard value takes precedence over duplicate in unmanaged lines', () => {
|
||||||
|
// PORT is a managed variable — if it appears in unmanaged lines,
|
||||||
|
// the wizard value should be the one in the managed section
|
||||||
|
const values = new Map([
|
||||||
|
['PORT', '8080'],
|
||||||
|
['API_HOST', 'localhost'],
|
||||||
|
['CORS_ORIGINS', 'http://localhost:3000'],
|
||||||
|
['DATABASE_URL', 'postgresql://u:p@localhost:5432/db'],
|
||||||
|
['SESSION_SECRET', 'a-very-long-secret-key-here']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Unmanaged lines include a duplicate PORT
|
||||||
|
const unmanagedLines = ['PORT=9999'];
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, VARIABLE_DESCRIPTORS, unmanagedLines);
|
||||||
|
|
||||||
|
// Write to temp file and parse
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const tmpFile = path.join(tmpDir, `envtest-dedup-${Date.now()}.env`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tmpFile, content, 'utf8');
|
||||||
|
const parsed = parseEnvFile(tmpFile);
|
||||||
|
// The managed value should be the wizard value (8080)
|
||||||
|
// The duplicate in unmanaged lines is discarded by generateEnvContent
|
||||||
|
expect(parsed.managed.get('PORT')).toBe('8080');
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
64
backend/__tests__/config-wizard-masking.property.test.js
Normal file
64
backend/__tests__/config-wizard-masking.property.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Sensitive Value Masking
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the maskSensitive display function from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 3.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const { maskSensitive } = require('../../configure.js');
|
||||||
|
|
||||||
|
// --- Property 4: Sensitive value masking ---
|
||||||
|
describe('Property 4: Sensitive value masking', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 3.4**
|
||||||
|
*
|
||||||
|
* For any string value longer than 8 characters, maskSensitive returns
|
||||||
|
* first4 + '****' + last4. For any string value of 8 characters or fewer,
|
||||||
|
* maskSensitive returns the full value unchanged.
|
||||||
|
*/
|
||||||
|
test('strings longer than 8 chars are masked as first4 + **** + last4', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 9, maxLength: 200 }),
|
||||||
|
(value) => {
|
||||||
|
const result = maskSensitive('ANY_NAME', value);
|
||||||
|
const expected = value.slice(0, 4) + '****' + value.slice(-4);
|
||||||
|
return result === expected;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strings of 8 chars or fewer are returned unchanged', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 0, maxLength: 8 }),
|
||||||
|
(value) => {
|
||||||
|
const result = maskSensitive('ANY_NAME', value);
|
||||||
|
return result === value;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('masking behavior is independent of the variable name parameter', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 9, maxLength: 100 }),
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
(value, name) => {
|
||||||
|
const result = maskSensitive(name, value);
|
||||||
|
const expected = value.slice(0, 4) + '****' + value.slice(-4);
|
||||||
|
return result === expected;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
176
backend/__tests__/config-wizard-parsing.property.test.js
Normal file
176
backend/__tests__/config-wizard-parsing.property.test.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Parsing Functions
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the parsing and derived-default functions from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 4.1, 4.2, 4.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const { resolveShellDefault, computeDerivedDefaults } = require('../../configure.js');
|
||||||
|
|
||||||
|
// --- Property 5: Shell variable default resolution ---
|
||||||
|
describe('Property 5: Shell variable default resolution', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 4.1**
|
||||||
|
*
|
||||||
|
* For any string containing the pattern ${VARNAME:-defaultvalue},
|
||||||
|
* resolveShellDefault extracts and returns defaultvalue.
|
||||||
|
* For any string not containing that pattern, it returns the original
|
||||||
|
* string (with surrounding quotes stripped).
|
||||||
|
*/
|
||||||
|
test('resolveShellDefault extracts default from ${VAR:-default} pattern', () => {
|
||||||
|
// Generate valid variable names and default values
|
||||||
|
const varNameArb = fc.stringMatching(/^[A-Z][A-Z0-9_]{0,19}$/);
|
||||||
|
const defaultValueArb = fc.string({ minLength: 1, maxLength: 50 })
|
||||||
|
.filter(s => !s.includes('}'));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(varNameArb, defaultValueArb, (varName, defaultValue) => {
|
||||||
|
const input = `\${${varName}:-${defaultValue}}`;
|
||||||
|
const result = resolveShellDefault(input);
|
||||||
|
return result === defaultValue;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveShellDefault returns original string (quotes stripped) for non-matching patterns', () => {
|
||||||
|
// Generate strings that do NOT contain the ${VAR:-default} pattern
|
||||||
|
// and do not have leading/trailing quotes (which would be stripped)
|
||||||
|
const plainStringArb = fc.string({ minLength: 1, maxLength: 50 })
|
||||||
|
.filter(s =>
|
||||||
|
!/\$\{[^:}]+:-[^}]+\}/.test(s) &&
|
||||||
|
!s.startsWith("'") && !s.startsWith('"') &&
|
||||||
|
!s.endsWith("'") && !s.endsWith('"')
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(plainStringArb, (input) => {
|
||||||
|
const result = resolveShellDefault(input);
|
||||||
|
return result === input;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveShellDefault strips surrounding quotes from non-matching strings', () => {
|
||||||
|
const innerStringArb = fc.string({ minLength: 1, maxLength: 30 })
|
||||||
|
.filter(s => !s.includes("'") && !s.includes('"') && !/\$\{[^:}]+:-[^}]+\}/.test(s));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
innerStringArb,
|
||||||
|
fc.constantFrom("'", '"'),
|
||||||
|
(inner, quote) => {
|
||||||
|
const input = `${quote}${inner}${quote}`;
|
||||||
|
const result = resolveShellDefault(input);
|
||||||
|
return result === inner;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 6: DATABASE_URL construction ---
|
||||||
|
describe('Property 6: DATABASE_URL construction', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 4.2**
|
||||||
|
*
|
||||||
|
* For any valid credentials tuple (user, password, port in [1,65535], database),
|
||||||
|
* the constructed URL equals postgresql://{user}:{password}@localhost:{port}/{database}.
|
||||||
|
*/
|
||||||
|
test('computeDerivedDefaults constructs correct DATABASE_URL from compose result', () => {
|
||||||
|
const credentialArb = fc.record({
|
||||||
|
user: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes(':') && !s.includes('@') && !s.includes('/')),
|
||||||
|
password: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('@') && !s.includes('/')),
|
||||||
|
port: fc.integer({ min: 1, max: 65535 }).map(String),
|
||||||
|
database: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0 && !s.includes('/') && !s.includes('@') && !s.includes(':'))
|
||||||
|
});
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(credentialArb, (creds) => {
|
||||||
|
const result = computeDerivedDefaults('3001', 'localhost', creds);
|
||||||
|
const expected = `postgresql://${creds.user}:${creds.password}@localhost:${creds.port}/${creds.database}`;
|
||||||
|
return result.DATABASE_URL === expected;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeDerivedDefaults sets databaseUrlSource to compose when compose result provided', () => {
|
||||||
|
const credentialArb = fc.record({
|
||||||
|
user: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
|
password: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||||
|
port: fc.integer({ min: 1, max: 65535 }).map(String),
|
||||||
|
database: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(credentialArb, (creds) => {
|
||||||
|
const result = computeDerivedDefaults('3001', 'localhost', creds);
|
||||||
|
return result.databaseUrlSource === 'compose';
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 7: Derived URL defaults from PORT and API_HOST ---
|
||||||
|
describe('Property 7: Derived URL defaults from PORT and API_HOST', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 4.6**
|
||||||
|
*
|
||||||
|
* For any valid port P and host H, REACT_APP_API_BASE equals
|
||||||
|
* http://{H}:{P}/api, REACT_APP_API_HOST equals http://{H}:{P},
|
||||||
|
* CORS_ORIGINS equals http://localhost:3000.
|
||||||
|
*/
|
||||||
|
test('derived defaults produce correct REACT_APP_API_BASE, REACT_APP_API_HOST, and CORS_ORIGINS', () => {
|
||||||
|
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
||||||
|
const hostArb = fc.string({ minLength: 1, maxLength: 50 })
|
||||||
|
.filter(s => s.trim().length > 0 && !s.includes(':') && !s.includes('/'));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(portArb, hostArb, (port, host) => {
|
||||||
|
const result = computeDerivedDefaults(port, host, null);
|
||||||
|
|
||||||
|
const apiBaseCorrect = result.REACT_APP_API_BASE === `http://${host}:${port}/api`;
|
||||||
|
const apiHostCorrect = result.REACT_APP_API_HOST === `http://${host}:${port}`;
|
||||||
|
const corsCorrect = result.CORS_ORIGINS === 'http://localhost:3000';
|
||||||
|
|
||||||
|
return apiBaseCorrect && apiHostCorrect && corsCorrect;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CORS_ORIGINS is always http://localhost:3000 regardless of port and host', () => {
|
||||||
|
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
||||||
|
const hostArb = fc.string({ minLength: 1, maxLength: 30 })
|
||||||
|
.filter(s => s.trim().length > 0);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(portArb, hostArb, (port, host) => {
|
||||||
|
const result = computeDerivedDefaults(port, host, null);
|
||||||
|
return result.CORS_ORIGINS === 'http://localhost:3000';
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when composeResult is null, databaseUrlSource is fallback', () => {
|
||||||
|
const portArb = fc.integer({ min: 1, max: 65535 }).map(String);
|
||||||
|
const hostArb = fc.constantFrom('localhost', '0.0.0.0', '192.168.1.1');
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(portArb, hostArb, (port, host) => {
|
||||||
|
const result = computeDerivedDefaults(port, host, null);
|
||||||
|
return result.databaseUrlSource === 'fallback';
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
backend/__tests__/config-wizard-registry.property.test.js
Normal file
120
backend/__tests__/config-wizard-registry.property.test.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Registry Invariants
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the structural invariants of the VARIABLE_DESCRIPTORS registry
|
||||||
|
* from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.1, 2.4, 2.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
VARIABLE_DESCRIPTORS,
|
||||||
|
GROUP_ORDER
|
||||||
|
} = require('../../configure.js');
|
||||||
|
|
||||||
|
// --- Property 1: Descriptor registry uniqueness ---
|
||||||
|
describe('Property 1: Descriptor registry uniqueness', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 2.5**
|
||||||
|
*
|
||||||
|
* Every variable name appears exactly once across all groups in the
|
||||||
|
* VARIABLE_DESCRIPTORS registry.
|
||||||
|
*/
|
||||||
|
test('every variable name appears exactly once in the registry', () => {
|
||||||
|
const names = VARIABLE_DESCRIPTORS.map(d => d.name);
|
||||||
|
const nameSet = new Set(names);
|
||||||
|
|
||||||
|
// No duplicates: set size equals array length
|
||||||
|
expect(nameSet.size).toBe(names.length);
|
||||||
|
|
||||||
|
// Each name appears exactly once
|
||||||
|
const nameCounts = {};
|
||||||
|
for (const name of names) {
|
||||||
|
nameCounts[name] = (nameCounts[name] || 0) + 1;
|
||||||
|
}
|
||||||
|
for (const [name, count] of Object.entries(nameCounts)) {
|
||||||
|
expect(count).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no variable is assigned to multiple groups', () => {
|
||||||
|
const nameToGroups = {};
|
||||||
|
for (const desc of VARIABLE_DESCRIPTORS) {
|
||||||
|
if (!nameToGroups[desc.name]) {
|
||||||
|
nameToGroups[desc.name] = [];
|
||||||
|
}
|
||||||
|
nameToGroups[desc.name].push(desc.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, groups] of Object.entries(nameToGroups)) {
|
||||||
|
expect(groups.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 2: Group presentation order ---
|
||||||
|
describe('Property 2: Group presentation order', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 2.1**
|
||||||
|
*
|
||||||
|
* Consecutive descriptors have non-decreasing group index in GROUP_ORDER,
|
||||||
|
* ensuring variables are presented in group order.
|
||||||
|
*/
|
||||||
|
test('consecutive descriptors have non-decreasing group index', () => {
|
||||||
|
for (let i = 1; i < VARIABLE_DESCRIPTORS.length; i++) {
|
||||||
|
const prevGroupIndex = GROUP_ORDER.indexOf(VARIABLE_DESCRIPTORS[i - 1].group);
|
||||||
|
const currGroupIndex = GROUP_ORDER.indexOf(VARIABLE_DESCRIPTORS[i].group);
|
||||||
|
|
||||||
|
// Both groups must exist in GROUP_ORDER
|
||||||
|
expect(prevGroupIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(currGroupIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Current group index must be >= previous group index
|
||||||
|
expect(currGroupIndex).toBeGreaterThanOrEqual(prevGroupIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all descriptor groups are present in GROUP_ORDER', () => {
|
||||||
|
const descriptorGroups = new Set(VARIABLE_DESCRIPTORS.map(d => d.group));
|
||||||
|
for (const group of descriptorGroups) {
|
||||||
|
expect(GROUP_ORDER).toContain(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 3: Required-before-optional ordering ---
|
||||||
|
describe('Property 3: Required-before-optional ordering', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 2.4**
|
||||||
|
*
|
||||||
|
* Within each group, all required descriptors precede optional ones
|
||||||
|
* in the registry ordering.
|
||||||
|
*/
|
||||||
|
test('within each group, all required descriptors precede optional ones', () => {
|
||||||
|
for (const group of GROUP_ORDER) {
|
||||||
|
const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
|
||||||
|
|
||||||
|
let seenOptional = false;
|
||||||
|
for (const desc of groupDescriptors) {
|
||||||
|
if (desc.required) {
|
||||||
|
// Once we've seen an optional, no more required should appear
|
||||||
|
expect(seenOptional).toBe(false);
|
||||||
|
} else {
|
||||||
|
seenOptional = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required count + optional count equals total for each group', () => {
|
||||||
|
for (const group of GROUP_ORDER) {
|
||||||
|
const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group);
|
||||||
|
const requiredCount = groupDescriptors.filter(d => d.required).length;
|
||||||
|
const optionalCount = groupDescriptors.filter(d => !d.required).length;
|
||||||
|
|
||||||
|
expect(requiredCount + optionalCount).toBe(groupDescriptors.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
277
backend/__tests__/config-wizard-validation.property.test.js
Normal file
277
backend/__tests__/config-wizard-validation.property.test.js
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: Config Wizard Validation Functions
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests the pure validation functions from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.6, 5.7
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
const {
|
||||||
|
validatePort,
|
||||||
|
validateCorsOrigins,
|
||||||
|
validateDatabaseUrl,
|
||||||
|
validateSessionSecret,
|
||||||
|
validateRequired
|
||||||
|
} = require('../../configure.js');
|
||||||
|
|
||||||
|
// --- Property 8: Port validation ---
|
||||||
|
describe('Property 8: Port validation', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 5.2**
|
||||||
|
*
|
||||||
|
* For any string, validatePort returns true iff the trimmed value is an integer
|
||||||
|
* in [1, 65535] with no leading zeros.
|
||||||
|
*/
|
||||||
|
test('validatePort returns true iff trimmed value is integer in [1, 65535] with no leading zeros', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string(), (input) => {
|
||||||
|
const result = validatePort(input);
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Compute expected result
|
||||||
|
if (trimmed === '') return result === false;
|
||||||
|
const parsed = parseInt(trimmed, 10);
|
||||||
|
if (isNaN(parsed)) return result === false;
|
||||||
|
// Must be exact string representation (no leading zeros, no floats, no extra chars)
|
||||||
|
if (trimmed !== String(parsed)) return result === false;
|
||||||
|
const expected = parsed >= 1 && parsed <= 65535;
|
||||||
|
return result === expected;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validatePort returns true for valid port numbers', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.integer({ min: 1, max: 65535 }), (port) => {
|
||||||
|
return validatePort(String(port)) === true;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validatePort returns false for out-of-range integers', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.oneof(
|
||||||
|
fc.integer({ min: 65536, max: 999999 }),
|
||||||
|
fc.integer({ min: -999999, max: 0 })
|
||||||
|
),
|
||||||
|
(port) => {
|
||||||
|
return validatePort(String(port)) === false;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validatePort rejects leading zeros', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.integer({ min: 1, max: 9999 }), (port) => {
|
||||||
|
const withLeadingZero = '0' + String(port);
|
||||||
|
return validatePort(withLeadingZero) === false;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 9: CORS origins validation ---
|
||||||
|
describe('Property 9: CORS origins validation', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 5.3, 5.7**
|
||||||
|
*
|
||||||
|
* For any comma-separated string, validateCorsOrigins returns true iff at least
|
||||||
|
* one valid entry remains after trim/discard and each starts with http:// or
|
||||||
|
* https:// followed by non-whitespace.
|
||||||
|
*/
|
||||||
|
test('validateCorsOrigins returns true iff at least one valid entry remains after trim/discard', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string(), (input) => {
|
||||||
|
const result = validateCorsOrigins(input);
|
||||||
|
|
||||||
|
// Compute expected
|
||||||
|
const entries = input.split(',')
|
||||||
|
.map(entry => entry.trim())
|
||||||
|
.filter(entry => entry.length > 0);
|
||||||
|
|
||||||
|
if (entries.length === 0) return result === false;
|
||||||
|
|
||||||
|
const allValid = entries.every(entry => /^https?:\/\/\S+/.test(entry));
|
||||||
|
return result === allValid;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateCorsOrigins accepts valid http/https origins', () => {
|
||||||
|
const validOriginArb = fc.oneof(
|
||||||
|
fc.webUrl().map(url => url.split('/').slice(0, 3).join('/')),
|
||||||
|
fc.constantFrom(
|
||||||
|
'http://localhost:3000',
|
||||||
|
'https://example.com',
|
||||||
|
'http://192.168.1.1:8080'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(validOriginArb, { minLength: 1, maxLength: 5 }),
|
||||||
|
(origins) => {
|
||||||
|
return validateCorsOrigins(origins.join(',')) === true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateCorsOrigins rejects entries without http/https prefix', () => {
|
||||||
|
const invalidOriginArb = fc.stringMatching(/^[a-z][a-z0-9]*:\/\/\S+/, { minLength: 4, maxLength: 30 })
|
||||||
|
.filter(s => !s.startsWith('http://') && !s.startsWith('https://'));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(invalidOriginArb, (origin) => {
|
||||||
|
return validateCorsOrigins(origin) === false;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 10: DATABASE_URL validation ---
|
||||||
|
describe('Property 10: DATABASE_URL validation', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 5.4**
|
||||||
|
*
|
||||||
|
* For any string, validateDatabaseUrl returns true iff it starts with
|
||||||
|
* `postgresql://` or equals `sqlite`.
|
||||||
|
*/
|
||||||
|
test('validateDatabaseUrl returns true iff starts with postgresql:// or equals sqlite', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string(), (input) => {
|
||||||
|
const result = validateDatabaseUrl(input);
|
||||||
|
const expected = input.startsWith('postgresql://') || input === 'sqlite';
|
||||||
|
return result === expected;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabaseUrl accepts any postgresql:// URL', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string({ minLength: 0, maxLength: 100 }), (suffix) => {
|
||||||
|
return validateDatabaseUrl('postgresql://' + suffix) === true;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabaseUrl accepts sqlite literal', () => {
|
||||||
|
expect(validateDatabaseUrl('sqlite')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabaseUrl rejects other strings', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }).filter(
|
||||||
|
s => !s.startsWith('postgresql://') && s !== 'sqlite'
|
||||||
|
),
|
||||||
|
(input) => {
|
||||||
|
return validateDatabaseUrl(input) === false;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 11: SESSION_SECRET validation ---
|
||||||
|
describe('Property 11: SESSION_SECRET validation', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 5.6**
|
||||||
|
*
|
||||||
|
* For any string, validateSessionSecret returns true iff length in [16, 256].
|
||||||
|
*/
|
||||||
|
test('validateSessionSecret returns true iff length in [16, 256]', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string({ minLength: 0, maxLength: 300 }), (input) => {
|
||||||
|
const result = validateSessionSecret(input);
|
||||||
|
const expected = input.length >= 16 && input.length <= 256;
|
||||||
|
return result === expected;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateSessionSecret accepts strings of length 16-256', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.integer({ min: 16, max: 256 }).chain(len =>
|
||||||
|
fc.string({ minLength: len, maxLength: len })
|
||||||
|
),
|
||||||
|
(input) => {
|
||||||
|
return validateSessionSecret(input) === true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateSessionSecret rejects strings shorter than 16', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string({ minLength: 0, maxLength: 15 }), (input) => {
|
||||||
|
return validateSessionSecret(input) === false;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 12: Required variable rejection of whitespace ---
|
||||||
|
describe('Property 12: Required variable rejection of whitespace', () => {
|
||||||
|
/**
|
||||||
|
* **Validates: Requirements 5.1**
|
||||||
|
*
|
||||||
|
* For any whitespace-only string, validateRequired returns false;
|
||||||
|
* for any string with non-whitespace, returns true.
|
||||||
|
*/
|
||||||
|
test('validateRequired returns false for whitespace-only strings', () => {
|
||||||
|
const whitespaceArb = fc.array(
|
||||||
|
fc.constantFrom(' ', '\t', '\n', '\r', '\f', '\v'),
|
||||||
|
{ minLength: 0, maxLength: 20 }
|
||||||
|
).map(chars => chars.join(''));
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(whitespaceArb, (input) => {
|
||||||
|
return validateRequired(input) === false;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateRequired returns true for strings with non-whitespace', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
||||||
|
(input) => {
|
||||||
|
return validateRequired(input) === true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateRequired equivalence: result matches trim().length > 0', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.string(), (input) => {
|
||||||
|
const result = validateRequired(input);
|
||||||
|
const expected = input.trim().length > 0;
|
||||||
|
return result === expected;
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
756
backend/__tests__/config-wizard.integration.test.js
Normal file
756
backend/__tests__/config-wizard.integration.test.js
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
/**
|
||||||
|
* Integration Tests: Config Wizard End-to-End Flows
|
||||||
|
*
|
||||||
|
* Feature: config-wizard
|
||||||
|
*
|
||||||
|
* Tests filesystem interactions, real-world data parsing, and end-to-end
|
||||||
|
* function composition from `configure.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.4, 1.5, 6.5, 6.6, 9.6, 9.7, 14.4, 16.2, 16.4, 16.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const {
|
||||||
|
VARIABLE_DESCRIPTORS,
|
||||||
|
GROUP_ORDER,
|
||||||
|
OPTIONAL_GROUPS,
|
||||||
|
parseEnvFile,
|
||||||
|
parseDockerCompose,
|
||||||
|
generateEnvContent,
|
||||||
|
writeEnvFile,
|
||||||
|
createBackup,
|
||||||
|
detectInfraState,
|
||||||
|
shouldSkipFrontendBuild,
|
||||||
|
checkNodeVersion,
|
||||||
|
checkProjectRoot,
|
||||||
|
} = require('../../configure.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary directory for test isolation.
|
||||||
|
* Returns the path to the created directory.
|
||||||
|
*/
|
||||||
|
function createTempDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'config-wizard-test-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively remove a directory and its contents.
|
||||||
|
*/
|
||||||
|
function removeTempDir(dirPath) {
|
||||||
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 1: Full wizard run with all defaults — verify correct files written
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Full wizard run with all defaults', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateEnvContent + writeEnvFile produces valid backend .env with all required defaults', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
||||||
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
writeEnvFile(filePath, content);
|
||||||
|
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
const written = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// Verify key values are present
|
||||||
|
expect(written).toContain('PORT=3001');
|
||||||
|
expect(written).toContain('API_HOST=localhost');
|
||||||
|
expect(written).toContain('CORS_ORIGINS=http://localhost:3000');
|
||||||
|
expect(written).toContain('SESSION_SECRET=a-very-long-secret-key-here-1234');
|
||||||
|
// DATABASE_URL contains special chars, should be quoted
|
||||||
|
expect(written).toContain('DATABASE_URL=');
|
||||||
|
// Ends with newline
|
||||||
|
expect(written.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateEnvContent + writeEnvFile produces valid frontend .env with defaults', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('REACT_APP_API_BASE', 'http://localhost:3001/api');
|
||||||
|
values.set('REACT_APP_API_HOST', 'http://localhost:3001');
|
||||||
|
|
||||||
|
const frontendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'frontend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, frontendDescriptors, []);
|
||||||
|
|
||||||
|
const filePath = path.join(tmpDir, 'frontend.env');
|
||||||
|
writeEnvFile(filePath, content);
|
||||||
|
|
||||||
|
const written = fs.readFileSync(filePath, 'utf8');
|
||||||
|
expect(written).toContain('REACT_APP_API_BASE=http://localhost:3001/api');
|
||||||
|
expect(written).toContain('REACT_APP_API_HOST=http://localhost:3001');
|
||||||
|
expect(written).toContain('# --- Frontend Settings ---');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 2: Wizard with existing .env files — values pre-filled correctly
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Wizard with existing .env files', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEnvFile reads existing values correctly', () => {
|
||||||
|
const envContent = [
|
||||||
|
'PORT=4000',
|
||||||
|
'API_HOST=192.168.1.100',
|
||||||
|
'CORS_ORIGINS=http://myhost:3000',
|
||||||
|
'DATABASE_URL="postgresql://user:pass@localhost:5433/mydb"',
|
||||||
|
'SESSION_SECRET=my-super-secret-session-key-123',
|
||||||
|
'MY_CUSTOM_VAR=preserved',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
fs.writeFileSync(filePath, envContent, 'utf8');
|
||||||
|
|
||||||
|
const result = parseEnvFile(filePath);
|
||||||
|
|
||||||
|
expect(result.managed.get('PORT')).toBe('4000');
|
||||||
|
expect(result.managed.get('API_HOST')).toBe('192.168.1.100');
|
||||||
|
expect(result.managed.get('CORS_ORIGINS')).toBe('http://myhost:3000');
|
||||||
|
expect(result.managed.get('DATABASE_URL')).toBe('postgresql://user:pass@localhost:5433/mydb');
|
||||||
|
expect(result.managed.get('SESSION_SECRET')).toBe('my-super-secret-session-key-123');
|
||||||
|
expect(result.unmanaged).toContain('MY_CUSTOM_VAR=preserved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEnvFile handles quoted values with spaces', () => {
|
||||||
|
const envContent = 'CORS_ORIGINS="http://localhost:3000, http://localhost:8080"\n';
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
fs.writeFileSync(filePath, envContent, 'utf8');
|
||||||
|
|
||||||
|
const result = parseEnvFile(filePath);
|
||||||
|
expect(result.managed.get('CORS_ORIGINS')).toBe('http://localhost:3000, http://localhost:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEnvFile returns empty maps for non-existent file', () => {
|
||||||
|
const result = parseEnvFile(path.join(tmpDir, 'nonexistent.env'));
|
||||||
|
expect(result.managed.size).toBe(0);
|
||||||
|
expect(result.unmanaged.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 3: Wizard with skipped groups — groups absent from output
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Wizard with skipped groups', () => {
|
||||||
|
test('generateEnvContent excludes variables from skipped groups', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
// Intentionally NOT setting any Ivanti, Atlas, Jira, CARD, GitLab, NVD values
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
// Skipped groups should not appear in output
|
||||||
|
expect(content).not.toContain('# --- NVD API ---');
|
||||||
|
expect(content).not.toContain('# --- Ivanti Integration ---');
|
||||||
|
expect(content).not.toContain('# --- Atlas Integration ---');
|
||||||
|
expect(content).not.toContain('# --- Jira Integration ---');
|
||||||
|
expect(content).not.toContain('# --- CARD Integration ---');
|
||||||
|
expect(content).not.toContain('# --- GitLab Integration ---');
|
||||||
|
|
||||||
|
// Required groups should still be present
|
||||||
|
expect(content).toContain('# --- Core Settings ---');
|
||||||
|
expect(content).toContain('# --- Database ---');
|
||||||
|
expect(content).toContain('# --- Session ---');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateEnvContent includes optional group when values are provided', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
values.set('NVD_API_KEY', 'my-nvd-key-12345');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
expect(content).toContain('# --- NVD API ---');
|
||||||
|
expect(content).toContain('NVD_API_KEY=my-nvd-key-12345');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 4: Missing project structure — error exit
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Missing project structure', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let originalCwd;
|
||||||
|
let mockExit;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
originalCwd = process.cwd();
|
||||||
|
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
mockExit.mockRestore();
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkProjectRoot exits when backend/ is missing', () => {
|
||||||
|
// Create only frontend/
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
|
||||||
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkProjectRoot exits when frontend/ is missing', () => {
|
||||||
|
// Create only backend/
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
|
||||||
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkProjectRoot exits when both are missing', () => {
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
|
||||||
|
expect(() => checkProjectRoot()).toThrow('process.exit called');
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkProjectRoot succeeds when both directories exist', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
|
||||||
|
expect(() => checkProjectRoot()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 5: File write permission error — graceful failure
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('File write permission error', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore permissions before cleanup
|
||||||
|
try {
|
||||||
|
fs.chmodSync(path.join(tmpDir, 'readonly'), 0o755);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeEnvFile throws on invalid path (non-existent nested directory)', () => {
|
||||||
|
// Use a deeply nested non-existent path that will fail regardless of user
|
||||||
|
const filePath = path.join(tmpDir, 'no', 'such', 'deep', 'path', '.env');
|
||||||
|
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeEnvFile succeeds on valid writable path', () => {
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
expect(() => writeEnvFile(filePath, 'PORT=3001\n')).not.toThrow();
|
||||||
|
expect(fs.readFileSync(filePath, 'utf8')).toBe('PORT=3001\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 6: Infrastructure state detection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Infrastructure state detection', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let originalCwd;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
originalCwd = process.cwd();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectInfraState returns correct values based on filesystem state', () => {
|
||||||
|
// Set up a minimal project structure
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend', 'node_modules'));
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'backend', '.env'), 'PORT=3001\n');
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'backend', 'db-schema.sql'), 'CREATE TABLE test();');
|
||||||
|
// No frontend node_modules, no frontend .env, no frontend build
|
||||||
|
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
const state = detectInfraState();
|
||||||
|
|
||||||
|
expect(state.backendNodeModules).toBe(true);
|
||||||
|
expect(state.frontendNodeModules).toBe(false);
|
||||||
|
expect(state.backendEnvExists).toBe(true);
|
||||||
|
expect(state.frontendEnvExists).toBe(false);
|
||||||
|
expect(state.frontendBuildExists).toBe(false);
|
||||||
|
expect(state.schemaFileExists).toBe(true);
|
||||||
|
expect(state.sqliteDbExists).toBe(false);
|
||||||
|
// npmAvailable should be true in test environment
|
||||||
|
expect(typeof state.npmAvailable).toBe('boolean');
|
||||||
|
expect(typeof state.dockerAvailable).toBe('boolean');
|
||||||
|
expect(typeof state.psqlAvailable).toBe('boolean');
|
||||||
|
expect(typeof state.postgresRunning).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectInfraState detects SQLite database when present', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'backend', 'cve_database.db'), '');
|
||||||
|
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
const state = detectInfraState();
|
||||||
|
|
||||||
|
expect(state.sqliteDbExists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectInfraState detects frontend build when present', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'backend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend'));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, 'frontend', 'build'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'frontend', 'build', 'index.html'), '<html></html>');
|
||||||
|
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
const state = detectInfraState();
|
||||||
|
|
||||||
|
expect(state.frontendBuildExists).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 7: Frontend build skip on unchanged REACT_APP_* values
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Frontend build skip on unchanged REACT_APP_* values', () => {
|
||||||
|
test('shouldSkipFrontendBuild returns true when REACT_APP_* values are identical', () => {
|
||||||
|
const oldEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
||||||
|
]);
|
||||||
|
const newEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipFrontendBuild returns false when REACT_APP_* values differ', () => {
|
||||||
|
const oldEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
||||||
|
]);
|
||||||
|
const newEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://192.168.1.100:4000/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://192.168.1.100:4000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipFrontendBuild returns false when oldFrontendEnv is null', () => {
|
||||||
|
const newEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(shouldSkipFrontendBuild(null, newEnv)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipFrontendBuild returns false when one REACT_APP_* key differs', () => {
|
||||||
|
const oldEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://localhost:3001'],
|
||||||
|
]);
|
||||||
|
const newEnv = new Map([
|
||||||
|
['REACT_APP_API_BASE', 'http://localhost:3001/api'],
|
||||||
|
['REACT_APP_API_HOST', 'http://newhost:3001'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(shouldSkipFrontendBuild(oldEnv, newEnv)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 8: Node.js version check
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('Node.js version check', () => {
|
||||||
|
let mockExit;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockExit.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkNodeVersion does not exit on current Node.js version (>= 18)', () => {
|
||||||
|
// Current test environment should be Node 18+
|
||||||
|
expect(() => checkNodeVersion()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkNodeVersion would exit on Node < 18 (simulated via version override)', () => {
|
||||||
|
const originalVersion = process.version;
|
||||||
|
Object.defineProperty(process, 'version', { value: 'v16.20.0', writable: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => checkNodeVersion()).toThrow('process.exit called');
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, 'version', { value: originalVersion, writable: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 9: parseDockerCompose with real docker-compose.yml
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('parseDockerCompose with real docker-compose.yml', () => {
|
||||||
|
test('correctly parses the project actual docker-compose.yml', () => {
|
||||||
|
const composePath = path.join(__dirname, '..', '..', 'docker-compose.yml');
|
||||||
|
const result = parseDockerCompose(composePath);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.user).toBe('steam');
|
||||||
|
expect(result.password).toBe('sV4xmC9xAUCFop0ypxMVS056QgPqGrX');
|
||||||
|
expect(result.database).toBe('cve_dashboard');
|
||||||
|
expect(result.port).toBe('5433');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDockerCompose returns null for non-existent file', () => {
|
||||||
|
const result = parseDockerCompose('/nonexistent/docker-compose.yml');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDockerCompose returns null for invalid YAML content', () => {
|
||||||
|
const tmpDir = createTempDir();
|
||||||
|
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
||||||
|
fs.writeFileSync(filePath, 'this is not valid yaml at all\nno services here\n');
|
||||||
|
|
||||||
|
const result = parseDockerCompose(filePath);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDockerCompose handles compose file with shell variable defaults', () => {
|
||||||
|
const tmpDir = createTempDir();
|
||||||
|
const filePath = path.join(tmpDir, 'docker-compose.yml');
|
||||||
|
const content = [
|
||||||
|
'services:',
|
||||||
|
' postgres:',
|
||||||
|
' image: postgres:16-alpine',
|
||||||
|
' environment:',
|
||||||
|
' POSTGRES_DB: testdb',
|
||||||
|
' POSTGRES_USER: testuser',
|
||||||
|
' POSTGRES_PASSWORD: ${PG_PASS:-mysecretpass}',
|
||||||
|
' ports:',
|
||||||
|
' - "5434:5432"',
|
||||||
|
].join('\n');
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
|
||||||
|
const result = parseDockerCompose(filePath);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.user).toBe('testuser');
|
||||||
|
expect(result.password).toBe('mysecretpass');
|
||||||
|
expect(result.database).toBe('testdb');
|
||||||
|
expect(result.port).toBe('5434');
|
||||||
|
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 10: parseEnvFile round-trip — write and re-read produces identical values
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('parseEnvFile round-trip', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writing and re-reading produces identical managed values', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'my-session-secret-at-least-16-chars');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
writeEnvFile(filePath, content);
|
||||||
|
|
||||||
|
const parsed = parseEnvFile(filePath);
|
||||||
|
|
||||||
|
// All values we set should be recovered
|
||||||
|
for (const [key, value] of values) {
|
||||||
|
const descriptor = VARIABLE_DESCRIPTORS.find(d => d.name === key);
|
||||||
|
if (descriptor && descriptor.target === 'backend') {
|
||||||
|
expect(parsed.managed.get(key)).toBe(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip preserves values with special characters', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://user:p@ss$word@localhost:5433/db');
|
||||||
|
values.set('SESSION_SECRET', 'secret with spaces and #hash and $dollar');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
const filePath = path.join(tmpDir, '.env');
|
||||||
|
writeEnvFile(filePath, content);
|
||||||
|
|
||||||
|
const parsed = parseEnvFile(filePath);
|
||||||
|
|
||||||
|
expect(parsed.managed.get('PORT')).toBe('3001');
|
||||||
|
expect(parsed.managed.get('API_HOST')).toBe('localhost');
|
||||||
|
// Values with special chars are quoted, parseEnvFile strips quotes
|
||||||
|
expect(parsed.managed.get('SESSION_SECRET')).toBe('secret with spaces and #hash and $dollar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 11: generateEnvContent with all groups — complete output format
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('generateEnvContent with all groups', () => {
|
||||||
|
test('produces complete output with all group headers and values', () => {
|
||||||
|
const values = new Map();
|
||||||
|
// Core Settings
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
// Database
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
// Session
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
// NVD API
|
||||||
|
values.set('NVD_API_KEY', 'nvd-key-123');
|
||||||
|
// Ivanti
|
||||||
|
values.set('IVANTI_API_KEY', 'ivanti-key-456');
|
||||||
|
values.set('IVANTI_CLIENT_ID', '1550');
|
||||||
|
// Atlas
|
||||||
|
values.set('ATLAS_API_URL', 'https://atlas.example.com');
|
||||||
|
values.set('ATLAS_API_USER', 'atlasuser');
|
||||||
|
values.set('ATLAS_API_PASS', 'atlaspass');
|
||||||
|
// Jira
|
||||||
|
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
||||||
|
values.set('JIRA_AUTH_METHOD', 'basic');
|
||||||
|
values.set('JIRA_API_USER', 'jirauser');
|
||||||
|
values.set('JIRA_API_TOKEN', 'jira-token-789');
|
||||||
|
// CARD
|
||||||
|
values.set('CARD_API_URL', 'https://card.example.com');
|
||||||
|
values.set('CARD_API_USER', 'carduser');
|
||||||
|
values.set('CARD_API_PASS', 'cardpass');
|
||||||
|
// GitLab
|
||||||
|
values.set('GITLAB_URL', 'http://steam-gitlab.charterlab.com');
|
||||||
|
values.set('GITLAB_PROJECT_ID', '42');
|
||||||
|
values.set('GITLAB_PAT', 'glpat-abc123');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
// Verify all group headers present
|
||||||
|
expect(content).toContain('# --- Core Settings ---');
|
||||||
|
expect(content).toContain('# --- Database ---');
|
||||||
|
expect(content).toContain('# --- Session ---');
|
||||||
|
expect(content).toContain('# --- NVD API ---');
|
||||||
|
expect(content).toContain('# --- Ivanti Integration ---');
|
||||||
|
expect(content).toContain('# --- Atlas Integration ---');
|
||||||
|
expect(content).toContain('# --- Jira Integration ---');
|
||||||
|
expect(content).toContain('# --- CARD Integration ---');
|
||||||
|
expect(content).toContain('# --- GitLab Integration ---');
|
||||||
|
|
||||||
|
// Verify values present
|
||||||
|
expect(content).toContain('PORT=3001');
|
||||||
|
expect(content).toContain('NVD_API_KEY=nvd-key-123');
|
||||||
|
expect(content).toContain('IVANTI_CLIENT_ID=1550');
|
||||||
|
expect(content).toContain('GITLAB_PROJECT_ID=42');
|
||||||
|
|
||||||
|
// Verify LF line endings (no \r)
|
||||||
|
expect(content).not.toContain('\r');
|
||||||
|
// Verify trailing newline
|
||||||
|
expect(content.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 12: generateEnvContent with skipped groups — excluded from output
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('generateEnvContent with skipped groups', () => {
|
||||||
|
test('skipped groups produce no KEY=value lines in output', () => {
|
||||||
|
const values = new Map();
|
||||||
|
// Only set required group values
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
// Verify no optional group variables appear
|
||||||
|
const optionalVarNames = VARIABLE_DESCRIPTORS
|
||||||
|
.filter(d => OPTIONAL_GROUPS.includes(d.group))
|
||||||
|
.map(d => d.name);
|
||||||
|
|
||||||
|
for (const varName of optionalVarNames) {
|
||||||
|
// Should not have any KEY= line for these variables
|
||||||
|
const regex = new RegExp(`^${varName}=`, 'm');
|
||||||
|
expect(content).not.toMatch(regex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial optional groups — only configured groups appear', () => {
|
||||||
|
const values = new Map();
|
||||||
|
values.set('PORT', '3001');
|
||||||
|
values.set('API_HOST', 'localhost');
|
||||||
|
values.set('CORS_ORIGINS', 'http://localhost:3000');
|
||||||
|
values.set('DATABASE_URL', 'postgresql://steam:pass@localhost:5433/cve_dashboard');
|
||||||
|
values.set('SESSION_SECRET', 'a-very-long-secret-key-here-1234');
|
||||||
|
// Only configure Jira
|
||||||
|
values.set('JIRA_BASE_URL', 'https://jira.example.com');
|
||||||
|
values.set('JIRA_API_USER', 'user');
|
||||||
|
values.set('JIRA_API_TOKEN', 'token-value-here');
|
||||||
|
|
||||||
|
const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
|
||||||
|
const content = generateEnvContent(values, GROUP_ORDER, backendDescriptors, []);
|
||||||
|
|
||||||
|
expect(content).toContain('# --- Jira Integration ---');
|
||||||
|
expect(content).toContain('JIRA_BASE_URL=https://jira.example.com');
|
||||||
|
// Other optional groups should not appear
|
||||||
|
expect(content).not.toContain('# --- NVD API ---');
|
||||||
|
expect(content).not.toContain('# --- Ivanti Integration ---');
|
||||||
|
expect(content).not.toContain('# --- Atlas Integration ---');
|
||||||
|
expect(content).not.toContain('# --- CARD Integration ---');
|
||||||
|
expect(content).not.toContain('# --- GitLab Integration ---');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test 13: createBackup — backup file creation with timestamp naming
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('createBackup', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
removeTempDir(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates backup file with timestamp naming', () => {
|
||||||
|
const originalPath = path.join(tmpDir, '.env');
|
||||||
|
fs.writeFileSync(originalPath, 'PORT=3001\nAPI_HOST=localhost\n');
|
||||||
|
|
||||||
|
const backupPath = createBackup(originalPath);
|
||||||
|
|
||||||
|
expect(fs.existsSync(backupPath)).toBe(true);
|
||||||
|
// Backup should match pattern: .env.backup.YYYYMMDD_HHmmss
|
||||||
|
expect(backupPath).toMatch(/\.env\.backup\.\d{8}_\d{6}$/);
|
||||||
|
// Content should be identical
|
||||||
|
const originalContent = fs.readFileSync(originalPath, 'utf8');
|
||||||
|
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
||||||
|
expect(backupContent).toBe(originalContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates numbered backup when timestamp backup already exists', () => {
|
||||||
|
const originalPath = path.join(tmpDir, '.env');
|
||||||
|
fs.writeFileSync(originalPath, 'PORT=3001\n');
|
||||||
|
|
||||||
|
// Create first backup
|
||||||
|
const firstBackup = createBackup(originalPath);
|
||||||
|
expect(fs.existsSync(firstBackup)).toBe(true);
|
||||||
|
|
||||||
|
// Modify original
|
||||||
|
fs.writeFileSync(originalPath, 'PORT=4000\n');
|
||||||
|
|
||||||
|
// Create second backup — since timestamp is same second, it should use .bak.N
|
||||||
|
// We simulate by creating the expected timestamp backup manually
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.getFullYear().toString() +
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0') +
|
||||||
|
String(now.getDate()).padStart(2, '0') + '_' +
|
||||||
|
String(now.getHours()).padStart(2, '0') +
|
||||||
|
String(now.getMinutes()).padStart(2, '0') +
|
||||||
|
String(now.getSeconds()).padStart(2, '0');
|
||||||
|
const expectedTimestampPath = `${originalPath}.backup.${timestamp}`;
|
||||||
|
|
||||||
|
// If the timestamp backup already exists (from first call), second call uses .bak.N
|
||||||
|
if (fs.existsSync(expectedTimestampPath)) {
|
||||||
|
const secondBackup = createBackup(originalPath);
|
||||||
|
expect(secondBackup).toMatch(/\.bak\.\d+$/);
|
||||||
|
expect(fs.existsSync(secondBackup)).toBe(true);
|
||||||
|
const content = fs.readFileSync(secondBackup, 'utf8');
|
||||||
|
expect(content).toBe('PORT=4000\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup preserves file content exactly', () => {
|
||||||
|
const originalPath = path.join(tmpDir, '.env');
|
||||||
|
const content = '# --- Core Settings ---\nPORT=3001\nAPI_HOST=localhost\n\n# Custom\nMY_VAR=hello\n';
|
||||||
|
fs.writeFileSync(originalPath, content);
|
||||||
|
|
||||||
|
const backupPath = createBackup(originalPath);
|
||||||
|
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
||||||
|
expect(backupContent).toBe(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
108
backend/__tests__/fp-submissions-cleanup.property.test.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: FP Submissions Cleanup
|
||||||
|
*
|
||||||
|
* Feature: fp-submissions-cleanup
|
||||||
|
*
|
||||||
|
* Tests the pure filtering functions used to determine which FP submissions
|
||||||
|
* are visible in the Queue Panel and which show the dismiss button.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.1, 2.1, 2.2, 2.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
// Mock db pool before importing the route module (avoids DATABASE_URL requirement)
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dependencies that the route module imports
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow');
|
||||||
|
|
||||||
|
// --- Generators ---
|
||||||
|
|
||||||
|
const lifecycleStatusArb = fc.constantFrom('submitted', 'approved', 'rejected', 'rework', 'resubmitted');
|
||||||
|
|
||||||
|
const dismissedAtArb = fc.oneof(
|
||||||
|
fc.constant(null),
|
||||||
|
fc.date({ min: new Date('2020-01-01T00:00:00.000Z'), max: new Date('2030-12-31T00:00:00.000Z') })
|
||||||
|
.filter(d => !isNaN(d.getTime()))
|
||||||
|
.map(d => d.toISOString())
|
||||||
|
);
|
||||||
|
|
||||||
|
const submissionArb = fc.record({
|
||||||
|
id: fc.integer({ min: 1, max: 100000 }),
|
||||||
|
lifecycle_status: lifecycleStatusArb,
|
||||||
|
dismissed_at: dismissedAtArb,
|
||||||
|
user_id: fc.integer({ min: 1, max: 1000 }),
|
||||||
|
ivanti_workflow_batch_id: fc.string({ minLength: 1, maxLength: 20 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const submissionsArrayArb = fc.array(submissionArb, { minLength: 0, maxLength: 50 });
|
||||||
|
|
||||||
|
// --- Property 1: Submission Visibility Filter ---
|
||||||
|
|
||||||
|
describe('Feature: fp-submissions-cleanup, Property 1: Submission Visibility Filter', () => {
|
||||||
|
/**
|
||||||
|
* For any array of FP submission objects with arbitrary lifecycle_status values
|
||||||
|
* and arbitrary dismissed_at values, filterVisibleSubmissions(submissions) should
|
||||||
|
* return only submissions where lifecycle_status is NOT "approved" AND dismissed_at
|
||||||
|
* is null. Additionally, every submission in the input that satisfies both conditions
|
||||||
|
* must appear in the output, and the output length must be <= input length.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.1, 2.2, 2.3
|
||||||
|
*/
|
||||||
|
it('returns only non-approved and non-dismissed submissions', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(submissionsArrayArb, (submissions) => {
|
||||||
|
const result = filterVisibleSubmissions(submissions);
|
||||||
|
|
||||||
|
// Output length must be <= input length
|
||||||
|
expect(result.length).toBeLessThanOrEqual(submissions.length);
|
||||||
|
|
||||||
|
// Every item in the result must be non-approved and non-dismissed
|
||||||
|
for (const s of result) {
|
||||||
|
expect(s.lifecycle_status).not.toBe('approved');
|
||||||
|
expect(s.dismissed_at).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every input item that satisfies both conditions must appear in the output
|
||||||
|
const expected = submissions.filter(
|
||||||
|
s => s.lifecycle_status !== 'approved' && s.dismissed_at == null
|
||||||
|
);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 2: Dismiss Button Visibility Predicate ---
|
||||||
|
|
||||||
|
describe('Feature: fp-submissions-cleanup, Property 2: Dismiss Button Visibility Predicate', () => {
|
||||||
|
/**
|
||||||
|
* For any FP submission object with a lifecycle_status value drawn from
|
||||||
|
* {submitted, approved, rejected, rework, resubmitted} and a dismissed_at value
|
||||||
|
* (null or timestamp), the dismiss button should be rendered if and only if
|
||||||
|
* lifecycle_status === 'rejected' AND dismissed_at is null.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.1
|
||||||
|
*/
|
||||||
|
it('returns true iff status is rejected and dismissed_at is null', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(submissionArb, (submission) => {
|
||||||
|
const result = shouldShowDismissButton(submission);
|
||||||
|
const expected = submission.lifecycle_status === 'rejected' && submission.dismissed_at == null;
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
240
backend/__tests__/fp-submissions-cleanup.test.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Unit and Integration Tests: FP Submissions Cleanup
|
||||||
|
*
|
||||||
|
* Feature: fp-submissions-cleanup
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Dismiss endpoint (happy path, wrong status, ownership check, not found)
|
||||||
|
* - Filter edge cases (all approved, all dismissed, mixed, empty array)
|
||||||
|
* - Integration: dismissed submissions remain in DB but are excluded from filtered list
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock auth middleware to bypass real session checks
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock audit log as a no-op
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock ivantiApi to avoid real network calls
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the db pool
|
||||||
|
const mockPool = {
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
};
|
||||||
|
jest.mock('../db', () => mockPool);
|
||||||
|
|
||||||
|
const createIvantiFpWorkflowRouter = require('../routes/ivantiFpWorkflow');
|
||||||
|
const { filterVisibleSubmissions, shouldShowDismissButton } = require('../routes/ivantiFpWorkflow');
|
||||||
|
|
||||||
|
// --- HTTP helper ---
|
||||||
|
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(body); } catch (e) { json = null; }
|
||||||
|
resolve({ statusCode: res.statusCode, body: json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dismiss Endpoint Tests (Task 8.1) ---
|
||||||
|
|
||||||
|
describe('PATCH /submissions/:id/dismiss', () => {
|
||||||
|
let app, server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path — dismisses a rejected submission owned by the user', async () => {
|
||||||
|
// First query: SELECT submission
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 1,
|
||||||
|
lifecycle_status: 'rejected',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
// Second query: UPDATE dismissed_at
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rowCount: 1 });
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true });
|
||||||
|
// Verify the UPDATE was called with the correct SQL pattern
|
||||||
|
expect(mockPool.query).toHaveBeenCalledTimes(2);
|
||||||
|
const updateCall = mockPool.query.mock.calls[1];
|
||||||
|
expect(updateCall[0]).toContain('dismissed_at');
|
||||||
|
expect(updateCall[1]).toContain('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when submission does not exist', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/999/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Submission not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when user does not own the submission', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 99, // different user
|
||||||
|
lifecycle_status: 'rejected',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.body.error).toBe('You can only dismiss your own submissions.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when submission is not in rejected status', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{
|
||||||
|
id: 42,
|
||||||
|
user_id: 1,
|
||||||
|
lifecycle_status: 'submitted',
|
||||||
|
dismissed_at: null,
|
||||||
|
ivanti_workflow_batch_id: 'WF-100'
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/ivanti/fp-workflow/submissions/42/dismiss');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Only rejected submissions can be dismissed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Filter Edge Cases (Task 8.2) ---
|
||||||
|
|
||||||
|
describe('filterVisibleSubmissions — edge cases', () => {
|
||||||
|
it('returns empty array when all submissions are approved', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 2, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 3, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
];
|
||||||
|
expect(filterVisibleSubmissions(submissions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when all submissions are dismissed', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z' },
|
||||||
|
{ id: 2, lifecycle_status: 'submitted', dismissed_at: '2026-04-15T08:00:00Z' },
|
||||||
|
{ id: 3, lifecycle_status: 'rework', dismissed_at: '2026-03-20T10:00:00Z' },
|
||||||
|
];
|
||||||
|
expect(filterVisibleSubmissions(submissions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct subset for mixed statuses', () => {
|
||||||
|
const submissions = [
|
||||||
|
{ id: 1, lifecycle_status: 'approved', dismissed_at: null },
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: null },
|
||||||
|
{ id: 3, lifecycle_status: 'submitted', dismissed_at: '2026-05-01T12:00:00Z' },
|
||||||
|
{ id: 4, lifecycle_status: 'rework', dismissed_at: null },
|
||||||
|
{ id: 5, lifecycle_status: 'resubmitted', dismissed_at: null },
|
||||||
|
];
|
||||||
|
const result = filterVisibleSubmissions(submissions);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: null },
|
||||||
|
{ id: 4, lifecycle_status: 'rework', dismissed_at: null },
|
||||||
|
{ id: 5, lifecycle_status: 'resubmitted', dismissed_at: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(filterVisibleSubmissions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Integration Test (Task 8.3) ---
|
||||||
|
|
||||||
|
describe('Integration: dismissed submissions remain in DB but are excluded from filtered list', () => {
|
||||||
|
it('dismissed submission is still in the database but excluded by filterVisibleSubmissions', async () => {
|
||||||
|
// Simulate the full database state after a dismiss operation:
|
||||||
|
// The submission record still exists with dismissed_at set
|
||||||
|
const allSubmissionsInDb = [
|
||||||
|
{ id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 2, lifecycle_status: 'rejected', dismissed_at: '2026-05-01T12:00:00Z', user_id: 1 },
|
||||||
|
{ id: 3, lifecycle_status: 'approved', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The dismissed submission (id: 2) is still in the database
|
||||||
|
const dismissedSubmission = allSubmissionsInDb.find(s => s.id === 2);
|
||||||
|
expect(dismissedSubmission).toBeDefined();
|
||||||
|
expect(dismissedSubmission.dismissed_at).not.toBeNull();
|
||||||
|
|
||||||
|
// But when we filter for visible submissions, it's excluded
|
||||||
|
const visibleSubmissions = filterVisibleSubmissions(allSubmissionsInDb);
|
||||||
|
|
||||||
|
// Dismissed submission (id: 2) is NOT in the visible list
|
||||||
|
expect(visibleSubmissions.find(s => s.id === 2)).toBeUndefined();
|
||||||
|
|
||||||
|
// Approved submission (id: 3) is also NOT in the visible list
|
||||||
|
expect(visibleSubmissions.find(s => s.id === 3)).toBeUndefined();
|
||||||
|
|
||||||
|
// Non-dismissed, non-approved submissions ARE in the visible list
|
||||||
|
expect(visibleSubmissions).toEqual([
|
||||||
|
{ id: 1, lifecycle_status: 'submitted', dismissed_at: null, user_id: 1 },
|
||||||
|
{ id: 4, lifecycle_status: 'rejected', dismissed_at: null, user_id: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the original array is unchanged (submissions remain in DB)
|
||||||
|
expect(allSubmissionsInDb.length).toBe(4);
|
||||||
|
expect(allSubmissionsInDb.find(s => s.id === 2)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
|
||||||
|
* Validates: Requirements 6.3, 6.4
|
||||||
|
*/
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock auth middleware
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, _res, next) => {
|
||||||
|
req.user = { id: 7, username: 'testuser' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (_req, _res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock audit log
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock the db pool
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
||||||
|
connect: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: send an HTTP request and return { statusCode, body }.
|
||||||
|
*/
|
||||||
|
function request(server, method, path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||||
|
resolve({ statusCode: res.statusCode, body });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/ivanti/todo-queue/ticket-links', () => {
|
||||||
|
let app;
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty links object when no associations exist', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toEqual({ links: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a map of queue_item_id to ticket info', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||||
|
{ queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||||
|
{ queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
links: {
|
||||||
|
'12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||||
|
'15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||||
|
'22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by the authenticated user ID', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||||
|
|
||||||
|
const [sql, params] = pool.query.mock.calls[0];
|
||||||
|
expect(sql).toContain('q.user_id = $1');
|
||||||
|
expect(params).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||||
|
|
||||||
|
const [sql] = pool.query.mock.calls[0];
|
||||||
|
expect(sql).toContain('jira_ticket_queue_items');
|
||||||
|
expect(sql).toContain('JOIN jira_tickets');
|
||||||
|
expect(sql).toContain('JOIN ivanti_todo_queue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on database error', async () => {
|
||||||
|
pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
109
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
109
backend/__tests__/jira-jql-window-invariant.property.test.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Test: JQL Window Invariant
|
||||||
|
*
|
||||||
|
* Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72 hours in bulk sync
|
||||||
|
*
|
||||||
|
* For any non-empty array of valid-looking issue keys passed to searchIssuesByKeys(),
|
||||||
|
* the generated JQL string SHALL contain the substring `updated >= -72h` and
|
||||||
|
* SHALL contain the substring `project =`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.1, 2.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
// Capture the JQL that flows through the HTTP layer.
|
||||||
|
let capturedJql = null;
|
||||||
|
|
||||||
|
// Mock https to intercept the request URL (which contains the JQL) and return
|
||||||
|
// a fake 200 response. This prevents real network calls while letting the
|
||||||
|
// real searchIssuesByKeys → searchIssues → jiraGet → jiraRequest chain execute.
|
||||||
|
jest.mock('https', () => ({
|
||||||
|
request: jest.fn((options, callback) => {
|
||||||
|
const fullPath = options.path || '';
|
||||||
|
const jqlMatch = fullPath.match(/[?&]jql=([^&]*)/);
|
||||||
|
if (jqlMatch) {
|
||||||
|
capturedJql = decodeURIComponent(jqlMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
statusCode: 200,
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
handler(JSON.stringify({ total: 0, issues: [] }));
|
||||||
|
}
|
||||||
|
if (event === 'end') {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// Use setImmediate so the callback fires on the same tick after promises
|
||||||
|
// resolve, but still asynchronously as Node's http expects.
|
||||||
|
setImmediate(() => callback(mockResponse));
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set required env vars before requiring the module so the module-level
|
||||||
|
// constants pick them up.
|
||||||
|
process.env.JIRA_PROJECT_KEY = 'TESTPROJ';
|
||||||
|
process.env.JIRA_BASE_URL = 'https://jira.example.com';
|
||||||
|
process.env.JIRA_API_USER = 'testuser';
|
||||||
|
process.env.JIRA_API_TOKEN = 'testtoken';
|
||||||
|
|
||||||
|
const jiraApi = require('../helpers/jiraApi');
|
||||||
|
|
||||||
|
describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always 72h in bulk sync', () => {
|
||||||
|
// Use fake timers so the rate-limiter's inter-request delays (1–2 seconds)
|
||||||
|
// resolve instantly. We preserve setImmediate so the https mock callback
|
||||||
|
// still fires asynchronously as expected.
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedJql = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generator: produces a valid Jira issue key like "AB-1", "PROJ-42", etc.
|
||||||
|
const issueKeyArb = fc.tuple(
|
||||||
|
fc.stringMatching(/^[A-Z]{2,10}$/),
|
||||||
|
fc.integer({ min: 1, max: 99999 })
|
||||||
|
).map(([prefix, num]) => `${prefix}-${num}`);
|
||||||
|
|
||||||
|
// Generator: non-empty array of issue keys (1 to 50 keys)
|
||||||
|
const issueKeysArb = fc.array(issueKeyArb, { minLength: 1, maxLength: 50 });
|
||||||
|
|
||||||
|
it('searchIssuesByKeys() always generates JQL containing `updated >= -72h` and `project =`', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(issueKeysArb, async (issueKeys) => {
|
||||||
|
capturedJql = null;
|
||||||
|
|
||||||
|
// Start the call — it will hit waitForDelay which uses setTimeout
|
||||||
|
const promise = jiraApi.searchIssuesByKeys(issueKeys);
|
||||||
|
|
||||||
|
// Advance fake timers to resolve any pending setTimeout from the
|
||||||
|
// rate limiter's waitForDelay function.
|
||||||
|
jest.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(capturedJql).not.toBeNull();
|
||||||
|
expect(capturedJql).toContain('updated >= -72h');
|
||||||
|
// project filter intentionally removed — issue keys are globally unique
|
||||||
|
// and the filter broke cross-project ticket sync
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
151
backend/__tests__/jira-route-removal.test.js
Normal file
151
backend/__tests__/jira-route-removal.test.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Example-Based Tests: Route Removal and Remaining Routes
|
||||||
|
*
|
||||||
|
* Feature: jira-api-compliance-cleanup
|
||||||
|
*
|
||||||
|
* Property 2: Search route is absent from router (Example)
|
||||||
|
* After the route removal, a POST request to /api/jira/search SHALL return HTTP 404.
|
||||||
|
* Validates: Requirements 1.1, 1.2
|
||||||
|
*
|
||||||
|
* Property 3: Existing routes remain functional after search route removal (Example)
|
||||||
|
* The routes GET /lookup/:issueKey, POST /sync-all, POST /:id/sync, and
|
||||||
|
* POST /create-in-jira SHALL continue to respond with non-404 status codes.
|
||||||
|
* Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the audit log helper to be a no-op.
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock the db module to avoid requiring DATABASE_URL in CI
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the jiraApi helper — mark it as not configured so routes return 503
|
||||||
|
// (which is fine; we only care that they are NOT 404).
|
||||||
|
jest.mock('../helpers/jiraApi', () => ({
|
||||||
|
isConfigured: false,
|
||||||
|
getRateLimitStatus: jest.fn(() => ({
|
||||||
|
burst: { remaining: 60, limit: 60 },
|
||||||
|
daily: { remaining: 1440, limit: 1440 },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||||
|
|
||||||
|
// Minimal db mock — callback-style methods that return empty results.
|
||||||
|
function createMockDb() {
|
||||||
|
return {
|
||||||
|
get: jest.fn((_sql, _params, cb) => cb(null, null)),
|
||||||
|
all: jest.fn((_sql, _params, cb) => cb(null, [])),
|
||||||
|
run: jest.fn(function (_sql, _params, cb) {
|
||||||
|
if (typeof cb === 'function') cb.call({ lastID: 1, changes: 0 }, null);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: send an HTTP request to the test server and return { statusCode }.
|
||||||
|
*/
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
// Consume the response body so the socket closes cleanly.
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ statusCode: res.statusCode });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Feature: jira-api-compliance-cleanup — route removal tests', () => {
|
||||||
|
let app;
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
const db = createMockDb();
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||||
|
|
||||||
|
// Listen on a random available port.
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 2: POST /api/jira-tickets/search returns 404
|
||||||
|
// Validates: Requirements 1.1, 1.2
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Property 2: Search route is absent', () => {
|
||||||
|
it('POST /api/jira-tickets/search returns HTTP 404', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/search', {
|
||||||
|
jql: 'project = TEST',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 3: Remaining routes respond with non-404 status codes
|
||||||
|
// Validates: Requirements 1.3, 1.4, 1.5, 1.6
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Property 3: Existing routes remain functional', () => {
|
||||||
|
it('GET /api/jira-tickets/lookup/:issueKey returns non-404', async () => {
|
||||||
|
const res = await request(server, 'GET', '/api/jira-tickets/lookup/TEST-1');
|
||||||
|
expect(res.statusCode).not.toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/jira-tickets/sync-all returns non-404', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/sync-all');
|
||||||
|
expect(res.statusCode).not.toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/jira-tickets/:id/sync returns non-404', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/sync');
|
||||||
|
expect(res.statusCode).not.toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/jira-tickets/create-in-jira returns non-404', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/create-in-jira', {
|
||||||
|
cve_id: 'CVE-2024-12345',
|
||||||
|
vendor: 'TestVendor',
|
||||||
|
summary: 'Test summary',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).not.toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Unit Tests: POST /api/jira-tickets/:id/queue-items
|
||||||
|
*
|
||||||
|
* Feature: multi-item-jira-ticket
|
||||||
|
*
|
||||||
|
* Tests the junction endpoint that links queue items to a Jira ticket.
|
||||||
|
* Validates: Requirements 5.3, 6.1, 6.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: (...groups) => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the audit log helper to be a no-op.
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock the jiraApi helper
|
||||||
|
jest.mock('../helpers/jiraApi', () => ({
|
||||||
|
isConfigured: false,
|
||||||
|
getRateLimitStatus: jest.fn(() => ({
|
||||||
|
burst: { remaining: 60, limit: 60 },
|
||||||
|
daily: { remaining: 1440, limit: 1440 },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: send an HTTP request to the test server and return { statusCode, body }.
|
||||||
|
*/
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
||||||
|
resolve({ statusCode: res.statusCode, body: parsed });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/jira-tickets/:id/queue-items', () => {
|
||||||
|
let app;
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pool.query.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Validation tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns 400 when queue_item_ids is missing', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when queue_item_ids is an empty array', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||||
|
queue_item_ids: [],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when queue_item_ids is not an array', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||||
|
queue_item_ids: 'not-an-array',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when queue_item_ids contains non-integers', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||||
|
queue_item_ids: [1, 2.5, 3],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when queue_item_ids contains strings', async () => {
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||||
|
queue_item_ids: [1, 'abc', 3],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Ticket existence check
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns 404 when jira ticket does not exist', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', {
|
||||||
|
queue_item_ids: [1, 2, 3],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Jira ticket not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Queue item existence check
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns 400 when some queue items do not exist', async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||||
|
queue_item_ids: [1, 2, 3],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toBe('One or more queue items not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Successful linking
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns 201 with linked_count on success', async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist
|
||||||
|
.mockResolvedValueOnce({ rowCount: 3 }); // insert result
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||||
|
queue_item_ids: [12, 15, 18],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.message).toBe('Queue items linked to ticket');
|
||||||
|
expect(res.body.ticket_id).toBe(42);
|
||||||
|
expect(res.body.linked_count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate)
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||||
|
queue_item_ids: [12, 15],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.linked_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Error handling
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('returns 500 on database error', async () => {
|
||||||
|
pool.query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost')); // insert fails
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||||
|
queue_item_ids: [12],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(res.body.error).toContain('Connection lost');
|
||||||
|
});
|
||||||
|
});
|
||||||
308
backend/__tests__/vcl-aggregated-burndown.property.test.js
Normal file
308
backend/__tests__/vcl-aggregated-burndown.property.test.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: VCL Aggregated Burndown
|
||||||
|
*
|
||||||
|
* Feature: vcl-aggregated-burndown
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions `deduplicateByHostname` and `computeAggregatedBurndown`
|
||||||
|
* from `backend/helpers/vclHelpers.js`.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 1.5, 1.6, 1.7, 2.2, 2.3, 2.4, 2.5, 4.1, 4.2, 4.3, 4.4, 5.1, 5.2, 5.4
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
// Mock db pool before importing anything
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
connect: jest.fn(() => Promise.resolve({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
release: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
deduplicateByHostname,
|
||||||
|
computeAggregatedBurndown,
|
||||||
|
} = require('../helpers/vclHelpers');
|
||||||
|
|
||||||
|
// --- Generators ---
|
||||||
|
|
||||||
|
const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 20 });
|
||||||
|
|
||||||
|
const validDateArb = fc.record({
|
||||||
|
year: fc.integer({ min: 2020, max: 2030 }),
|
||||||
|
month: fc.integer({ min: 1, max: 12 }),
|
||||||
|
day: fc.integer({ min: 1, max: 28 }),
|
||||||
|
}).map(({ year, month, day }) =>
|
||||||
|
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const verticalCodeArb = fc.constantFrom('NTS_AEO', 'SDIT_CISO', 'TSI', 'SR', 'AllOthers');
|
||||||
|
|
||||||
|
const deviceArb = fc.record({
|
||||||
|
hostname: hostnameArb,
|
||||||
|
resolution_date: fc.oneof(fc.constant(null), validDateArb),
|
||||||
|
vertical: verticalCodeArb,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generator for items that may have duplicate hostnames (for deduplication testing)
|
||||||
|
const duplicateItemsArb = fc.array(
|
||||||
|
fc.record({
|
||||||
|
hostname: fc.constantFrom('srv-001', 'srv-002', 'srv-003', 'srv-004', 'srv-005'),
|
||||||
|
resolution_date: fc.oneof(fc.constant(null), validDateArb),
|
||||||
|
vertical: verticalCodeArb,
|
||||||
|
}),
|
||||||
|
{ minLength: 0, maxLength: 30 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Property 1: Partition Invariant ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 1: Partition Invariant', () => {
|
||||||
|
/**
|
||||||
|
* For any array of device objects passed to computeAggregatedBurndown,
|
||||||
|
* blockers + with_dates = total.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.2**
|
||||||
|
*/
|
||||||
|
it('blockers + with_dates = total for any input', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.blockers + result.with_dates).toBe(result.total);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 2: Monthly Bucket Conservation ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 2: Monthly Bucket Conservation', () => {
|
||||||
|
/**
|
||||||
|
* For any array of device objects, the sum of all values in monthly
|
||||||
|
* must equal with_dates.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.3, 1.5**
|
||||||
|
*/
|
||||||
|
it('sum of monthly values = with_dates', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
const monthlySum = Object.values(result.monthly).reduce((s, v) => s + v, 0);
|
||||||
|
expect(monthlySum).toBe(result.with_dates);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 3: Chronological Monthly Ordering ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 3: Chronological Monthly Ordering', () => {
|
||||||
|
/**
|
||||||
|
* For any array of device objects, the keys of monthly must be in
|
||||||
|
* ascending chronological order (lexicographic sort of YYYY-MM strings).
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.4**
|
||||||
|
*/
|
||||||
|
it('monthly keys are in ascending chronological order', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
const keys = Object.keys(result.monthly);
|
||||||
|
for (let i = 1; i < keys.length; i++) {
|
||||||
|
expect(keys[i - 1] < keys[i]).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 4: Cumulative Projection Consistency ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 4: Cumulative Projection Consistency', () => {
|
||||||
|
/**
|
||||||
|
* For any array of device objects, projection[month].remaining =
|
||||||
|
* total - (cumulative sum of monthly[m] for all m <= month).
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.5**
|
||||||
|
*/
|
||||||
|
it('projection remaining = total - cumulative remediated', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
const months = Object.keys(result.monthly);
|
||||||
|
let cumulative = 0;
|
||||||
|
for (const month of months) {
|
||||||
|
cumulative += result.monthly[month];
|
||||||
|
expect(result.projection[month].remediated).toBe(result.monthly[month]);
|
||||||
|
expect(result.projection[month].remaining).toBe(result.total - cumulative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 5: Projected Clear Date Logic ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 5: Projected Clear Date Logic', () => {
|
||||||
|
/**
|
||||||
|
* If blockers > 0, projected_clear_date must be null.
|
||||||
|
* If blockers = 0 and with_dates > 0, projected_clear_date must equal the last month key.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 1.7**
|
||||||
|
*/
|
||||||
|
it('null when blockers > 0, last month key when blockers = 0 and with_dates > 0', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
if (result.blockers > 0) {
|
||||||
|
expect(result.projected_clear_date).toBeNull();
|
||||||
|
} else if (result.with_dates > 0) {
|
||||||
|
const months = Object.keys(result.monthly);
|
||||||
|
expect(result.projected_clear_date).toBe(months[months.length - 1]);
|
||||||
|
} else {
|
||||||
|
// total = 0 case
|
||||||
|
expect(result.projected_clear_date).toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 6: Hostname Deduplication with Earliest Date ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 6: Hostname Deduplication with Earliest Date', () => {
|
||||||
|
/**
|
||||||
|
* For any array of items where the same hostname appears multiple times,
|
||||||
|
* deduplicateByHostname produces exactly one entry per unique hostname,
|
||||||
|
* and that entry's resolution_date is the earliest non-null date (or null if all null).
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 1.6**
|
||||||
|
*/
|
||||||
|
it('one entry per hostname with earliest non-null date', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
duplicateItemsArb,
|
||||||
|
(items) => {
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
|
||||||
|
// One entry per unique hostname
|
||||||
|
const uniqueHostnames = new Set(items.map(i => i.hostname));
|
||||||
|
expect(result.length).toBe(uniqueHostnames.size);
|
||||||
|
|
||||||
|
// Each result hostname appears exactly once
|
||||||
|
const resultHostnames = result.map(r => r.hostname);
|
||||||
|
expect(new Set(resultHostnames).size).toBe(result.length);
|
||||||
|
|
||||||
|
// For each hostname, verify the date is the earliest non-null
|
||||||
|
for (const entry of result) {
|
||||||
|
const allForHost = items.filter(i => i.hostname === entry.hostname);
|
||||||
|
const nonNullDates = allForHost
|
||||||
|
.map(i => i.resolution_date)
|
||||||
|
.filter(d => d != null);
|
||||||
|
|
||||||
|
if (nonNullDates.length === 0) {
|
||||||
|
expect(entry.resolution_date).toBeNull();
|
||||||
|
} else {
|
||||||
|
const earliest = nonNullDates.sort()[0];
|
||||||
|
expect(entry.resolution_date).toBe(earliest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 7: Aggregation Consistency with Per-Vertical Computation ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 7: Aggregation Consistency with Per-Vertical Computation', () => {
|
||||||
|
/**
|
||||||
|
* Aggregated total = sum of per-vertical totals.
|
||||||
|
* Aggregated blockers = sum of per-vertical blockers.
|
||||||
|
* Aggregated with_dates = sum of per-vertical with_dates.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||||
|
*/
|
||||||
|
it('aggregated totals = sum of per-vertical totals', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
|
||||||
|
const sumTotal = result.by_vertical.reduce((s, v) => s + v.total, 0);
|
||||||
|
const sumBlockers = result.by_vertical.reduce((s, v) => s + v.blockers, 0);
|
||||||
|
const sumWithDates = result.by_vertical.reduce((s, v) => s + v.with_dates, 0);
|
||||||
|
|
||||||
|
expect(sumTotal).toBe(result.total);
|
||||||
|
expect(sumBlockers).toBe(result.blockers);
|
||||||
|
expect(sumWithDates).toBe(result.with_dates);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 8: By-Vertical Sorting and Filtering ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-aggregated-burndown, Property 8: By-Vertical Sorting and Filtering', () => {
|
||||||
|
/**
|
||||||
|
* by_vertical is sorted descending by total, contains no zero-total entries,
|
||||||
|
* and the sum of all by_vertical[i].total equals the overall total.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 5.1, 5.2, 5.4**
|
||||||
|
*/
|
||||||
|
it('sorted descending by total, no zero entries, sum = total', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(deviceArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(devices) => {
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
|
||||||
|
// Sorted descending by total
|
||||||
|
for (let i = 1; i < result.by_vertical.length; i++) {
|
||||||
|
expect(result.by_vertical[i - 1].total).toBeGreaterThanOrEqual(result.by_vertical[i].total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No zero-total entries
|
||||||
|
for (const v of result.by_vertical) {
|
||||||
|
expect(v.total).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum = overall total
|
||||||
|
const sum = result.by_vertical.reduce((s, v) => s + v.total, 0);
|
||||||
|
expect(sum).toBe(result.total);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
371
backend/__tests__/vcl-aggregated-burndown.test.js
Normal file
371
backend/__tests__/vcl-aggregated-burndown.test.js
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Unit and Integration Tests: VCL Aggregated Burndown
|
||||||
|
*
|
||||||
|
* Feature: vcl-aggregated-burndown
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - deduplicateByHostname edge cases
|
||||||
|
* - computeAggregatedBurndown edge cases
|
||||||
|
* - GET /burndown endpoint with mocked DB
|
||||||
|
* - Empty DB returns zero/empty response
|
||||||
|
* - All-blocker scenario
|
||||||
|
* - Auth middleware enforcement
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock auth middleware
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock driftChecker
|
||||||
|
jest.mock('../helpers/driftChecker', () => ({
|
||||||
|
loadConfig: jest.fn(() => ({})),
|
||||||
|
compareSchemaToDrift: jest.fn(() => null),
|
||||||
|
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockPool = {
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
connect: jest.fn(() => Promise.resolve({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
release: jest.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
jest.mock('../db', () => mockPool);
|
||||||
|
|
||||||
|
const {
|
||||||
|
deduplicateByHostname,
|
||||||
|
computeAggregatedBurndown,
|
||||||
|
} = require('../helpers/vclHelpers');
|
||||||
|
|
||||||
|
const { createVCLMultiVerticalRouter } = require('../routes/vclMultiVertical');
|
||||||
|
|
||||||
|
// --- HTTP helper ---
|
||||||
|
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const rawBody = Buffer.concat(chunks).toString();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
||||||
|
resolve({ statusCode: res.statusCode, body: json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Setup ---
|
||||||
|
|
||||||
|
let app, server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const mockUpload = { array: () => (req, res, next) => next() };
|
||||||
|
const router = createVCLMultiVerticalRouter(mockUpload);
|
||||||
|
app.use('/api/compliance/vcl-multi', router);
|
||||||
|
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPool.query.mockReset();
|
||||||
|
mockPool.connect.mockReset();
|
||||||
|
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- deduplicateByHostname unit tests ---
|
||||||
|
|
||||||
|
describe('deduplicateByHostname', () => {
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(deduplicateByHostname([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through single item unchanged', () => {
|
||||||
|
const items = [{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }];
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
expect(result).toEqual([{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates by hostname keeping earliest non-null date', () => {
|
||||||
|
const items = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-08-15', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-07-10', vertical: 'TSI' },
|
||||||
|
];
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].hostname).toBe('srv-001');
|
||||||
|
expect(result[0].resolution_date).toBe('2026-06-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null date when all entries for a hostname have null dates', () => {
|
||||||
|
const items = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||||
|
];
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].resolution_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks earliest non-null date even when some entries are null', () => {
|
||||||
|
const items = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-09-01', vertical: 'SDIT_CISO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
||||||
|
];
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].resolution_date).toBe('2026-06-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves vertical from the first entry', () => {
|
||||||
|
const items = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-01', vertical: 'SDIT_CISO' },
|
||||||
|
];
|
||||||
|
const result = deduplicateByHostname(items);
|
||||||
|
expect(result[0].vertical).toBe('NTS_AEO');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- computeAggregatedBurndown unit tests ---
|
||||||
|
|
||||||
|
describe('computeAggregatedBurndown', () => {
|
||||||
|
it('returns zero/empty for empty input', () => {
|
||||||
|
const result = computeAggregatedBurndown([]);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.blockers).toBe(0);
|
||||||
|
expect(result.with_dates).toBe(0);
|
||||||
|
expect(result.monthly).toEqual({});
|
||||||
|
expect(result.projection).toEqual({});
|
||||||
|
expect(result.projected_clear_date).toBeNull();
|
||||||
|
expect(result.by_vertical).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all blockers — with_dates=0, monthly={}, projected_clear_date=null', () => {
|
||||||
|
const devices = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||||
|
];
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(result.blockers).toBe(3);
|
||||||
|
expect(result.with_dates).toBe(0);
|
||||||
|
expect(result.monthly).toEqual({});
|
||||||
|
expect(result.projected_clear_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single device with date — correct monthly bucket and projection', () => {
|
||||||
|
const devices = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||||
|
];
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.blockers).toBe(0);
|
||||||
|
expect(result.with_dates).toBe(1);
|
||||||
|
expect(result.monthly).toEqual({ '2026-06': 1 });
|
||||||
|
expect(result.projection).toEqual({ '2026-06': { remediated: 1, remaining: 0 } });
|
||||||
|
expect(result.projected_clear_date).toBe('2026-06');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed blockers and in-progress — projected_clear_date is null', () => {
|
||||||
|
const devices = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
];
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
expect(result.blockers).toBe(1);
|
||||||
|
expect(result.with_dates).toBe(1);
|
||||||
|
expect(result.projected_clear_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple months — correct cumulative projection', () => {
|
||||||
|
const devices = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: '2026-06-20', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-003', resolution_date: '2026-07-10', vertical: 'SDIT_CISO' },
|
||||||
|
{ hostname: 'srv-004', resolution_date: '2026-08-01', vertical: 'TSI' },
|
||||||
|
];
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.total).toBe(4);
|
||||||
|
expect(result.monthly).toEqual({ '2026-06': 2, '2026-07': 1, '2026-08': 1 });
|
||||||
|
expect(result.projection['2026-06'].remaining).toBe(2); // 4 - 2
|
||||||
|
expect(result.projection['2026-07'].remaining).toBe(1); // 4 - 3
|
||||||
|
expect(result.projection['2026-08'].remaining).toBe(0); // 4 - 4
|
||||||
|
expect(result.projected_clear_date).toBe('2026-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by_vertical sorted descending by total, omits zero-total verticals', () => {
|
||||||
|
const devices = [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'TSI' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-004', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
||||||
|
];
|
||||||
|
const result = computeAggregatedBurndown(devices);
|
||||||
|
expect(result.by_vertical[0].vertical).toBe('NTS_AEO');
|
||||||
|
expect(result.by_vertical[0].total).toBe(3);
|
||||||
|
expect(result.by_vertical[1].vertical).toBe('TSI');
|
||||||
|
expect(result.by_vertical[1].total).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- GET /burndown endpoint tests ---
|
||||||
|
|
||||||
|
describe('GET /api/compliance/vcl-multi/burndown', () => {
|
||||||
|
it('returns zero/empty response when no active devices exist', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.total_non_compliant).toBe(0);
|
||||||
|
expect(res.body.blockers).toBe(0);
|
||||||
|
expect(res.body.with_dates).toBe(0);
|
||||||
|
expect(res.body.monthly_forecast).toEqual({});
|
||||||
|
expect(res.body.projected_clear_date).toBeNull();
|
||||||
|
expect(res.body.by_vertical).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct burndown data with mocked DB rows', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: '2026-07-01', vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-003', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-08-01', vertical: 'SDIT_CISO' }, // duplicate hostname
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
// srv-001 deduplicated: earliest date is 2026-06-15
|
||||||
|
expect(res.body.total_non_compliant).toBe(3); // srv-001, srv-002, srv-003
|
||||||
|
expect(res.body.blockers).toBe(1); // srv-003
|
||||||
|
expect(res.body.with_dates).toBe(2); // srv-001, srv-002
|
||||||
|
expect(res.body.monthly_forecast['2026-06']).toBe(1);
|
||||||
|
expect(res.body.monthly_forecast['2026-07']).toBe(1);
|
||||||
|
expect(res.body.projected_clear_date).toBeNull(); // blockers > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all-blocker response correctly', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, vertical: 'NTS_AEO' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: null, vertical: 'SDIT_CISO' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.total_non_compliant).toBe(2);
|
||||||
|
expect(res.body.blockers).toBe(2);
|
||||||
|
expect(res.body.with_dates).toBe(0);
|
||||||
|
expect(res.body.monthly_forecast).toEqual({});
|
||||||
|
expect(res.body.projected_clear_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on database error', async () => {
|
||||||
|
mockPool.query.mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
expect(res.body.error).toBe('Database error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response shape matches API contract', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', vertical: 'NTS_AEO' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('total_non_compliant');
|
||||||
|
expect(res.body).toHaveProperty('blockers');
|
||||||
|
expect(res.body).toHaveProperty('with_dates');
|
||||||
|
expect(res.body).toHaveProperty('monthly_forecast');
|
||||||
|
expect(res.body).toHaveProperty('projected_clear_date');
|
||||||
|
expect(res.body).toHaveProperty('by_vertical');
|
||||||
|
expect(Array.isArray(res.body.by_vertical)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Auth enforcement test ---
|
||||||
|
|
||||||
|
describe('GET /burndown — auth enforcement', () => {
|
||||||
|
it('returns 401 when auth middleware rejects', async () => {
|
||||||
|
// Create a separate app with rejecting auth
|
||||||
|
const rejectApp = express();
|
||||||
|
rejectApp.use(express.json());
|
||||||
|
|
||||||
|
// Override requireAuth to reject
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createVCLMultiVerticalRouter: createRouter } = require('../routes/vclMultiVertical');
|
||||||
|
const mockUpload = { array: () => (req, res, next) => next() };
|
||||||
|
const router = createRouter(mockUpload);
|
||||||
|
rejectApp.use('/api/compliance/vcl-multi', router);
|
||||||
|
|
||||||
|
const rejectServer = await new Promise((resolve) => {
|
||||||
|
const s = rejectApp.listen(0, '127.0.0.1', () => resolve(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await request(rejectServer, 'GET', '/api/compliance/vcl-multi/burndown');
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Authentication required');
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => rejectServer.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
501
backend/__tests__/vcl-compliance-reporting.property.test.js
Normal file
501
backend/__tests__/vcl-compliance-reporting.property.test.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* Property-Based Tests: VCL Compliance Reporting
|
||||||
|
*
|
||||||
|
* Feature: vcl-compliance-reporting
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions used for VCL compliance reporting computations.
|
||||||
|
*
|
||||||
|
* Validates: Requirements 2.4, 2.5, 3.2, 3.3, 5.2, 5.3, 6.1, 6.3, 7.5, 8.2, 8.3, 8.4, 8.7, 9.2, 9.3, 9.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fc = require('fast-check');
|
||||||
|
|
||||||
|
// Mock db pool before importing anything (avoids DATABASE_URL requirement)
|
||||||
|
jest.mock('../db', () => ({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
connect: jest.fn(() => Promise.resolve({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
release: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock dependencies that the route module imports
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
truncateText,
|
||||||
|
validateRemediationPlan,
|
||||||
|
computeVCLStats,
|
||||||
|
formatPct,
|
||||||
|
categorizeNonCompliant,
|
||||||
|
rankHeavyHitters,
|
||||||
|
computeForecastBurndown,
|
||||||
|
matchByHostname,
|
||||||
|
computeBulkDiff,
|
||||||
|
mapColumnHeaders,
|
||||||
|
isValidDateString,
|
||||||
|
} = require('../helpers/vclHelpers');
|
||||||
|
|
||||||
|
// --- Generators ---
|
||||||
|
|
||||||
|
const hostnameArb = fc.stringMatching(/^[a-zA-Z0-9._-]+$/, { minLength: 1, maxLength: 30 });
|
||||||
|
|
||||||
|
const validDateArb = fc.record({
|
||||||
|
year: fc.integer({ min: 2020, max: 2030 }),
|
||||||
|
month: fc.integer({ min: 1, max: 12 }),
|
||||||
|
day: fc.integer({ min: 1, max: 28 }), // 1-28 always valid
|
||||||
|
}).map(({ year, month, day }) =>
|
||||||
|
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const complianceItemArb = fc.record({
|
||||||
|
hostname: hostnameArb,
|
||||||
|
is_compliant: fc.boolean(),
|
||||||
|
in_scope: fc.constant(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonCompliantItemArb = fc.record({
|
||||||
|
hostname: hostnameArb,
|
||||||
|
resolution_date: fc.oneof(fc.constant(null), validDateArb),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verticalArb = fc.record({
|
||||||
|
vertical: fc.string({ minLength: 1, maxLength: 20 }),
|
||||||
|
team: fc.string({ minLength: 1, maxLength: 20 }),
|
||||||
|
non_compliant: fc.integer({ min: 0, max: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 2: Text Truncation ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 2: Text Truncation', () => {
|
||||||
|
/**
|
||||||
|
* For any string, truncateText(text, 80) should return the original string if its
|
||||||
|
* length is <= 80, or the first 80 characters followed by "…" if its length exceeds 80.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.4**
|
||||||
|
*/
|
||||||
|
it('returns original for short strings, truncated + ellipsis for long strings', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 0, maxLength: 200 }),
|
||||||
|
fc.integer({ min: 1, max: 100 }),
|
||||||
|
(text, maxLen) => {
|
||||||
|
const result = truncateText(text, maxLen);
|
||||||
|
if (text.length <= maxLen) {
|
||||||
|
expect(result).toBe(text);
|
||||||
|
} else {
|
||||||
|
expect(result).toBe(text.slice(0, maxLen) + '\u2026');
|
||||||
|
expect(result.length).toBe(maxLen + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 3: Remediation Plan Length Validation ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 3: Remediation Plan Length Validation', () => {
|
||||||
|
/**
|
||||||
|
* For any string, validateRemediationPlan(text) should return valid if and only if
|
||||||
|
* the string length is <= 2000 characters.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 2.5, 9.4**
|
||||||
|
*/
|
||||||
|
it('accepts strings <= 2000 chars, rejects longer', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.string({ minLength: 1, maxLength: 3000 }),
|
||||||
|
(text) => {
|
||||||
|
const result = validateRemediationPlan(text);
|
||||||
|
if (text.length <= 2000) {
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 4: Summary Statistics Computation Invariants ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 4: Summary Statistics Computation Invariants', () => {
|
||||||
|
/**
|
||||||
|
* For any set of compliance items, computeVCLStats produces correct arithmetic:
|
||||||
|
* non_compliant + compliant = in_scope, and correct percentage.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 3.2, 7.3**
|
||||||
|
*/
|
||||||
|
it('non_compliant + compliant = in_scope, correct percentage', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(complianceItemArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
fc.integer({ min: 0, max: 100 }),
|
||||||
|
(items, targetPct) => {
|
||||||
|
const stats = computeVCLStats(items, targetPct);
|
||||||
|
|
||||||
|
// in_scope items are those with in_scope === true
|
||||||
|
const in_scope = items.filter(i => i.in_scope).length;
|
||||||
|
const compliant = items.filter(i => i.is_compliant).length;
|
||||||
|
|
||||||
|
expect(stats.non_compliant + stats.compliant).toBe(stats.in_scope);
|
||||||
|
expect(stats.in_scope).toBe(in_scope);
|
||||||
|
expect(stats.compliant).toBe(compliant);
|
||||||
|
|
||||||
|
if (in_scope > 0) {
|
||||||
|
expect(stats.compliance_pct).toBe(Math.round((compliant / in_scope) * 100));
|
||||||
|
} else {
|
||||||
|
expect(stats.compliance_pct).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(stats.target_pct).toBe(targetPct);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 5: Percentage Formatting ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 5: Percentage Formatting', () => {
|
||||||
|
/**
|
||||||
|
* For any decimal number between 0 and 1, formatPct produces a string matching /^\d{1,3}%$/.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 3.3**
|
||||||
|
*/
|
||||||
|
it('produces correct percentage string matching /^\\d{1,3}%$/', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
(decimal) => {
|
||||||
|
const result = formatPct(decimal);
|
||||||
|
expect(result).toMatch(/^\d{1,3}%$/);
|
||||||
|
expect(result).toBe(Math.round(decimal * 100) + '%');
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 6: Non-Compliant Device Categorization Partition ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 6: Non-Compliant Device Categorization Partition', () => {
|
||||||
|
/**
|
||||||
|
* For any array of non-compliant device objects, categorizeNonCompliant produces
|
||||||
|
* two groups (blocked, in_progress) where blocked.count + in_progress.count = items.length.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 5.2, 5.3**
|
||||||
|
*/
|
||||||
|
it('two groups sum to total', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(items) => {
|
||||||
|
const result = categorizeNonCompliant(items);
|
||||||
|
|
||||||
|
expect(result.blocked.count + result.in_progress.count).toBe(items.length);
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
expect(result.blocked.pct).toBe(Math.round((result.blocked.count / items.length) * 100));
|
||||||
|
expect(result.in_progress.pct).toBe(Math.round((result.in_progress.count / items.length) * 100));
|
||||||
|
} else {
|
||||||
|
expect(result.blocked.pct).toBe(0);
|
||||||
|
expect(result.in_progress.pct).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 7: Heavy Hitters Descending Sort ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 7: Heavy Hitters Descending Sort', () => {
|
||||||
|
/**
|
||||||
|
* For any array of vertical objects, rankHeavyHitters returns the array sorted
|
||||||
|
* in non-increasing order by non_compliant.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 6.1, 6.3**
|
||||||
|
*/
|
||||||
|
it('sorted non-increasing by non_compliant', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(verticalArb, { minLength: 0, maxLength: 30 }),
|
||||||
|
(verticals) => {
|
||||||
|
const result = rankHeavyHitters(verticals);
|
||||||
|
|
||||||
|
expect(result.length).toBe(verticals.length);
|
||||||
|
|
||||||
|
for (let i = 1; i < result.length; i++) {
|
||||||
|
expect(result[i - 1].non_compliant).toBeGreaterThanOrEqual(result[i].non_compliant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 8: Forecasted Burndown Projection ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 8: Forecasted Burndown Projection', () => {
|
||||||
|
/**
|
||||||
|
* For any set of non-compliant devices with resolution_date values,
|
||||||
|
* computeForecastBurndown produces monthly buckets where the sum of all
|
||||||
|
* monthly forecast counts equals the number of items with non-null resolution_dates.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 7.5**
|
||||||
|
*/
|
||||||
|
it('bucket sum = count of items with non-null resolution_dates', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(nonCompliantItemArb, { minLength: 0, maxLength: 50 }),
|
||||||
|
(items) => {
|
||||||
|
const buckets = computeForecastBurndown(items);
|
||||||
|
const bucketSum = Object.values(buckets).reduce((sum, count) => sum + count, 0);
|
||||||
|
const itemsWithDate = items.filter(i => i.resolution_date != null).length;
|
||||||
|
|
||||||
|
expect(bucketSum).toBe(itemsWithDate);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 9: Hostname Matching with Unmatched Flagging ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 9: Hostname Matching with Unmatched Flagging', () => {
|
||||||
|
/**
|
||||||
|
* For any array of uploaded rows and a set of existing hostnames,
|
||||||
|
* matchByHostname produces matched + unmatched = total, and matched hostnames
|
||||||
|
* all exist in the set.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 8.2, 8.7**
|
||||||
|
*/
|
||||||
|
it('matched + unmatched = total, matched hostnames in set', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 30 }),
|
||||||
|
fc.array(hostnameArb, { minLength: 0, maxLength: 20 }),
|
||||||
|
(rows, existingList) => {
|
||||||
|
const existingSet = new Set(existingList);
|
||||||
|
const { matched, unmatched } = matchByHostname(rows, existingSet);
|
||||||
|
|
||||||
|
expect(matched.length + unmatched.length).toBe(rows.length);
|
||||||
|
|
||||||
|
for (const row of matched) {
|
||||||
|
expect(existingSet.has(row.hostname)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of unmatched) {
|
||||||
|
expect(existingSet.has(row.hostname)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 10: Bulk Diff Change Detection ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 10: Bulk Diff Change Detection', () => {
|
||||||
|
/**
|
||||||
|
* For any array of matched row pairs, computeBulkDiff flags a row as "changed"
|
||||||
|
* if and only if at least one field value differs.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 8.3, 8.4**
|
||||||
|
*/
|
||||||
|
it('changed iff at least one field differs', () => {
|
||||||
|
const fieldValueArb = fc.oneof(fc.constant(null), fc.string({ minLength: 1, maxLength: 20 }));
|
||||||
|
|
||||||
|
// When uploaded values match current data exactly, status should be 'unchanged'
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(hostnameArb, { minLength: 1, maxLength: 20 }).chain(hostnames => {
|
||||||
|
// Ensure unique hostnames to avoid map overwrite issues
|
||||||
|
const uniqueHostnames = [...new Set(hostnames)];
|
||||||
|
return fc.tuple(
|
||||||
|
...uniqueHostnames.map(h =>
|
||||||
|
fc.record({
|
||||||
|
hostname: fc.constant(h),
|
||||||
|
resolution_date: fieldValueArb,
|
||||||
|
remediation_plan: fieldValueArb,
|
||||||
|
notes: fieldValueArb,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
(matchedRows) => {
|
||||||
|
// Build currentData with same values as uploaded
|
||||||
|
const currentData = new Map();
|
||||||
|
for (const row of matchedRows) {
|
||||||
|
currentData.set(row.hostname, {
|
||||||
|
resolution_date: row.resolution_date,
|
||||||
|
remediation_plan: row.remediation_plan,
|
||||||
|
notes: row.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = computeBulkDiff(matchedRows, currentData);
|
||||||
|
for (const r of results) {
|
||||||
|
expect(r.status).toBe('unchanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// When at least one field differs, status should be 'changed'
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
hostnameArb,
|
||||||
|
fc.string({ minLength: 1, maxLength: 20 }),
|
||||||
|
fc.string({ minLength: 1, maxLength: 20 }),
|
||||||
|
(hostname, oldVal, newVal) => {
|
||||||
|
fc.pre(oldVal !== newVal);
|
||||||
|
|
||||||
|
const matchedRows = [{ hostname, resolution_date: newVal }];
|
||||||
|
const currentData = new Map();
|
||||||
|
currentData.set(hostname, { resolution_date: oldVal, remediation_plan: null, notes: null });
|
||||||
|
|
||||||
|
const results = computeBulkDiff(matchedRows, currentData);
|
||||||
|
expect(results[0].status).toBe('changed');
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 11: Column Header Mapping ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 11: Column Header Mapping', () => {
|
||||||
|
/**
|
||||||
|
* mapColumnHeaders correctly identifies known columns case-insensitively.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 9.2**
|
||||||
|
*/
|
||||||
|
it('identifies known columns case-insensitively', () => {
|
||||||
|
const knownHeaders = ['Hostname', 'Resolution Date', 'Remediation Plan', 'Notes',
|
||||||
|
'hostname', 'resolution_date', 'remediation_plan', 'notes',
|
||||||
|
'HOSTNAME', 'RESOLUTION DATE', 'REMEDIATION PLAN', 'NOTES'];
|
||||||
|
|
||||||
|
const caseVariantArb = fc.constantFrom(...knownHeaders);
|
||||||
|
const unknownHeaderArb = fc.stringMatching(/^[a-z]{5,10}$/).filter(
|
||||||
|
s => !['hostname', 'notes'].includes(s.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.oneof(caseVariantArb, unknownHeaderArb), { minLength: 1, maxLength: 10 }),
|
||||||
|
(headers) => {
|
||||||
|
const mapping = mapColumnHeaders(headers);
|
||||||
|
|
||||||
|
// Every mapped key should be a known field
|
||||||
|
const validKeys = new Set(['hostname', 'resolution_date', 'remediation_plan', 'notes']);
|
||||||
|
for (const key of Object.keys(mapping)) {
|
||||||
|
expect(validKeys.has(key)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that known headers are mapped correctly
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = headers[i].trim().toLowerCase();
|
||||||
|
if (normalized === 'hostname') {
|
||||||
|
expect(mapping.hostname).toBeDefined();
|
||||||
|
}
|
||||||
|
if (normalized === 'resolution date' || normalized === 'resolution_date') {
|
||||||
|
expect(mapping.resolution_date).toBeDefined();
|
||||||
|
}
|
||||||
|
if (normalized === 'remediation plan' || normalized === 'remediation_plan') {
|
||||||
|
expect(mapping.remediation_plan).toBeDefined();
|
||||||
|
}
|
||||||
|
if (normalized === 'notes') {
|
||||||
|
expect(mapping.notes).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 12: Date String Validation ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 12: Date String Validation', () => {
|
||||||
|
/**
|
||||||
|
* isValidDateString rejects invalid calendar dates and non-date strings.
|
||||||
|
* Returns true only for valid YYYY-MM-DD dates.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 9.3**
|
||||||
|
*/
|
||||||
|
it('rejects invalid dates and non-date strings', () => {
|
||||||
|
// Valid dates should return true
|
||||||
|
fc.assert(
|
||||||
|
fc.property(validDateArb, (dateStr) => {
|
||||||
|
expect(isValidDateString(dateStr)).toBe(true);
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalid dates should return false
|
||||||
|
const invalidDateArb = fc.oneof(
|
||||||
|
fc.constant(null),
|
||||||
|
fc.constant(''),
|
||||||
|
fc.constant('not-a-date'),
|
||||||
|
fc.constant('2026-02-30'),
|
||||||
|
fc.constant('2026-13-01'),
|
||||||
|
fc.constant('2026-00-15'),
|
||||||
|
fc.constant('abcd-ef-gh'),
|
||||||
|
fc.integer().map(n => String(n)),
|
||||||
|
fc.string({ minLength: 1, maxLength: 5 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(invalidDateArb, (val) => {
|
||||||
|
expect(isValidDateString(val)).toBe(false);
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Property 13: Row Count Arithmetic Invariant ---
|
||||||
|
|
||||||
|
describe('Feature: vcl-compliance-reporting, Property 13: Row Count Arithmetic (matched + unmatched = total)', () => {
|
||||||
|
/**
|
||||||
|
* For any bulk upload, matched + unmatched = total input rows.
|
||||||
|
*
|
||||||
|
* **Validates: Requirements 9.6**
|
||||||
|
*/
|
||||||
|
it('matched + unmatched = total invariant holds', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.record({ hostname: hostnameArb }), { minLength: 0, maxLength: 50 }),
|
||||||
|
fc.array(hostnameArb, { minLength: 0, maxLength: 30 }),
|
||||||
|
(rows, existingList) => {
|
||||||
|
const existingSet = new Set(existingList);
|
||||||
|
const { matched, unmatched } = matchByHostname(rows, existingSet);
|
||||||
|
|
||||||
|
// Core invariant: matched + unmatched = total
|
||||||
|
expect(matched.length + unmatched.length).toBe(rows.length);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
346
backend/__tests__/vcl-compliance-reporting.test.js
Normal file
346
backend/__tests__/vcl-compliance-reporting.test.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* Unit and Integration Tests: VCL Compliance Reporting
|
||||||
|
*
|
||||||
|
* Feature: vcl-compliance-reporting
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - PATCH /items/:hostname/metadata (happy path, invalid date, plan too long, not found)
|
||||||
|
* - GET /vcl/stats with no data (zero/empty response)
|
||||||
|
* - Bulk preview with all unmatched hostnames
|
||||||
|
* - Bulk preview with mixed valid/invalid rows
|
||||||
|
* - Integration test for full bulk flow (preview → commit)
|
||||||
|
* - Trend endpoint with < 2 months (no forecast)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock auth middleware to bypass real session checks
|
||||||
|
jest.mock('../middleware/auth', () => ({
|
||||||
|
requireAuth: () => (req, res, next) => {
|
||||||
|
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireGroup: () => (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock audit log as a no-op
|
||||||
|
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||||
|
|
||||||
|
// Mock ivantiApi to avoid real network calls
|
||||||
|
jest.mock('../helpers/ivantiApi', () => ({
|
||||||
|
ivantiFormPost: jest.fn(),
|
||||||
|
ivantiPost: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the db pool
|
||||||
|
const mockPool = {
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
connect: jest.fn(() => Promise.resolve({
|
||||||
|
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||||
|
release: jest.fn(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
jest.mock('../db', () => mockPool);
|
||||||
|
|
||||||
|
// Mock driftChecker to avoid file system dependencies
|
||||||
|
jest.mock('../helpers/driftChecker', () => ({
|
||||||
|
loadConfig: jest.fn(() => ({})),
|
||||||
|
compareSchemaToDrift: jest.fn(() => null),
|
||||||
|
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createComplianceRouter } = require('../routes/compliance');
|
||||||
|
|
||||||
|
// --- HTTP helper ---
|
||||||
|
|
||||||
|
function request(server, method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const addr = server.address();
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: addr.port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const rawBody = Buffer.concat(chunks).toString();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
||||||
|
resolve({ statusCode: res.statusCode, body: json });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Setup ---
|
||||||
|
|
||||||
|
let app, server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Mock multer upload middleware
|
||||||
|
const mockUpload = { single: () => (req, res, next) => next() };
|
||||||
|
const router = createComplianceRouter(mockUpload);
|
||||||
|
app.use('/api/compliance', router);
|
||||||
|
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPool.query.mockReset();
|
||||||
|
mockPool.connect.mockReset();
|
||||||
|
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.1: PATCH /items/:hostname/metadata ---
|
||||||
|
|
||||||
|
describe('PATCH /items/:hostname/metadata', () => {
|
||||||
|
it('happy path — updates resolution_date and remediation_plan', async () => {
|
||||||
|
// Mock client.query: first call = SELECT current values, second+ = INSERT history / UPDATE
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
// Override connect to return our mock client
|
||||||
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||||
|
// The first call from the handler is BEGIN, then SELECT, then inserts, then UPDATE, then COMMIT
|
||||||
|
mockClient.query = jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current values
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||||
|
resolution_date: '2026-06-15',
|
||||||
|
remediation_plan: 'Patch in next maintenance window',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.updated).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid date format', async () => {
|
||||||
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||||
|
resolution_date: 'not-a-date',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toContain('Invalid resolution_date format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when remediation plan exceeds 2000 characters', async () => {
|
||||||
|
const longPlan = 'x'.repeat(2001);
|
||||||
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||||
|
remediation_plan: longPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toContain('2000 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when hostname not found', async () => {
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current values — empty = not found
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||||
|
|
||||||
|
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
||||||
|
resolution_date: '2026-06-15',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.body.error).toBe('Device not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.2: GET /vcl/stats with no data ---
|
||||||
|
|
||||||
|
describe('GET /vcl/stats with no data', () => {
|
||||||
|
it('returns zero/empty response when no compliance data exists', async () => {
|
||||||
|
// First query: active items
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
// Second query: latest upload
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl/stats');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.stats).toBeDefined();
|
||||||
|
expect(res.body.stats.total_devices).toBe(0);
|
||||||
|
expect(res.body.stats.in_scope).toBe(0);
|
||||||
|
expect(res.body.stats.compliant).toBe(0);
|
||||||
|
expect(res.body.stats.non_compliant).toBe(0);
|
||||||
|
expect(res.body.stats.compliance_pct).toBe(0);
|
||||||
|
expect(res.body.donut).toBeDefined();
|
||||||
|
expect(res.body.heavy_hitters).toEqual([]);
|
||||||
|
expect(res.body.vertical_breakdown).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.3: Bulk preview with all unmatched hostnames ---
|
||||||
|
|
||||||
|
describe('POST /vcl/bulk-preview — all unmatched', () => {
|
||||||
|
it('returns all rows as unmatched when no hostnames exist in DB', async () => {
|
||||||
|
// Query for existing hostnames returns empty
|
||||||
|
mockPool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'unknown-1', resolution_date: '2026-06-15' },
|
||||||
|
{ hostname: 'unknown-2', resolution_date: '2026-07-01' },
|
||||||
|
{ hostname: 'unknown-3', resolution_date: '2026-08-01' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.matched).toBe(0);
|
||||||
|
expect(res.body.unmatched).toBe(3);
|
||||||
|
expect(res.body.changes).toBe(0);
|
||||||
|
expect(res.body.unmatched_rows).toEqual(['unknown-1', 'unknown-2', 'unknown-3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.4: Bulk preview with mixed valid/invalid rows ---
|
||||||
|
|
||||||
|
describe('POST /vcl/bulk-preview — mixed valid/invalid', () => {
|
||||||
|
it('correctly classifies valid and invalid rows', async () => {
|
||||||
|
// Query for existing hostnames
|
||||||
|
mockPool.query
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001' },
|
||||||
|
{ hostname: 'srv-002' },
|
||||||
|
{ hostname: 'srv-003' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// Query for current data (DISTINCT ON)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
|
||||||
|
{ hostname: 'srv-003', resolution_date: null, remediation_plan: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15' }, // valid, matched
|
||||||
|
{ hostname: 'srv-002', resolution_date: 'bad-date' }, // invalid date, matched
|
||||||
|
{ hostname: 'srv-003', resolution_date: '2026-07-01' }, // valid, matched
|
||||||
|
{ hostname: 'unknown-1', resolution_date: '2026-08-01' }, // unmatched
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.matched).toBe(3);
|
||||||
|
expect(res.body.unmatched).toBe(1);
|
||||||
|
expect(res.body.invalid).toBe(1);
|
||||||
|
expect(res.body.invalid_rows[0].hostname).toBe('srv-002');
|
||||||
|
expect(res.body.invalid_rows[0].errors[0]).toContain('invalid date');
|
||||||
|
expect(res.body.unmatched_rows).toEqual(['unknown-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.5: Integration test for full bulk flow ---
|
||||||
|
|
||||||
|
describe('Integration: full bulk upload flow (preview → commit)', () => {
|
||||||
|
it('preview shows changes, commit updates DB', async () => {
|
||||||
|
// --- Preview phase ---
|
||||||
|
// Query for existing hostnames
|
||||||
|
mockPool.query
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ hostname: 'srv-001' }, { hostname: 'srv-002' }],
|
||||||
|
})
|
||||||
|
// Query for current data
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: null, remediation_plan: null },
|
||||||
|
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewRes = await request(server, 'POST', '/api/compliance/vcl/bulk-preview', {
|
||||||
|
rows: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
|
||||||
|
{ hostname: 'srv-002', resolution_date: '2026-01-01', remediation_plan: 'Old plan' }, // unchanged
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previewRes.statusCode).toBe(200);
|
||||||
|
expect(previewRes.body.matched).toBe(2);
|
||||||
|
expect(previewRes.body.changes).toBe(1); // only srv-001 changed
|
||||||
|
|
||||||
|
// --- Commit phase ---
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
mockClient.query
|
||||||
|
.mockResolvedValueOnce({}) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ hostname: 'srv-001', resolution_date: null, remediation_plan: null }] }) // SELECT current values for all hostnames
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
||||||
|
.mockResolvedValueOnce({}); // COMMIT
|
||||||
|
|
||||||
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||||
|
|
||||||
|
const commitRes = await request(server, 'POST', '/api/compliance/vcl/bulk-commit', {
|
||||||
|
changes: [
|
||||||
|
{ hostname: 'srv-001', resolution_date: '2026-06-15', remediation_plan: 'New plan' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commitRes.statusCode).toBe(200);
|
||||||
|
expect(commitRes.body.committed).toBe(1);
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
|
||||||
|
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 18.6: Trend endpoint with < 2 months (no forecast) ---
|
||||||
|
|
||||||
|
describe('GET /vcl/trend — fewer than 2 months', () => {
|
||||||
|
it('returns data without forecast when < 2 months exist', async () => {
|
||||||
|
mockPool.query.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{ snapshot_month: '2026-01', compliant_count: 900, compliance_pct: '82.0' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(server, 'GET', '/api/compliance/vcl/trend');
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.months).toHaveLength(1);
|
||||||
|
expect(res.body.months[0].month).toBe('2026-01');
|
||||||
|
expect(res.body.months[0].forecast_pct).toBeNull();
|
||||||
|
expect(res.body.months[0].target_pct).toBe(95);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
backend/cve_database.db.backupNVD
Normal file
BIN
backend/cve_database.db.backupNVD
Normal file
Binary file not shown.
BIN
backend/cve_database.db.pre-postgres-backup
Normal file
BIN
backend/cve_database.db.pre-postgres-backup
Normal file
Binary file not shown.
479
backend/db-schema.sql
Normal file
479
backend/db-schema.sql
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- CVE Dashboard — Complete PostgreSQL Schema (v1.0.0)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Translates the full SQLite schema (setup.js) to PostgreSQL 16.
|
||||||
|
-- Designed for idempotent execution: safe to run multiple times via psql or
|
||||||
|
-- pool.query() without errors or duplicate data.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- psql -h localhost -p 5433 -U steam -d cve_dashboard -f backend/db-schema.sql
|
||||||
|
-- OR
|
||||||
|
-- const schema = fs.readFileSync('backend/db-schema.sql', 'utf8');
|
||||||
|
-- await pool.query(schema);
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Core CVE tracking tables
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cves (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cve_id VARCHAR(20) NOT NULL,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
published_date DATE,
|
||||||
|
status VARCHAR(50) DEFAULT 'Open',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by INTEGER,
|
||||||
|
UNIQUE(cve_id, vendor)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_status ON cves(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cve_id VARCHAR(20) NOT NULL,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_size VARCHAR(20),
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_cve_id ON documents(cve_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS required_documents (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
vendor VARCHAR(100) NOT NULL,
|
||||||
|
document_type VARCHAR(50) NOT NULL,
|
||||||
|
is_mandatory BOOLEAN DEFAULT TRUE,
|
||||||
|
description TEXT,
|
||||||
|
UNIQUE(vendor, document_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Authentication and session management
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_login TIMESTAMPTZ,
|
||||||
|
user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'
|
||||||
|
CHECK (user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')),
|
||||||
|
bu_teams TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Audit logging
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER,
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id VARCHAR(100),
|
||||||
|
details TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Jira integration
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
ticket_key TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
status TEXT DEFAULT 'Open',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Archer integration
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
exc_number TEXT NOT NULL UNIQUE,
|
||||||
|
archer_url TEXT,
|
||||||
|
status TEXT DEFAULT 'Draft' CHECK (status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Knowledge base
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
file_path VARCHAR(500),
|
||||||
|
file_name VARCHAR(255),
|
||||||
|
file_type VARCHAR(50),
|
||||||
|
file_size INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug ON knowledge_base(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category ON knowledge_base(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at ON knowledge_base(created_at DESC);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti findings — individual rows (replaces findings_json blob)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
severity NUMERIC(4,2) NOT NULL DEFAULT 0,
|
||||||
|
vrr_group TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
dns TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
sla_status TEXT NOT NULL DEFAULT '',
|
||||||
|
due_date DATE,
|
||||||
|
last_found_on DATE,
|
||||||
|
bu_ownership TEXT NOT NULL DEFAULT '',
|
||||||
|
cves TEXT[] DEFAULT '{}',
|
||||||
|
workflow_id TEXT,
|
||||||
|
workflow_state TEXT,
|
||||||
|
workflow_type TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
override_host_name TEXT,
|
||||||
|
override_dns TEXT,
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_state ON ivanti_findings(state);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_bu ON ivanti_findings(bu_ownership);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_severity ON ivanti_findings(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_state_bu ON ivanti_findings(state, bu_ownership);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti sync state (single-row pattern — replaces ivanti_findings_cache metadata)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
workflows_json TEXT DEFAULT '[]',
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti counts cache (single-row pattern for FP workflow counts)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
open_count INTEGER DEFAULT 0,
|
||||||
|
closed_count INTEGER DEFAULT 0,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
fp_workflow_counts_json TEXT DEFAULT '{}',
|
||||||
|
fp_id_counts_json TEXT DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti counts history
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history_by_bu (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
bu_ownership TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('open', 'closed')),
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_counts_history_bu ON ivanti_counts_history_by_bu(bu_ownership);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_counts_history_bu_date ON ivanti_counts_history_by_bu(recorded_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti FP (False Positive) submissions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
ivanti_workflow_batch_id INTEGER,
|
||||||
|
ivanti_generated_id TEXT,
|
||||||
|
ivanti_workflow_batch_uuid TEXT,
|
||||||
|
workflow_name TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
expiration_date TEXT NOT NULL,
|
||||||
|
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
||||||
|
finding_ids_json TEXT NOT NULL,
|
||||||
|
queue_item_ids_json TEXT NOT NULL,
|
||||||
|
attachment_count INTEGER DEFAULT 0,
|
||||||
|
attachment_results_json TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'failed')),
|
||||||
|
lifecycle_status TEXT NOT NULL DEFAULT 'submitted'
|
||||||
|
CHECK (lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted')),
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
submission_id INTEGER NOT NULL REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN (
|
||||||
|
'created', 'fields_updated', 'findings_added',
|
||||||
|
'attachments_added', 'status_changed'
|
||||||
|
)),
|
||||||
|
change_details_json TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti todo queue (FP, Archer, CARD, GRANITE workflows)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'complete')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Ivanti archive detection and anomaly tracking
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
current_state TEXT NOT NULL CHECK (current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED', 'CLOSED_GONE')),
|
||||||
|
last_severity NUMERIC(4,2) NOT NULL DEFAULT 0,
|
||||||
|
first_archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
archive_id INTEGER NOT NULL REFERENCES ivanti_finding_archives(id),
|
||||||
|
from_state TEXT NOT NULL,
|
||||||
|
to_state TEXT NOT NULL,
|
||||||
|
severity_at_transition NUMERIC(4,2) NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transition_archive_id ON ivanti_archive_transitions(archive_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sync_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
open_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
|
closed_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
|
newly_archived_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
returned_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
classification_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
return_classification_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_significant BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
previous_bu TEXT NOT NULL,
|
||||||
|
new_bu TEXT NOT NULL,
|
||||||
|
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Atlas action plans cache
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL UNIQUE,
|
||||||
|
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id ON atlas_action_plans_cache(host_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Compliance (NTS AEO) tracking
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
report_date TEXT,
|
||||||
|
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
new_count INTEGER DEFAULT 0,
|
||||||
|
resolved_count INTEGER DEFAULT 0,
|
||||||
|
recurring_count INTEGER DEFAULT 0,
|
||||||
|
summary_json TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
team TEXT,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT,
|
||||||
|
category TEXT,
|
||||||
|
extra_json TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'resolved')),
|
||||||
|
first_seen_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||||
|
resolved_upload_id INTEGER REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||||
|
seen_count INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload ON compliance_items(upload_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity ON compliance_items(hostname, metric_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status ON compliance_items(team, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
group_id TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity ON compliance_notes(hostname, metric_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Seed data
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Required documents (idempotent via unique constraint on vendor + document_type)
|
||||||
|
INSERT INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
||||||
|
('Microsoft', 'advisory', TRUE, 'Official Microsoft Security Advisory'),
|
||||||
|
('Microsoft', 'screenshot', FALSE, 'Proof of patch application'),
|
||||||
|
('Cisco', 'advisory', TRUE, 'Cisco Security Advisory'),
|
||||||
|
('Oracle', 'advisory', TRUE, 'Oracle Security Alert'),
|
||||||
|
('VMware', 'advisory', TRUE, 'VMware Security Advisory'),
|
||||||
|
('Adobe', 'advisory', TRUE, 'Adobe Security Bulletin')
|
||||||
|
ON CONFLICT (vendor, document_type) DO NOTHING;
|
||||||
|
|
||||||
|
-- Ivanti sync state — ensure single row exists
|
||||||
|
INSERT INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Ivanti counts cache — ensure single row exists
|
||||||
|
INSERT INTO ivanti_counts_cache (id, open_count, closed_count, fp_workflow_counts_json, fp_id_counts_json)
|
||||||
|
VALUES (1, 0, 0, '{}', '{}')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
46
backend/db.js
Normal file
46
backend/db.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// PostgreSQL Connection Pool
|
||||||
|
// All route files import this module instead of receiving a sqlite3 `db` parameter.
|
||||||
|
// Configured via DATABASE_URL environment variable.
|
||||||
|
|
||||||
|
// Ensure dotenv is loaded before accessing env vars
|
||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
||||||
|
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.error('[DB] FATAL: DATABASE_URL environment variable is not set.');
|
||||||
|
console.error('[DB] Expected format: postgresql://user:password@host:port/database');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 10, // Maximum connections in pool
|
||||||
|
idleTimeoutMillis: 30000, // Close idle connections after 30s
|
||||||
|
connectionTimeoutMillis: 5000, // Fail if connection takes >5s
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log unexpected pool errors (connection drops, etc.)
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('[DB Pool] Unexpected error on idle client:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track active connections and warn when approaching exhaustion
|
||||||
|
let _activeCount = 0;
|
||||||
|
pool.on('acquire', () => {
|
||||||
|
_activeCount++;
|
||||||
|
if (_activeCount >= 8) {
|
||||||
|
console.warn(`[DB Pool] WARNING: ${_activeCount}/10 connections active — approaching exhaustion`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pool.on('release', () => { _activeCount--; });
|
||||||
|
|
||||||
|
// Health check — verify connection on startup
|
||||||
|
pool.query('SELECT NOW()')
|
||||||
|
.then(() => console.log('[DB Pool] Connected to PostgreSQL'))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[DB Pool] Failed to connect:', err.message);
|
||||||
|
console.error('[DB Pool] Check DATABASE_URL and ensure Postgres is running on port 5433');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
104
backend/helpers/atlasApi.js
Normal file
104
backend/helpers/atlasApi.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Shared Atlas InfoSec API helpers
|
||||||
|
// Centralizes HTTP calls so the atlas router uses a single implementation.
|
||||||
|
// Follows the same promise-based pattern as ivantiApi.js.
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration — read from process.env at module load
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ATLAS_API_URL = process.env.ATLAS_API_URL || '';
|
||||||
|
const ATLAS_API_USER = process.env.ATLAS_API_USER || '';
|
||||||
|
const ATLAS_API_PASS = process.env.ATLAS_API_PASS || '';
|
||||||
|
const ATLAS_SKIP_TLS = process.env.ATLAS_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
const requiredVars = ['ATLAS_API_URL', 'ATLAS_API_USER', 'ATLAS_API_PASS'];
|
||||||
|
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
console.warn(`[atlas-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Atlas API calls will fail.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfigured = missingVars.length === 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic request — supports GET, PUT, PATCH, POST
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function atlasRequest(method, urlPath, body, options) {
|
||||||
|
const timeout = (options && options.timeout) || 15000;
|
||||||
|
const authString = Buffer.from(ATLAS_API_USER + ':' + ATLAS_API_PASS).toString('base64');
|
||||||
|
const fullUrl = new URL(ATLAS_API_URL + urlPath);
|
||||||
|
const isHttps = fullUrl.protocol === 'https:';
|
||||||
|
const transport = isHttps ? https : http;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'authorization': 'Basic ' + authString
|
||||||
|
};
|
||||||
|
|
||||||
|
let bodyStr = null;
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
bodyStr = JSON.stringify(body);
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
timeout: timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHttps) {
|
||||||
|
reqOptions.rejectUnauthorized = !ATLAS_SKIP_TLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bodyStr) {
|
||||||
|
req.write(bodyStr);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function atlasGet(urlPath, options) {
|
||||||
|
return atlasRequest('GET', urlPath, null, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function atlasPut(urlPath, body, options) {
|
||||||
|
return atlasRequest('PUT', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function atlasPatch(urlPath, body, options) {
|
||||||
|
return atlasRequest('PATCH', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function atlasPost(urlPath, body, options) {
|
||||||
|
return atlasRequest('POST', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isConfigured,
|
||||||
|
atlasRequest,
|
||||||
|
atlasGet,
|
||||||
|
atlasPut,
|
||||||
|
atlasPatch,
|
||||||
|
atlasPost
|
||||||
|
};
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
// Audit Log Helper
|
// Audit Log Helper
|
||||||
// Fire-and-forget insert - never blocks the response
|
// Fire-and-forget insert - never blocks the response
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) {
|
function logAudit({ userId, username, action, entityType, entityId, details, ipAddress }) {
|
||||||
const detailsStr = details && typeof details === 'object'
|
const detailsStr = details && typeof details === 'object'
|
||||||
? JSON.stringify(details)
|
? JSON.stringify(details)
|
||||||
: details || null;
|
: details || null;
|
||||||
|
|
||||||
db.run(
|
pool.query(
|
||||||
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
|
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null],
|
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null]
|
||||||
(err) => {
|
).catch((err) => {
|
||||||
if (err) {
|
console.error('Audit log error:', err.message);
|
||||||
console.error('Audit log error:', err.message);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = logAudit;
|
module.exports = logAudit;
|
||||||
|
|||||||
305
backend/helpers/cardApi.js
Normal file
305
backend/helpers/cardApi.js
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
// Shared CARD API helpers
|
||||||
|
// Centralizes HTTP calls for the CARD asset ownership API.
|
||||||
|
// Follows the same promise-based pattern as atlasApi.js, with the addition
|
||||||
|
// of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry).
|
||||||
|
//
|
||||||
|
// CARD API versioning:
|
||||||
|
// - Read endpoints (GET): /api/v1/...
|
||||||
|
// - Mutation endpoints (POST): /api/v2/...
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration — read from process.env at module load
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CARD_API_URL = process.env.CARD_API_URL || '';
|
||||||
|
const CARD_API_USER = process.env.CARD_API_USER || '';
|
||||||
|
const CARD_API_PASS = process.env.CARD_API_PASS || '';
|
||||||
|
const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS'];
|
||||||
|
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfigured = missingVars.length === 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token Manager — OAuth Bearer token with 1-hour TTL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let cachedToken = null; // { token: string, expiresAt: number (epoch ms) }
|
||||||
|
|
||||||
|
function tokenIsValid() {
|
||||||
|
if (!cachedToken) return false;
|
||||||
|
// Refresh if within 60 seconds of expiry
|
||||||
|
return cachedToken.expiresAt - Date.now() > 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateToken() {
|
||||||
|
cachedToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth.
|
||||||
|
* Caches the token in memory with a 1-hour TTL.
|
||||||
|
*/
|
||||||
|
function acquireToken(timeout) {
|
||||||
|
const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64');
|
||||||
|
const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token');
|
||||||
|
const isHttps = fullUrl.protocol === 'https:';
|
||||||
|
const transport = isHttps ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
'authorization': 'Basic ' + authString,
|
||||||
|
'content-length': '0',
|
||||||
|
},
|
||||||
|
timeout: timeout || 15000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHttps) {
|
||||||
|
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
return reject(new Error(
|
||||||
|
`[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CARD API returns the token as a JSON string or object.
|
||||||
|
// Try to parse; fall back to raw body as the token string.
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
token = typeof parsed === 'string' ? parsed
|
||||||
|
: parsed.token || parsed.access_token || data.trim();
|
||||||
|
} catch (_) {
|
||||||
|
// Response may be a plain token string (unquoted)
|
||||||
|
token = data.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return reject(new Error('[card-api] Token parse failure: empty token in response body.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL
|
||||||
|
};
|
||||||
|
resolve(cachedToken.token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out')));
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`));
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure we have a valid Bearer token, acquiring or refreshing as needed.
|
||||||
|
*/
|
||||||
|
async function ensureToken(timeout) {
|
||||||
|
if (tokenIsValid()) return cachedToken.token;
|
||||||
|
return acquireToken(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function cardRequest(method, urlPath, body, options) {
|
||||||
|
const timeout = (options && options.timeout) || 15000;
|
||||||
|
const skipAuth = (options && options.skipAuth) || false;
|
||||||
|
|
||||||
|
async function doRequest(bearerToken) {
|
||||||
|
const fullUrl = new URL(CARD_API_URL + urlPath);
|
||||||
|
const isHttps = fullUrl.protocol === 'https:';
|
||||||
|
const transport = isHttps ? https : http;
|
||||||
|
|
||||||
|
const headers = { 'accept': 'application/json' };
|
||||||
|
|
||||||
|
if (bearerToken) {
|
||||||
|
headers['authorization'] = 'Bearer ' + bearerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyStr = null;
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
bodyStr = JSON.stringify(body);
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHttps) {
|
||||||
|
reqOptions.rejectUnauthorized = !CARD_SKIP_TLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`)));
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bodyStr) req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip auth for the token endpoint itself
|
||||||
|
if (skipAuth) {
|
||||||
|
return doRequest(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal flow: ensure token → request → retry once on 401
|
||||||
|
let token = await ensureToken(timeout);
|
||||||
|
let result = await doRequest(token);
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
// Invalidate and retry exactly once
|
||||||
|
invalidateToken();
|
||||||
|
token = await ensureToken(timeout);
|
||||||
|
result = await doRequest(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function cardGet(urlPath, options) {
|
||||||
|
return cardRequest('GET', urlPath, null, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardPost(urlPath, body, options) {
|
||||||
|
return cardRequest('POST', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// High-level helpers used by the UAT test and routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection by acquiring a token. Returns { ok, token } or { ok, error }.
|
||||||
|
*/
|
||||||
|
async function testConnection() {
|
||||||
|
try {
|
||||||
|
const token = await acquireToken();
|
||||||
|
return { ok: true, token: token.substring(0, 12) + '...' };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/teams — list all CARD teams.
|
||||||
|
*/
|
||||||
|
async function getTeams() {
|
||||||
|
const res = await cardGet('/api/v1/teams');
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/team/{teamName}/assets — list assets for a team.
|
||||||
|
*/
|
||||||
|
async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (disposition) params.set('disposition', disposition);
|
||||||
|
if (page) params.set('page', String(page));
|
||||||
|
params.set('page_size', String(pageSize || 50));
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`);
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||||
|
*/
|
||||||
|
async function getOwner(assetId) {
|
||||||
|
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/owner/{assetId}/confirm — confirm asset to a team.
|
||||||
|
*/
|
||||||
|
async function confirmAsset(assetId, teamName, updateToken, comment) {
|
||||||
|
const params = new URLSearchParams({ update_token: updateToken });
|
||||||
|
if (comment) params.set('comment', comment);
|
||||||
|
const res = await cardPost(
|
||||||
|
`/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`,
|
||||||
|
{ name: teamName }
|
||||||
|
);
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/owner/{assetId}/decline — decline asset from a team.
|
||||||
|
*/
|
||||||
|
async function declineAsset(assetId, teamName, updateToken, comment) {
|
||||||
|
const params = new URLSearchParams({ update_token: updateToken });
|
||||||
|
if (comment) params.set('comment', comment);
|
||||||
|
const res = await cardPost(
|
||||||
|
`/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`,
|
||||||
|
{ name: teamName }
|
||||||
|
);
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams.
|
||||||
|
*/
|
||||||
|
async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||||
|
const params = new URLSearchParams({ update_token: updateToken });
|
||||||
|
const res = await cardPost(
|
||||||
|
`/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`,
|
||||||
|
{ name: toTeam }
|
||||||
|
);
|
||||||
|
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isConfigured,
|
||||||
|
missingVars,
|
||||||
|
cardRequest,
|
||||||
|
cardGet,
|
||||||
|
cardPost,
|
||||||
|
testConnection,
|
||||||
|
getTeams,
|
||||||
|
getTeamAssets,
|
||||||
|
getOwner,
|
||||||
|
confirmAsset,
|
||||||
|
declineAsset,
|
||||||
|
redirectAsset,
|
||||||
|
invalidateToken,
|
||||||
|
};
|
||||||
332
backend/helpers/driftChecker.js
Normal file
332
backend/helpers/driftChecker.js
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
// Drift Checker — compares xlsx schema against parser config to detect structural drift
|
||||||
|
// Returns categorised findings: breaking, silent_miss, cosmetic
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and validate the compliance parser configuration file.
|
||||||
|
* @param {string} configPath — absolute or relative path to compliance_config.json
|
||||||
|
* @returns {object} parsed config with metric_categories, core_cols, skip_sheets
|
||||||
|
* @throws {Error} descriptive error if file missing, invalid JSON, or missing required keys
|
||||||
|
*/
|
||||||
|
function loadConfig(configPath) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(configPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
throw new Error(`Configuration file not found: ${configPath}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to read configuration file: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Configuration file contains invalid JSON: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.metric_categories || typeof config.metric_categories !== 'object' || Array.isArray(config.metric_categories)) {
|
||||||
|
throw new Error('Configuration file is missing required key "metric_categories" (must be an object)');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(config.core_cols)) {
|
||||||
|
throw new Error('Configuration file is missing required key "core_cols" (must be an array)');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(config.skip_sheets)) {
|
||||||
|
throw new Error('Configuration file is missing required key "skip_sheets" (must be an array)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare an xlsx schema against the parser config and produce a drift report.
|
||||||
|
* @param {object} schema — output of extract_xlsx_schema.py: { sheets: [{ name, columns, metric_values? }] }
|
||||||
|
* @param {object} config — parsed compliance_config.json: { metric_categories, core_cols, skip_sheets }
|
||||||
|
* @returns {{ breaking: Array, silent_miss: Array, cosmetic: Array }}
|
||||||
|
*/
|
||||||
|
function compareSchemaToDrift(schema, config) {
|
||||||
|
const breaking = [];
|
||||||
|
const silent_miss = [];
|
||||||
|
const cosmetic = [];
|
||||||
|
|
||||||
|
const metricCategoryKeys = new Set(Object.keys(config.metric_categories));
|
||||||
|
const coreCols = new Set(config.core_cols);
|
||||||
|
const skipSheets = new Set(config.skip_sheets);
|
||||||
|
|
||||||
|
// Build lookup of xlsx sheet names and find the Summary sheet
|
||||||
|
const xlsxSheetNames = new Set();
|
||||||
|
let summarySheet = null;
|
||||||
|
|
||||||
|
for (const sheet of schema.sheets) {
|
||||||
|
xlsxSheetNames.add(sheet.name);
|
||||||
|
if (sheet.name === 'Summary') {
|
||||||
|
summarySheet = sheet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify detail sheets: present in xlsx AND not in skip_sheets
|
||||||
|
const detailSheets = schema.sheets.filter(s => !skipSheets.has(s.name));
|
||||||
|
|
||||||
|
// Build set of metric values from the Summary sheet (used by multiple rules)
|
||||||
|
const summaryMetrics = new Set(
|
||||||
|
(summarySheet && Array.isArray(summarySheet.metric_values)) ? summarySheet.metric_values : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Breaking rules ---
|
||||||
|
|
||||||
|
// Missing core column: a detail sheet is missing a column from core_cols.
|
||||||
|
// Collect per-column stats first, then classify: if a column is missing from
|
||||||
|
// ALL detail sheets it's breaking. If missing from only some (e.g. 5.8.1 uses
|
||||||
|
// CMDB columns), it's cosmetic — the parser handles it via extra_json.
|
||||||
|
const coreColMissingMap = {}; // col -> [sheet names missing it]
|
||||||
|
for (const sheet of detailSheets) {
|
||||||
|
const sheetCols = new Set(sheet.columns || []);
|
||||||
|
for (const coreCol of config.core_cols) {
|
||||||
|
if (!sheetCols.has(coreCol)) {
|
||||||
|
if (!coreColMissingMap[coreCol]) coreColMissingMap[coreCol] = [];
|
||||||
|
coreColMissingMap[coreCol].push(sheet.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const coreCol of Object.keys(coreColMissingMap)) {
|
||||||
|
const missingSheets = coreColMissingMap[coreCol];
|
||||||
|
if (detailSheets.length > 0 && missingSheets.length >= detailSheets.length) {
|
||||||
|
// Missing from ALL detail sheets — genuinely breaking
|
||||||
|
breaking.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
message: `Core column "${coreCol}" is missing from all ${detailSheets.length} detail sheet(s)`,
|
||||||
|
value: coreCol,
|
||||||
|
sheet: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Missing from some sheets — structural difference, not drift
|
||||||
|
cosmetic.push({
|
||||||
|
severity: 'cosmetic',
|
||||||
|
message: `Core column "${coreCol}" is missing from ${missingSheets.length} of ${detailSheets.length} detail sheet(s): ${missingSheets.join(', ')}`,
|
||||||
|
value: coreCol,
|
||||||
|
sheet: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing detail sheet: a sheet in metric_categories (not in skip_sheets) is absent from xlsx.
|
||||||
|
// If the metric still appears in the Summary's metric_values, it's tracked but has zero
|
||||||
|
// violations this week — downgrade to cosmetic instead of breaking.
|
||||||
|
for (const metricKey of metricCategoryKeys) {
|
||||||
|
if (!skipSheets.has(metricKey) && !xlsxSheetNames.has(metricKey)) {
|
||||||
|
if (summaryMetrics.has(metricKey)) {
|
||||||
|
cosmetic.push({
|
||||||
|
severity: 'cosmetic',
|
||||||
|
message: `Metric "${metricKey}" has no detail sheet this week — still tracked in Summary (zero violations)`,
|
||||||
|
value: metricKey,
|
||||||
|
sheet: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
breaking.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
message: `Expected detail sheet "${metricKey}" (metric category) is missing from the workbook`,
|
||||||
|
value: metricKey,
|
||||||
|
sheet: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Silent-miss rules ---
|
||||||
|
|
||||||
|
// Unknown metric value: a metric value in Summary is not a key in metric_categories
|
||||||
|
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||||
|
for (const metricVal of summarySheet.metric_values) {
|
||||||
|
if (!metricCategoryKeys.has(metricVal)) {
|
||||||
|
silent_miss.push({
|
||||||
|
severity: 'silent_miss',
|
||||||
|
message: `Unknown metric "${metricVal}" in Summary — not in metric_categories`,
|
||||||
|
value: metricVal,
|
||||||
|
sheet: 'Summary'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown sheet: an xlsx sheet not in skip_sheets and not in metric_categories
|
||||||
|
for (const sheet of schema.sheets) {
|
||||||
|
if (!skipSheets.has(sheet.name) && !metricCategoryKeys.has(sheet.name)) {
|
||||||
|
silent_miss.push({
|
||||||
|
severity: 'silent_miss',
|
||||||
|
message: `Unknown sheet "${sheet.name}" — not in skip_sheets or metric_categories`,
|
||||||
|
value: sheet.name,
|
||||||
|
sheet: sheet.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cosmetic rules ---
|
||||||
|
|
||||||
|
// New column in detail sheet: a detail sheet has columns not in core_cols
|
||||||
|
for (const sheet of detailSheets) {
|
||||||
|
for (const col of (sheet.columns || [])) {
|
||||||
|
if (!coreCols.has(col)) {
|
||||||
|
cosmetic.push({
|
||||||
|
severity: 'cosmetic',
|
||||||
|
message: `New column "${col}" in sheet "${sheet.name}" — will be captured in extra_json`,
|
||||||
|
value: col,
|
||||||
|
sheet: sheet.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale metric category: a key in metric_categories not in Summary metric values
|
||||||
|
for (const metricKey of metricCategoryKeys) {
|
||||||
|
if (!summaryMetrics.has(metricKey)) {
|
||||||
|
cosmetic.push({
|
||||||
|
severity: 'cosmetic',
|
||||||
|
message: `Stale metric category "${metricKey}" — not found in Summary sheet metric values`,
|
||||||
|
value: metricKey,
|
||||||
|
sheet: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { breaking, silent_miss, cosmetic };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the parser config to resolve breaking drift findings.
|
||||||
|
*
|
||||||
|
* Breaking — "missing detail sheet":
|
||||||
|
* A metric_categories key has no matching xlsx sheet. But if the metric
|
||||||
|
* still appears in the Summary sheet's metric_values, it's a legitimate
|
||||||
|
* tracked metric that simply doesn't have violations this week — keep it.
|
||||||
|
* Only remove metrics absent from BOTH the xlsx sheets AND the Summary.
|
||||||
|
*
|
||||||
|
* Breaking — "missing core column":
|
||||||
|
* A core_cols entry is absent from one or more detail sheets. Only remove
|
||||||
|
* if the column is missing from ALL detail sheets (some sheets like 5.8.1
|
||||||
|
* have a completely different column structure and shouldn't cause removal).
|
||||||
|
*
|
||||||
|
* Silent-miss — "unknown metric":
|
||||||
|
* A metric value in the Summary is not in metric_categories. Add it as 'Other'.
|
||||||
|
*
|
||||||
|
* Silent-miss — "unknown sheet":
|
||||||
|
* Left as a warning. Auto-adding unknown sheets creates a reconcile loop.
|
||||||
|
*
|
||||||
|
* @param {string} configPath — path to compliance_config.json
|
||||||
|
* @param {object} driftReport — the drift report from compareSchemaToDrift()
|
||||||
|
* @param {object} [schema] — optional xlsx schema (with sheets[].name and Summary metric_values)
|
||||||
|
* @returns {{ changes: Array<{ action: string, key: string, value: string }>, config: object }}
|
||||||
|
*/
|
||||||
|
function reconcileConfig(configPath, driftReport, schema) {
|
||||||
|
const config = loadConfig(configPath);
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
// Build a set of metric values from the Summary sheet (if schema provided)
|
||||||
|
const summaryMetrics = new Set();
|
||||||
|
if (schema && Array.isArray(schema.sheets)) {
|
||||||
|
const summarySheet = schema.sheets.find(function(s) { return s.name === 'Summary'; });
|
||||||
|
if (summarySheet && Array.isArray(summarySheet.metric_values)) {
|
||||||
|
summarySheet.metric_values.forEach(function(v) { summaryMetrics.add(v); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a set of xlsx sheet names (if schema provided)
|
||||||
|
const xlsxSheetNames = new Set();
|
||||||
|
if (schema && Array.isArray(schema.sheets)) {
|
||||||
|
schema.sheets.forEach(function(s) { xlsxSheetNames.add(s.name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many detail sheets exist in the xlsx (excluding skip_sheets)
|
||||||
|
const skipSheets = new Set(config.skip_sheets);
|
||||||
|
const detailSheetCount = schema
|
||||||
|
? schema.sheets.filter(function(s) { return !skipSheets.has(s.name); }).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// --- Resolve breaking findings ---
|
||||||
|
|
||||||
|
for (const finding of (driftReport.breaking || [])) {
|
||||||
|
// Missing detail sheet: remove from metric_categories ONLY if the metric
|
||||||
|
// is also absent from the Summary's metric_values. If it's in the Summary,
|
||||||
|
// it's still a tracked metric — the sheet just has zero violations this week.
|
||||||
|
if (finding.message.includes('is missing from the workbook') && finding.value in config.metric_categories) {
|
||||||
|
if (summaryMetrics.has(finding.value)) {
|
||||||
|
// Metric is in the Summary — keep it, just note it's sheet-less this week
|
||||||
|
changes.push({
|
||||||
|
action: 'kept',
|
||||||
|
key: 'metric_categories',
|
||||||
|
value: finding.value,
|
||||||
|
detail: `Kept metric "${finding.value}" — no detail sheet this week but still tracked in Summary`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const oldCategory = config.metric_categories[finding.value];
|
||||||
|
delete config.metric_categories[finding.value];
|
||||||
|
changes.push({
|
||||||
|
action: 'removed',
|
||||||
|
key: 'metric_categories',
|
||||||
|
value: finding.value,
|
||||||
|
detail: `Removed stale metric category "${finding.value}" (was "${oldCategory}") — absent from both workbook sheets and Summary`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing core column: only remove if the column is missing from ALL detail sheets.
|
||||||
|
// Some sheets (e.g. 5.8.1 with CMDB columns) have a completely different structure
|
||||||
|
// and shouldn't cause removal of columns that exist in most other sheets.
|
||||||
|
if (finding.message.includes('is missing core column') && config.core_cols.includes(finding.value)) {
|
||||||
|
if (!changes.some(function(c) { return c.key === 'core_cols' && c.value === finding.value; })) {
|
||||||
|
const missingFromCount = (driftReport.breaking || []).filter(
|
||||||
|
function(f) { return f.message.includes('is missing core column') && f.value === finding.value; }
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (detailSheetCount > 0 && missingFromCount >= detailSheetCount) {
|
||||||
|
// Missing from ALL detail sheets — safe to remove
|
||||||
|
config.core_cols = config.core_cols.filter(function(c) { return c !== finding.value; });
|
||||||
|
changes.push({
|
||||||
|
action: 'removed',
|
||||||
|
key: 'core_cols',
|
||||||
|
value: finding.value,
|
||||||
|
detail: `Removed core column "${finding.value}" — missing from all ${detailSheetCount} detail sheet(s)`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Missing from some sheets but present in others — keep it
|
||||||
|
changes.push({
|
||||||
|
action: 'kept',
|
||||||
|
key: 'core_cols',
|
||||||
|
value: finding.value,
|
||||||
|
detail: `Kept core column "${finding.value}" — missing from ${missingFromCount} of ${detailSheetCount} detail sheet(s)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resolve silent-miss findings ---
|
||||||
|
|
||||||
|
for (const finding of (driftReport.silent_miss || [])) {
|
||||||
|
// Unknown metric in Summary: add to metric_categories as 'Other'
|
||||||
|
if (finding.message.includes('not in metric_categories') && !(finding.value in config.metric_categories)) {
|
||||||
|
config.metric_categories[finding.value] = 'Other';
|
||||||
|
changes.push({
|
||||||
|
action: 'added',
|
||||||
|
key: 'metric_categories',
|
||||||
|
value: finding.value,
|
||||||
|
detail: `Added new metric "${finding.value}" to metric_categories as "Other"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown sheet: left as a warning — auto-adding creates a reconcile loop.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only write if there were actual config mutations (not just 'kept' entries)
|
||||||
|
const hasMutations = changes.some(function(c) { return c.action !== 'kept'; });
|
||||||
|
if (hasMutations) {
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changes, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { compareSchemaToDrift, loadConfig, reconcileConfig };
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
|
|
||||||
* @param {string} inputPath - Path to original Excel file
|
|
||||||
* @param {string} outputPath - Path for processed Excel file
|
|
||||||
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
|
|
||||||
*/
|
|
||||||
function processVulnerabilityReport(inputPath, outputPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
|
|
||||||
|
|
||||||
// Verify script exists
|
|
||||||
if (!fs.existsSync(scriptPath)) {
|
|
||||||
return reject(new Error(`Python script not found: ${scriptPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify input file exists
|
|
||||||
if (!fs.existsSync(inputPath)) {
|
|
||||||
return reject(new Error(`Input file not found: ${inputPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
// 30 second timeout
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
python.kill();
|
|
||||||
reject(new Error('Processing timed out. File may be too large or corrupted.'));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
python.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('close', (code) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (timedOut) return;
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
// Parse Python error messages
|
|
||||||
if (stderr.includes('Sheet') && stderr.includes('not found')) {
|
|
||||||
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
|
|
||||||
}
|
|
||||||
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
|
|
||||||
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
|
|
||||||
}
|
|
||||||
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output for row counts
|
|
||||||
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
|
|
||||||
const newMatch = stdout.match(/New rows:\s*(\d+)/);
|
|
||||||
|
|
||||||
if (!originalMatch || !newMatch) {
|
|
||||||
return reject(new Error('Failed to parse row counts from Python output'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify output file was created
|
|
||||||
if (!fs.existsSync(outputPath)) {
|
|
||||||
return reject(new Error('Processed file was not created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
original_rows: parseInt(originalMatch[1]),
|
|
||||||
processed_rows: parseInt(newMatch[1]),
|
|
||||||
output_path: outputPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
python.on('error', (err) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
reject(new Error('Python 3 is required but not found. Please install Python.'));
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { processVulnerabilityReport };
|
|
||||||
154
backend/helpers/ivantiApi.js
Normal file
154
backend/helpers/ivantiApi.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Shared Ivanti / RiskSense API helpers
|
||||||
|
// Centralizes HTTP calls so ivantiWorkflows.js, ivantiFindings.js, and
|
||||||
|
// ivantiFpWorkflow.js all use the same implementation.
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON POST — used for search, workflow creation, etc.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': Buffer.byteLength(bodyStr)
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 15000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multipart POST — used for file attachment uploads.
|
||||||
|
// Constructs multipart/form-data manually using Node's https module.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls) {
|
||||||
|
const boundary = '----IvantiUpload' + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
// Build multipart body
|
||||||
|
const preamble = Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
|
||||||
|
`Content-Type: application/octet-stream\r\n\r\n`
|
||||||
|
);
|
||||||
|
const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||||
|
const bodyBuffer = Buffer.concat([preamble, fileBuffer, epilogue]);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': bodyBuffer.length
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 30000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyBuffer);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multipart form POST — used for endpoints that accept mixed form fields + files.
|
||||||
|
// fields: array of { name, value } for text form fields
|
||||||
|
// files: array of { name, buffer, filename } for file uploads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiFormPost(urlPath, fields, files, apiKey, skipTls) {
|
||||||
|
const boundary = '----IvantiForm' + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Text fields
|
||||||
|
for (const { name, value } of fields) {
|
||||||
|
parts.push(Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
|
||||||
|
`${value}\r\n`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// File fields
|
||||||
|
for (const { name, buffer, filename, contentType } of files) {
|
||||||
|
parts.push(Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||||
|
`Content-Type: ${contentType || 'application/octet-stream'}\r\n\r\n`
|
||||||
|
));
|
||||||
|
parts.push(buffer);
|
||||||
|
parts.push(Buffer.from('\r\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
||||||
|
const bodyBuffer = Buffer.concat(parts);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': bodyBuffer.length
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 60000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyBuffer);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost, ivantiFormPost };
|
||||||
455
backend/helpers/jiraApi.js
Normal file
455
backend/helpers/jiraApi.js
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
// Shared Jira Data Center REST API helpers
|
||||||
|
// Centralizes HTTP calls for Jira issue operations.
|
||||||
|
// Follows the same promise-based pattern as atlasApi.js and ivantiApi.js.
|
||||||
|
//
|
||||||
|
// =========================================================================
|
||||||
|
// Charter Jira REST API Compliance
|
||||||
|
// =========================================================================
|
||||||
|
// Authentication:
|
||||||
|
// - Service accounts use Basic Auth (required for shared integrations).
|
||||||
|
// - PATs require ATLSUP approval and naming convention:
|
||||||
|
// Function - Team - Approved ATLSUP ticket
|
||||||
|
// - SSO must NOT be used for REST API integrations.
|
||||||
|
//
|
||||||
|
// Rate limiting (Charter-posted):
|
||||||
|
// - 1 440 requests/day max
|
||||||
|
// - Burst cap of 60 requests/minute (accumulates 1 req/idle minute)
|
||||||
|
// - 429 response when limits are hit server-side
|
||||||
|
//
|
||||||
|
// Automation delays (Charter requirement):
|
||||||
|
// - 1 second delay between GET requests
|
||||||
|
// - 2 second delay between PUT, POST, or DELETE requests
|
||||||
|
//
|
||||||
|
// Forbidden patterns:
|
||||||
|
// - /rest/api/2/field — must specify fields explicitly in every call
|
||||||
|
// - /rest/api/2/issue/bulk — bulk updates are not allowed
|
||||||
|
// - Single-issue GET loops — use bulk JQL search instead
|
||||||
|
//
|
||||||
|
// Required patterns:
|
||||||
|
// - All GET requests MUST include a ?fields= parameter
|
||||||
|
// - JQL MUST include at least one of: project+updated, assignee+updated,
|
||||||
|
// status+updated
|
||||||
|
// - JQL should use &updated>=-Xh to only fetch changed issues
|
||||||
|
// - maxResults=1000 for search queries
|
||||||
|
// - Issues must be updated one at a time (no bulk PUT)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration — read from process.env at module load
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const JIRA_BASE_URL = process.env.JIRA_BASE_URL || '';
|
||||||
|
const JIRA_AUTH_METHOD = (process.env.JIRA_AUTH_METHOD || 'basic').toLowerCase();
|
||||||
|
const JIRA_API_USER = process.env.JIRA_API_USER || '';
|
||||||
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN || '';
|
||||||
|
const JIRA_PAT = process.env.JIRA_PAT || '';
|
||||||
|
const JIRA_SKIP_TLS = process.env.JIRA_SKIP_TLS === 'true';
|
||||||
|
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY || '';
|
||||||
|
const JIRA_ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || 'Task';
|
||||||
|
|
||||||
|
const requiredVars = JIRA_AUTH_METHOD === 'pat'
|
||||||
|
? ['JIRA_BASE_URL', 'JIRA_PAT']
|
||||||
|
: ['JIRA_BASE_URL', 'JIRA_API_USER', 'JIRA_API_TOKEN'];
|
||||||
|
|
||||||
|
const missingVars = requiredVars.filter((v) => !process.env[v]);
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
console.warn(`[jira-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. Jira API calls will fail.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfigured = missingVars.length === 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default fields — every GET must specify fields explicitly.
|
||||||
|
// /rest/api/2/field is forbidden; we define the field list here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DEFAULT_FIELDS = [
|
||||||
|
'summary', 'status', 'assignee', 'created', 'updated',
|
||||||
|
'priority', 'issuetype', 'project', 'resolution'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rate limiter — enforces Charter's posted limits
|
||||||
|
// 1 440 events/day, burst of 60 events/minute
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DAILY_LIMIT = 1440;
|
||||||
|
const BURST_LIMIT = 60;
|
||||||
|
const MINUTE_MS = 60 * 1000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let dailyLog = [];
|
||||||
|
let minuteLog = [];
|
||||||
|
|
||||||
|
function pruneLog(log, windowMs) {
|
||||||
|
const cutoff = Date.now() - windowMs;
|
||||||
|
while (log.length > 0 && log[0] < cutoff) {
|
||||||
|
log.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit() {
|
||||||
|
pruneLog(dailyLog, DAY_MS);
|
||||||
|
pruneLog(minuteLog, MINUTE_MS);
|
||||||
|
|
||||||
|
if (dailyLog.length >= DAILY_LIMIT) {
|
||||||
|
return { allowed: false, reason: `Daily Jira API limit reached (${DAILY_LIMIT}/day). Resets at midnight.` };
|
||||||
|
}
|
||||||
|
if (minuteLog.length >= BURST_LIMIT) {
|
||||||
|
return { allowed: false, reason: `Burst Jira API limit reached (${BURST_LIMIT}/min). Wait and retry.` };
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordRequest() {
|
||||||
|
const now = Date.now();
|
||||||
|
dailyLog.push(now);
|
||||||
|
minuteLog.push(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return current rate limit usage for diagnostics.
|
||||||
|
*/
|
||||||
|
function getRateLimitStatus() {
|
||||||
|
pruneLog(dailyLog, DAY_MS);
|
||||||
|
pruneLog(minuteLog, MINUTE_MS);
|
||||||
|
return {
|
||||||
|
daily: { used: dailyLog.length, limit: DAILY_LIMIT, remaining: DAILY_LIMIT - dailyLog.length },
|
||||||
|
burst: { used: minuteLog.length, limit: BURST_LIMIT, remaining: BURST_LIMIT - minuteLog.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inter-request delay — Charter automation requirements
|
||||||
|
// 1s between GETs, 2s between PUT/POST/DELETE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const GET_DELAY_MS = 1000;
|
||||||
|
const WRITE_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
let lastRequestTime = 0;
|
||||||
|
let lastRequestMethod = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait the required delay before issuing the next request.
|
||||||
|
* GET → 1s, PUT/POST/DELETE → 2s since the previous request.
|
||||||
|
*/
|
||||||
|
function waitForDelay(method) {
|
||||||
|
const now = Date.now();
|
||||||
|
const requiredDelay = (lastRequestMethod === 'GET') ? GET_DELAY_MS
|
||||||
|
: (lastRequestMethod !== '') ? WRITE_DELAY_MS : 0;
|
||||||
|
const elapsed = now - lastRequestTime;
|
||||||
|
const remaining = requiredDelay - elapsed;
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, remaining));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Blocked endpoint guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const BLOCKED_PATHS = [
|
||||||
|
'/rest/api/2/field', // Must specify fields in call, not query field list
|
||||||
|
'/rest/api/2/issue/bulk', // Bulk updates are not allowed
|
||||||
|
];
|
||||||
|
|
||||||
|
function isBlockedPath(urlPath) {
|
||||||
|
return BLOCKED_PATHS.some(blocked => urlPath.startsWith(blocked));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic request — supports GET, POST, PUT, DELETE
|
||||||
|
// Enforces rate limits, inter-request delays, and blocked-path guards.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function jiraRequest(method, urlPath, body, options) {
|
||||||
|
// Block forbidden endpoints
|
||||||
|
if (isBlockedPath(urlPath)) {
|
||||||
|
return Promise.reject(new Error(`Blocked: ${urlPath} is not allowed per Charter Jira API policy.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = checkRateLimit();
|
||||||
|
if (!limit.allowed) {
|
||||||
|
return Promise.reject(new Error(limit.reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce inter-request delay
|
||||||
|
await waitForDelay(method);
|
||||||
|
|
||||||
|
const timeout = (options && options.timeout) || 15000;
|
||||||
|
const fullUrl = new URL(JIRA_BASE_URL + urlPath);
|
||||||
|
const isHttps = fullUrl.protocol === 'https:';
|
||||||
|
const transport = isHttps ? https : http;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auth header
|
||||||
|
if (JIRA_AUTH_METHOD === 'pat') {
|
||||||
|
headers['authorization'] = 'Bearer ' + JIRA_PAT;
|
||||||
|
} else {
|
||||||
|
const authString = Buffer.from(JIRA_API_USER + ':' + JIRA_API_TOKEN).toString('base64');
|
||||||
|
headers['authorization'] = 'Basic ' + authString;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyStr = null;
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
bodyStr = JSON.stringify(body);
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
headers['content-length'] = Buffer.byteLength(bodyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest();
|
||||||
|
lastRequestTime = Date.now();
|
||||||
|
lastRequestMethod = method;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
timeout: timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHttps) {
|
||||||
|
reqOptions.rejectUnauthorized = !JIRA_SKIP_TLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 429) {
|
||||||
|
resolve({ status: 429, body: data, rateLimited: true });
|
||||||
|
} else {
|
||||||
|
resolve({ status: res.statusCode, body: data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error(method + ' ' + urlPath + ' timed out')));
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(new Error(method + ' ' + urlPath + ' failed: ' + err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bodyStr) {
|
||||||
|
req.write(bodyStr);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function jiraGet(urlPath, options) {
|
||||||
|
return jiraRequest('GET', urlPath, null, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jiraPost(urlPath, body, options) {
|
||||||
|
return jiraRequest('POST', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jiraPut(urlPath, body, options) {
|
||||||
|
return jiraRequest('PUT', urlPath, body, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jiraDelete(urlPath, options) {
|
||||||
|
return jiraRequest('DELETE', urlPath, null, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// High-level Jira operations — all comply with Charter requirements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single issue by key using a GET with explicit ?fields= parameter.
|
||||||
|
* Charter requires all GETs to specify fields — /rest/api/2/field is forbidden.
|
||||||
|
*
|
||||||
|
* NOTE: For syncing multiple tickets, prefer searchIssuesByKeys() which uses
|
||||||
|
* a single bulk JQL search instead of one GET per issue.
|
||||||
|
*
|
||||||
|
* @param {string} issueKey - e.g. "VULN-123"
|
||||||
|
* @param {string[]} [fields] - Jira field names to return
|
||||||
|
*/
|
||||||
|
async function getIssue(issueKey, fields) {
|
||||||
|
// Use JQL search to look up a single issue by key.
|
||||||
|
// Issue keys are globally unique in Jira — no project filter needed.
|
||||||
|
// Charter compliance: uses GET /rest/api/2/search with explicit field list.
|
||||||
|
const jql = `key = "${issueKey}"`;
|
||||||
|
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||||
|
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||||
|
return { ok: true, data: result.data.issues[0] };
|
||||||
|
}
|
||||||
|
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
|
||||||
|
return { ok: false, status: 404, body: 'Issue not found' };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-fetch issues by their keys using a single JQL search.
|
||||||
|
* This is the Charter-compliant way to sync multiple tickets — avoids
|
||||||
|
* querying one issue at a time.
|
||||||
|
*
|
||||||
|
* @param {string[]} issueKeys - Array of Jira issue keys
|
||||||
|
* @param {object} [opts] - { fields, maxResults }
|
||||||
|
*/
|
||||||
|
async function searchIssuesByKeys(issueKeys, opts) {
|
||||||
|
if (!issueKeys || issueKeys.length === 0) {
|
||||||
|
return { ok: true, data: { total: 0, issues: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build JQL: key in (KEY-1, KEY-2, ...) — issue keys are globally unique,
|
||||||
|
// so no project filter needed. Add updated clause for Charter compliance.
|
||||||
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||||
|
const jql = `key in (${keyList}) AND updated >= -72h`;
|
||||||
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
|
|
||||||
|
return searchIssues(jql, { fields, maxResults, startAt: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search issues via JQL (POST to /rest/api/2/search).
|
||||||
|
* Charter requirements enforced:
|
||||||
|
* - fields array is always specified (never omitted)
|
||||||
|
* - maxResults capped at 1000
|
||||||
|
*
|
||||||
|
* The caller is responsible for including an &updated clause in the JQL
|
||||||
|
* for recurring/scheduled queries.
|
||||||
|
*
|
||||||
|
* @param {string} jql - JQL query string
|
||||||
|
* @param {object} [opts] - { startAt, maxResults, fields }
|
||||||
|
*/
|
||||||
|
async function searchIssues(jql, opts) {
|
||||||
|
const startAt = (opts && opts.startAt) || 0;
|
||||||
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
|
|
||||||
|
const fieldList = encodeURIComponent(fields.join(','));
|
||||||
|
const encodedJql = encodeURIComponent(jql);
|
||||||
|
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
|
||||||
|
const res = await jiraGet('/rest/api/2/search' + queryString);
|
||||||
|
if (res.status === 200) {
|
||||||
|
return { ok: true, data: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Jira issue (POST, subject to 2s delay).
|
||||||
|
* @param {object} fields - Jira issue fields object
|
||||||
|
*/
|
||||||
|
async function createIssue(fields) {
|
||||||
|
const res = await jiraPost('/rest/api/2/issue', { fields });
|
||||||
|
if (res.status === 201) {
|
||||||
|
return { ok: true, data: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single Jira issue (PUT, subject to 2s delay).
|
||||||
|
* Charter forbids bulk updates — issues must be updated one at a time.
|
||||||
|
* @param {string} issueKey
|
||||||
|
* @param {object} fields - Fields to update
|
||||||
|
*/
|
||||||
|
async function updateIssue(issueKey, fields) {
|
||||||
|
const res = await jiraPut(
|
||||||
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
|
||||||
|
{ fields }
|
||||||
|
);
|
||||||
|
// Jira returns 204 on successful update
|
||||||
|
if (res.status === 204) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a comment to an existing issue (POST, subject to 2s delay).
|
||||||
|
*/
|
||||||
|
async function addComment(issueKey, commentBody) {
|
||||||
|
const res = await jiraPost(
|
||||||
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/comment`,
|
||||||
|
{ body: commentBody }
|
||||||
|
);
|
||||||
|
if (res.status === 201) {
|
||||||
|
return { ok: true, data: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition an issue to a new status (POST, subject to 2s delay).
|
||||||
|
* @param {string} issueKey
|
||||||
|
* @param {string} transitionId
|
||||||
|
*/
|
||||||
|
async function transitionIssue(issueKey, transitionId) {
|
||||||
|
const res = await jiraPost(
|
||||||
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`,
|
||||||
|
{ transition: { id: transitionId } }
|
||||||
|
);
|
||||||
|
if (res.status === 204) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available transitions for an issue.
|
||||||
|
* Uses GET with explicit fields parameter (transitions endpoint returns
|
||||||
|
* transitions by default, but we include the query param for compliance).
|
||||||
|
*/
|
||||||
|
async function getTransitions(issueKey) {
|
||||||
|
const res = await jiraGet(
|
||||||
|
`/rest/api/2/issue/${encodeURIComponent(issueKey)}/transitions`
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
return { ok: true, data: JSON.parse(res.body) };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connectivity — calls /rest/api/2/myself to verify credentials.
|
||||||
|
* This is a lightweight GET that returns the authenticated user.
|
||||||
|
*/
|
||||||
|
async function testConnection() {
|
||||||
|
try {
|
||||||
|
const res = await jiraGet('/rest/api/2/myself');
|
||||||
|
if (res.status === 200) {
|
||||||
|
const user = JSON.parse(res.body);
|
||||||
|
return { ok: true, user: { name: user.name, displayName: user.displayName, emailAddress: user.emailAddress } };
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, body: res.body };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isConfigured,
|
||||||
|
jiraRequest,
|
||||||
|
jiraGet,
|
||||||
|
jiraPost,
|
||||||
|
jiraPut,
|
||||||
|
jiraDelete,
|
||||||
|
getIssue,
|
||||||
|
searchIssuesByKeys,
|
||||||
|
searchIssues,
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
addComment,
|
||||||
|
transitionIssue,
|
||||||
|
getTransitions,
|
||||||
|
testConnection,
|
||||||
|
getRateLimitStatus,
|
||||||
|
DEFAULT_FIELDS,
|
||||||
|
JIRA_PROJECT_KEY,
|
||||||
|
JIRA_ISSUE_TYPE
|
||||||
|
};
|
||||||
26
backend/helpers/teams.js
Normal file
26
backend/helpers/teams.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Shared BU team constants and validation
|
||||||
|
// Used by user management routes, auth middleware, and frontend-facing endpoints.
|
||||||
|
|
||||||
|
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a comma-separated teams string.
|
||||||
|
* @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG')
|
||||||
|
* @returns {{ valid: boolean, teams: string[], invalid: string[] }}
|
||||||
|
*/
|
||||||
|
function validateTeams(teamsString) {
|
||||||
|
if (!teamsString || typeof teamsString !== 'string' || teamsString.trim() === '') {
|
||||||
|
return { valid: true, teams: [], invalid: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = teamsString.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const invalid = teams.filter(t => !KNOWN_TEAMS.includes(t));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: invalid.length === 0,
|
||||||
|
teams,
|
||||||
|
invalid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { KNOWN_TEAMS, validateTeams };
|
||||||
546
backend/helpers/vclHelpers.js
Normal file
546
backend/helpers/vclHelpers.js
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
// Pure helper functions for VCL Compliance Reporting
|
||||||
|
// No database dependencies — all functions are stateless and testable in isolation.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates text to maxLen characters with an ellipsis.
|
||||||
|
* Returns '' for null/undefined input.
|
||||||
|
*/
|
||||||
|
function truncateText(text, maxLen = 80) {
|
||||||
|
if (text == null) return '';
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen) + '\u2026';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a remediation plan does not exceed 2000 characters.
|
||||||
|
* Null/undefined/empty values are considered valid (no plan documented).
|
||||||
|
*/
|
||||||
|
function validateRemediationPlan(text) {
|
||||||
|
if (text == null || text === '') return { valid: true };
|
||||||
|
if (text.length > 2000) return { valid: false, error: 'Remediation plan exceeds 2000 characters' };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true only for strings parseable as real calendar dates.
|
||||||
|
* Rejects null, undefined, empty string, and invalid dates like "2026-02-30".
|
||||||
|
*/
|
||||||
|
function isValidDateString(str) {
|
||||||
|
if (str == null || str === '') return false;
|
||||||
|
if (typeof str !== 'string') return false;
|
||||||
|
|
||||||
|
// Expect YYYY-MM-DD format
|
||||||
|
const match = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return false;
|
||||||
|
|
||||||
|
const year = parseInt(match[1], 10);
|
||||||
|
const month = parseInt(match[2], 10);
|
||||||
|
const day = parseInt(match[3], 10);
|
||||||
|
|
||||||
|
// Month must be 1-12
|
||||||
|
if (month < 1 || month > 12) return false;
|
||||||
|
|
||||||
|
// Create date and verify components match (catches invalid days like Feb 30)
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return (
|
||||||
|
date.getFullYear() === year &&
|
||||||
|
date.getMonth() === month - 1 &&
|
||||||
|
date.getDate() === day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a decimal as a whole-number percentage string.
|
||||||
|
* Returns '0%' for null, undefined, or NaN input.
|
||||||
|
*/
|
||||||
|
function formatPct(decimal) {
|
||||||
|
if (decimal == null || isNaN(decimal)) return '0%';
|
||||||
|
return Math.round(decimal * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes VCL summary statistics from an array of device objects.
|
||||||
|
* Each item should have at least { is_compliant: boolean, in_scope: boolean }.
|
||||||
|
*/
|
||||||
|
function computeVCLStats(items, targetPct) {
|
||||||
|
const total = items.length;
|
||||||
|
const in_scope = items.filter(item => item.in_scope).length;
|
||||||
|
const compliant = items.filter(item => item.is_compliant).length;
|
||||||
|
const non_compliant = in_scope - compliant;
|
||||||
|
const remediations_required = non_compliant;
|
||||||
|
const compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
in_scope,
|
||||||
|
compliant,
|
||||||
|
non_compliant,
|
||||||
|
remediations_required,
|
||||||
|
compliance_pct,
|
||||||
|
target_pct: targetPct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partitions non-compliant items into "blocked" (no resolution_date) and
|
||||||
|
* "in_progress" (resolution_date set). Returns counts and percentages.
|
||||||
|
*/
|
||||||
|
function categorizeNonCompliant(items) {
|
||||||
|
const total = items.length;
|
||||||
|
const blocked = items.filter(item => item.resolution_date == null);
|
||||||
|
const in_progress = items.filter(item => item.resolution_date != null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocked: {
|
||||||
|
count: blocked.length,
|
||||||
|
pct: total > 0 ? Math.round((blocked.length / total) * 100) : 0,
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
count: in_progress.length,
|
||||||
|
pct: total > 0 ? Math.round((in_progress.length / total) * 100) : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts verticals by non_compliant count in descending order.
|
||||||
|
* Returns a new sorted array (does not mutate input).
|
||||||
|
*/
|
||||||
|
function rankHeavyHitters(verticalData) {
|
||||||
|
return [...verticalData].sort((a, b) => b.non_compliant - a.non_compliant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets non-compliant items by resolution_date month (YYYY-MM).
|
||||||
|
* Items with null resolution_date are skipped.
|
||||||
|
* Returns an object like { '2026-05': 3, '2026-06': 7 }.
|
||||||
|
*/
|
||||||
|
function computeForecastBurndown(items) {
|
||||||
|
const buckets = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.resolution_date == null) continue;
|
||||||
|
const dateStr = typeof item.resolution_date === 'string'
|
||||||
|
? item.resolution_date
|
||||||
|
: item.resolution_date.toISOString().slice(0, 10);
|
||||||
|
const month = dateStr.slice(0, 7); // YYYY-MM
|
||||||
|
buckets[month] = (buckets[month] || 0) + 1;
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches uploaded rows to existing hostnames.
|
||||||
|
* Returns { matched: [...], unmatched: [...] }.
|
||||||
|
*/
|
||||||
|
function matchByHostname(uploadedRows, existingHostnames) {
|
||||||
|
const matched = [];
|
||||||
|
const unmatched = [];
|
||||||
|
for (const row of uploadedRows) {
|
||||||
|
if (existingHostnames.has(row.hostname)) {
|
||||||
|
matched.push(row);
|
||||||
|
} else {
|
||||||
|
unmatched.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { matched, unmatched };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares uploaded row values against current DB values.
|
||||||
|
* currentData is a Map of hostname -> { resolution_date, remediation_plan, notes }.
|
||||||
|
* Returns array of { hostname, status: 'changed'|'unchanged', fields: { fieldName: { old, new } } }.
|
||||||
|
*/
|
||||||
|
function computeBulkDiff(matchedRows, currentData) {
|
||||||
|
const results = [];
|
||||||
|
const COMPARE_FIELDS = ['resolution_date', 'remediation_plan', 'notes'];
|
||||||
|
|
||||||
|
for (const row of matchedRows) {
|
||||||
|
const current = currentData.get(row.hostname) || {};
|
||||||
|
const fields = {};
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
for (const field of COMPARE_FIELDS) {
|
||||||
|
if (field in row) {
|
||||||
|
const oldVal = current[field] != null ? current[field] : null;
|
||||||
|
const newVal = row[field] != null ? row[field] : null;
|
||||||
|
if (oldVal !== newVal) {
|
||||||
|
fields[field] = { old: oldVal, new: newVal };
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
hostname: row.hostname,
|
||||||
|
status: hasChange ? 'changed' : 'unchanged',
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps column header strings to known field names (case-insensitive).
|
||||||
|
* Returns a mapping object like { hostname: 0, resolution_date: 3 } where values are column indices.
|
||||||
|
*/
|
||||||
|
function mapColumnHeaders(headers) {
|
||||||
|
const mapping = {};
|
||||||
|
const KNOWN_MAPPINGS = {
|
||||||
|
hostname: 'hostname',
|
||||||
|
'resolution date': 'resolution_date',
|
||||||
|
resolution_date: 'resolution_date',
|
||||||
|
'remediation plan': 'remediation_plan',
|
||||||
|
remediation_plan: 'remediation_plan',
|
||||||
|
notes: 'notes',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const normalized = headers[i].trim().toLowerCase();
|
||||||
|
if (KNOWN_MAPPINGS[normalized]) {
|
||||||
|
mapping[KNOWN_MAPPINGS[normalized]] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts vertical code and report date from a filename.
|
||||||
|
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||||
|
* The vertical is everything before the trailing _YYYY_MM_DD portion.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||||
|
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||||
|
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||||
|
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||||
|
*
|
||||||
|
* Returns null if the filename does not match the expected pattern.
|
||||||
|
*/
|
||||||
|
function parseVerticalFilename(filename) {
|
||||||
|
// Strip .xlsx extension (case-insensitive)
|
||||||
|
const stem = filename.replace(/\.xlsx$/i, '');
|
||||||
|
// Match: everything up to the last _YYYY_MM_DD
|
||||||
|
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const vertical = match[1];
|
||||||
|
const date = `${match[2]}-${match[3]}-${match[4]}`;
|
||||||
|
|
||||||
|
// Validate the date portion is a real date
|
||||||
|
if (!isValidDateString(date)) return null;
|
||||||
|
|
||||||
|
return { vertical, date };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes per-vertical burndown forecast from non-compliant items.
|
||||||
|
* Returns breakdown of items with/without resolution dates and monthly projections.
|
||||||
|
*/
|
||||||
|
function computeVerticalBurndown(items) {
|
||||||
|
const total = items.length;
|
||||||
|
const withDates = items.filter(i => i.resolution_date != null);
|
||||||
|
const blockers = items.filter(i => i.resolution_date == null);
|
||||||
|
|
||||||
|
// Bucket by month
|
||||||
|
const monthly = {};
|
||||||
|
for (const item of withDates) {
|
||||||
|
const dateStr = typeof item.resolution_date === 'string'
|
||||||
|
? item.resolution_date
|
||||||
|
: item.resolution_date.toISOString().slice(0, 10);
|
||||||
|
const month = dateStr.slice(0, 7); // YYYY-MM
|
||||||
|
monthly[month] = (monthly[month] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative projection — how many remain after each month
|
||||||
|
let remaining = total;
|
||||||
|
const projection = {};
|
||||||
|
for (const month of Object.keys(monthly).sort()) {
|
||||||
|
remaining -= monthly[month];
|
||||||
|
projection[month] = { remediated: monthly[month], remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projected clear date — first month where remaining hits 0 (excluding blockers)
|
||||||
|
let projectedClearDate = null;
|
||||||
|
if (blockers.length === 0 && Object.keys(projection).length > 0) {
|
||||||
|
const sortedMonths = Object.keys(projection).sort();
|
||||||
|
projectedClearDate = sortedMonths[sortedMonths.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
blockers: blockers.length,
|
||||||
|
with_dates: withDates.length,
|
||||||
|
monthly,
|
||||||
|
projection,
|
||||||
|
projected_clear_date: projectedClearDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
|
||||||
|
* A device appearing in multiple metrics counts once.
|
||||||
|
*
|
||||||
|
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
|
||||||
|
* @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
|
||||||
|
*/
|
||||||
|
function deduplicateByHostname(items) {
|
||||||
|
const map = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.hostname;
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = { hostname: item.hostname, resolution_date: item.resolution_date || null, vertical: item.vertical };
|
||||||
|
} else {
|
||||||
|
// Keep the earliest non-null resolution_date
|
||||||
|
const existing = map[key];
|
||||||
|
if (item.resolution_date != null) {
|
||||||
|
if (existing.resolution_date == null || item.resolution_date < existing.resolution_date) {
|
||||||
|
existing.resolution_date = item.resolution_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes aggregated burndown from a deduplicated array of device objects.
|
||||||
|
* Each device has { hostname, resolution_date, vertical }.
|
||||||
|
*
|
||||||
|
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
|
||||||
|
* @returns {{
|
||||||
|
* total: number,
|
||||||
|
* blockers: number,
|
||||||
|
* with_dates: number,
|
||||||
|
* monthly: Object<string, number>,
|
||||||
|
* projection: Object<string, { remediated: number, remaining: number }>,
|
||||||
|
* projected_clear_date: string|null,
|
||||||
|
* by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function computeAggregatedBurndown(devices) {
|
||||||
|
const total = devices.length;
|
||||||
|
const withDates = devices.filter(d => d.resolution_date != null);
|
||||||
|
const blockerDevices = devices.filter(d => d.resolution_date == null);
|
||||||
|
const blockers = blockerDevices.length;
|
||||||
|
const with_dates = withDates.length;
|
||||||
|
|
||||||
|
// Bucket by month (YYYY-MM)
|
||||||
|
const monthly = {};
|
||||||
|
for (const device of withDates) {
|
||||||
|
const dateStr = typeof device.resolution_date === 'string'
|
||||||
|
? device.resolution_date
|
||||||
|
: device.resolution_date.toISOString().slice(0, 10);
|
||||||
|
const month = dateStr.slice(0, 7);
|
||||||
|
monthly[month] = (monthly[month] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort monthly keys chronologically
|
||||||
|
const sortedMonths = Object.keys(monthly).sort();
|
||||||
|
const sortedMonthly = {};
|
||||||
|
for (const m of sortedMonths) {
|
||||||
|
sortedMonthly[m] = monthly[m];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative projection
|
||||||
|
let remaining = total;
|
||||||
|
const projection = {};
|
||||||
|
for (const month of sortedMonths) {
|
||||||
|
remaining -= sortedMonthly[month];
|
||||||
|
projection[month] = { remediated: sortedMonthly[month], remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projected clear date
|
||||||
|
let projected_clear_date = null;
|
||||||
|
if (blockers === 0 && sortedMonths.length > 0) {
|
||||||
|
projected_clear_date = sortedMonths[sortedMonths.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-vertical breakdown
|
||||||
|
const verticalMap = {};
|
||||||
|
for (const device of devices) {
|
||||||
|
const v = device.vertical;
|
||||||
|
if (!verticalMap[v]) {
|
||||||
|
verticalMap[v] = { vertical: v, total: 0, blockers: 0, with_dates: 0 };
|
||||||
|
}
|
||||||
|
verticalMap[v].total++;
|
||||||
|
if (device.resolution_date == null) {
|
||||||
|
verticalMap[v].blockers++;
|
||||||
|
} else {
|
||||||
|
verticalMap[v].with_dates++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending by total, filter out zero-total entries
|
||||||
|
const by_vertical = Object.values(verticalMap)
|
||||||
|
.filter(v => v.total > 0)
|
||||||
|
.sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
blockers,
|
||||||
|
with_dates,
|
||||||
|
monthly: sortedMonthly,
|
||||||
|
projection,
|
||||||
|
projected_clear_date,
|
||||||
|
by_vertical,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes per-metric forecast burndown from device records and historical snapshots.
|
||||||
|
*
|
||||||
|
* Pure function — no side effects, no database access. Suitable for property-based testing.
|
||||||
|
*
|
||||||
|
* @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
|
||||||
|
* Active non-compliant devices for the metric
|
||||||
|
* @param {number} totalAssets
|
||||||
|
* Total device count in scope for this metric (from snapshot or summary)
|
||||||
|
* @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
|
||||||
|
* Pre-computed historical data points (up to 4 months)
|
||||||
|
* @returns {{
|
||||||
|
* historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||||
|
* forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||||
|
* current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) {
|
||||||
|
// Compute compliance_pct helper
|
||||||
|
function calcCompliancePct(total, nc) {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round(((total - nc) / total) * 1000) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical — pass through as-is
|
||||||
|
const historical = (historicalSnapshots || []).map(snap => ({
|
||||||
|
month: snap.month,
|
||||||
|
total_assets: snap.total_assets,
|
||||||
|
non_compliant: snap.non_compliant,
|
||||||
|
compliance_pct: snap.compliance_pct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets
|
||||||
|
if (!currentDevices || currentDevices.length === 0) {
|
||||||
|
return {
|
||||||
|
historical,
|
||||||
|
forecast: [],
|
||||||
|
current_snapshot: {
|
||||||
|
total_assets: totalAssets,
|
||||||
|
non_compliant: 0,
|
||||||
|
compliant: 0,
|
||||||
|
compliance_pct: 0,
|
||||||
|
blockers: 0,
|
||||||
|
with_dates: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonCompliant = currentDevices.length;
|
||||||
|
|
||||||
|
// Partition devices into blockers (no resolution_date) and with_dates
|
||||||
|
const blockers = currentDevices.filter(d => d.resolution_date == null).length;
|
||||||
|
const withDates = nonCompliant - blockers;
|
||||||
|
|
||||||
|
// Current snapshot
|
||||||
|
const compliant = totalAssets - nonCompliant;
|
||||||
|
const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant);
|
||||||
|
|
||||||
|
const current_snapshot = {
|
||||||
|
total_assets: totalAssets,
|
||||||
|
non_compliant: nonCompliant,
|
||||||
|
compliant: compliant,
|
||||||
|
compliance_pct: currentCompliancePct,
|
||||||
|
blockers: blockers,
|
||||||
|
with_dates: withDates,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no devices have resolution dates, return empty forecast
|
||||||
|
if (withDates === 0) {
|
||||||
|
return { historical, forecast: [], current_snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current month (YYYY-MM)
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth(); // 0-indexed
|
||||||
|
|
||||||
|
function formatMonth(year, month) {
|
||||||
|
return `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMonthStr = formatMonth(currentYear, currentMonth);
|
||||||
|
|
||||||
|
// Bucket devices with resolution dates by their resolution month
|
||||||
|
// Past-due dates (month before current month) are treated as remediated in current month
|
||||||
|
const buckets = {};
|
||||||
|
for (const device of currentDevices) {
|
||||||
|
if (device.resolution_date == null) continue;
|
||||||
|
// Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
|
||||||
|
const dateStr = device.resolution_date instanceof Date
|
||||||
|
? device.resolution_date.toISOString().slice(0, 7)
|
||||||
|
: String(device.resolution_date).slice(0, 7);
|
||||||
|
const resMonth = dateStr; // YYYY-MM
|
||||||
|
if (resMonth < currentMonthStr) {
|
||||||
|
// Past-due: treat as remediated in current month
|
||||||
|
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
buckets[resMonth] = (buckets[resMonth] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate forecast months starting from NEXT month, up to 12 months max
|
||||||
|
const forecast = [];
|
||||||
|
let remainingNonCompliant = nonCompliant;
|
||||||
|
|
||||||
|
// Account for devices remediated in the current month (past-due dates bucketed here)
|
||||||
|
if (buckets[currentMonthStr]) {
|
||||||
|
remainingNonCompliant -= buckets[currentMonthStr];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
|
||||||
|
const forecastMonth = (currentMonth + i) % 12;
|
||||||
|
const monthStr = formatMonth(forecastYear, forecastMonth);
|
||||||
|
|
||||||
|
// Decrement by devices remediated in this month
|
||||||
|
if (buckets[monthStr]) {
|
||||||
|
remainingNonCompliant -= buckets[monthStr];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = calcCompliancePct(totalAssets, remainingNonCompliant);
|
||||||
|
|
||||||
|
forecast.push({
|
||||||
|
month: monthStr,
|
||||||
|
total_assets: totalAssets,
|
||||||
|
non_compliant: remainingNonCompliant,
|
||||||
|
compliance_pct: pct,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminate early if all dated devices are remediated (only blockers remain)
|
||||||
|
if (remainingNonCompliant <= blockers) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { historical, forecast, current_snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
truncateText,
|
||||||
|
validateRemediationPlan,
|
||||||
|
isValidDateString,
|
||||||
|
formatPct,
|
||||||
|
computeVCLStats,
|
||||||
|
categorizeNonCompliant,
|
||||||
|
rankHeavyHitters,
|
||||||
|
computeForecastBurndown,
|
||||||
|
matchByHostname,
|
||||||
|
computeBulkDiff,
|
||||||
|
mapColumnHeaders,
|
||||||
|
parseVerticalFilename,
|
||||||
|
computeVerticalBurndown,
|
||||||
|
deduplicateByHostname,
|
||||||
|
computeAggregatedBurndown,
|
||||||
|
computeMetricForecastBurndown,
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Authentication Middleware
|
// Authentication Middleware
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
// Require authenticated user
|
// Require authenticated user — no parameters needed, pool is imported directly
|
||||||
function requireAuth(db) {
|
function requireAuth() {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
const sessionId = req.cookies?.session_id;
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
@@ -10,19 +11,15 @@ function requireAuth(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await new Promise((resolve, reject) => {
|
const { rows } = await pool.query(
|
||||||
db.get(
|
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
FROM sessions s
|
||||||
FROM sessions s
|
JOIN users u ON s.user_id = u.id
|
||||||
JOIN users u ON s.user_id = u.id
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
[sessionId]
|
||||||
[sessionId],
|
);
|
||||||
(err, row) => {
|
|
||||||
if (err) reject(err);
|
const session = rows[0];
|
||||||
else resolve(row);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({ error: 'Session expired or invalid' });
|
return res.status(401).json({ error: 'Session expired or invalid' });
|
||||||
@@ -37,7 +34,9 @@ function requireAuth(db) {
|
|||||||
id: session.user_id,
|
id: session.user_id,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
role: session.role
|
role: session.role,
|
||||||
|
group: session.user_group,
|
||||||
|
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -48,18 +47,18 @@ function requireAuth(db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require specific role(s)
|
// Require specific group(s)
|
||||||
function requireRole(...allowedRoles) {
|
function requireGroup(...allowedGroups) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowedRoles.includes(req.user.role)) {
|
if (!allowedGroups.includes(req.user.group)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Insufficient permissions',
|
error: 'Insufficient permissions',
|
||||||
required: allowedRoles,
|
required: allowedGroups,
|
||||||
current: req.user.role
|
current: req.user.group
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,4 +66,4 @@ function requireRole(...allowedRoles) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { requireAuth, requireRole };
|
module.exports = { requireAuth, requireGroup };
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Migration script: Add audit_logs table
|
|
||||||
// Run: node migrate-audit-log.js
|
|
||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const DB_FILE = './cve_database.db';
|
|
||||||
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
|
||||||
|
|
||||||
function run(db, sql, params = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.run(sql, params, function(err) {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function get(db, sql, params = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.get(sql, params, (err, row) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log('╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ CVE Database Migration: Add Audit Logs ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
if (!fs.existsSync(DB_FILE)) {
|
|
||||||
console.log('❌ Database not found. Run setup.js for fresh install.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup database
|
|
||||||
console.log('📦 Creating backup...');
|
|
||||||
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
|
||||||
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
|
||||||
|
|
||||||
const db = new sqlite3.Database(DB_FILE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if table already exists
|
|
||||||
const exists = await get(db,
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
console.log('⏭️ audit_logs table already exists, nothing to do.');
|
|
||||||
} else {
|
|
||||||
console.log('1️⃣ Creating audit_logs table...');
|
|
||||||
await run(db, `
|
|
||||||
CREATE TABLE audit_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER,
|
|
||||||
username VARCHAR(50) NOT NULL,
|
|
||||||
action VARCHAR(50) NOT NULL,
|
|
||||||
entity_type VARCHAR(50) NOT NULL,
|
|
||||||
entity_id VARCHAR(100),
|
|
||||||
details TEXT,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
console.log(' ✓ Table created');
|
|
||||||
|
|
||||||
console.log('2️⃣ Creating indexes...');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)');
|
|
||||||
console.log(' ✓ Indexes created');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ MIGRATION COMPLETE! ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
|
||||||
console.log('\n📋 Summary:');
|
|
||||||
console.log(' ✓ audit_logs table ready');
|
|
||||||
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
|
||||||
console.log('\n🚀 Restart your server to apply changes.\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Migration failed:', error.message);
|
|
||||||
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Migration script: v1.0.0 -> v1.1.0
|
|
||||||
// Adds: users, sessions tables, multi-vendor support, vendor column in documents
|
|
||||||
// Run: node migrate-to-1.1.js
|
|
||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const DB_FILE = './cve_database.db';
|
|
||||||
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log('╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ CVE Database Migration: v1.0.0 → v1.1.0 ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
// Check if database exists
|
|
||||||
if (!fs.existsSync(DB_FILE)) {
|
|
||||||
console.log('❌ Database not found. Run setup.js for fresh install.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup database
|
|
||||||
console.log('📦 Creating backup...');
|
|
||||||
fs.copyFileSync(DB_FILE, BACKUP_FILE);
|
|
||||||
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
|
|
||||||
|
|
||||||
const db = new sqlite3.Database(DB_FILE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Run migrations in sequence
|
|
||||||
await addUsersTable(db);
|
|
||||||
await addSessionsTable(db);
|
|
||||||
await addVendorToDocuments(db);
|
|
||||||
await updateCvesConstraint(db);
|
|
||||||
await createDefaultAdmin(db);
|
|
||||||
await updateView(db);
|
|
||||||
|
|
||||||
console.log('\n╔════════════════════════════════════════════════════════╗');
|
|
||||||
console.log('║ MIGRATION COMPLETE! ║');
|
|
||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
|
||||||
console.log('\n📋 Summary:');
|
|
||||||
console.log(' ✓ Users table added');
|
|
||||||
console.log(' ✓ Sessions table added');
|
|
||||||
console.log(' ✓ Vendor column added to documents');
|
|
||||||
console.log(' ✓ Multi-vendor constraint applied to cves');
|
|
||||||
console.log(' ✓ Default admin user created (admin/admin123)');
|
|
||||||
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
|
|
||||||
console.log('\n🚀 Restart your server to apply changes.\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Migration failed:', error.message);
|
|
||||||
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(db, sql, params = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.run(sql, params, function(err) {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function get(db, sql, params = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.get(sql, params, (err, row) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function all(db, sql, params = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.all(sql, params, (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUsersTable(db) {
|
|
||||||
console.log('1️⃣ Adding users table...');
|
|
||||||
|
|
||||||
const exists = await get(db,
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
console.log(' ⏭️ Users table already exists, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await run(db, `
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_login TIMESTAMP,
|
|
||||||
CHECK (role IN ('admin', 'editor', 'viewer'))
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
|
|
||||||
console.log(' ✓ Users table created');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSessionsTable(db) {
|
|
||||||
console.log('2️⃣ Adding sessions table...');
|
|
||||||
|
|
||||||
const exists = await get(db,
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
console.log(' ⏭️ Sessions table already exists, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await run(db, `
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
|
|
||||||
console.log(' ✓ Sessions table created');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addVendorToDocuments(db) {
|
|
||||||
console.log('3️⃣ Adding vendor column to documents...');
|
|
||||||
|
|
||||||
// Check if vendor column exists
|
|
||||||
const columns = await all(db, "PRAGMA table_info(documents)");
|
|
||||||
const hasVendor = columns.some(col => col.name === 'vendor');
|
|
||||||
|
|
||||||
if (hasVendor) {
|
|
||||||
console.log(' ⏭️ Vendor column already exists, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add vendor column
|
|
||||||
await run(db, "ALTER TABLE documents ADD COLUMN vendor VARCHAR(100)");
|
|
||||||
|
|
||||||
// Populate vendor from the cves table based on cve_id
|
|
||||||
await run(db, `
|
|
||||||
UPDATE documents
|
|
||||||
SET vendor = (
|
|
||||||
SELECT c.vendor
|
|
||||||
FROM cves c
|
|
||||||
WHERE c.cve_id = documents.cve_id
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
WHERE vendor IS NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Set default for any remaining nulls
|
|
||||||
await run(db, "UPDATE documents SET vendor = 'Unknown' WHERE vendor IS NULL");
|
|
||||||
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor)');
|
|
||||||
console.log(' ✓ Vendor column added and populated');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCvesConstraint(db) {
|
|
||||||
console.log('4️⃣ Updating CVEs table for multi-vendor support...');
|
|
||||||
|
|
||||||
// Check current schema
|
|
||||||
const tableInfo = await get(db,
|
|
||||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='cves'"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tableInfo.sql.includes('UNIQUE(cve_id, vendor)')) {
|
|
||||||
console.log(' ⏭️ Multi-vendor constraint already exists, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite doesn't support ALTER CONSTRAINT, so we need to rebuild the table
|
|
||||||
console.log(' 📋 Rebuilding table with new constraint...');
|
|
||||||
|
|
||||||
// Create new table with correct schema
|
|
||||||
await run(db, `
|
|
||||||
CREATE TABLE cves_new (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
cve_id VARCHAR(20) NOT NULL,
|
|
||||||
vendor VARCHAR(100) NOT NULL,
|
|
||||||
severity VARCHAR(20) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
published_date DATE,
|
|
||||||
status VARCHAR(50) DEFAULT 'Open',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(cve_id, vendor)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Copy data
|
|
||||||
await run(db, `
|
|
||||||
INSERT INTO cves_new (id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at)
|
|
||||||
SELECT id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at
|
|
||||||
FROM cves
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Drop old table
|
|
||||||
await run(db, 'DROP TABLE cves');
|
|
||||||
|
|
||||||
// Rename new table
|
|
||||||
await run(db, 'ALTER TABLE cves_new RENAME TO cves');
|
|
||||||
|
|
||||||
// Recreate indexes
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity)');
|
|
||||||
await run(db, 'CREATE INDEX IF NOT EXISTS idx_status ON cves(status)');
|
|
||||||
|
|
||||||
console.log(' ✓ Multi-vendor constraint applied');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createDefaultAdmin(db) {
|
|
||||||
console.log('5️⃣ Creating default admin user...');
|
|
||||||
|
|
||||||
const exists = await get(db, "SELECT id FROM users WHERE username = 'admin'");
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
console.log(' ⏭️ Admin user already exists, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
|
||||||
|
|
||||||
await run(db, `
|
|
||||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`, ['admin', 'admin@localhost', passwordHash, 'admin', 1]);
|
|
||||||
|
|
||||||
console.log(' ✓ Admin user created (admin/admin123)');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateView(db) {
|
|
||||||
console.log('6️⃣ Updating document status view...');
|
|
||||||
|
|
||||||
// Drop old view if exists
|
|
||||||
await run(db, 'DROP VIEW IF EXISTS cve_document_status');
|
|
||||||
|
|
||||||
// Create updated view with multi-vendor support
|
|
||||||
await run(db, `
|
|
||||||
CREATE VIEW cve_document_status AS
|
|
||||||
SELECT
|
|
||||||
c.id as record_id,
|
|
||||||
c.cve_id,
|
|
||||||
c.vendor,
|
|
||||||
c.severity,
|
|
||||||
c.status,
|
|
||||||
COUNT(DISTINCT d.id) as total_documents,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
|
||||||
THEN 'Complete'
|
|
||||||
ELSE 'Missing Required Docs'
|
|
||||||
END as compliance_status
|
|
||||||
FROM cves c
|
|
||||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
|
||||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(' ✓ View updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migration
|
|
||||||
migrate();
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// Migration: Add jira_tickets table
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, 'cve_database.db');
|
|
||||||
const db = new sqlite3.Database(dbPath);
|
|
||||||
|
|
||||||
console.log('Starting JIRA tickets migration...');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
// Create jira_tickets table
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
cve_id TEXT NOT NULL,
|
|
||||||
vendor TEXT NOT NULL,
|
|
||||||
ticket_key TEXT NOT NULL,
|
|
||||||
url TEXT,
|
|
||||||
summary TEXT,
|
|
||||||
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) console.error('Error creating table:', err);
|
|
||||||
else console.log('✓ jira_tickets table created');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
|
||||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
|
||||||
|
|
||||||
console.log('✓ Indexes created');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.close(() => {
|
|
||||||
console.log('Migration complete!');
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const db = new sqlite3.Database('./cve_database.db');
|
|
||||||
|
|
||||||
console.log('🔄 Starting database migration for multi-vendor support...\n');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
// Backup existing data
|
|
||||||
console.log('📦 Creating backup tables...');
|
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS cves_backup AS SELECT * FROM cves`, (err) => {
|
|
||||||
if (err) console.error('Backup error:', err);
|
|
||||||
else console.log('✓ CVEs backed up');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS documents_backup AS SELECT * FROM documents`, (err) => {
|
|
||||||
if (err) console.error('Backup error:', err);
|
|
||||||
else console.log('✓ Documents backed up');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop old table
|
|
||||||
console.log('\n🗑️ Dropping old cves table...');
|
|
||||||
db.run(`DROP TABLE IF EXISTS cves`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Drop error:', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('✓ Old table dropped');
|
|
||||||
|
|
||||||
// Create new table with UNIQUE(cve_id, vendor) instead of UNIQUE(cve_id)
|
|
||||||
console.log('\n🏗️ Creating new cves table with multi-vendor support...');
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE cves (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
cve_id VARCHAR(20) NOT NULL,
|
|
||||||
vendor VARCHAR(100) NOT NULL,
|
|
||||||
severity VARCHAR(20) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
published_date DATE,
|
|
||||||
status VARCHAR(50) DEFAULT 'Open',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(cve_id, vendor)
|
|
||||||
)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Create error:', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('✓ New table created with UNIQUE(cve_id, vendor)');
|
|
||||||
|
|
||||||
// Restore data
|
|
||||||
console.log('\n📥 Restoring data...');
|
|
||||||
db.run(`INSERT INTO cves SELECT * FROM cves_backup`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Restore error:', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('✓ Data restored');
|
|
||||||
|
|
||||||
// Recreate indexes
|
|
||||||
console.log('\n🔍 Creating indexes...');
|
|
||||||
db.run(`CREATE INDEX idx_cve_id ON cves(cve_id)`, () => {
|
|
||||||
console.log('✓ Index: idx_cve_id');
|
|
||||||
});
|
|
||||||
db.run(`CREATE INDEX idx_vendor ON cves(vendor)`, () => {
|
|
||||||
console.log('✓ Index: idx_vendor');
|
|
||||||
});
|
|
||||||
db.run(`CREATE INDEX idx_severity ON cves(severity)`, () => {
|
|
||||||
console.log('✓ Index: idx_severity');
|
|
||||||
});
|
|
||||||
db.run(`CREATE INDEX idx_status ON cves(status)`, () => {
|
|
||||||
console.log('✓ Index: idx_status');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update view
|
|
||||||
console.log('\n👁️ Updating cve_document_status view...');
|
|
||||||
db.run(`DROP VIEW IF EXISTS cve_document_status`, (err) => {
|
|
||||||
if (err) console.error('Drop view error:', err);
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE VIEW cve_document_status AS
|
|
||||||
SELECT
|
|
||||||
c.id as record_id,
|
|
||||||
c.cve_id,
|
|
||||||
c.vendor,
|
|
||||||
c.severity,
|
|
||||||
c.status,
|
|
||||||
COUNT(DISTINCT d.id) as total_documents,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
|
|
||||||
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
|
|
||||||
THEN 'Complete'
|
|
||||||
ELSE 'Missing Required Docs'
|
|
||||||
END as compliance_status
|
|
||||||
FROM cves c
|
|
||||||
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
|
||||||
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Create view error:', err);
|
|
||||||
} else {
|
|
||||||
console.log('✓ View recreated');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ Migration complete!');
|
|
||||||
console.log('\n📊 Summary:');
|
|
||||||
|
|
||||||
db.get('SELECT COUNT(*) as count FROM cves', (err, row) => {
|
|
||||||
if (!err) console.log(` Total CVE entries: ${row.count}`);
|
|
||||||
|
|
||||||
db.get('SELECT COUNT(DISTINCT cve_id) as count FROM cves', (err, row) => {
|
|
||||||
if (!err) console.log(` Unique CVE IDs: ${row.count}`);
|
|
||||||
|
|
||||||
console.log('\n💡 Next steps:');
|
|
||||||
console.log(' 1. Restart backend: pkill -f "node server.js" && node server.js &');
|
|
||||||
console.log(' 2. Replace frontend/src/App.js with multi-vendor version');
|
|
||||||
console.log(' 3. Test by adding same CVE with multiple vendors\n');
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
41
backend/migrations/README.md
Normal file
41
backend/migrations/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
These migration scripts were used to evolve the database schema during development. **They are NOT needed for fresh deployments** — `setup.js` contains the complete v1.0.0 schema.
|
||||||
|
|
||||||
|
These are retained for reference and for upgrading existing deployments that were set up before v1.0.0.
|
||||||
|
|
||||||
|
## Schema Migrations (run in order for existing deployments)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `add_ivanti_sync_table.js` | Creates `ivanti_sync_state` table for tracking Ivanti sync status |
|
||||||
|
| `add_ivanti_findings_tables.js` | Creates `ivanti_findings_cache`, `ivanti_finding_notes`, `ivanti_counts_cache`, `ivanti_finding_overrides` tables |
|
||||||
|
| `add_ivanti_counts_history_table.js` | Creates `ivanti_counts_history` table for trend chart data |
|
||||||
|
| `add_ivanti_todo_queue_table.js` | Creates `ivanti_todo_queue` table for FP/Archer workflow queuing |
|
||||||
|
| `add_todo_queue_hostname.js` | Adds `hostname` column to `ivanti_todo_queue` |
|
||||||
|
| `add_todo_queue_ip_address.js` | Adds `ip_address` column to `ivanti_todo_queue` |
|
||||||
|
| `add_fp_submissions_table.js` | Creates `ivanti_fp_submissions` table for false positive workflow tracking |
|
||||||
|
| `add_fp_submission_editing.js` | Adds `lifecycle_status`, `ivanti_workflow_batch_uuid`, `updated_at` columns and `ivanti_fp_submission_history` table |
|
||||||
|
| `add_knowledge_base_table.js` | Creates `knowledge_base` table for KB article storage |
|
||||||
|
| `add_user_groups.js` | Adds `user_group` column to `users` table with validation triggers |
|
||||||
|
| `add_created_by_columns.js` | Adds `created_by` column to `compliance_notes` and `knowledge_base` tables |
|
||||||
|
| `add_compliance_tables.js` | Creates `compliance_uploads`, `compliance_items`, `compliance_notes` tables |
|
||||||
|
| `add_compliance_notes_group_id.js` | Adds `group_id` column to `compliance_notes` for multi-metric note grouping |
|
||||||
|
| `add_archer_tickets_table.js` | Creates `archer_tickets` table for Archer exception tracking |
|
||||||
|
| `add_archer_tickets_timestamps.js` | Adds `created_at` and `updated_at` columns to `archer_tickets` |
|
||||||
|
| `add_jira_sync_columns.js` | Adds Jira sync-related columns to `jira_tickets` |
|
||||||
|
| `add_card_workflow_type.js` | Adds `CARD` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
|
||||||
|
| `add_granite_workflow_type.js` | Adds `GRANITE` to `workflow_type` CHECK constraint on `ivanti_todo_queue` |
|
||||||
|
| `add_finding_archive_tables.js` | Creates `ivanti_finding_archives` and `ivanti_archive_transitions` tables |
|
||||||
|
| `add_closed_gone_state.js` | Adds `CLOSED_GONE` to `current_state` CHECK constraint on `ivanti_finding_archives` |
|
||||||
|
| `add_sync_anomaly_tables.js` | Creates `ivanti_sync_anomaly_log` and `ivanti_finding_bu_history` tables |
|
||||||
|
| `add_atlas_action_plans_cache.js` | Creates `atlas_action_plans_cache` table for Atlas API caching |
|
||||||
|
| `add_return_classification.js` | Adds `return_classification_json` column to `ivanti_sync_anomaly_log` |
|
||||||
|
|
||||||
|
## Data Migrations (one-time backfills)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `backfill_anomaly_log.js` | Synthesizes anomaly log entries from existing archive transitions for historical chart data |
|
||||||
|
| `backfill_return_classification.js` | Populates `return_classification_json` for existing anomaly rows with returned findings. Supports `--force` flag to re-run. |
|
||||||
|
| `reclassify_bu_roundtrips.js` | Reclassifies archive transitions that were BU reassignment round-trips (archived then returned within 14 days) from the default `severity_score_drift` to `bu_reassignment` |
|
||||||
56
backend/migrations/add_archer_tickets_timestamps.js
Normal file
56
backend/migrations/add_archer_tickets_timestamps.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Migration: Add created_at / updated_at columns to archer_tickets
|
||||||
|
//
|
||||||
|
// SQLite does not support ALTER TABLE ADD COLUMN IF NOT EXISTS, so we check
|
||||||
|
// PRAGMA table_info first and only add the column when it is absent.
|
||||||
|
//
|
||||||
|
// Run on any instance where archer_tickets was created before these columns
|
||||||
|
// were added to the schema (symptoms: every /api/archer-tickets call → 500).
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_archer_tickets_timestamps.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting archer_tickets timestamp migration...');
|
||||||
|
|
||||||
|
db.all('PRAGMA table_info(archer_tickets)', [], (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error reading table info:', err);
|
||||||
|
return db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = columns.map(c => c.name);
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
if (!names.includes('created_at')) {
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE archer_tickets ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error adding created_at:', err);
|
||||||
|
else console.log('✓ created_at column added');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('✓ created_at already exists — skipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!names.includes('updated_at')) {
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE archer_tickets ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`,
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error adding updated_at:', err);
|
||||||
|
else console.log('✓ updated_at column added');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('✓ updated_at already exists — skipping');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete. Restart the backend server.');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
37
backend/migrations/add_atlas_action_plans_cache.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Migration: Add atlas_action_plans_cache table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Atlas action plans cache migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Cache table — one row per host, holding cached Atlas action plan status
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
host_id INTEGER NOT NULL UNIQUE,
|
||||||
|
has_action_plan INTEGER NOT NULL DEFAULT 0,
|
||||||
|
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating atlas_action_plans_cache table:', err);
|
||||||
|
else console.log('✓ atlas_action_plans_cache table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atlas_cache_host_id
|
||||||
|
ON atlas_action_plans_cache(host_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating host_id index:', err);
|
||||||
|
else console.log('✓ idx_atlas_cache_host_id index created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
79
backend/migrations/add_card_workflow_type.js
Normal file
79
backend/migrations/add_card_workflow_type.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue
|
||||||
|
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_card_workflow_type migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||||
|
if (err) console.error('PRAGMA error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('BEGIN TRANSACTION', (err) => {
|
||||||
|
if (err) { console.error('BEGIN error:', err); return; }
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE ivanti_todo_queue_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating new table:', err);
|
||||||
|
else console.log('✓ ivanti_todo_queue_new created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error copying data:', err);
|
||||||
|
else console.log('✓ Data copied');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||||
|
if (err) console.error('Error dropping old table:', err);
|
||||||
|
else console.log('✓ Old table dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error renaming table:', err);
|
||||||
|
else console.log('✓ Table renamed');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ Index recreated');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('COMMIT', (err) => {
|
||||||
|
if (err) console.error('COMMIT error:', err);
|
||||||
|
else console.log('✓ Transaction committed');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('PRAGMA foreign_keys = ON', () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
130
backend/migrations/add_closed_gone_state.js
Normal file
130
backend/migrations/add_closed_gone_state.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Migration: Add CLOSED_GONE state to ivanti_finding_archives
|
||||||
|
//
|
||||||
|
// The archive table tracks findings that disappear from the Open findings set.
|
||||||
|
// Previously it only tracked: ARCHIVED → RETURNED → CLOSED.
|
||||||
|
//
|
||||||
|
// This migration adds a CLOSED_GONE state for findings that were confirmed
|
||||||
|
// in the Ivanti Closed set but then disappeared from it on a subsequent sync.
|
||||||
|
// This closes a visibility gap where findings could vanish from the Closed API
|
||||||
|
// results (e.g., due to VRR rescore below the severity threshold) without
|
||||||
|
// being tracked.
|
||||||
|
//
|
||||||
|
// SQLite does not support ALTER TABLE to modify CHECK constraints, so this
|
||||||
|
// migration recreates the table with the expanded constraint.
|
||||||
|
//
|
||||||
|
// Safe to re-run — uses IF NOT EXISTS and checks for existing data.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_closed_gone_state.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting CLOSED_GONE state migration...');
|
||||||
|
|
||||||
|
function run(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
// Check if the table already has the CLOSED_GONE state
|
||||||
|
const tableInfo = await all("SELECT sql FROM sqlite_master WHERE name='ivanti_finding_archives'");
|
||||||
|
if (tableInfo.length > 0 && tableInfo[0].sql.includes('CLOSED_GONE')) {
|
||||||
|
console.log('✓ ivanti_finding_archives already has CLOSED_GONE state — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableInfo.length === 0) {
|
||||||
|
// Table doesn't exist yet — create it fresh with the new constraint
|
||||||
|
await run(`
|
||||||
|
CREATE TABLE ivanti_finding_archives (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||||
|
last_severity REAL NOT NULL DEFAULT 0,
|
||||||
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ Created ivanti_finding_archives with CLOSED_GONE state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table exists but needs the constraint updated — recreate with data migration
|
||||||
|
console.log(' Recreating table with expanded CHECK constraint...');
|
||||||
|
|
||||||
|
await run('BEGIN TRANSACTION');
|
||||||
|
try {
|
||||||
|
// 1. Rename existing table
|
||||||
|
await run('ALTER TABLE ivanti_finding_archives RENAME TO ivanti_finding_archives_old');
|
||||||
|
|
||||||
|
// 2. Create new table with expanded constraint
|
||||||
|
await run(`
|
||||||
|
CREATE TABLE ivanti_finding_archives (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED','RETURNED','CLOSED','CLOSED_GONE')),
|
||||||
|
last_severity REAL NOT NULL DEFAULT 0,
|
||||||
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Copy data
|
||||||
|
await run(`
|
||||||
|
INSERT INTO ivanti_finding_archives
|
||||||
|
(id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||||
|
last_severity, first_archived_at, last_transition_at, created_at)
|
||||||
|
SELECT id, finding_id, finding_title, host_name, ip_address, current_state,
|
||||||
|
last_severity, first_archived_at, last_transition_at, created_at
|
||||||
|
FROM ivanti_finding_archives_old
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Recreate indexes
|
||||||
|
await run('CREATE INDEX IF NOT EXISTS idx_archive_finding_id ON ivanti_finding_archives(finding_id)');
|
||||||
|
await run('CREATE INDEX IF NOT EXISTS idx_archive_current_state ON ivanti_finding_archives(current_state)');
|
||||||
|
|
||||||
|
// 5. Drop old table
|
||||||
|
await run('DROP TABLE ivanti_finding_archives_old');
|
||||||
|
|
||||||
|
await run('COMMIT');
|
||||||
|
console.log('✓ ivanti_finding_archives updated with CLOSED_GONE state');
|
||||||
|
} catch (err) {
|
||||||
|
await run('ROLLBACK').catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
db.close();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
44
backend/migrations/add_compliance_item_history.js
Normal file
44
backend/migrations/add_compliance_item_history.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting compliance_item_history migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_by TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ compliance_item_history table created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||||
|
ON compliance_item_history(hostname, field_name)
|
||||||
|
`);
|
||||||
|
console.log('✓ hostname/field_name index created');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||||
|
ON compliance_item_history(changed_at)
|
||||||
|
`);
|
||||||
|
console.log('✓ changed_at index created');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
|
|
||||||
|
// Self-execute when run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||||
|
}
|
||||||
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Migration: Add group_id column to compliance_notes table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_compliance_notes_group_id migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
|
||||||
|
if (err) console.error('Error adding group_id column:', err);
|
||||||
|
else console.log('✓ group_id column added to compliance_notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
|
||||||
|
if (err) console.error('Error creating group_id index:', err);
|
||||||
|
else console.log('✓ idx_compliance_notes_group created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
|
||||||
|
if (err) console.error('Error backfilling group_id:', err);
|
||||||
|
else console.log('✓ Existing rows backfilled with legacy group_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
108
backend/migrations/add_compliance_tables.js
Normal file
108
backend/migrations/add_compliance_tables.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Migration: Add compliance_uploads, compliance_items, compliance_notes tables
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_compliance_tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Each xlsx upload — one row per file ingested
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
report_date TEXT,
|
||||||
|
uploaded_by INTEGER,
|
||||||
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
new_count INTEGER DEFAULT 0,
|
||||||
|
resolved_count INTEGER DEFAULT 0,
|
||||||
|
recurring_count INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_uploads:', err);
|
||||||
|
else console.log('✓ compliance_uploads created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// One row per non-compliant asset per metric per upload.
|
||||||
|
// hostname + metric_id is the stable identity key used to link history and notes.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
upload_id INTEGER NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
team TEXT,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT,
|
||||||
|
category TEXT,
|
||||||
|
extra_json TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
||||||
|
first_seen_upload_id INTEGER,
|
||||||
|
resolved_upload_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_items:', err);
|
||||||
|
else console.log('✓ compliance_items created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload
|
||||||
|
ON compliance_items(upload_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating upload index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_upload created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity
|
||||||
|
ON compliance_items(hostname, metric_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating identity index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_identity created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status
|
||||||
|
ON compliance_items(team, status)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating team/status index:', err);
|
||||||
|
else console.log('✓ idx_compliance_items_team_status created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes keyed on (hostname, metric_id) — persists across uploads.
|
||||||
|
// Each note is its own row so history is preserved.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating compliance_notes:', err);
|
||||||
|
else console.log('✓ compliance_notes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity
|
||||||
|
ON compliance_notes(hostname, metric_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating notes identity index:', err);
|
||||||
|
else console.log('✓ idx_compliance_notes_identity created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
76
backend/migrations/add_created_by_columns.js
Normal file
76
backend/migrations/add_created_by_columns.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
|
||||||
|
// Stores the user ID of the creator for ownership-based delete checks.
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration against the given database instance.
|
||||||
|
* Exported for testing with in-memory databases.
|
||||||
|
* @param {sqlite3.Database} db
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function runMigration(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
tables.forEach((table) => {
|
||||||
|
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
// Table may not exist yet — skip gracefully
|
||||||
|
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
|
||||||
|
completed++;
|
||||||
|
if (completed === tables.length) resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCreatedBy = columns.some(col => col.name === 'created_by');
|
||||||
|
|
||||||
|
if (hasCreatedBy) {
|
||||||
|
console.log(`✓ ${table}.created_by already exists — skipping`);
|
||||||
|
completed++;
|
||||||
|
if (completed === tables.length) resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`✓ Added created_by column to ${table}`);
|
||||||
|
completed++;
|
||||||
|
if (completed === tables.length) resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run directly if executed as a script
|
||||||
|
if (require.main === module) {
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
console.log('Starting add_created_by_columns migration...');
|
||||||
|
|
||||||
|
runMigration(db)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Database connection closed.');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runMigration };
|
||||||
33
backend/migrations/add_decom_workflow_type.js
Normal file
33
backend/migrations/add_decom_workflow_type.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Migration: Add DECOM to workflow_type CHECK constraint on ivanti_todo_queue
|
||||||
|
// Run from backend/: node migrations/add_decom_workflow_type.js
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting add_decom_workflow_type migration...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drop the existing constraint and add the updated one
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE ivanti_todo_queue
|
||||||
|
DROP CONSTRAINT IF EXISTS ivanti_todo_queue_workflow_type_check
|
||||||
|
`);
|
||||||
|
console.log('✓ Dropped old workflow_type constraint');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE ivanti_todo_queue
|
||||||
|
ADD CONSTRAINT ivanti_todo_queue_workflow_type_check
|
||||||
|
CHECK (workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE', 'DECOM'))
|
||||||
|
`);
|
||||||
|
console.log('✓ Added updated workflow_type constraint (includes DECOM)');
|
||||||
|
|
||||||
|
console.log('Migration complete!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
75
backend/migrations/add_finding_archive_tables.js
Normal file
75
backend/migrations/add_finding_archive_tables.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Migration: Add ivanti_finding_archives and ivanti_archive_transitions tables
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting finding archive tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Archive records — one row per finding that has entered the archive lifecycle
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_archives (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
current_state TEXT NOT NULL CHECK(current_state IN ('ARCHIVED', 'RETURNED', 'CLOSED')),
|
||||||
|
last_severity REAL NOT NULL DEFAULT 0,
|
||||||
|
first_archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_transition_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating ivanti_finding_archives table:', err);
|
||||||
|
else console.log('✓ ivanti_finding_archives table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transition history — one row per state change on an archive record
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_archive_transitions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
archive_id INTEGER NOT NULL,
|
||||||
|
from_state TEXT NOT NULL,
|
||||||
|
to_state TEXT NOT NULL,
|
||||||
|
severity_at_transition REAL NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
transitioned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (archive_id) REFERENCES ivanti_finding_archives(id)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating ivanti_archive_transitions table:', err);
|
||||||
|
else console.log('✓ ivanti_archive_transitions table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes for query performance
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
|
||||||
|
ON ivanti_finding_archives(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating idx_archive_finding_id:', err);
|
||||||
|
else console.log('✓ idx_archive_finding_id index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_current_state
|
||||||
|
ON ivanti_finding_archives(current_state)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating idx_archive_current_state:', err);
|
||||||
|
else console.log('✓ idx_archive_current_state index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
|
||||||
|
ON ivanti_archive_transitions(archive_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating idx_transition_archive_id:', err);
|
||||||
|
else console.log('✓ idx_transition_archive_id index created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
90
backend/migrations/add_flexible_jira_ticket_creation.js
Normal file
90
backend/migrations/add_flexible_jira_ticket_creation.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Migration: Add flexible Jira ticket creation support
|
||||||
|
// - Drops NOT NULL on cve_id and vendor columns
|
||||||
|
// - Adds source_context column with CHECK constraint
|
||||||
|
// - Backfills existing rows with source_context = 'manual'
|
||||||
|
// - Adds index on source_context
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting flexible Jira ticket creation migration...');
|
||||||
|
|
||||||
|
// Verify jira_tickets table exists before proceeding
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||||
|
`);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ jira_tickets table exists');
|
||||||
|
|
||||||
|
// Drop NOT NULL constraint on cve_id (idempotent — no-op if already nullable)
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
|
||||||
|
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
|
||||||
|
|
||||||
|
// Drop NOT NULL constraint on vendor (idempotent — no-op if already nullable)
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
|
||||||
|
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
|
||||||
|
|
||||||
|
// Add jira_id, jira_status, last_synced_at, created_by columns
|
||||||
|
// (originally from SQLite migration add_jira_sync_columns.js — never ported to Postgres run-all)
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
||||||
|
console.log('✓ jira_id column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
||||||
|
console.log('✓ jira_status column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
||||||
|
console.log('✓ last_synced_at column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
||||||
|
console.log('✓ created_by column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
||||||
|
console.log('✓ jira_id index created (or already exists)');
|
||||||
|
|
||||||
|
// Add source_context column with default value (IF NOT EXISTS makes it idempotent)
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE jira_tickets
|
||||||
|
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
|
||||||
|
`);
|
||||||
|
console.log('✓ source_context column added (or already exists)');
|
||||||
|
|
||||||
|
// Add CHECK constraint for allowed source_context values (idempotent guard)
|
||||||
|
await pool.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE jira_tickets
|
||||||
|
ADD CONSTRAINT jira_tickets_source_context_check
|
||||||
|
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
console.log('✓ source_context CHECK constraint added (or already exists)');
|
||||||
|
|
||||||
|
// Backfill existing rows where source_context is NULL
|
||||||
|
const result = await pool.query(`
|
||||||
|
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
|
||||||
|
`);
|
||||||
|
console.log(`✓ Backfilled ${result.rowCount} rows with source_context = 'manual'`);
|
||||||
|
|
||||||
|
// Add index on source_context for filtering performance
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
|
||||||
|
ON jira_tickets(source_context)
|
||||||
|
`);
|
||||||
|
console.log('✓ source_context index created (or already exists)');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
94
backend/migrations/add_fp_submission_editing.js
Normal file
94
backend/migrations/add_fp_submission_editing.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Migration: Add FP submission editing support (lifecycle status, batch UUID, history table)
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting FP submission editing migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Add lifecycle_status column to ivanti_fp_submissions
|
||||||
|
// Wrapped in try/catch style via callback — SQLite throws if column already exists
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'))`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ lifecycle_status column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding lifecycle_status column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ lifecycle_status column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add ivanti_workflow_batch_uuid column
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ ivanti_workflow_batch_uuid column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding ivanti_workflow_batch_uuid column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ ivanti_workflow_batch_uuid column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add updated_at column (SQLite requires constant defaults for ALTER TABLE, so default to NULL)
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT NULL`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ updated_at column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding updated_at column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ updated_at column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create submission history table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||||
|
'created', 'fields_updated', 'findings_added',
|
||||||
|
'attachments_added', 'status_changed'
|
||||||
|
)),
|
||||||
|
change_details_json TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating history table:', err.message);
|
||||||
|
else console.log('✓ ivanti_fp_submission_history table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index on submission_id for history lookups
|
||||||
|
db.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating history index:', err.message);
|
||||||
|
else console.log('✓ idx_fp_history_submission index created');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ Migration statements queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
17
backend/migrations/add_fp_submissions_dismissed.js
Normal file
17
backend/migrations/add_fp_submissions_dismissed.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Migration: Add dismissed_at column to ivanti_fp_submissions table
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting FP submissions dismissed migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ DEFAULT NULL`);
|
||||||
|
console.log('✓ dismissed_at column added (or already exists)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding dismissed_at column:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
17
backend/migrations/add_fp_submissions_requeued_at.js
Normal file
17
backend/migrations/add_fp_submissions_requeued_at.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Migration: Add requeued_at column to ivanti_fp_submissions table
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting FP submissions requeued_at migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS requeued_at TIMESTAMPTZ DEFAULT NULL`);
|
||||||
|
console.log('✓ requeued_at column added (or already exists)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding requeued_at column:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
57
backend/migrations/add_fp_submissions_table.js
Normal file
57
backend/migrations/add_fp_submissions_table.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Migration: Add ivanti_fp_submissions table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting ivanti_fp_submissions migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
ivanti_workflow_batch_id INTEGER,
|
||||||
|
ivanti_generated_id TEXT,
|
||||||
|
workflow_name TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
expiration_date TEXT NOT NULL,
|
||||||
|
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
||||||
|
finding_ids_json TEXT NOT NULL,
|
||||||
|
queue_item_ids_json TEXT NOT NULL,
|
||||||
|
attachment_count INTEGER DEFAULT 0,
|
||||||
|
attachment_results_json TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
|
||||||
|
error_message TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ ivanti_fp_submissions table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ user_id index created');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ ivanti_generated_id index created');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ Migration statements queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
80
backend/migrations/add_granite_workflow_type.js
Normal file
80
backend/migrations/add_granite_workflow_type.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Migration: Add GRANITE to workflow_type CHECK constraint on ivanti_todo_queue
|
||||||
|
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_granite_workflow_type migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||||
|
if (err) console.error('PRAGMA error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('BEGIN TRANSACTION', (err) => {
|
||||||
|
if (err) { console.error('BEGIN error:', err); return; }
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE ivanti_todo_queue_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating new table:', err);
|
||||||
|
else console.log('✓ ivanti_todo_queue_new created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO ivanti_todo_queue_new SELECT id, user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type, status, created_at, updated_at FROM ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error copying data:', err);
|
||||||
|
else console.log('✓ Data copied');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||||
|
if (err) console.error('Error dropping old table:', err);
|
||||||
|
else console.log('✓ Old table dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error renaming table:', err);
|
||||||
|
else console.log('✓ Table renamed');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ Index recreated');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('COMMIT', (err) => {
|
||||||
|
if (err) console.error('COMMIT error:', err);
|
||||||
|
else console.log('✓ Transaction committed');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('PRAGMA foreign_keys = ON', () => {}); // FIXME: Callback does not handle the error parameter (should be `(err) => { if (err) ... }`)
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Migration: Add ivanti_counts_history table
|
||||||
|
//
|
||||||
|
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
|
||||||
|
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
|
||||||
|
// accumulates all snapshots so time-series charts can be built from it.
|
||||||
|
//
|
||||||
|
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
|
||||||
|
// to the last snapshot per calendar day using a ROW_NUMBER window function.
|
||||||
|
//
|
||||||
|
// NOTE: This table is also created automatically at server startup via
|
||||||
|
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
|
||||||
|
// This script is provided for manual setup on fresh installs and for
|
||||||
|
// documentation consistency with other migration files.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting ivanti_counts_history migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating ivanti_counts_history table:', err);
|
||||||
|
else console.log('✓ ivanti_counts_history table created (or already exists)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
});
|
||||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Ivanti findings tables migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Cache table — single row holding the latest sync result
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
findings_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating findings cache table:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding findings cache row:', err);
|
||||||
|
else console.log('✓ ivanti_findings_cache row seeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes table — one row per finding, persists across cache refreshes
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating finding notes table:', err);
|
||||||
|
else console.log('✓ ivanti_finding_notes table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating notes index:', err);
|
||||||
|
else console.log('✓ finding_id index created');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Migration: Add ivanti_todo_queue table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting ivanti_todo_queue migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ ivanti_todo_queue table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ User+status index created');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ Migration statements queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
63
backend/migrations/add_jira_sync_columns.js
Normal file
63
backend/migrations/add_jira_sync_columns.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Migration: Add Jira API sync columns to jira_tickets table
|
||||||
|
// Adds jira_id, jira_status, and last_synced_at columns to support
|
||||||
|
// live synchronization with Jira Data Center REST API.
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Jira sync columns migration...');
|
||||||
|
|
||||||
|
const newColumns = [
|
||||||
|
{ name: 'jira_id', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_id TEXT' },
|
||||||
|
{ name: 'jira_status', sql: 'ALTER TABLE jira_tickets ADD COLUMN jira_status TEXT' },
|
||||||
|
{ name: 'last_synced_at', sql: 'ALTER TABLE jira_tickets ADD COLUMN last_synced_at DATETIME' }
|
||||||
|
];
|
||||||
|
|
||||||
|
db.all('PRAGMA table_info(jira_tickets)', (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Could not inspect jira_tickets:', err.message);
|
||||||
|
console.log('Run migrate_jira_tickets.js first to create the table.');
|
||||||
|
db.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNames = new Set(columns.map(c => c.name));
|
||||||
|
let pending = 0;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
newColumns.forEach(({ name, sql }) => {
|
||||||
|
if (existingNames.has(name)) {
|
||||||
|
console.log(`✓ jira_tickets.${name} already exists — skipping`);
|
||||||
|
} else {
|
||||||
|
pending++;
|
||||||
|
db.run(sql, (runErr) => {
|
||||||
|
if (runErr) {
|
||||||
|
console.error(`✗ Failed to add ${name}:`, runErr.message);
|
||||||
|
} else {
|
||||||
|
console.log(`✓ Added jira_tickets.${name}`);
|
||||||
|
}
|
||||||
|
pending--;
|
||||||
|
if (pending === 0) finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index on jira_id for lookups
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)', (idxErr) => {
|
||||||
|
if (idxErr) console.error('Index error:', idxErr.message);
|
||||||
|
else console.log('✓ jira_id index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pending === 0) finish();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
|
}
|
||||||
42
backend/migrations/add_jira_sync_columns_pg.js
Normal file
42
backend/migrations/add_jira_sync_columns_pg.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Migration: Add Jira sync columns to jira_tickets (Postgres version)
|
||||||
|
// Adds jira_id, jira_status, last_synced_at, and created_by columns.
|
||||||
|
// These were originally added via a SQLite migration that was never ported to Postgres.
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Adding Jira sync columns to jira_tickets (Postgres)...');
|
||||||
|
|
||||||
|
// Verify table exists
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||||
|
`);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
||||||
|
console.log('✓ jira_id column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
||||||
|
console.log('✓ jira_status column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
||||||
|
console.log('✓ last_synced_at column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
||||||
|
console.log('✓ created_by column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
||||||
|
console.log('✓ jira_id index created (or already exists)');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Migration: Add multi-item Jira ticket junction table
|
||||||
|
// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items
|
||||||
|
// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id)
|
||||||
|
// - Adds indexes on queue_item_id and jira_ticket_id
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting multi-item Jira ticket migration...');
|
||||||
|
|
||||||
|
// Verify prerequisite tables exist
|
||||||
|
const { rows: jiraTable } = await pool.query(`
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||||
|
`);
|
||||||
|
if (jiraTable.length === 0) {
|
||||||
|
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ jira_tickets table exists');
|
||||||
|
|
||||||
|
const { rows: queueTable } = await pool.query(`
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
||||||
|
`);
|
||||||
|
if (queueTable.length === 0) {
|
||||||
|
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ ivanti_todo_queue table exists');
|
||||||
|
|
||||||
|
// Create junction table
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
||||||
|
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE (jira_ticket_id, queue_item_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ jira_ticket_queue_items table created (or already exists)');
|
||||||
|
|
||||||
|
// Add index on queue_item_id for efficient lookup of tickets by queue item
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
||||||
|
ON jira_ticket_queue_items(queue_item_id)
|
||||||
|
`);
|
||||||
|
console.log('✓ queue_item_id index created (or already exists)');
|
||||||
|
|
||||||
|
// Add index on jira_ticket_id for efficient lookup of queue items by ticket
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
||||||
|
ON jira_ticket_queue_items(jira_ticket_id)
|
||||||
|
`);
|
||||||
|
console.log('✓ jira_ticket_id index created (or already exists)');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
45
backend/migrations/add_notifications_table.js
Normal file
45
backend/migrations/add_notifications_table.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting notifications table migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'issue_resolved',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
issue_number INTEGER,
|
||||||
|
read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ notifications table created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_username
|
||||||
|
ON notifications(username)
|
||||||
|
`);
|
||||||
|
console.log('✓ username index created');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_read
|
||||||
|
ON notifications(username, read)
|
||||||
|
`);
|
||||||
|
console.log('✓ username/read index created');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
|
|
||||||
|
// Self-execute when run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||||
|
}
|
||||||
57
backend/migrations/add_return_classification.js
Normal file
57
backend/migrations/add_return_classification.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Migration: Add return_classification_json column to ivanti_sync_anomaly_log
|
||||||
|
//
|
||||||
|
// Stores the classification breakdown for returned findings (e.g., how many
|
||||||
|
// returned due to BU reassignment back to team, severity re-escalation, etc.)
|
||||||
|
//
|
||||||
|
// Safe to re-run — uses ALTER TABLE with IF NOT EXISTS pattern.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_return_classification.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting return classification migration...');
|
||||||
|
|
||||||
|
function run(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
// Check if column already exists
|
||||||
|
const columns = await all(`PRAGMA table_info(ivanti_sync_anomaly_log)`);
|
||||||
|
const hasColumn = columns.some(c => c.name === 'return_classification_json');
|
||||||
|
|
||||||
|
if (!hasColumn) {
|
||||||
|
await run(`ALTER TABLE ivanti_sync_anomaly_log ADD COLUMN return_classification_json TEXT NOT NULL DEFAULT '{}'`);
|
||||||
|
console.log('✓ Added return_classification_json column to ivanti_sync_anomaly_log');
|
||||||
|
} else {
|
||||||
|
console.log('✓ return_classification_json column already exists — skipping');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
db.close();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
90
backend/migrations/add_sync_anomaly_tables.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Migration: Add sync anomaly detection and BU drift monitoring tables
|
||||||
|
//
|
||||||
|
// Creates two new tables:
|
||||||
|
// - ivanti_sync_anomaly_log — stores one row per sync cycle with the
|
||||||
|
// anomaly summary breakdown (count deltas, classification, significance).
|
||||||
|
// - ivanti_finding_bu_history — records BU change events detected on
|
||||||
|
// individual findings across syncs.
|
||||||
|
//
|
||||||
|
// Safe to re-run — uses CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_sync_anomaly_tables.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting sync anomaly tables migration...');
|
||||||
|
|
||||||
|
function run(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
// 1. Create ivanti_sync_anomaly_log table
|
||||||
|
await run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_anomaly_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sync_timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
open_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
|
closed_count_delta INTEGER NOT NULL DEFAULT 0,
|
||||||
|
newly_archived_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
returned_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
classification_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_significant INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ ivanti_sync_anomaly_log table ready');
|
||||||
|
|
||||||
|
// 2. Create ivanti_finding_bu_history table
|
||||||
|
await run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_bu_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT NOT NULL DEFAULT '',
|
||||||
|
host_name TEXT NOT NULL DEFAULT '',
|
||||||
|
previous_bu TEXT NOT NULL,
|
||||||
|
new_bu TEXT NOT NULL,
|
||||||
|
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ ivanti_finding_bu_history table ready');
|
||||||
|
|
||||||
|
// 3. Create indexes
|
||||||
|
await run('CREATE INDEX IF NOT EXISTS idx_anomaly_sync_timestamp ON ivanti_sync_anomaly_log(sync_timestamp)');
|
||||||
|
console.log('✓ idx_anomaly_sync_timestamp index ready');
|
||||||
|
|
||||||
|
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_finding_id ON ivanti_finding_bu_history(finding_id)');
|
||||||
|
console.log('✓ idx_bu_history_finding_id index ready');
|
||||||
|
|
||||||
|
await run('CREATE INDEX IF NOT EXISTS idx_bu_history_detected_at ON ivanti_finding_bu_history(detected_at)');
|
||||||
|
console.log('✓ idx_bu_history_detected_at index ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
db.close();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
25
backend/migrations/add_todo_queue_hostname.js
Normal file
25
backend/migrations/add_todo_queue_hostname.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Migration: Add hostname column to ivanti_todo_queue
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_todo_queue_hostname migration...');
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT',
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
// Column may already exist if migration was run before
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ hostname column already exists, skipping');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding column:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ hostname column added');
|
||||||
|
}
|
||||||
|
db.close(() => console.log('Migration complete!'));
|
||||||
|
}
|
||||||
|
);
|
||||||
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
25
backend/migrations/add_todo_queue_ip_address.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Migration: Add ip_address column to ivanti_todo_queue
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_todo_queue_ip_address migration...');
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue ADD COLUMN ip_address TEXT',
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
// Column may already exist if migration was run before
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ ip_address column already exists, skipping');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding column:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ ip_address column added');
|
||||||
|
}
|
||||||
|
db.close(() => console.log('Migration complete!'));
|
||||||
|
}
|
||||||
|
);
|
||||||
68
backend/migrations/add_user_bu_teams.js
Normal file
68
backend/migrations/add_user_bu_teams.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Migration: Add bu_teams column to users table
|
||||||
|
// Stores comma-separated BU team identifiers per user (e.g. 'STEAM,ACCESS-ENG')
|
||||||
|
// Existing users get empty string (admin must assign teams post-migration)
|
||||||
|
// Idempotent — safe to run multiple times
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DB_FILE = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration against the given database instance.
|
||||||
|
* Exported for testing with in-memory databases.
|
||||||
|
* @param {sqlite3.Database} db
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function runMigration(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
// Check if bu_teams column already exists
|
||||||
|
db.all("PRAGMA table_info(users)", (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBuTeams = columns.some(col => col.name === 'bu_teams');
|
||||||
|
|
||||||
|
if (hasBuTeams) {
|
||||||
|
console.log('✓ bu_teams column already exists — skipping migration');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Adding bu_teams column to users table...');
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE users ADD COLUMN bu_teams TEXT NOT NULL DEFAULT ''`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('✓ Added bu_teams column (default: empty string)');
|
||||||
|
console.log(' Note: Admin must assign teams to existing users via user management UI');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run directly if executed as a script
|
||||||
|
if (require.main === module) {
|
||||||
|
const db = new sqlite3.Database(DB_FILE);
|
||||||
|
runMigration(db)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
db.close();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runMigration };
|
||||||
146
backend/migrations/add_user_groups.js
Normal file
146
backend/migrations/add_user_groups.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Migration: Add user_group column to users table and map legacy roles
|
||||||
|
// Mapping: admin→Admin, editor→Standard_User, viewer→Read_Only
|
||||||
|
// NULL/unrecognized roles default to Read_Only
|
||||||
|
// Idempotent — safe to run multiple times
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migration against the given database instance.
|
||||||
|
* Exported for testing with in-memory databases.
|
||||||
|
* @param {sqlite3.Database} db
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function runMigration(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
// Check if user_group column already exists
|
||||||
|
db.all("PRAGMA table_info(users)", (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUserGroup = columns.some(col => col.name === 'user_group');
|
||||||
|
|
||||||
|
if (hasUserGroup) {
|
||||||
|
console.log('✓ user_group column already exists — skipping migration');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Adding user_group column to users table...');
|
||||||
|
|
||||||
|
// SQLite doesn't support ADD COLUMN with CHECK inline in all versions,
|
||||||
|
// so we add the column first, map values, then recreate with constraint.
|
||||||
|
// However, SQLite also doesn't support ALTER TABLE ADD CONSTRAINT.
|
||||||
|
// Strategy: add column, map values, create index.
|
||||||
|
// The CHECK constraint is enforced via table rebuild.
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE users ADD COLUMN user_group VARCHAR(20) NOT NULL DEFAULT 'Read_Only'`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('✓ Added user_group column');
|
||||||
|
|
||||||
|
// Map existing roles to groups
|
||||||
|
db.run(
|
||||||
|
`UPDATE users SET user_group = 'Admin' WHERE role = 'admin'`,
|
||||||
|
function(err) {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log(` ✓ Mapped ${this.changes} admin(s) → Admin`);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE users SET user_group = 'Standard_User' WHERE role = 'editor'`,
|
||||||
|
function(err) {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log(` ✓ Mapped ${this.changes} editor(s) → Standard_User`);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE users SET user_group = 'Read_Only' WHERE role = 'viewer'`,
|
||||||
|
function(err) {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log(` ✓ Mapped ${this.changes} viewer(s) → Read_Only`);
|
||||||
|
|
||||||
|
// Map NULL or unrecognized roles to Read_Only
|
||||||
|
db.run(
|
||||||
|
`UPDATE users SET user_group = 'Read_Only' WHERE user_group = 'Read_Only' AND role NOT IN ('admin', 'editor', 'viewer')`,
|
||||||
|
function(err) {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log(` ✓ Mapped ${this.changes} unrecognized role(s) → Read_Only`);
|
||||||
|
|
||||||
|
// Create index on user_group
|
||||||
|
db.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_users_user_group ON users(user_group)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) { reject(err); return; }
|
||||||
|
console.log('✓ Created idx_users_user_group index');
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run directly if executed as a script
|
||||||
|
if (require.main === module) {
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
console.log('Starting add_user_groups migration...');
|
||||||
|
|
||||||
|
runMigration(db)
|
||||||
|
.then(() => {
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Database connection closed.');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runMigration };
|
||||||
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
65
backend/migrations/add_vcl_multi_vertical.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Migration: Add multi-vertical support for VCL compliance reporting
|
||||||
|
// Adds vertical column to compliance_items and compliance_uploads,
|
||||||
|
// creates vcl_multi_vertical_summary table for per-vertical metric data.
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting VCL multi-vertical migration...');
|
||||||
|
try {
|
||||||
|
// Add vertical column to compliance_items
|
||||||
|
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||||
|
console.log('✓ vertical column added to compliance_items');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
||||||
|
console.log('✓ idx_compliance_items_vertical index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
||||||
|
console.log('✓ idx_compliance_items_vertical_status index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_metric ON compliance_items(vertical, metric_id, status)`);
|
||||||
|
console.log('✓ idx_compliance_items_vertical_metric index created');
|
||||||
|
|
||||||
|
// Add vertical column to compliance_uploads
|
||||||
|
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||||
|
console.log('✓ vertical column added to compliance_uploads');
|
||||||
|
|
||||||
|
// Create summary table for per-vertical metric data from Summary sheets
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
vertical TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT DEFAULT '',
|
||||||
|
category TEXT DEFAULT 'Other',
|
||||||
|
team TEXT DEFAULT '',
|
||||||
|
priority TEXT DEFAULT '',
|
||||||
|
non_compliant INTEGER DEFAULT 0,
|
||||||
|
compliant INTEGER DEFAULT 0,
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||||
|
target NUMERIC(5,2) DEFAULT 0,
|
||||||
|
status TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ vcl_multi_vertical_summary table created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
||||||
|
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
||||||
|
console.log('✓ idx_vcl_multi_summary_upload index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical_metric ON vcl_multi_vertical_summary(vertical, metric_id)`);
|
||||||
|
console.log('✓ idx_vcl_multi_summary_vertical_metric index created');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
38
backend/migrations/add_vcl_reporting_columns.js
Normal file
38
backend/migrations/add_vcl_reporting_columns.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Migration: Add VCL reporting columns to compliance_items and create compliance_snapshots table
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting VCL reporting migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS resolution_date DATE DEFAULT NULL`);
|
||||||
|
console.log('✓ resolution_date column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS remediation_plan TEXT DEFAULT NULL`);
|
||||||
|
console.log('✓ remediation_plan column added (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
snapshot_month TEXT NOT NULL,
|
||||||
|
vertical TEXT NOT NULL,
|
||||||
|
total_devices INTEGER NOT NULL DEFAULT 0,
|
||||||
|
compliant INTEGER NOT NULL DEFAULT 0,
|
||||||
|
non_compliant INTEGER NOT NULL DEFAULT 0,
|
||||||
|
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(snapshot_month, vertical)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ compliance_snapshots table created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_snapshots_month ON compliance_snapshots(snapshot_month)`);
|
||||||
|
console.log('✓ idx_compliance_snapshots_month index created (or already exists)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
26
backend/migrations/add_vcl_vertical_metadata.js
Normal file
26
backend/migrations/add_vcl_vertical_metadata.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Migration: Create vcl_vertical_metadata table for editable team-level notes, RAs, and compliance dates
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting vcl_vertical_metadata migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vcl_vertical_metadata (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
team TEXT NOT NULL UNIQUE,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
risk_acceptances INTEGER DEFAULT 0,
|
||||||
|
compliance_date TEXT DEFAULT NULL,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ vcl_vertical_metadata table created (or already exists)');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// Migration: Add weekly_reports table for vulnerability report uploads
|
|
||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
|
||||||
const db = new sqlite3.Database(dbPath);
|
|
||||||
|
|
||||||
console.log('Running migration: add_weekly_reports_table');
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS weekly_reports (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
upload_date DATE NOT NULL,
|
|
||||||
week_label VARCHAR(50),
|
|
||||||
original_filename VARCHAR(255),
|
|
||||||
processed_filename VARCHAR(255),
|
|
||||||
original_file_path VARCHAR(500),
|
|
||||||
processed_file_path VARCHAR(500),
|
|
||||||
row_count_original INTEGER,
|
|
||||||
row_count_processed INTEGER,
|
|
||||||
uploaded_by INTEGER,
|
|
||||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_current BOOLEAN DEFAULT 0,
|
|
||||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating weekly_reports table:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created weekly_reports table');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
|
|
||||||
ON weekly_reports(upload_date DESC)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating date index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on upload_date');
|
|
||||||
});
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
|
|
||||||
ON weekly_reports(is_current)
|
|
||||||
`, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating current index:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('✓ Created index on is_current');
|
|
||||||
console.log('\nMigration completed successfully!');
|
|
||||||
db.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
160
backend/migrations/backfill_anomaly_log.js
Normal file
160
backend/migrations/backfill_anomaly_log.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// backfill_anomaly_log.js — One-time backfill of ivanti_sync_anomaly_log
|
||||||
|
//
|
||||||
|
// Synthesizes anomaly log entries from existing ivanti_archive_transitions
|
||||||
|
// and ivanti_counts_history data so the archive activity sparkline on the
|
||||||
|
// Findings Trend chart has historical data to display.
|
||||||
|
//
|
||||||
|
// Safe to run multiple times — checks for existing rows before inserting.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/backfill_anomaly_log.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGet(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
|
||||||
|
// Check if anomaly log already has data
|
||||||
|
const existing = await dbGet(db, 'SELECT COUNT(*) as cnt FROM ivanti_sync_anomaly_log');
|
||||||
|
if (existing.cnt > 0) {
|
||||||
|
console.log(`ivanti_sync_anomaly_log already has ${existing.cnt} rows — skipping backfill.`);
|
||||||
|
console.log('To force re-run, delete existing rows first:');
|
||||||
|
console.log(' sqlite3 backend/cve_database.db "DELETE FROM ivanti_sync_anomaly_log;"');
|
||||||
|
db.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get archive transitions grouped by date
|
||||||
|
const transitions = await dbAll(db,
|
||||||
|
`SELECT DATE(transitioned_at) as date,
|
||||||
|
to_state,
|
||||||
|
reason,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM ivanti_archive_transitions
|
||||||
|
GROUP BY date, to_state, reason
|
||||||
|
ORDER BY date`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get counts history (last snapshot per day) for delta computation
|
||||||
|
const countsRows = await dbAll(db,
|
||||||
|
`SELECT date, open_count, closed_count FROM (
|
||||||
|
SELECT DATE(recorded_at) AS date,
|
||||||
|
open_count, closed_count,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY DATE(recorded_at)
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
) WHERE rn = 1
|
||||||
|
ORDER BY date ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a map of date -> { open_count, closed_count }
|
||||||
|
const countsMap = {};
|
||||||
|
for (const row of countsRows) {
|
||||||
|
countsMap[row.date] = { open: row.open_count, closed: row.closed_count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-date anomaly summaries from transitions
|
||||||
|
const dateMap = {};
|
||||||
|
for (const t of transitions) {
|
||||||
|
if (!dateMap[t.date]) {
|
||||||
|
dateMap[t.date] = { archived: 0, returned: 0, classification: {} };
|
||||||
|
}
|
||||||
|
const entry = dateMap[t.date];
|
||||||
|
|
||||||
|
if (t.to_state === 'ARCHIVED') {
|
||||||
|
entry.archived += t.cnt;
|
||||||
|
// All pre-feature transitions have reason 'severity_score_drift'
|
||||||
|
// but from the investigation we know the 04/24 batch was mostly
|
||||||
|
// BU reassignment. We can't retroactively classify without the
|
||||||
|
// Ivanti API, so we label them as 'unclassified' (pre-feature).
|
||||||
|
entry.classification.unclassified = (entry.classification.unclassified || 0) + t.cnt;
|
||||||
|
} else if (t.to_state === 'RETURNED') {
|
||||||
|
entry.returned += t.cnt;
|
||||||
|
}
|
||||||
|
// CLOSED transitions are not archive events — they're findings
|
||||||
|
// confirmed in the closed set, so we don't count them as archived.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute deltas and insert rows
|
||||||
|
const dates = Object.keys(dateMap).sort();
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
const entry = dateMap[date];
|
||||||
|
const counts = countsMap[date];
|
||||||
|
|
||||||
|
// Find the previous day's counts for delta computation
|
||||||
|
const dateIdx = countsRows.findIndex(r => r.date === date);
|
||||||
|
let openDelta = 0;
|
||||||
|
let closedDelta = 0;
|
||||||
|
|
||||||
|
if (counts && dateIdx > 0) {
|
||||||
|
const prev = countsRows[dateIdx - 1];
|
||||||
|
openDelta = counts.open - prev.open_count;
|
||||||
|
closedDelta = counts.closed - prev.closed_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSignificant = entry.archived > 5 ? 1 : 0;
|
||||||
|
const classificationJson = JSON.stringify(entry.classification);
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_sync_anomaly_log
|
||||||
|
(sync_timestamp, open_count_delta, closed_count_delta,
|
||||||
|
newly_archived_count, returned_count, classification_json, is_significant)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
`${date}T23:59:00`,
|
||||||
|
openDelta,
|
||||||
|
closedDelta,
|
||||||
|
entry.archived,
|
||||||
|
entry.returned,
|
||||||
|
classificationJson,
|
||||||
|
isSignificant,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
|
||||||
|
const sigLabel = isSignificant ? ' [SIGNIFICANT]' : '';
|
||||||
|
console.log(` ${date}: ${entry.archived} archived, ${entry.returned} returned, delta open=${openDelta} closed=${closedDelta}${sigLabel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nBackfill complete: ${inserted} anomaly log entries created.`);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
165
backend/migrations/backfill_return_classification.js
Normal file
165
backend/migrations/backfill_return_classification.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// backfill_return_classification.js
|
||||||
|
//
|
||||||
|
// Retroactively populates return_classification_json for existing anomaly log
|
||||||
|
// rows that have returned_count > 0 but an empty return classification.
|
||||||
|
//
|
||||||
|
// For each such row, looks at archive transitions that went ARCHIVED → RETURNED
|
||||||
|
// on that date, then finds the *prior* archive reason (the most recent
|
||||||
|
// transition to ARCHIVED for that same archive record) to determine why the
|
||||||
|
// finding originally left — which tells us why it came back.
|
||||||
|
//
|
||||||
|
// Safe to run multiple times — only updates rows with empty classification.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/backfill_return_classification.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGet(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
|
||||||
|
// Find anomaly log rows that have returned findings but no return classification
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT id, sync_timestamp, returned_count, return_classification_json
|
||||||
|
FROM ivanti_sync_anomaly_log
|
||||||
|
WHERE returned_count > 0
|
||||||
|
ORDER BY sync_timestamp ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('No anomaly log rows with returned findings found — nothing to backfill.');
|
||||||
|
db.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Skip if already has a non-empty classification (unless --force)
|
||||||
|
if (!force) {
|
||||||
|
let existing = {};
|
||||||
|
try { existing = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
||||||
|
const hasData = Object.values(existing).some(v => v > 0);
|
||||||
|
if (hasData) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the date of this anomaly row
|
||||||
|
const date = row.sync_timestamp.split('T')[0].split(' ')[0];
|
||||||
|
|
||||||
|
// Find all ARCHIVED → RETURNED transitions on this date
|
||||||
|
const returnTransitions = await dbAll(db,
|
||||||
|
`SELECT archive_id
|
||||||
|
FROM ivanti_archive_transitions
|
||||||
|
WHERE to_state = 'RETURNED'
|
||||||
|
AND DATE(transitioned_at) = ?`,
|
||||||
|
[date]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (returnTransitions.length === 0) {
|
||||||
|
// No transitions found for this date — try a wider window (±1 day)
|
||||||
|
// since sync_timestamp and transitioned_at might not align exactly
|
||||||
|
const wider = await dbAll(db,
|
||||||
|
`SELECT archive_id
|
||||||
|
FROM ivanti_archive_transitions
|
||||||
|
WHERE to_state = 'RETURNED'
|
||||||
|
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')`,
|
||||||
|
[date, date]
|
||||||
|
);
|
||||||
|
if (wider.length === 0) {
|
||||||
|
console.log(` ${date}: ${row.returned_count} returned but no matching transitions found — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
returnTransitions.push(...wider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each returned finding, look up the prior archive reason
|
||||||
|
const classification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const rt of returnTransitions) {
|
||||||
|
if (seen.has(rt.archive_id)) continue;
|
||||||
|
seen.add(rt.archive_id);
|
||||||
|
|
||||||
|
// Find the most recent ARCHIVED transition *before* this return
|
||||||
|
// (the reason it was archived before it came back)
|
||||||
|
const archiveTransition = await dbGet(db,
|
||||||
|
`SELECT reason FROM ivanti_archive_transitions
|
||||||
|
WHERE archive_id = ? AND to_state = 'ARCHIVED'
|
||||||
|
AND transitioned_at <= (
|
||||||
|
SELECT transitioned_at FROM ivanti_archive_transitions
|
||||||
|
WHERE archive_id = ? AND to_state = 'RETURNED'
|
||||||
|
AND DATE(transitioned_at) BETWEEN DATE(?, '-1 day') AND DATE(?, '+1 day')
|
||||||
|
ORDER BY transitioned_at DESC LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY transitioned_at DESC LIMIT 1`,
|
||||||
|
[rt.archive_id, rt.archive_id, date, date]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (archiveTransition && archiveTransition.reason) {
|
||||||
|
const reasonKey = archiveTransition.reason.split(':')[0];
|
||||||
|
if (reasonKey in classification) {
|
||||||
|
classification[reasonKey]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const classificationJson = JSON.stringify(classification);
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_sync_anomaly_log
|
||||||
|
SET return_classification_json = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[classificationJson, row.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = Object.entries(classification)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([k, v]) => `${v} ${k}`);
|
||||||
|
const breakdown = parts.length > 0 ? parts.join(', ') : 'unclassified';
|
||||||
|
|
||||||
|
console.log(` ${date}: ${row.returned_count} returned — ${breakdown}`);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nBackfill complete: ${updated} rows updated, ${skipped} already had data.`);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
18
backend/migrations/drop_jira_status_check_constraint.js
Normal file
18
backend/migrations/drop_jira_status_check_constraint.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Migration: Drop CHECK constraint on jira_tickets.status
|
||||||
|
// Allows storing raw Jira status strings (e.g. "Approval/Handoff", "Prioritizing")
|
||||||
|
// instead of mapping to the limited set of Open/In Progress/Closed.
|
||||||
|
// Idempotent — safe to run multiple times.
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('[Migration] Dropping jira_tickets_status_check constraint...');
|
||||||
|
await pool.query(`ALTER TABLE jira_tickets DROP CONSTRAINT IF EXISTS jira_tickets_status_check`);
|
||||||
|
console.log('✓ jira_tickets status CHECK constraint dropped (or did not exist)');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
102
backend/migrations/reclassify_bu_roundtrips.js
Normal file
102
backend/migrations/reclassify_bu_roundtrips.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// reclassify_bu_roundtrips.js
|
||||||
|
//
|
||||||
|
// Reclassifies archive transitions that were part of a BU reassignment
|
||||||
|
// round-trip. These are findings that were archived (disappeared from sync)
|
||||||
|
// and then returned within a short window — indicating they were temporarily
|
||||||
|
// reassigned to a different BU and then reassigned back.
|
||||||
|
//
|
||||||
|
// The original drift checker couldn't classify these correctly because by the
|
||||||
|
// time it queried Ivanti, the findings had already been reassigned back to
|
||||||
|
// the expected BUs.
|
||||||
|
//
|
||||||
|
// After running this, re-run backfill_return_classification.js to update
|
||||||
|
// the anomaly log with the corrected reasons.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/reclassify_bu_roundtrips.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
|
||||||
|
// Findings that were archived and returned within this many days are
|
||||||
|
// considered BU reassignment round-trips
|
||||||
|
const ROUNDTRIP_WINDOW_DAYS = 14;
|
||||||
|
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
|
||||||
|
// Find archive transitions where the finding was archived and then returned
|
||||||
|
// within the roundtrip window, and the archive reason is still the default
|
||||||
|
// severity_score_drift placeholder
|
||||||
|
const roundtrips = await dbAll(db, `
|
||||||
|
SELECT
|
||||||
|
t_arch.id AS archive_transition_id,
|
||||||
|
t_arch.archive_id,
|
||||||
|
a.finding_id,
|
||||||
|
a.finding_title,
|
||||||
|
t_arch.reason AS current_reason,
|
||||||
|
DATE(t_arch.transitioned_at) AS archived_date,
|
||||||
|
DATE(t_ret.transitioned_at) AS returned_date,
|
||||||
|
JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at) AS days_between
|
||||||
|
FROM ivanti_archive_transitions t_arch
|
||||||
|
JOIN ivanti_finding_archives a ON a.id = t_arch.archive_id
|
||||||
|
JOIN ivanti_archive_transitions t_ret
|
||||||
|
ON t_ret.archive_id = t_arch.archive_id
|
||||||
|
AND t_ret.to_state = 'RETURNED'
|
||||||
|
AND t_ret.transitioned_at > t_arch.transitioned_at
|
||||||
|
WHERE t_arch.to_state = 'ARCHIVED'
|
||||||
|
AND t_arch.reason = 'severity_score_drift'
|
||||||
|
AND (JULIANDAY(t_ret.transitioned_at) - JULIANDAY(t_arch.transitioned_at)) BETWEEN 0 AND ?
|
||||||
|
ORDER BY t_arch.transitioned_at DESC
|
||||||
|
`, [ROUNDTRIP_WINDOW_DAYS]);
|
||||||
|
|
||||||
|
if (roundtrips.length === 0) {
|
||||||
|
console.log('No BU reassignment round-trips found to reclassify.');
|
||||||
|
db.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${roundtrips.length} archive transitions to reclassify as bu_reassignment:\n`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const rt of roundtrips) {
|
||||||
|
console.log(` Finding ${rt.finding_id}: archived ${rt.archived_date}, returned ${rt.returned_date} (${Math.round(rt.days_between)}d) — ${rt.current_reason} → bu_reassignment`);
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_archive_transitions SET reason = 'bu_reassignment' WHERE id = ?`,
|
||||||
|
[rt.archive_transition_id]
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nReclassified ${updated} transitions.`);
|
||||||
|
console.log('\nNow run the return classification backfill to update anomaly log rows:');
|
||||||
|
console.log(' node backend/migrations/backfill_return_classification.js');
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
61
backend/migrations/run-all.js
Normal file
61
backend/migrations/run-all.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Run all Postgres-compatible migrations in order.
|
||||||
|
// Each migration is idempotent (safe to re-run).
|
||||||
|
// Used by CI/CD pipeline during deploy to ensure schema is up to date.
|
||||||
|
//
|
||||||
|
// Usage: cd backend && node migrations/run-all.js
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = __dirname;
|
||||||
|
|
||||||
|
// Only run migrations that use the Postgres pool (not legacy SQLite ones).
|
||||||
|
// Add new migrations to this list as they're created.
|
||||||
|
const POSTGRES_MIGRATIONS = [
|
||||||
|
'add_decom_workflow_type.js',
|
||||||
|
'add_fp_submissions_dismissed.js',
|
||||||
|
'add_fp_submissions_requeued_at.js',
|
||||||
|
'add_vcl_reporting_columns.js',
|
||||||
|
'add_vcl_vertical_metadata.js',
|
||||||
|
'add_vcl_multi_vertical.js',
|
||||||
|
'add_compliance_item_history.js',
|
||||||
|
'add_jira_sync_columns_pg.js',
|
||||||
|
'add_flexible_jira_ticket_creation.js',
|
||||||
|
'add_multi_item_jira_ticket.js',
|
||||||
|
'drop_jira_status_check_constraint.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runAll() {
|
||||||
|
console.log(`[Migrations] Running ${POSTGRES_MIGRATIONS.length} Postgres migration(s)...`);
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const file of POSTGRES_MIGRATIONS) {
|
||||||
|
const fullPath = path.join(MIGRATIONS_DIR, file);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
console.error(` [FAIL] ${file}: file not found`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(` [run] ${file}`);
|
||||||
|
execSync(`node ${fullPath}`, {
|
||||||
|
cwd: path.join(MIGRATIONS_DIR, '..'),
|
||||||
|
stdio: 'inherit',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` [FAIL] ${file}: exit code ${err.status}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Migrations] Done: ${succeeded} applied, ${failed} failed`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAll();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// routes/archerTickets.js
|
// routes/archerTickets.js
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
const pool = require('../db');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
const logAudit = require('../helpers/auditLog');
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
// Validation helpers
|
// Validation helpers
|
||||||
@@ -13,42 +14,43 @@ function isValidVendor(vendor) {
|
|||||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createArcherTicketsRouter(db) {
|
function createArcherTicketsRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all Archer tickets (with optional filters)
|
// Get all Archer tickets (with optional filters)
|
||||||
router.get('/', requireAuth(db), (req, res) => {
|
router.get('/', requireAuth(), async (req, res) => {
|
||||||
const { cve_id, vendor, status } = req.query;
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
||||||
const params = [];
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (cve_id) {
|
if (cve_id) {
|
||||||
query += ' AND cve_id = ?';
|
query += ` AND cve_id = $${paramIndex++}`;
|
||||||
params.push(cve_id);
|
params.push(cve_id);
|
||||||
}
|
}
|
||||||
if (vendor) {
|
if (vendor) {
|
||||||
query += ' AND vendor = ?';
|
query += ` AND vendor = $${paramIndex++}`;
|
||||||
params.push(vendor);
|
params.push(vendor);
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
query += ' AND status = ?';
|
query += ` AND status = $${paramIndex++}`;
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ' ORDER BY created_at DESC';
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
db.all(query, params, (err, rows) => {
|
try {
|
||||||
if (err) {
|
const { rows } = await pool.query(query, params);
|
||||||
console.error('Error fetching Archer tickets:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
} catch (err) {
|
||||||
|
console.error('Error fetching Archer tickets:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Archer ticket
|
// Create Archer ticket
|
||||||
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -73,38 +75,38 @@ function createArcherTicketsRouter(db) {
|
|||||||
|
|
||||||
const validatedStatus = status || 'Draft';
|
const validatedStatus = status || 'Draft';
|
||||||
|
|
||||||
db.run(
|
try {
|
||||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
|
const { rows } = await pool.query(
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
function(err) {
|
RETURNING id`,
|
||||||
if (err) {
|
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id]
|
||||||
console.error('Error creating Archer ticket:', err);
|
);
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
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(rows[0].id),
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: this.lastID,
|
id: rows[0].id,
|
||||||
message: 'Archer ticket created successfully'
|
message: 'Archer ticket created successfully'
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating Archer ticket:', err);
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||||
}
|
}
|
||||||
);
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update Archer ticket
|
// Update Archer ticket
|
||||||
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { exc_number, archer_url, status } = req.body;
|
const { exc_number, archer_url, status } = req.body;
|
||||||
|
|
||||||
@@ -124,29 +126,27 @@ function createArcherTicketsRouter(db) {
|
|||||||
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing ticket
|
try {
|
||||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
|
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||||
if (err) {
|
const existing = rows[0];
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (exc_number !== undefined) {
|
if (exc_number !== undefined) {
|
||||||
updates.push('exc_number = ?');
|
updates.push(`exc_number = $${paramIndex++}`);
|
||||||
params.push(exc_number.trim());
|
params.push(exc_number.trim());
|
||||||
}
|
}
|
||||||
if (archer_url !== undefined) {
|
if (archer_url !== undefined) {
|
||||||
updates.push('archer_url = ?');
|
updates.push(`archer_url = $${paramIndex++}`);
|
||||||
params.push(archer_url || null);
|
params.push(archer_url || null);
|
||||||
}
|
}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updates.push('status = ?');
|
updates.push(`status = $${paramIndex++}`);
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,67 +154,113 @@ function createArcherTicketsRouter(db) {
|
|||||||
return res.status(400).json({ error: 'No fields to update.' });
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
updates.push('updated_at = NOW()');
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
db.run(
|
const result = await pool.query(
|
||||||
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
params,
|
params
|
||||||
function(err) {
|
|
||||||
if (err) {
|
|
||||||
console.error(err);
|
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
|
||||||
}
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
|
||||||
userId: req.user.id,
|
|
||||||
action: 'UPDATE_ARCHER_TICKET',
|
|
||||||
targetType: 'archer_ticket',
|
|
||||||
targetId: id,
|
|
||||||
details: { before: existing, changes: req.body },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'UPDATE_ARCHER_TICKET',
|
||||||
|
entityType: 'archer_ticket',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { before: existing, changes: req.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Archer ticket updated successfully', changes: result.rowCount });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete Archer ticket
|
// Delete Archer ticket
|
||||||
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
try {
|
||||||
if (err) {
|
const { rows } = await pool.query('SELECT * FROM archer_tickets WHERE id = $1', [id]);
|
||||||
console.error(err);
|
const ticket = rows[0];
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return res.status(404).json({ error: 'Archer ticket not found.' });
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
// Admin bypasses all delete restrictions
|
||||||
if (err) {
|
if (req.user.group === 'Admin') {
|
||||||
console.error(err);
|
return performArcherDelete();
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
// Standard_User: ownership check
|
||||||
|
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard_User: compliance linkage check
|
||||||
|
const excNumber = ticket.exc_number;
|
||||||
|
try {
|
||||||
|
const { rows: compLinks } = await pool.query(
|
||||||
|
`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 ci.extra_json ILIKE $1`,
|
||||||
|
[`%${excNumber}%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLinked = (compLinks || []).some(cl => {
|
||||||
|
const json = cl.extra_json || '';
|
||||||
|
return json.includes(excNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLinked) {
|
||||||
|
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||||
|
}
|
||||||
|
} catch (compErr) {
|
||||||
|
if (!compErr.message.includes('does not exist')) throw compErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return performArcherDelete();
|
||||||
|
|
||||||
|
async function performArcherDelete() {
|
||||||
|
await pool.query('DELETE FROM archer_tickets WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
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
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: 'Archer ticket deleted successfully' });
|
res.json({ message: 'Archer ticket deleted successfully' });
|
||||||
});
|
}
|
||||||
});
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status-trend — ticket counts grouped by creation date + status
|
||||||
|
router.get('/status-trend', requireAuth(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||||
|
FROM archer_tickets
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY date ASC`
|
||||||
|
);
|
||||||
|
res.json({ statusTrend: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching Archer status trend:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
638
backend/routes/atlas.js
Normal file
638
backend/routes/atlas.js
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
// Atlas InfoSec Action Plans Routes
|
||||||
|
// Proxies CRUD operations to the Atlas API and maintains a local cache
|
||||||
|
// for fast badge rendering on the ReportingPage.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const VALID_PLAN_TYPES = ['decommission', 'remediation', 'false_positive', 'risk_acceptance', 'scan_exclusion'];
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
// Diagnostic log helper
|
||||||
|
function syncLog(msg) {
|
||||||
|
const line = `${new Date().toISOString()} ${msg}\n`;
|
||||||
|
try { fs.appendFileSync(path.join(__dirname, '..', 'atlas-sync-debug.log'), line); } catch (_) { /* ignore */ }
|
||||||
|
console.log(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure aggregation function — exported for testability
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function aggregateAtlasMetrics(rows) {
|
||||||
|
const result = {
|
||||||
|
totalHosts: rows.length,
|
||||||
|
hostsWithPlans: 0,
|
||||||
|
hostsWithoutPlans: 0,
|
||||||
|
plansByType: {},
|
||||||
|
plansByStatus: {},
|
||||||
|
totalPlans: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.has_action_plan === true || row.has_action_plan === 1) {
|
||||||
|
result.hostsWithPlans++;
|
||||||
|
} else {
|
||||||
|
result.hostsWithoutPlans++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plans;
|
||||||
|
try {
|
||||||
|
plans = JSON.parse(row.plans_json);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(plans)) continue;
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
result.totalPlans++;
|
||||||
|
if (plan.plan_type) {
|
||||||
|
result.plansByType[plan.plan_type] = (result.plansByType[plan.plan_type] || 0) + 1;
|
||||||
|
}
|
||||||
|
if (plan.status) {
|
||||||
|
result.plansByStatus[plan.status] = (result.plansByStatus[plan.status] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createAtlasRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /metrics
|
||||||
|
*
|
||||||
|
* Returns aggregated Atlas action plan metrics from the local cache.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
* @returns {Object} 500 - { error } on database failure
|
||||||
|
*/
|
||||||
|
router.get('/metrics', requireAuth(), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache`
|
||||||
|
);
|
||||||
|
const metrics = aggregateAtlasMetrics(rows);
|
||||||
|
res.json(metrics);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] Error fetching metrics:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Atlas metrics.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /status
|
||||||
|
*
|
||||||
|
* Returns the full atlas_action_plans_cache table contents for status display.
|
||||||
|
*
|
||||||
|
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, synced_at }
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
* @returns {Object} 500 - { error } on database failure
|
||||||
|
*/
|
||||||
|
router.get('/status', requireAuth(), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] Error fetching status:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Atlas status.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /sync
|
||||||
|
*
|
||||||
|
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
||||||
|
* Fetches plans per host in batches of 5 and upserts into the local cache.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { synced, withPlans, failed }
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
* @returns {Object} 500 - { error } on unexpected failure
|
||||||
|
*/
|
||||||
|
router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read Ivanti findings and extract unique non-null hostIds
|
||||||
|
const { rows: findingsRows } = await pool.query(
|
||||||
|
`SELECT DISTINCT host_id FROM ivanti_findings WHERE host_id IS NOT NULL AND host_id > 0`
|
||||||
|
);
|
||||||
|
const hostIds = findingsRows.map(r => r.host_id);
|
||||||
|
|
||||||
|
if (hostIds.length === 0) {
|
||||||
|
return res.json({ synced: 0, withPlans: 0, failed: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let withPlans = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
|
||||||
|
for (let i = 0; i < hostIds.length; i += BATCH_SIZE) {
|
||||||
|
const batch = hostIds.slice(i, i + BATCH_SIZE);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (hostId) => {
|
||||||
|
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||||
|
return { hostId, result };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const settled of results) {
|
||||||
|
if (settled.status === 'rejected') {
|
||||||
|
failed++;
|
||||||
|
console.warn('[Atlas Sync] Request failed for host:', settled.reason?.message || settled.reason);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostId, result } = settled.value;
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let allPlans = [];
|
||||||
|
let activePlans = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result.body);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||||
|
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||||
|
allPlans = [...activePlans, ...inactive];
|
||||||
|
} else if (Array.isArray(parsed)) {
|
||||||
|
allPlans = parsed;
|
||||||
|
activePlans = parsed;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
allPlans = [];
|
||||||
|
activePlans = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const planCount = activePlans.length;
|
||||||
|
const hasActionPlan = planCount > 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasActionPlan) {
|
||||||
|
const { rows: existingRows } = await pool.query(
|
||||||
|
`SELECT has_action_plan, plans_json, synced_at FROM atlas_action_plans_cache WHERE host_id = $1`,
|
||||||
|
[hostId]
|
||||||
|
);
|
||||||
|
const existing = existingRows[0];
|
||||||
|
if (existing && existing.has_action_plan === true) {
|
||||||
|
let existingPlans = [];
|
||||||
|
try { existingPlans = JSON.parse(existing.plans_json || '[]'); } catch (_) {}
|
||||||
|
const hasBulkStub = existingPlans.some(p => p.source === 'bulk-create');
|
||||||
|
if (hasBulkStub) {
|
||||||
|
const ageMs = Date.now() - new Date(existing.synced_at).getTime();
|
||||||
|
const TEN_MINUTES = 10 * 60 * 1000;
|
||||||
|
if (ageMs < TEN_MINUTES) {
|
||||||
|
synced++;
|
||||||
|
withPlans++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
|
plan_count = EXCLUDED.plan_count,
|
||||||
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
synced_at = EXCLUDED.synced_at`,
|
||||||
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||||
|
);
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
synced++;
|
||||||
|
if (hasActionPlan) withPlans++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
console.warn(`[Atlas Sync] Non-2xx response for host ${hostId}: status ${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'ATLAS_SYNC',
|
||||||
|
entityType: 'atlas_action_plans',
|
||||||
|
entityId: null,
|
||||||
|
details: { synced, withPlans, failed, totalHosts: hostIds.length },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ synced, withPlans, failed });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas Sync] Unexpected error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Atlas sync failed: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /hosts/:hostId/action-plans
|
||||||
|
*
|
||||||
|
* Proxies a request to Atlas to retrieve action plans for a specific host.
|
||||||
|
*
|
||||||
|
* @param {number} req.params.hostId - Positive integer host identifier
|
||||||
|
* @returns {Object} 2xx - Action plans response from Atlas API
|
||||||
|
* @returns {Object} 400 - { error } when hostId is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.get('/hosts/:hostId/action-plans', requireAuth(), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = parseInt(req.params.hostId, 10);
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
res.status(result.status).json(body);
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] GET action-plans failed for host', hostId, ':', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /hosts/:hostId/action-plans
|
||||||
|
*
|
||||||
|
* Creates a new action plan for a host via the Atlas API.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} req.params.hostId - Positive integer host identifier
|
||||||
|
* @param {Object} req.body
|
||||||
|
* @param {string} req.body.plan_type - One of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
||||||
|
* @param {string} req.body.commit_date - Date in YYYY-MM-DD format
|
||||||
|
* @returns {Object} 2xx - Created plan response from Atlas API
|
||||||
|
* @returns {Object} 400 - { error } when hostId, plan_type, or commit_date is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.put('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = parseInt(req.params.hostId, 10);
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plan_type, commit_date } = req.body || {};
|
||||||
|
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||||
|
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||||
|
}
|
||||||
|
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||||
|
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasPut('/hosts/' + hostId + '/action-plans', req.body);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'ATLAS_CREATE_PLAN',
|
||||||
|
entityType: 'atlas_action_plan',
|
||||||
|
entityId: String(hostId),
|
||||||
|
details: { hostId, plan_type, commit_date },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
res.status(result.status).json(body);
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] PUT action-plans failed for host', hostId, ':', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /hosts/:hostId/action-plans
|
||||||
|
*
|
||||||
|
* Updates an existing action plan for a host via the Atlas API.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} req.params.hostId - Positive integer host identifier
|
||||||
|
* @param {Object} req.body
|
||||||
|
* @param {string} req.body.action_plan_id - Non-empty string identifying the plan to update
|
||||||
|
* @param {Object} req.body.updates - Object containing fields to update
|
||||||
|
* @returns {Object} 2xx - Updated plan response from Atlas API
|
||||||
|
* @returns {Object} 400 - { error } when hostId, action_plan_id, or updates is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.patch('/hosts/:hostId/action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = parseInt(req.params.hostId, 10);
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action_plan_id, updates } = req.body || {};
|
||||||
|
if (!action_plan_id || typeof action_plan_id !== 'string' || action_plan_id.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'action_plan_id is required and must be a non-empty string' });
|
||||||
|
}
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
return res.status(400).json({ error: 'updates is required and must be an object' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasPatch('/hosts/' + hostId + '/action-plans', req.body);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'ATLAS_UPDATE_PLAN',
|
||||||
|
entityType: 'atlas_action_plan',
|
||||||
|
entityId: String(hostId),
|
||||||
|
details: { hostId, action_plan_id },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
res.status(result.status).json(body);
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] PATCH action-plans failed for host', hostId, ':', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /hosts/bulk-action-plans
|
||||||
|
*
|
||||||
|
* Creates action plans for multiple hosts in a single request via the Atlas API.
|
||||||
|
* Optimistically updates the local cache with stub plans after a successful response.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {Object} req.body
|
||||||
|
* @param {number[]} req.body.host_ids - Non-empty array of positive integer host identifiers
|
||||||
|
* @param {string} req.body.plan_type - One of: decommission, remediation, false_positive, risk_acceptance, scan_exclusion
|
||||||
|
* @param {string} req.body.commit_date - Date in YYYY-MM-DD format
|
||||||
|
* @returns {Object} 2xx - Bulk creation response from Atlas API
|
||||||
|
* @returns {Object} 400 - { error } when host_ids, plan_type, or commit_date is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.post('/hosts/bulk-action-plans', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host_ids, plan_type, commit_date } = req.body || {};
|
||||||
|
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||||
|
}
|
||||||
|
for (const id of host_ids) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!plan_type || !VALID_PLAN_TYPES.includes(plan_type)) {
|
||||||
|
return res.status(400).json({ error: 'plan_type must be one of: ' + VALID_PLAN_TYPES.join(', ') });
|
||||||
|
}
|
||||||
|
if (!commit_date || !DATE_PATTERN.test(commit_date)) {
|
||||||
|
return res.status(400).json({ error: 'commit_date must be a valid YYYY-MM-DD date string' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasPost('/hosts/create-bulk-action-plans', req.body);
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
|
||||||
|
// Optimistically update local cache
|
||||||
|
for (const hid of host_ids) {
|
||||||
|
try {
|
||||||
|
const { rows: existingRows } = await pool.query(
|
||||||
|
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
|
||||||
|
[hid]
|
||||||
|
);
|
||||||
|
const existing = existingRows[0];
|
||||||
|
|
||||||
|
let existingPlans = [];
|
||||||
|
if (existing && existing.plans_json) {
|
||||||
|
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() };
|
||||||
|
const updatedPlans = [...existingPlans, stubPlan];
|
||||||
|
const newCount = updatedPlans.length;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
|
VALUES ($1, true, $2, $3, NOW())
|
||||||
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
|
has_action_plan = true,
|
||||||
|
plan_count = EXCLUDED.plan_count,
|
||||||
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
synced_at = EXCLUDED.synced_at`,
|
||||||
|
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||||
|
);
|
||||||
|
} catch (cacheErr) {
|
||||||
|
console.error('[Atlas] Cache update failed for host', hid, ':', cacheErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'ATLAS_BULK_CREATE_PLANS',
|
||||||
|
entityType: 'atlas_action_plan',
|
||||||
|
entityId: null,
|
||||||
|
details: { host_ids, plan_type, commit_date, count: host_ids.length },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(result.status).json(body);
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] POST bulk-action-plans failed:', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /hosts/:hostId/refresh-cache
|
||||||
|
*
|
||||||
|
* Triggers Atlas to refresh its Ivanti data cache, then updates the local
|
||||||
|
* action plans cache for the specified host. Useful when action plan creation
|
||||||
|
* fails due to stale finding IDs.
|
||||||
|
* Requires Admin or Standard_User group.
|
||||||
|
*
|
||||||
|
* @param {number} req.params.hostId - Positive integer host identifier
|
||||||
|
* @returns {Object} 200 - { success, message } on successful cache refresh
|
||||||
|
* @returns {Object} 400 - { error } when hostId is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.post('/hosts/:hostId/refresh-cache', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = parseInt(req.params.hostId, 10);
|
||||||
|
if (!Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'hostId must be a positive integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasPost('/cache/refresh-ivanti', {}, { timeout: 30000 });
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
// Also refresh our local action plans cache for this host
|
||||||
|
const plansResult = await atlasGet('/hosts/' + hostId + '/action-plans');
|
||||||
|
if (plansResult.status >= 200 && plansResult.status < 300) {
|
||||||
|
let allPlans = [];
|
||||||
|
let activePlans = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(plansResult.body);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
|
||||||
|
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
|
||||||
|
allPlans = [...activePlans, ...inactive];
|
||||||
|
} else if (Array.isArray(parsed)) {
|
||||||
|
allPlans = parsed;
|
||||||
|
activePlans = parsed;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const planCount = activePlans.length;
|
||||||
|
const hasActionPlan = planCount > 0;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
|
plan_count = EXCLUDED.plan_count,
|
||||||
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
synced_at = EXCLUDED.synced_at`,
|
||||||
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Atlas cache refreshed for host ' + hostId });
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] POST refresh-cache failed for host', hostId, ':', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /hosts/vulnerabilities
|
||||||
|
*
|
||||||
|
* Fetches Ivanti vulnerability data for the specified hosts from Atlas.
|
||||||
|
*
|
||||||
|
* @param {Object} req.body
|
||||||
|
* @param {number[]} req.body.host_ids - Non-empty array of positive integer host identifiers
|
||||||
|
* @returns {Object} 2xx - Vulnerability data response from Atlas API
|
||||||
|
* @returns {Object} 400 - { error } when host_ids is invalid
|
||||||
|
* @returns {Object} 502 - { error } when Atlas API is unreachable
|
||||||
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
|
*/
|
||||||
|
router.post('/hosts/vulnerabilities', requireAuth(), async (req, res) => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host_ids } = req.body || {};
|
||||||
|
if (!Array.isArray(host_ids) || host_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||||
|
}
|
||||||
|
for (const id of host_ids) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
|
||||||
|
|
||||||
|
if (result.status >= 200 && result.status < 300) {
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
|
||||||
|
res.status(result.status).json(body);
|
||||||
|
} else {
|
||||||
|
let errorBody;
|
||||||
|
try { errorBody = JSON.parse(result.body); } catch (e) { errorBody = { error: result.body }; }
|
||||||
|
res.status(result.status).json(errorBody);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Atlas] POST hosts/vulnerabilities failed:', err.message);
|
||||||
|
res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createAtlasRouter;
|
||||||
|
module.exports.aggregateAtlasMetrics = aggregateAtlasMetrics;
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
// Audit Log Routes (Admin only)
|
// Audit Log Routes (Admin only)
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
function createAuditLogRouter(db, requireAuth, requireRole) {
|
function createAuditLogRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// All routes require admin role
|
// All routes require Admin group
|
||||||
router.use(requireAuth(db), requireRole('admin'));
|
router.use(requireAuth(), requireGroup('Admin'));
|
||||||
|
|
||||||
// Get paginated audit logs with filters
|
// Get paginated audit logs with filters
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -24,25 +26,26 @@ function createAuditLogRouter(db, requireAuth, requireRole) {
|
|||||||
|
|
||||||
let where = [];
|
let where = [];
|
||||||
let params = [];
|
let params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
where.push('username LIKE ?');
|
where.push(`username ILIKE $${paramIndex++}`);
|
||||||
params.push(`%${user}%`);
|
params.push(`%${user}%`);
|
||||||
}
|
}
|
||||||
if (action) {
|
if (action) {
|
||||||
where.push('action = ?');
|
where.push(`action = $${paramIndex++}`);
|
||||||
params.push(action);
|
params.push(action);
|
||||||
}
|
}
|
||||||
if (entityType) {
|
if (entityType) {
|
||||||
where.push('entity_type = ?');
|
where.push(`entity_type = $${paramIndex++}`);
|
||||||
params.push(entityType);
|
params.push(entityType);
|
||||||
}
|
}
|
||||||
if (startDate) {
|
if (startDate) {
|
||||||
where.push('created_at >= ?');
|
where.push(`created_at >= $${paramIndex++}`);
|
||||||
params.push(startDate);
|
params.push(startDate);
|
||||||
}
|
}
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
where.push('created_at <= ?');
|
where.push(`created_at <= $${paramIndex++}`);
|
||||||
params.push(endDate + ' 23:59:59');
|
params.push(endDate + ' 23:59:59');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,36 +53,25 @@ function createAuditLogRouter(db, requireAuth, requireRole) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get total count
|
// Get total count
|
||||||
const countRow = await new Promise((resolve, reject) => {
|
const countResult = await pool.query(
|
||||||
db.get(
|
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
|
||||||
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
|
params
|
||||||
params,
|
);
|
||||||
(err, row) => {
|
const total = parseInt(countResult.rows[0].total);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(row);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get paginated results
|
// Get paginated results
|
||||||
const rows = await new Promise((resolve, reject) => {
|
const dataResult = await pool.query(
|
||||||
db.all(
|
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
[...params, pageSize, offset]
|
||||||
[...params, pageSize, offset],
|
);
|
||||||
(err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
logs: rows,
|
logs: dataResult.rows,
|
||||||
pagination: {
|
pagination: {
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
total: countRow.total,
|
total: total,
|
||||||
totalPages: Math.ceil(countRow.total / pageSize)
|
totalPages: Math.ceil(total / pageSize)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -91,16 +83,9 @@ function createAuditLogRouter(db, requireAuth, requireRole) {
|
|||||||
// Get distinct action types for filter dropdown
|
// Get distinct action types for filter dropdown
|
||||||
router.get('/actions', async (req, res) => {
|
router.get('/actions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await new Promise((resolve, reject) => {
|
const { rows } = await pool.query(
|
||||||
db.all(
|
'SELECT DISTINCT action FROM audit_logs ORDER BY action'
|
||||||
'SELECT DISTINCT action FROM audit_logs ORDER BY action',
|
);
|
||||||
(err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(rows.map(r => r.action));
|
res.json(rows.map(r => r.action));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Audit log actions error:', err);
|
console.error('Audit log actions error:', err);
|
||||||
|
|||||||
@@ -2,12 +2,36 @@
|
|||||||
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 rateLimit = require('express-rate-limit');
|
||||||
|
const pool = require('../db');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
function createAuthRouter(db, logAudit) {
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20, // 20 attempts per window
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many login attempts. Please try again in 15 minutes.' }
|
||||||
|
});
|
||||||
|
|
||||||
|
function createAuthRouter(logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Login
|
/**
|
||||||
router.post('/login', async (req, res) => {
|
* POST /api/auth/login
|
||||||
|
*
|
||||||
|
* Authenticates a user with username and password, creates a session,
|
||||||
|
* and sets an httpOnly session cookie. Rate-limited to 20 attempts per 15 minutes.
|
||||||
|
*
|
||||||
|
* @body {string} username - The user's login username
|
||||||
|
* @body {string} password - The user's password
|
||||||
|
* @returns {object} 200 - { message: 'Login successful', user: { id, username, email, group } }
|
||||||
|
* @returns {object} 400 - { error: 'Username and password are required' }
|
||||||
|
* @returns {object} 401 - { error: 'Invalid username or password' } | { error: 'Account is disabled' }
|
||||||
|
* @returns {object} 429 - { error: 'Too many login attempts. Please try again in 15 minutes.' }
|
||||||
|
* @returns {object} 500 - { error: 'Login failed' }
|
||||||
|
*/
|
||||||
|
router.post('/login', loginLimiter, async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
@@ -16,19 +40,14 @@ function createAuthRouter(db, logAudit) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Find user
|
// Find user
|
||||||
const user = await new Promise((resolve, reject) => {
|
const { rows } = await pool.query(
|
||||||
db.get(
|
'SELECT * FROM users WHERE username = $1',
|
||||||
'SELECT * FROM users WHERE username = ?',
|
[username]
|
||||||
[username],
|
);
|
||||||
(err, row) => {
|
const user = rows[0];
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(row);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
userId: null,
|
userId: null,
|
||||||
username: username,
|
username: username,
|
||||||
action: 'login_failed',
|
action: 'login_failed',
|
||||||
@@ -41,7 +60,7 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: username,
|
username: username,
|
||||||
action: 'login_failed',
|
action: 'login_failed',
|
||||||
@@ -56,7 +75,7 @@ function createAuthRouter(db, logAudit) {
|
|||||||
// Verify password
|
// Verify password
|
||||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: username,
|
username: username,
|
||||||
action: 'login_failed',
|
action: 'login_failed',
|
||||||
@@ -73,28 +92,16 @@ function createAuthRouter(db, logAudit) {
|
|||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
await new Promise((resolve, reject) => {
|
await pool.query(
|
||||||
db.run(
|
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES ($1, $2, $3)',
|
||||||
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)',
|
[sessionId, user.id, expiresAt.toISOString()]
|
||||||
[sessionId, user.id, expiresAt.toISOString()],
|
);
|
||||||
(err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
await new Promise((resolve, reject) => {
|
await pool.query(
|
||||||
db.run(
|
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
|
[user.id]
|
||||||
[user.id],
|
);
|
||||||
(err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set cookie
|
// Set cookie
|
||||||
res.cookie('session_id', sessionId, {
|
res.cookie('session_id', sessionId, {
|
||||||
@@ -104,13 +111,13 @@ function createAuthRouter(db, logAudit) {
|
|||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
});
|
});
|
||||||
|
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
action: 'login',
|
action: 'login',
|
||||||
entityType: 'auth',
|
entityType: 'auth',
|
||||||
entityId: null,
|
entityId: null,
|
||||||
details: { role: user.role },
|
details: { group: user.user_group },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +127,8 @@ function createAuthRouter(db, logAudit) {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role
|
group: user.user_group,
|
||||||
|
teams: user.bu_teams ? user.bu_teams.split(',').filter(Boolean) : []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -129,33 +137,44 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout
|
/**
|
||||||
|
* POST /api/auth/logout
|
||||||
|
*
|
||||||
|
* Ends the current user session by deleting it from the database
|
||||||
|
* and clearing the session cookie.
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - { message: 'Logged out successfully' }
|
||||||
|
*/
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
const sessionId = req.cookies?.session_id;
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// Look up user before deleting session
|
// Look up user before deleting session
|
||||||
const session = await new Promise((resolve) => {
|
let session = null;
|
||||||
db.get(
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
`SELECT u.id as user_id, u.username FROM sessions s
|
`SELECT u.id as user_id, u.username FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_id = ?`,
|
WHERE s.session_id = $1`,
|
||||||
[sessionId],
|
[sessionId]
|
||||||
(err, row) => resolve(row || null)
|
|
||||||
);
|
);
|
||||||
});
|
session = rows[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical — proceed with logout
|
||||||
|
}
|
||||||
|
|
||||||
// Delete session from database
|
// Delete session from database
|
||||||
await new Promise((resolve) => {
|
try {
|
||||||
db.run(
|
await pool.query(
|
||||||
'DELETE FROM sessions WHERE session_id = ?',
|
'DELETE FROM sessions WHERE session_id = $1',
|
||||||
[sessionId],
|
[sessionId]
|
||||||
() => resolve()
|
|
||||||
);
|
);
|
||||||
});
|
} catch (err) {
|
||||||
|
// Non-critical — proceed with logout
|
||||||
|
}
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
logAudit(db, {
|
logAudit({
|
||||||
userId: session.user_id,
|
userId: session.user_id,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
action: 'logout',
|
action: 'logout',
|
||||||
@@ -172,7 +191,16 @@ function createAuthRouter(db, logAudit) {
|
|||||||
res.json({ message: 'Logged out successfully' });
|
res.json({ message: 'Logged out successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current user
|
/**
|
||||||
|
* GET /api/auth/me
|
||||||
|
*
|
||||||
|
* Returns the currently authenticated user based on the session cookie.
|
||||||
|
* Clears the cookie and returns 401 if the session is expired or the account is disabled.
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - { user: { id, username, email, group } }
|
||||||
|
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
|
||||||
|
* @returns {object} 500 - { error: 'Failed to get user' }
|
||||||
|
*/
|
||||||
router.get('/me', async (req, res) => {
|
router.get('/me', async (req, res) => {
|
||||||
const sessionId = req.cookies?.session_id;
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
@@ -181,19 +209,15 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await new Promise((resolve, reject) => {
|
const { rows } = await pool.query(
|
||||||
db.get(
|
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
FROM sessions s
|
||||||
FROM sessions s
|
JOIN users u ON s.user_id = u.id
|
||||||
JOIN users u ON s.user_id = u.id
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
||||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
[sessionId]
|
||||||
[sessionId],
|
);
|
||||||
(err, row) => {
|
|
||||||
if (err) reject(err);
|
const session = rows[0];
|
||||||
else resolve(row);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.clearCookie('session_id');
|
res.clearCookie('session_id');
|
||||||
@@ -210,7 +234,8 @@ function createAuthRouter(db, logAudit) {
|
|||||||
id: session.user_id,
|
id: session.user_id,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
role: session.role
|
group: session.user_group,
|
||||||
|
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,23 +244,136 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up expired sessions (admin only)
|
/**
|
||||||
router.post('/cleanup-sessions', async (req, res) => {
|
* GET /api/auth/profile
|
||||||
// Basic auth check - require a valid session to call this
|
*
|
||||||
const sessionId = req.cookies?.session_id;
|
* Returns the full profile for the currently authenticated user.
|
||||||
if (!sessionId) {
|
* Queries the database for up-to-date account details including
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
* creation date and last login timestamp.
|
||||||
}
|
*
|
||||||
|
* @returns {object} 200 - { id, username, email, group, created_at, last_login }
|
||||||
|
* @returns {object} 401 - { error: 'Account is disabled' } (clears session cookie)
|
||||||
|
* @returns {object} 500 - { error: 'Failed to fetch profile' }
|
||||||
|
*/
|
||||||
|
router.get('/profile', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
const { rows } = await pool.query(
|
||||||
db.run(
|
'SELECT id, username, email, user_group, created_at, last_login, is_active FROM users WHERE id = $1',
|
||||||
"DELETE FROM sessions WHERE expires_at < datetime('now')",
|
[req.user.id]
|
||||||
(err) => {
|
);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
const user = rows[0];
|
||||||
}
|
|
||||||
);
|
if (!user || !user.is_active) {
|
||||||
|
res.clearCookie('session_id');
|
||||||
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
group: user.user_group,
|
||||||
|
created_at: user.created_at,
|
||||||
|
last_login: user.last_login
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile fetch error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiter for password change — 5 attempts per 15-minute window, keyed by session cookie
|
||||||
|
const passwordChangeLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.cookies?.session_id || req.ip,
|
||||||
|
message: { error: 'Too many password change attempts. Please try again later.' }
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/change-password
|
||||||
|
*
|
||||||
|
* Allows the authenticated user to change their own password.
|
||||||
|
* Rate-limited to 5 attempts per 15-minute window per session.
|
||||||
|
*
|
||||||
|
* @body {string} currentPassword - The user's current password
|
||||||
|
* @body {string} newPassword - The desired new password (min 8 characters)
|
||||||
|
* @returns {object} 200 - { message: 'Password changed successfully' }
|
||||||
|
* @returns {object} 400 - { error: 'Current password and new password are required' } | { error: 'New password must be at least 8 characters' }
|
||||||
|
* @returns {object} 401 - { error: 'Account is disabled' } | { error: 'Current password is incorrect' }
|
||||||
|
* @returns {object} 429 - { error: 'Too many password change attempts. Please try again later.' }
|
||||||
|
* @returns {object} 500 - { error: 'Failed to change password' }
|
||||||
|
*/
|
||||||
|
router.post('/change-password', requireAuth(), passwordChangeLimiter, async (req, res) => {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch user's password hash and active status
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT password_hash, is_active FROM users WHERE id = $1',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||||
|
if (!validPassword) {
|
||||||
|
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update
|
||||||
|
const newHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE users SET password_hash = $1 WHERE id = $2',
|
||||||
|
[newHash, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'password_change',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: null,
|
||||||
|
details: null,
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Password changed successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Password change error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to change password' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/cleanup-sessions
|
||||||
|
*
|
||||||
|
* Deletes all expired sessions from the database. Requires Admin group.
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - { message: 'Expired sessions cleaned up' }
|
||||||
|
* @returns {object} 401 - { error: 'Authentication required' }
|
||||||
|
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
|
||||||
|
* @returns {object} 500 - { error: 'Cleanup failed' }
|
||||||
|
*/
|
||||||
|
router.post('/cleanup-sessions', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query("DELETE FROM sessions WHERE expires_at < NOW()");
|
||||||
res.json({ message: 'Expired sessions cleaned up' });
|
res.json({ message: 'Expired sessions cleaned up' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Session cleanup error:', err);
|
console.error('Session cleanup error:', err);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user