Compare commits
108 Commits
feature/iv
...
feature/cv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| 3fb20c147d | |||
| f2e6069c08 | |||
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e |
@@ -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
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -37,10 +37,20 @@ frontend.pid
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
backend/uploads/temp/
|
backend/uploads/temp/
|
||||||
claude.md
|
|
||||||
claude_status.md
|
|
||||||
feature_request*.md
|
feature_request*.md
|
||||||
|
|
||||||
|
# Planning docs
|
||||||
|
docs/aeo-compliance-ui-plan.md
|
||||||
|
docs/aeo-compliance-wireframe.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
|
||||||
|
|
||||||
|
# Kiro implementation summary (internal only)
|
||||||
|
docs/kiro-implementation-summary.md
|
||||||
|
|||||||
16
.kiro/hooks/check-component-conventions.kiro.hook
Normal file
16
.kiro/hooks/check-component-conventions.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Check Component Conventions",
|
||||||
|
"description": "On save of files in frontend/src/components/, verifies the component follows project conventions and flags deviations as inline comments.",
|
||||||
|
"version": "1",
|
||||||
|
"when": {
|
||||||
|
"type": "fileEdited",
|
||||||
|
"patterns": [
|
||||||
|
"frontend/src/components/**/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "Review the saved component file and verify it follows these project conventions:\n\n1. Functional component with hooks (no class components)\n2. Uses Lucide icons for iconography (not raw SVGs or other icon libraries)\n3. Uses inline styles or existing CSS classes from App.css (no CSS modules, no styled-components)\n4. Fetches data with fetch() using relative API paths and credentials: 'include' (no axios, no absolute URLs)\n5. Handles loading and error states when fetching data\n\nFor any deviations found, add inline comments in the code flagging the issue, e.g. // ⚠️ CONVENTION: Use lucide-react icons instead of raw SVGs\n\nOnly flag actual deviations. Do not modify working logic or refactor the component."
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.kiro/hooks/jsdoc-route-docs.kiro.hook
Normal file
16
.kiro/hooks/jsdoc-route-docs.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "JSDoc Route Documentation",
|
||||||
|
"description": "On save of files in backend/routes/, ensures every exported route handler has a JSDoc comment documenting the HTTP method, path, query parameters, request body shape, and response shape. Uses the existing documentation style in the file. Does not add comments to internal helper functions.",
|
||||||
|
"version": "1",
|
||||||
|
"when": {
|
||||||
|
"type": "fileEdited",
|
||||||
|
"patterns": [
|
||||||
|
"backend/routes/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "Review the saved route file and ensure every exported route handler (e.g., router.get, router.post, router.put, router.patch, router.delete) has a JSDoc comment directly above it documenting: the HTTP method, the route path, any query parameters, the request body shape (if applicable), and the response shape. Match the existing documentation style already used in the file. Do NOT add JSDoc comments to internal helper functions that are not route handlers. Only add missing documentation — do not modify or remove existing JSDoc comments that are already correct."
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.kiro/hooks/sqlite3-safety-check.kiro.hook
Normal file
16
.kiro/hooks/sqlite3-safety-check.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "SQLite3 Safety Check",
|
||||||
|
"description": "On save of files containing db.run, db.get, or db.all, verifies all sqlite3 calls use parameterized queries (? placeholders) instead of string concatenation, handle the error parameter first in every callback, and use hardcoded table/column names. Flags violations as inline comments prefixed with \"// FIXME:\".",
|
||||||
|
"version": "1",
|
||||||
|
"when": {
|
||||||
|
"type": "fileEdited",
|
||||||
|
"patterns": [
|
||||||
|
"backend/**/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "The saved file may contain sqlite3 calls (db.run, db.get, or db.all). Scan the file and verify all sqlite3 calls follow these rules:\n\n1. Parameterized queries only: All SQL queries must use ? placeholders for dynamic values. Never use string concatenation or template literals to inject values into SQL strings.\n2. Error-first callbacks: Every callback passed to db.run, db.get, or db.all must handle the error parameter first (e.g., `if (err) { ... }`).\n3. Hardcoded table/column names: All table and column names in SQL strings must be hardcoded string literals, never sourced from variables or parameters.\n\nIf the file does not contain any db.run, db.get, or db.all calls, skip the check silently.\n\nFor any violations found, add an inline comment on the offending line prefixed with \"// FIXME:\" describing the specific issue. Do not modify any other code."
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.kiro/hooks/verify-migration-pattern.kiro.hook
Normal file
16
.kiro/hooks/verify-migration-pattern.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Verify Migration Pattern",
|
||||||
|
"description": "On save or create of migration files (migrate*.js), verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions. Compares against existing migrations for style consistency.",
|
||||||
|
"version": "1",
|
||||||
|
"when": {
|
||||||
|
"type": "fileEdited",
|
||||||
|
"patterns": [
|
||||||
|
"**/migrate*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "A migration file was just saved. Review the edited file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the edited file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.kiro/hooks/verify-new-migration.kiro.hook
Normal file
16
.kiro/hooks/verify-new-migration.kiro.hook
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Verify New Migration",
|
||||||
|
"description": "On creation of new migration files in backend/migrations/, verifies the migration follows existing project patterns: uses CREATE TABLE IF NOT EXISTS, includes explicit column types, adds appropriate indexes, and wraps multiple statements in transactions.",
|
||||||
|
"version": "1",
|
||||||
|
"when": {
|
||||||
|
"type": "fileCreated",
|
||||||
|
"patterns": [
|
||||||
|
"**/migrations/*.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "askAgent",
|
||||||
|
"prompt": "A new migration file was just created. Review the file and verify it follows the existing migration pattern used in this project. Check the existing migrations in backend/migrations/ for reference, then verify the new file:\n\n1. Uses CREATE TABLE IF NOT EXISTS (not just CREATE TABLE)\n2. Includes all columns with explicit SQLite types (TEXT, INTEGER, REAL, etc.)\n3. Adds appropriate indexes for foreign keys and frequently queried columns\n4. Wraps operations in a serialized transaction (db.serialize + db.run(\"BEGIN TRANSACTION\") / COMMIT) if there are multiple statements\n5. Follows the same callback-based db.run() style as existing migrations\n6. Includes proper error handling\n\nCompare the file against the existing migrations in backend/migrations/ for style consistency. Report any deviations or issues found."
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.kiro/specs/batch-finding-disposition/.config.kiro
Normal file
1
.kiro/specs/batch-finding-disposition/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "9f5c16d4-43ea-4d7a-beb1-9329d79a5acc", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
331
.kiro/specs/batch-finding-disposition/design.md
Normal file
331
.kiro/specs/batch-finding-disposition/design.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Design Document: Batch Finding Disposition
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds multi-select capability to the Vulnerability Triage page's findings table, enabling engineers to select multiple findings and add them all to the Ivanti Queue in a single operation. The current flow requires clicking each finding individually, configuring a popover, and submitting one at a time — this design replaces that with a batch selection toolbar and a bulk-add API endpoint while preserving the existing single-select popover for one-off additions.
|
||||||
|
|
||||||
|
The design touches three layers:
|
||||||
|
1. A new `POST /api/ivanti/todo-queue/batch` backend endpoint that accepts an array of findings in a single transactional insert
|
||||||
|
2. Frontend multi-select state management (selection set, shift-click range select, select-all)
|
||||||
|
3. A sticky Selection Toolbar component with workflow type toggles, vendor input, and batch submit
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature extends the existing Ivanti Queue subsystem without introducing new services or tables. The `ivanti_todo_queue` table schema is unchanged — batch add simply inserts multiple rows in a single SQLite transaction.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Frontend ["Frontend (ReportingPage.js)"]
|
||||||
|
CB[Row Checkboxes] --> SS[Selection State<br/>Set of finding IDs]
|
||||||
|
SS --> ST[Selection Toolbar]
|
||||||
|
ST -->|"Add to Queue"| BA[Batch API Call]
|
||||||
|
CB -->|"No selection + click"| PO[AddToQueuePopover<br/>existing single-add]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend ["Backend (ivantiTodoQueue.js)"]
|
||||||
|
BA -->|"POST /batch"| BH[Batch Handler]
|
||||||
|
BH -->|"BEGIN TRANSACTION"| DB[(ivanti_todo_queue)]
|
||||||
|
BH -->|"logAudit()"| AL[(audit_logs)]
|
||||||
|
PO -->|"POST /"| SH[Single Handler<br/>existing]
|
||||||
|
SH --> DB
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **No new database table or migration** — batch insert reuses the existing `ivanti_todo_queue` schema. Each finding becomes its own row, identical to what the single-add endpoint creates.
|
||||||
|
|
||||||
|
2. **SQLite transaction for atomicity** — all findings in a batch are inserted inside `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the entire batch is rolled back. This satisfies the all-or-nothing requirement (Req 3.7, 3.8, 3.11).
|
||||||
|
|
||||||
|
3. **Selection state lives in the VulnerabilityTriagePage component** — a `Set<string>` of finding IDs managed via `useState`. This keeps the selection co-located with the existing `findings`, `sorted`, `filtered`, and `queueItems` state. No new context or global store needed.
|
||||||
|
|
||||||
|
4. **Dual-mode checkbox behavior** — when no findings are selected, clicking a checkbox opens the existing `AddToQueuePopover` (preserving the single-select flow per Req 5). Once one or more findings are selected, subsequent checkbox clicks toggle selection instead. This is the simplest UX that satisfies both Req 1 and Req 5.
|
||||||
|
|
||||||
|
5. **Selection Toolbar as inline sticky bar** — rendered between the table header controls and the `<table>` element, using `position: sticky` to stay visible during scroll. This avoids portal complexity and keeps the toolbar visually anchored to the table.
|
||||||
|
|
||||||
|
6. **200-item batch limit** — prevents oversized payloads and keeps SQLite transaction time reasonable. The findings table typically has 200-800 rows, so this covers most realistic batch sizes.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### `POST /api/ivanti/todo-queue/batch`
|
||||||
|
|
||||||
|
Added to the existing `createIvantiTodoQueueRouter` factory in `backend/routes/ivantiTodoQueue.js`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"finding_id": "FID-12345",
|
||||||
|
"finding_title": "OpenSSL vulnerability",
|
||||||
|
"cves": ["CVE-2024-0001"],
|
||||||
|
"ip_address": "10.0.1.50"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"vendor": "Juniper"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
- `findings` — array, 1–200 items
|
||||||
|
- Each item: `finding_id` required, non-empty string; `finding_title`, `cves`, `ip_address` optional
|
||||||
|
- `workflow_type` — must be `FP`, `Archer`, or `CARD`
|
||||||
|
- `vendor` — required non-empty string (≤200 chars) for FP/Archer; ignored for CARD
|
||||||
|
- If any finding fails validation, reject entire batch with 400
|
||||||
|
|
||||||
|
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"user_id": 1,
|
||||||
|
"finding_id": "FID-12345",
|
||||||
|
"finding_title": "OpenSSL vulnerability",
|
||||||
|
"cves_json": "[\"CVE-2024-0001\"]",
|
||||||
|
"ip_address": "10.0.1.50",
|
||||||
|
"vendor": "Juniper",
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2025-01-15 12:00:00",
|
||||||
|
"updated_at": "2025-01-15 12:00:00",
|
||||||
|
"cves": ["CVE-2024-0001"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
- `400` — validation failure (descriptive message)
|
||||||
|
- `401` — not authenticated
|
||||||
|
- `403` — insufficient permissions
|
||||||
|
- `500` — database transaction failure (all inserts rolled back)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### Selection State (in VulnerabilityTriagePage)
|
||||||
|
|
||||||
|
New state variables added to the main component:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set()); // Set<string> of finding IDs
|
||||||
|
const [lastClickedId, setLastClickedId] = useState(null); // for shift-click range select
|
||||||
|
const [batchSubmitting, setBatchSubmitting] = useState(false); // loading state
|
||||||
|
const [batchError, setBatchError] = useState(null); // error message from failed batch
|
||||||
|
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
|
||||||
|
const [batchVendor, setBatchVendor] = useState('');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Checkbox Click Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
onClick(finding, event):
|
||||||
|
if finding is already queued → return (no-op)
|
||||||
|
if selectedIds.size === 0 AND not shift-click:
|
||||||
|
→ open AddToQueuePopover (existing single-select flow)
|
||||||
|
else:
|
||||||
|
if shift-click AND lastClickedId exists:
|
||||||
|
→ range-select all visible findings between lastClickedId and finding.id
|
||||||
|
else:
|
||||||
|
→ toggle finding.id in selectedIds
|
||||||
|
set lastClickedId = finding.id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SelectionToolbar Component
|
||||||
|
|
||||||
|
Rendered inline above the table when `selectedIds.size > 0`. Contains:
|
||||||
|
- Selected count badge
|
||||||
|
- "Clear Selection" button
|
||||||
|
- Workflow type toggle buttons (FP / Archer / CARD) with existing color scheme
|
||||||
|
- Vendor text input (hidden when CARD selected)
|
||||||
|
- "Add to Queue" submit button (disabled until valid)
|
||||||
|
- Error message display area
|
||||||
|
|
||||||
|
#### Selection Persistence Across Filters
|
||||||
|
|
||||||
|
When `columnFilters`, `actionFilter`, or `excFilter` change, the selection set is pruned to only include IDs that remain in the `filtered` array. This is done via a `useEffect` that intersects `selectedIds` with the current filtered finding IDs.
|
||||||
|
|
||||||
|
#### Select All / Deselect All
|
||||||
|
|
||||||
|
The checkbox column header renders a "Select All" control when `selectedIds.size > 0` or as a standard header otherwise. Clicking it:
|
||||||
|
- If not all visible non-queued findings are selected → selects all visible non-queued findings
|
||||||
|
- If all are already selected → deselects all
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Database Schema (unchanged)
|
||||||
|
|
||||||
|
The `ivanti_todo_queue` table is reused as-is:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_todo_queue (
|
||||||
|
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
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each batch-added finding creates one row, identical to single-add. The `vendor` and `workflow_type` are shared across all findings in a batch (set once in the toolbar).
|
||||||
|
|
||||||
|
### API Request Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchAddRequest {
|
||||||
|
findings: Array<{
|
||||||
|
finding_id: string (required, non-empty, trimmed)
|
||||||
|
finding_title: string | null (max 500 chars)
|
||||||
|
cves: string[] | null
|
||||||
|
ip_address: string | null (max 64 chars)
|
||||||
|
}> (1–200 items)
|
||||||
|
workflow_type: "FP" | "Archer" | "CARD"
|
||||||
|
vendor: string (required for FP/Archer, ≤200 chars; empty/absent for CARD)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend State Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
Selection State:
|
||||||
|
selectedIds: Set<string> — finding IDs currently selected
|
||||||
|
lastClickedId: string | null — last checkbox clicked (for shift-range)
|
||||||
|
batchSubmitting: boolean — true while POST /batch in flight
|
||||||
|
batchError: string | null — error message from last failed batch
|
||||||
|
batchWorkflowType: "FP" | "Archer" | "CARD"
|
||||||
|
batchVendor: string
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Selection pruning preserves only visible findings
|
||||||
|
|
||||||
|
*For any* set of selected finding IDs and any set of currently visible (filtered) finding IDs, pruning the selection after a filter change should produce exactly the intersection of the two sets — every ID in the result is both selected and visible, and no visible selected ID is lost.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.4**
|
||||||
|
|
||||||
|
### Property 2: Select-all produces the complete visible non-queued set
|
||||||
|
|
||||||
|
*For any* list of visible findings and any set of queued finding IDs, the select-all operation should produce a set containing exactly the IDs of visible findings that are not in the queued set — no queued findings included, no non-queued visible findings omitted.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.6**
|
||||||
|
|
||||||
|
### Property 3: Submit button enabled state matches validation rule
|
||||||
|
|
||||||
|
*For any* workflow type (FP, Archer, CARD) and any vendor string, the "Add to Queue" button should be enabled if and only if the workflow type is CARD, or the vendor string trimmed is non-empty. No other combination should enable the button.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.7**
|
||||||
|
|
||||||
|
### Property 4: Batch size validation accepts only 1–200 items
|
||||||
|
|
||||||
|
*For any* integer N representing the number of findings in a batch request, the endpoint should accept the request (assuming all other fields are valid) if and only if 1 ≤ N ≤ 200. Arrays of size 0 or greater than 200 should be rejected with a 400 response.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.2**
|
||||||
|
|
||||||
|
### Property 5: Vendor validation is conditional on workflow type
|
||||||
|
|
||||||
|
*For any* workflow type and any vendor string, the batch endpoint should require a non-empty vendor of 200 characters or fewer when workflow_type is FP or Archer, and should accept any vendor value (including empty or absent) when workflow_type is CARD.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.5, 3.6**
|
||||||
|
|
||||||
|
### Property 6: One invalid finding rejects the entire batch
|
||||||
|
|
||||||
|
*For any* valid batch of findings, if exactly one finding is replaced with an invalid finding (empty finding_id, missing finding_id, or non-string finding_id) at any position in the array, the entire batch should be rejected with a 400 response and zero rows should be inserted.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3, 3.8**
|
||||||
|
|
||||||
|
### Property 7: Successful batch response matches request
|
||||||
|
|
||||||
|
*For any* valid batch request of N findings, the 201 response should contain exactly N items, each with a unique numeric `id`, and the set of `finding_id` values in the response should equal the set of `finding_id` values in the request.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.9**
|
||||||
|
|
||||||
|
### Property 8: Shift-click range select covers exactly the between range
|
||||||
|
|
||||||
|
*For any* sorted list of visible findings, any last-clicked index, and any current-click index, the shift-click range select should produce a set containing exactly the non-queued findings between those two indices (inclusive), regardless of which index is larger.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.1**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend Errors
|
||||||
|
|
||||||
|
| Scenario | Response | Behavior |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| Empty findings array or > 200 items | 400 | `{ error: "findings array must contain 1-200 items." }` |
|
||||||
|
| Any finding missing/empty finding_id | 400 | `{ error: "Each finding must have a non-empty finding_id string." }` |
|
||||||
|
| Invalid workflow_type | 400 | `{ error: "workflow_type must be FP, Archer, or CARD." }` |
|
||||||
|
| Missing vendor for FP/Archer | 400 | `{ error: "vendor is required for FP and Archer workflows." }` |
|
||||||
|
| Vendor exceeds 200 chars | 400 | `{ error: "vendor must be under 200 chars." }` |
|
||||||
|
| Not authenticated | 401 | Standard auth middleware response |
|
||||||
|
| Insufficient permissions (Read_Only) | 403 | Standard group middleware response |
|
||||||
|
| SQLite transaction failure | 500 | Transaction rolled back, `{ error: "Internal server error." }` |
|
||||||
|
|
||||||
|
### Frontend Errors
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Batch POST returns 4xx/5xx | Display error message in Selection Toolbar, keep selection intact |
|
||||||
|
| Network failure during batch POST | Display "Network error — please try again" in toolbar, keep selection |
|
||||||
|
| Batch POST timeout | Same as network failure handling |
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- **Duplicate finding_ids in batch**: Allowed — the same finding could appear on multiple hosts. The backend does not enforce uniqueness on finding_id within a batch.
|
||||||
|
- **Finding already in queue**: The frontend prevents selecting already-queued findings (checkbox is disabled), so duplicates should not reach the API. No server-side duplicate check is added to keep the endpoint simple.
|
||||||
|
- **Concurrent batch submissions**: The SQLite transaction serializes writes. If two users submit overlapping batches, both succeed independently (each user has their own queue scoped by user_id).
|
||||||
|
- **Selection of 0 findings**: The "Add to Queue" button is only rendered when selectedIds.size > 0, so this state cannot be reached through the UI. The backend still validates for it.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Focus on specific examples and edge cases:
|
||||||
|
|
||||||
|
- **Backend validation**: Test each validation rule with concrete valid/invalid inputs (empty array, 201 items, missing finding_id, invalid workflow_type, vendor edge cases)
|
||||||
|
- **Transaction rollback**: Mock a database error mid-insert, verify no rows are committed
|
||||||
|
- **Frontend checkbox dual-mode**: Test that clicking with empty selection opens popover, clicking with existing selection toggles selection
|
||||||
|
- **Toolbar visibility**: Test toolbar appears/disappears based on selection state
|
||||||
|
- **Clear selection**: Test that clear button empties selection
|
||||||
|
- **Escape key**: Test that Escape clears selection
|
||||||
|
- **Select-all toggle**: Test select-all and deselect-all behavior
|
||||||
|
- **Queue panel update**: Test that successful batch updates queueItems state
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Using [fast-check](https://github.com/dubzzz/fast-check) for JavaScript property-based testing.
|
||||||
|
|
||||||
|
Each property test runs a minimum of 100 iterations with randomly generated inputs. Tests are tagged with their corresponding design property.
|
||||||
|
|
||||||
|
| Property | What's Generated | What's Verified |
|
||||||
|
|----------|-----------------|-----------------|
|
||||||
|
| Property 1: Selection pruning | Random sets of selected IDs and filtered IDs | Result = intersection of both sets |
|
||||||
|
| Property 2: Select-all | Random finding lists and queued ID sets | Result = visible IDs minus queued IDs |
|
||||||
|
| Property 3: Submit enabled | Random workflow types and vendor strings | Enabled iff CARD or non-empty vendor |
|
||||||
|
| Property 4: Batch size | Random integers 0–300 | Accepted iff 1 ≤ N ≤ 200 |
|
||||||
|
| Property 5: Vendor validation | Random workflow types and vendor strings (0–300 chars) | Conditional acceptance rule |
|
||||||
|
| Property 6: Invalid finding rejection | Valid batches with one injected invalid item | Entire batch rejected, 0 rows inserted |
|
||||||
|
| Property 7: Response shape | Valid batches of 1–50 findings | Response count matches, IDs match |
|
||||||
|
| Property 8: Range select | Random sorted lists and two index positions | Correct range of non-queued findings |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- End-to-end batch submission: POST valid batch, verify rows in database, verify response shape
|
||||||
|
- Auth enforcement: Verify 401 for unauthenticated, 403 for Read_Only users
|
||||||
|
- Transaction atomicity: Verify rollback on database error
|
||||||
|
- Frontend → Backend: Mock API, verify correct request payload from toolbar submit
|
||||||
97
.kiro/specs/batch-finding-disposition/requirements.md
Normal file
97
.kiro/specs/batch-finding-disposition/requirements.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Batch Finding Disposition feature adds multi-select capability to the Vulnerability Triage page's findings table, allowing engineers to select multiple findings at once and add them all to the Ivanti Queue with a shared workflow type and vendor in a single operation. Currently, each finding must be individually clicked, configured via a popover, and submitted — a repetitive process that slows down triage when working through many findings. This feature replaces that one-at-a-time flow with a batch selection toolbar and a bulk-add API endpoint.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Findings_Table**: The sortable, filterable table of Ivanti host findings rendered in the VulnerabilityTriagePage component (`ReportingPage.js`), where each row represents one finding.
|
||||||
|
- **Selection_Toolbar**: A floating toolbar that appears above the Findings_Table when one or more findings are selected via their row checkboxes, displaying the count of selected findings and batch action controls.
|
||||||
|
- **Batch_Add_Panel**: The inline panel within the Selection_Toolbar that provides workflow type selection (FP, Archer, CARD), an optional vendor input, and a submit button for adding all selected findings to the queue in one operation.
|
||||||
|
- **Todo_Queue_API**: The backend Express router at `/api/ivanti/todo-queue` that manages CRUD operations on the `ivanti_todo_queue` table.
|
||||||
|
- **Queue_Panel**: The existing right-side slide-out panel (`QueuePanel` component) that displays the user's current queue items grouped by vendor.
|
||||||
|
- **Workflow_Type**: One of three disposition categories: FP (false positive), Archer (risk acceptance), or CARD (remediation card). Each finding added to the queue is assigned exactly one Workflow_Type.
|
||||||
|
- **Finding**: A single Ivanti host vulnerability record containing an ID, title, CVEs, IP address, severity, and other metadata.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Multi-Select Findings via Row Checkboxes
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to select multiple findings using checkboxes so that I can batch-process them instead of handling each one individually.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Findings_Table SHALL render a checkbox in the first column of each finding row that is not already in the queue.
|
||||||
|
2. WHEN a user clicks a finding row's checkbox, THE Findings_Table SHALL toggle that Finding's selected state without opening the AddToQueuePopover.
|
||||||
|
3. WHEN one or more findings are selected, THE Findings_Table SHALL visually distinguish selected rows from unselected rows using a highlighted background.
|
||||||
|
4. THE Findings_Table SHALL maintain the selected findings set across sort and filter changes, removing only findings that are no longer visible after filtering.
|
||||||
|
5. WHEN a finding is already in the queue, THE Findings_Table SHALL display that row's checkbox as checked and disabled, preventing re-selection.
|
||||||
|
6. WHILE findings are selected, THE Findings_Table SHALL display a "Select All (visible)" control in the checkbox column header that selects all visible, non-queued findings.
|
||||||
|
7. WHEN the "Select All" control is clicked while all visible non-queued findings are already selected, THE Findings_Table SHALL deselect all findings.
|
||||||
|
|
||||||
|
### Requirement 2: Selection Toolbar with Batch Actions
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want a toolbar that appears when I have findings selected so that I can see how many are selected and take batch actions on them.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN one or more findings are selected, THE Selection_Toolbar SHALL appear as a sticky bar above the Findings_Table header row.
|
||||||
|
2. THE Selection_Toolbar SHALL display the count of currently selected findings.
|
||||||
|
3. THE Selection_Toolbar SHALL provide a "Clear Selection" button that deselects all findings and hides the Selection_Toolbar.
|
||||||
|
4. THE Selection_Toolbar SHALL provide workflow type toggle buttons for FP, Archer, and CARD, matching the existing color scheme (FP: amber, Archer: blue, CARD: green).
|
||||||
|
5. WHEN the selected Workflow_Type is FP or Archer, THE Selection_Toolbar SHALL display a vendor text input field.
|
||||||
|
6. WHEN the selected Workflow_Type is CARD, THE Selection_Toolbar SHALL hide the vendor input field and display a "No vendor required" indicator.
|
||||||
|
7. THE Selection_Toolbar SHALL provide an "Add to Queue" submit button that is enabled only when a Workflow_Type is selected and vendor is provided (for FP/Archer) or Workflow_Type is CARD.
|
||||||
|
8. THE Selection_Toolbar SHALL follow the existing dark theme design system (monospace fonts, dark gradient backgrounds, accent-colored borders).
|
||||||
|
|
||||||
|
### Requirement 3: Bulk Add to Queue API Endpoint
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want the backend to accept multiple findings in a single request so that batch additions are processed efficiently.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Todo_Queue_API SHALL expose a `POST /api/ivanti/todo-queue/batch` endpoint that accepts an array of finding objects with a shared workflow_type and vendor.
|
||||||
|
2. THE Todo_Queue_API SHALL validate that the findings array contains between 1 and 200 items.
|
||||||
|
3. THE Todo_Queue_API SHALL validate that each finding object contains a non-empty finding_id string.
|
||||||
|
4. THE Todo_Queue_API SHALL validate that workflow_type is one of FP, Archer, or CARD.
|
||||||
|
5. WHEN workflow_type is FP or Archer, THE Todo_Queue_API SHALL validate that vendor is a non-empty string of 200 characters or fewer.
|
||||||
|
6. WHEN workflow_type is CARD, THE Todo_Queue_API SHALL accept an empty or absent vendor field.
|
||||||
|
7. THE Todo_Queue_API SHALL insert all valid findings into the `ivanti_todo_queue` table within a single database transaction.
|
||||||
|
8. IF any finding in the batch fails validation, THEN THE Todo_Queue_API SHALL reject the entire batch and return a 400 response with a descriptive error message.
|
||||||
|
9. THE Todo_Queue_API SHALL return a 201 response containing the array of newly created queue items with their assigned IDs.
|
||||||
|
10. THE Todo_Queue_API SHALL require authentication and the Admin or Standard_User group.
|
||||||
|
11. IF a database error occurs during the transaction, THEN THE Todo_Queue_API SHALL roll back all inserts and return a 500 response.
|
||||||
|
|
||||||
|
### Requirement 4: Frontend Batch Submission Flow
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want clicking "Add to Queue" on the toolbar to submit all selected findings at once so that I save time during triage.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user clicks "Add to Queue" on the Selection_Toolbar, THE Findings_Table SHALL send a single POST request to `POST /api/ivanti/todo-queue/batch` containing all selected findings with the chosen workflow_type and vendor.
|
||||||
|
2. WHILE the batch request is in progress, THE Selection_Toolbar SHALL disable the "Add to Queue" button and display a loading indicator.
|
||||||
|
3. WHEN the batch request succeeds, THE Findings_Table SHALL add all returned queue items to the local queue state, clear the selection, and hide the Selection_Toolbar.
|
||||||
|
4. WHEN the batch request succeeds, THE Findings_Table SHALL update each newly queued finding's row checkbox to show the checked-and-disabled (already queued) state.
|
||||||
|
5. IF the batch request fails, THEN THE Selection_Toolbar SHALL display the error message returned by the API and keep the current selection intact.
|
||||||
|
6. WHEN the batch request succeeds and the Queue_Panel is open, THE Queue_Panel SHALL reflect the newly added items immediately without requiring a manual refresh.
|
||||||
|
|
||||||
|
### Requirement 5: Preserve Single-Select Popover Flow
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to still be able to add a single finding to the queue quickly without going through the batch flow, so that simple one-off additions remain fast.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN no findings are currently selected and a user clicks a finding row's checkbox, THE Findings_Table SHALL open the existing AddToQueuePopover for that single finding.
|
||||||
|
2. WHEN one or more findings are already selected and a user clicks another finding row's checkbox, THE Findings_Table SHALL add that finding to the selection set instead of opening the AddToQueuePopover.
|
||||||
|
3. THE AddToQueuePopover SHALL continue to use the existing single-item `POST /api/ivanti/todo-queue` endpoint for individual additions.
|
||||||
|
|
||||||
|
### Requirement 6: Keyboard Accessibility for Multi-Select
|
||||||
|
|
||||||
|
**User Story:** As an engineer, I want to use keyboard shortcuts to speed up multi-select so that I can triage even faster.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user holds Shift and clicks a finding row's checkbox, THE Findings_Table SHALL select all visible findings between the last clicked checkbox and the current checkbox (range select).
|
||||||
|
2. THE Selection_Toolbar SHALL be navigable via keyboard Tab order, with all interactive elements (workflow buttons, vendor input, submit button) reachable by Tab key.
|
||||||
|
3. WHEN the Escape key is pressed while the Selection_Toolbar is visible, THE Findings_Table SHALL clear the selection and hide the Selection_Toolbar.
|
||||||
116
.kiro/specs/batch-finding-disposition/tasks.md
Normal file
116
.kiro/specs/batch-finding-disposition/tasks.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Implementation Plan: Batch Finding Disposition
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add multi-select capability to the Vulnerability Triage findings table with a batch-add-to-queue API endpoint. The backend gets a new `POST /api/ivanti/todo-queue/batch` route in `ivantiTodoQueue.js`. The frontend gets selection state, checkbox dual-mode logic, a SelectionToolbar component, shift-click range select, select-all, and Escape-to-clear — all within `ReportingPage.js`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add `POST /api/ivanti/todo-queue/batch` endpoint
|
||||||
|
- [x] 1.1 Add batch route handler to `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Add `POST /batch` route inside `createIvantiTodoQueueRouter`, before the `POST /` route
|
||||||
|
- Apply `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware
|
||||||
|
- Validate request body: `findings` array (1–200 items), each with non-empty `finding_id` string
|
||||||
|
- Validate `workflow_type` is one of `FP`, `Archer`, `CARD`
|
||||||
|
- Validate `vendor`: required non-empty string ≤200 chars for FP/Archer; ignored for CARD
|
||||||
|
- If any validation fails, return 400 with descriptive error message and reject entire batch
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8, 3.10_
|
||||||
|
- [x] 1.2 Implement transactional batch insert with SQLite
|
||||||
|
- Use `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT` to insert all findings atomically
|
||||||
|
- For each finding: insert row into `ivanti_todo_queue` with `user_id`, `finding_id`, `finding_title`, `cves_json`, `ip_address`, `vendor`, `workflow_type`
|
||||||
|
- On success: fetch all inserted rows, parse `cves_json` back to arrays, return 201 with `{ items: [...] }`
|
||||||
|
- On any DB error: `ROLLBACK` the transaction and return 500
|
||||||
|
- _Requirements: 3.7, 3.8, 3.9, 3.11_
|
||||||
|
- [x] 1.3 Add audit logging for batch additions
|
||||||
|
- After successful commit, call `logAudit(db, { ... })` with action `'batch_add_to_queue'`, entityType `'ivanti_todo_queue'`, and details including the count and workflow_type
|
||||||
|
- Import `logAudit` from `../helpers/auditLog`
|
||||||
|
- _Requirements: 3.7_
|
||||||
|
|
||||||
|
- [x] 2. Checkpoint — Verify backend endpoint
|
||||||
|
- Ensure the batch endpoint is syntactically correct and the route file has no errors. Ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 3. Add multi-select state and checkbox dual-mode logic to `ReportingPage.js`
|
||||||
|
- [x] 3.1 Add selection state variables to `VulnerabilityTriagePage`
|
||||||
|
- Add `selectedIds` (`new Set()`), `lastClickedId` (null), `batchSubmitting` (false), `batchError` (null), `batchWorkflowType` ('FP'), `batchVendor` ('') as new `useState` hooks
|
||||||
|
- _Requirements: 1.1, 2.1_
|
||||||
|
- [x] 3.2 Implement checkbox dual-mode click handler
|
||||||
|
- Replace the existing `<td>` onClick in the checkbox cell with new logic:
|
||||||
|
- If finding is already queued → no-op (existing behavior)
|
||||||
|
- If `selectedIds.size === 0` AND not shift-click → open `AddToQueuePopover` (preserves single-select flow)
|
||||||
|
- If shift-click AND `lastClickedId` exists → range-select all visible non-queued findings between `lastClickedId` and current finding in the `sorted` array
|
||||||
|
- Otherwise → toggle finding.id in `selectedIds`
|
||||||
|
- Always update `lastClickedId` when toggling selection
|
||||||
|
- _Requirements: 1.1, 1.2, 5.1, 5.2, 6.1_
|
||||||
|
- [x] 3.3 Add visual highlighting for selected rows
|
||||||
|
- When a finding's ID is in `selectedIds`, apply a highlighted background (e.g. `rgba(14,165,233,0.12)`) to the row
|
||||||
|
- Override the existing alternating row background and hover for selected rows
|
||||||
|
- _Requirements: 1.3_
|
||||||
|
- [x] 3.4 Disable checkbox for already-queued findings
|
||||||
|
- Keep existing behavior: queued findings show checked + disabled checkbox, preventing re-selection
|
||||||
|
- Ensure queued findings are excluded from shift-click range select and select-all
|
||||||
|
- _Requirements: 1.5_
|
||||||
|
|
||||||
|
- [x] 4. Implement Select All / Deselect All in column header
|
||||||
|
- Modify the checkbox column `<th>` to render a clickable "Select All" checkbox when `selectedIds.size > 0` or when the user interacts with it
|
||||||
|
- Click behavior: if not all visible non-queued findings are selected → select all visible non-queued; if all are selected → deselect all
|
||||||
|
- _Requirements: 1.6, 1.7_
|
||||||
|
|
||||||
|
- [x] 5. Add selection pruning on filter changes
|
||||||
|
- Add a `useEffect` that watches `filtered` (the filtered findings array) and prunes `selectedIds` to only include IDs still present in the filtered set
|
||||||
|
- This ensures selection stays consistent when `columnFilters`, `actionFilter`, or `excFilter` change
|
||||||
|
- _Requirements: 1.4_
|
||||||
|
|
||||||
|
- [x] 6. Implement SelectionToolbar component
|
||||||
|
- [x] 6.1 Create the `SelectionToolbar` inline component in `ReportingPage.js`
|
||||||
|
- Render between the panel header controls and the `<table>` element, only when `selectedIds.size > 0`
|
||||||
|
- Use `position: sticky` with appropriate `top` value to stay visible during scroll
|
||||||
|
- Follow the dark theme design system: monospace fonts, dark gradient background, accent-colored borders
|
||||||
|
- _Requirements: 2.1, 2.8_
|
||||||
|
- [x] 6.2 Add toolbar controls: count badge, Clear Selection, workflow toggles, vendor input, submit button
|
||||||
|
- Display selected count badge (e.g. "12 selected")
|
||||||
|
- "Clear Selection" button that empties `selectedIds` and hides toolbar
|
||||||
|
- Workflow type toggle buttons (FP / Archer / CARD) using existing color scheme: FP = amber (`#F59E0B`), Archer = blue (`#0EA5E9`), CARD = green (`#10B981`)
|
||||||
|
- Vendor text input (hidden when CARD is selected, show "No vendor required" indicator for CARD)
|
||||||
|
- "Add to Queue" submit button — enabled only when workflow_type is CARD, or vendor is non-empty
|
||||||
|
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 7. Implement batch submission flow
|
||||||
|
- [x] 7.1 Add `submitBatch` async function to `VulnerabilityTriagePage`
|
||||||
|
- Build request payload from `selectedIds` (map each ID to its finding object from `sorted`/`filtered` for `finding_id`, `finding_title`, `cves`, `ip_address`), plus `batchWorkflowType` and `batchVendor`
|
||||||
|
- POST to `${API_BASE}/ivanti/todo-queue/batch` with `credentials: 'include'`
|
||||||
|
- Set `batchSubmitting = true` before request, `false` after
|
||||||
|
- _Requirements: 4.1, 4.2_
|
||||||
|
- [x] 7.2 Handle batch success response
|
||||||
|
- On 201: merge returned items into `queueItems` state (sorted by vendor then id, matching existing pattern)
|
||||||
|
- Clear `selectedIds`, reset `batchWorkflowType` to 'FP', reset `batchVendor` to '', clear `batchError`
|
||||||
|
- The newly queued findings will automatically show as checked+disabled via the existing `isQueued()` helper
|
||||||
|
- _Requirements: 4.3, 4.4, 4.6_
|
||||||
|
- [x] 7.3 Handle batch error response
|
||||||
|
- On 4xx/5xx: parse error message from response JSON, set `batchError` to display in toolbar
|
||||||
|
- On network failure: set `batchError` to "Network error — please try again"
|
||||||
|
- Keep selection intact on error so user can retry
|
||||||
|
- _Requirements: 4.5_
|
||||||
|
|
||||||
|
- [x] 8. Add Escape key handler to clear selection
|
||||||
|
- Add a `useEffect` with a `keydown` listener for Escape that clears `selectedIds` when the SelectionToolbar is visible (i.e. `selectedIds.size > 0`)
|
||||||
|
- Ensure it doesn't conflict with the existing Escape handler on `AddToQueuePopover`
|
||||||
|
- _Requirements: 6.3_
|
||||||
|
|
||||||
|
- [x] 9. Ensure keyboard Tab accessibility for SelectionToolbar
|
||||||
|
- Verify all interactive elements in the toolbar (workflow buttons, vendor input, submit button, clear button) are focusable via Tab key
|
||||||
|
- Use native `<button>` and `<input>` elements (which are inherently tabbable) rather than `<div>` with onClick
|
||||||
|
- _Requirements: 6.2_
|
||||||
|
|
||||||
|
- [x] 10. Final checkpoint — Full integration verification
|
||||||
|
- Ensure all files have no syntax errors or diagnostic issues
|
||||||
|
- Verify the checkbox dual-mode logic: no selection → popover, existing selection → toggle
|
||||||
|
- Verify the SelectionToolbar renders/hides correctly based on selection state
|
||||||
|
- Verify batch submit wires through to the backend endpoint and updates queue state
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No new database migration needed — batch insert reuses the existing `ivanti_todo_queue` schema
|
||||||
|
- The batch endpoint must be registered before `POST /` in the router to avoid Express route conflicts
|
||||||
|
- All testing is done on the dev server after push — no local test tasks included
|
||||||
|
- Each task references specific acceptance criteria from the requirements document for traceability
|
||||||
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
1
.kiro/specs/cve-tooltip-hover/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
229
.kiro/specs/cve-tooltip-hover/design.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Design Document: CVE Tooltip Hover
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds a hover tooltip to CVE badges in the Reporting Page findings table. When a user pauses their cursor over a CVE identifier badge, the system fetches a brief description and severity from the backend and displays it in a styled floating tooltip. Responses are cached in-memory to avoid redundant API calls, and a 300ms hover delay prevents tooltip flicker during fast mouse movement.
|
||||||
|
|
||||||
|
The implementation spans two layers:
|
||||||
|
1. A new lightweight backend endpoint (`/api/cves/:cveId/tooltip`) that queries the existing `cves` SQLite table and returns a trimmed response.
|
||||||
|
2. A frontend `CveTooltip` component rendered via a React portal, with an in-memory cache (React ref), hover delay timer, and viewport-aware positioning.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant CVEBadge as CVE Badge (ReportingPage)
|
||||||
|
participant Tooltip as CveTooltip Component
|
||||||
|
participant Cache as Tooltip Cache (useRef)
|
||||||
|
participant API as /api/cves/:cveId/tooltip
|
||||||
|
participant DB as SQLite (cves table)
|
||||||
|
|
||||||
|
User->>CVEBadge: mouseenter
|
||||||
|
CVEBadge->>Tooltip: start 300ms delay timer
|
||||||
|
Note over Tooltip: If mouseout before 300ms, cancel
|
||||||
|
|
||||||
|
alt Cache hit
|
||||||
|
Tooltip->>Cache: lookup(cveId)
|
||||||
|
Cache-->>Tooltip: cached data
|
||||||
|
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||||
|
else Cache miss
|
||||||
|
Tooltip->>API: GET /api/cves/:cveId/tooltip
|
||||||
|
API->>DB: SELECT cve_id, description, severity FROM cves WHERE cve_id = ?
|
||||||
|
DB-->>API: row or null
|
||||||
|
API-->>Tooltip: { exists, cve_id, description, severity }
|
||||||
|
Tooltip->>Cache: store response
|
||||||
|
Tooltip->>User: show tooltip (or skip if exists:false)
|
||||||
|
end
|
||||||
|
|
||||||
|
User->>CVEBadge: mouseleave
|
||||||
|
CVEBadge->>Tooltip: hide + clear timer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Inline endpoint in server.js** — The tooltip endpoint is a single GET route on the existing `/api/cves` path prefix. It follows the pattern of other simple CVE endpoints already defined inline in `server.js` (e.g., `/api/cves/check/:cveId`, `/api/cves/:cveId/vendors`). No separate route module needed.
|
||||||
|
|
||||||
|
2. **React portal for tooltip rendering** — The tooltip is rendered via `ReactDOM.createPortal` to `document.body`, avoiding overflow/clipping issues from the table's scroll container. The ReportingPage already imports `ReactDOM` for other portal usage.
|
||||||
|
|
||||||
|
3. **useRef for cache instead of useState** — The cache is a plain `Map` stored in a `useRef`. This avoids re-renders when cache entries are added and persists across renders without triggering updates. The cache is cleared when the findings data is re-synced.
|
||||||
|
|
||||||
|
4. **Single shared tooltip instance** — Only one tooltip is visible at a time. The parent component tracks which CVE badge is hovered and passes the active CVE ID + badge position to the tooltip component.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### `GET /api/cves/:cveId/tooltip`
|
||||||
|
|
||||||
|
Added inline in `server.js` alongside existing CVE endpoints.
|
||||||
|
|
||||||
|
- **Auth**: `requireAuth(db)` — session cookie required
|
||||||
|
- **Params**: `:cveId` — validated against `CVE_ID_PATTERN` (`/^CVE-\d{4}-\d{4,}$/`)
|
||||||
|
- **Query**: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||||
|
- **Response (found)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exists": true,
|
||||||
|
"cve_id": "CVE-2024-12345",
|
||||||
|
"description": "A vulnerability in...",
|
||||||
|
"severity": "High"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (not found)**:
|
||||||
|
```json
|
||||||
|
{ "exists": false }
|
||||||
|
```
|
||||||
|
- **Description truncation**: If `description.length > 300`, return `description.substring(0, 300) + '…'`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### `CveTooltip` Component (new file: `frontend/src/components/CveTooltip.js`)
|
||||||
|
|
||||||
|
A portal-rendered tooltip that receives positioning data and CVE info.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `cveId` | `string \| null` | The CVE ID to display. `null` hides the tooltip. |
|
||||||
|
| `anchorRect` | `DOMRect \| null` | Bounding rect of the hovered badge for positioning. |
|
||||||
|
| `cache` | `React.MutableRefObject<Map>` | Shared cache ref from parent. |
|
||||||
|
|
||||||
|
**Internal state:**
|
||||||
|
- `data` — fetched tooltip payload (`{ exists, cve_id, description, severity }` or `null`)
|
||||||
|
- `loading` — boolean, true while fetch is in-flight
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
1. When `cveId` changes to a non-null value, check `cache.current` for the CVE ID.
|
||||||
|
2. If cached and `exists: false`, render nothing.
|
||||||
|
3. If cached and `exists: true`, display immediately.
|
||||||
|
4. If not cached, set `loading = true`, fetch from API, store result in cache, set `loading = false`.
|
||||||
|
5. Position the tooltip above the badge by default. If the tooltip would overflow the top of the viewport, position it below instead.
|
||||||
|
6. Render via `ReactDOM.createPortal` to `document.body`.
|
||||||
|
|
||||||
|
#### ReportingPage Integration
|
||||||
|
|
||||||
|
Modifications to the existing `renderCell` function for the `'cves'` case:
|
||||||
|
|
||||||
|
- Add `onMouseEnter` / `onMouseLeave` handlers to each CVE badge `<span>`.
|
||||||
|
- `onMouseEnter`: Start a 300ms `setTimeout`. On fire, set active CVE ID + badge `getBoundingClientRect()` into state.
|
||||||
|
- `onMouseLeave`: Clear the timeout. Set active CVE ID to `null`.
|
||||||
|
- Render a single `<CveTooltip>` instance at the bottom of the component, passing the active CVE ID, anchor rect, and cache ref.
|
||||||
|
- On data sync (when findings are refreshed), call `cache.current.clear()`.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Existing: `cves` Table (SQLite)
|
||||||
|
|
||||||
|
The tooltip endpoint queries the existing table. No schema changes required.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE cves (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
severity TEXT CHECK(severity IN ('Critical', 'High', 'Medium', 'Low')),
|
||||||
|
description TEXT,
|
||||||
|
published_date TEXT,
|
||||||
|
status TEXT DEFAULT 'Open',
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(cve_id, vendor)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The query uses `LIMIT 1` since a CVE may have multiple vendor rows — the description and severity from any row suffice for the tooltip blurb.
|
||||||
|
|
||||||
|
### Frontend Cache Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// cache.current is a Map<string, object>
|
||||||
|
// Key: CVE ID string (e.g. "CVE-2024-12345")
|
||||||
|
// Value: API response object
|
||||||
|
// { exists: false }
|
||||||
|
// OR
|
||||||
|
// { exists: true, cve_id: string, description: string, severity: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Tooltip endpoint returns correct data for existing CVEs
|
||||||
|
|
||||||
|
*For any* CVE record inserted into the `cves` table with a valid `cve_id`, `description`, and `severity`, a GET request to `/api/cves/:cveId/tooltip` SHALL return `{ exists: true }` with the matching `cve_id` and `severity`, and a `description` that is either the original (if ≤ 300 chars) or truncated to 300 chars + ellipsis.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.3, 1.5**
|
||||||
|
|
||||||
|
### Property 2: Description truncation preserves content and enforces length
|
||||||
|
|
||||||
|
*For any* string of arbitrary length, the truncation function SHALL return the original string unchanged if its length is ≤ 300, or return exactly the first 300 characters followed by "…" if its length exceeds 300. In both cases, the output starts with the same characters as the input.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.5**
|
||||||
|
|
||||||
|
### Property 3: Tooltip positioning flips based on available viewport space
|
||||||
|
|
||||||
|
*For any* anchor rectangle position and viewport height, the tooltip SHALL be positioned above the anchor when `anchorRect.top` provides sufficient space for the tooltip height, and below the anchor otherwise. The tooltip SHALL never overflow the top or bottom of the viewport.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
|
### Property 4: Cache round-trip — fetch then cache-hit avoids network call
|
||||||
|
|
||||||
|
*For any* CVE ID, after the tooltip system fetches data from the API and stores it in the cache, a subsequent tooltip request for the same CVE ID SHALL return the identical cached data object without making an additional network request.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.2**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Layer | Behavior |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| Invalid CVE ID format in URL param | Backend | Return `400 { error: 'Invalid CVE ID format.' }` |
|
||||||
|
| Database query error | Backend | Log error, return `500 { error: 'Internal server error.' }` |
|
||||||
|
| No session cookie / expired session | Backend | `requireAuth` middleware returns `401` |
|
||||||
|
| Network error during fetch | Frontend | Catch error, hide tooltip (do not cache failures), log to console |
|
||||||
|
| Fetch timeout / slow response | Frontend | Show loading state; if user moves away, cancel via AbortController |
|
||||||
|
| Component unmounts during fetch | Frontend | AbortController signal aborts in-flight request, no state update |
|
||||||
|
|
||||||
|
**Key principle**: Transient errors (network failures, timeouts) are NOT cached. Only successful API responses (both `exists: true` and `exists: false`) are stored in the cache. This ensures a retry on next hover for failed requests.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Example-Based)
|
||||||
|
|
||||||
|
| Test | Validates |
|
||||||
|
|------|-----------|
|
||||||
|
| Endpoint returns `{ exists: false }` for unknown CVE ID | Req 1.2 |
|
||||||
|
| Endpoint returns 401 without session cookie | Req 1.4 |
|
||||||
|
| Endpoint returns 400 for malformed CVE ID (e.g. "not-a-cve") | Req 1.1 (error path) |
|
||||||
|
| Tooltip appears after 300ms hover delay | Req 5.1 |
|
||||||
|
| Tooltip cancelled if mouseout before 300ms | Req 5.2 |
|
||||||
|
| Tooltip hidden on mouseleave | Req 2.2 |
|
||||||
|
| Loading indicator shown while fetching | Req 2.5 |
|
||||||
|
| No tooltip shown when API returns `exists: false` | Req 2.6 |
|
||||||
|
| Severity badge uses correct color per level | Req 2.4 |
|
||||||
|
| Tooltip has max-width of 320px | Req 3.3 |
|
||||||
|
| Tooltip includes directional arrow element | Req 3.5 |
|
||||||
|
| Cache cleared on data sync/refresh | Req 4.4 |
|
||||||
|
| Cached `exists: false` suppresses tooltip and API call | Req 4.3 |
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Property-based tests use **fast-check** (JavaScript PBT library, already compatible with the Jest/react-scripts test runner).
|
||||||
|
|
||||||
|
Each property test runs a minimum of **100 iterations**.
|
||||||
|
|
||||||
|
| Property | Tag | Focus |
|
||||||
|
|----------|-----|-------|
|
||||||
|
| Property 1 | `Feature: cve-tooltip-hover, Property 1: Tooltip endpoint returns correct data for existing CVEs` | Generate random CVE records (varying description lengths 0–1000, all 4 severity levels), insert into test DB, call endpoint, verify response shape and truncation |
|
||||||
|
| Property 2 | `Feature: cve-tooltip-hover, Property 2: Description truncation preserves content and enforces length` | Generate random strings of length 0–2000, apply truncation function, verify length invariant and prefix preservation |
|
||||||
|
| Property 3 | `Feature: cve-tooltip-hover, Property 3: Tooltip positioning flips based on available viewport space` | Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200), verify position is within viewport bounds |
|
||||||
|
| Property 4 | `Feature: cve-tooltip-hover, Property 4: Cache round-trip` | Generate random CVE IDs and response payloads, store in cache Map, verify subsequent lookups return identical objects and no fetch is triggered |
|
||||||
|
|
||||||
|
### Test Configuration
|
||||||
|
|
||||||
|
- Test runner: `react-scripts test` (Jest) — already configured in the project
|
||||||
|
- PBT library: `fast-check` — install via `npm install --save-dev fast-check` in the `frontend/` directory
|
||||||
|
- Backend endpoint tests: Use supertest or direct handler invocation with a test SQLite DB
|
||||||
|
- Frontend component tests: React Testing Library with mocked fetch
|
||||||
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
73
.kiro/specs/cve-tooltip-hover/requirements.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Add a hover tooltip to CVE badges in the Reporting Page (vuln triage view). When a user hovers over a CVE identifier badge in the findings table, the system checks whether that CVE exists in the local SQLite database. If it does, a small tooltip appears showing a brief description/blurb about that CVE. CVEs not present in the database show no tooltip.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Reporting_Page**: The vulnerability triage view at `frontend/src/components/pages/ReportingPage.js` that displays Ivanti host findings in a sortable, filterable table.
|
||||||
|
- **CVE_Badge**: The styled `<span>` element in the CVEs column of the findings table that displays a CVE identifier (e.g. CVE-2024-12345) with a purple pill/box appearance.
|
||||||
|
- **CVE_Tooltip**: A small floating box that appears on mouse hover over a CVE_Badge, displaying a text blurb about the CVE.
|
||||||
|
- **CVE_Database**: The `cves` table in the SQLite database (`backend/cve_database.db`) that stores CVE records including descriptions, severity, and vendor information.
|
||||||
|
- **Tooltip_Cache**: An in-memory lookup (React state or ref) that stores previously fetched CVE descriptions to avoid redundant API calls during the same session.
|
||||||
|
- **API_Server**: The Express backend at `backend/server.js` that serves CVE data via `/api` endpoints.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: CVE Tooltip Data Endpoint
|
||||||
|
|
||||||
|
**User Story:** As a frontend component, I want to fetch a brief description for a given CVE ID, so that the tooltip can display relevant information without loading unnecessary data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is made to `/api/cves/:cveId/tooltip`, THE API_Server SHALL return a JSON object containing the `cve_id`, `description`, and `severity` fields for the matching CVE record.
|
||||||
|
2. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that does not exist in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: false }` and HTTP status 200.
|
||||||
|
3. WHEN a GET request is made to `/api/cves/:cveId/tooltip` for a CVE ID that exists in the CVE_Database, THE API_Server SHALL return a JSON object with `{ exists: true, cve_id, description, severity }` and HTTP status 200.
|
||||||
|
4. THE API_Server SHALL require a valid session cookie for the `/api/cves/:cveId/tooltip` endpoint.
|
||||||
|
5. WHEN the `description` field exceeds 300 characters, THE API_Server SHALL truncate the description to 300 characters and append an ellipsis ("…").
|
||||||
|
|
||||||
|
### Requirement 2: Tooltip Display on CVE Badge Hover
|
||||||
|
|
||||||
|
**User Story:** As a security analyst triaging findings, I want to see a brief description of a CVE when I hover over its badge in the findings table, so that I can quickly understand the vulnerability without leaving the page.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user hovers the mouse cursor over a CVE_Badge in the Reporting_Page findings table, THE Reporting_Page SHALL display a CVE_Tooltip near the hovered badge.
|
||||||
|
2. WHEN the user moves the mouse cursor away from the CVE_Badge, THE Reporting_Page SHALL hide the CVE_Tooltip.
|
||||||
|
3. THE CVE_Tooltip SHALL display the CVE description text returned by the API_Server.
|
||||||
|
4. THE CVE_Tooltip SHALL display the severity level of the CVE using the existing severity color scheme (Critical: red, High: amber, Medium: sky blue, Low: emerald).
|
||||||
|
5. WHILE the CVE data is being fetched from the API_Server, THE CVE_Tooltip SHALL display a loading indicator.
|
||||||
|
6. WHEN the API_Server returns `exists: false` for a CVE ID, THE Reporting_Page SHALL not display a CVE_Tooltip for that badge.
|
||||||
|
|
||||||
|
### Requirement 3: Tooltip Positioning and Styling
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the CVE tooltip to be readable and not obstruct other table content, so that I can continue triaging while viewing CVE details.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE CVE_Tooltip SHALL appear above the hovered CVE_Badge by default.
|
||||||
|
2. WHEN there is insufficient viewport space above the CVE_Badge, THE CVE_Tooltip SHALL appear below the badge instead.
|
||||||
|
3. THE CVE_Tooltip SHALL have a maximum width of 320 pixels.
|
||||||
|
4. THE CVE_Tooltip SHALL use the design system dark theme styling: dark background gradient, accent border, monospace font for the CVE ID, and standard font for the description text.
|
||||||
|
5. THE CVE_Tooltip SHALL include a small directional arrow pointing toward the CVE_Badge.
|
||||||
|
|
||||||
|
### Requirement 4: Tooltip Response Caching
|
||||||
|
|
||||||
|
**User Story:** As a security analyst scrolling through many findings, I want CVE tooltip data to load instantly for CVEs I have already hovered over, so that repeated hovers do not cause redundant network requests.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Reporting_Page fetches tooltip data for a CVE ID, THE Tooltip_Cache SHALL store the response for that CVE ID.
|
||||||
|
2. WHEN the user hovers over a CVE_Badge for a CVE ID that exists in the Tooltip_Cache, THE Reporting_Page SHALL display the cached data without making an API call.
|
||||||
|
3. WHEN the user hovers over a CVE_Badge for a CVE ID where the Tooltip_Cache stores `exists: false`, THE Reporting_Page SHALL not display a tooltip and SHALL not make an API call.
|
||||||
|
4. WHEN the Reporting_Page performs a full data sync (refresh), THE Tooltip_Cache SHALL be cleared.
|
||||||
|
|
||||||
|
### Requirement 5: Hover Delay
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the tooltip to only appear after a brief pause on a CVE badge, so that tooltips do not flash distractingly when I move the mouse across the table quickly.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user hovers over a CVE_Badge, THE Reporting_Page SHALL wait 300 milliseconds before initiating the tooltip display sequence.
|
||||||
|
2. IF the user moves the mouse away from the CVE_Badge before 300 milliseconds have elapsed, THEN THE Reporting_Page SHALL cancel the tooltip display and not make an API call.
|
||||||
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
107
.kiro/specs/cve-tooltip-hover/tasks.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Implementation Plan: CVE Tooltip Hover
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement a hover tooltip for CVE badges in the Reporting Page findings table. The feature spans a backend endpoint (`GET /api/cves/:cveId/tooltip`) and a frontend `CveTooltip` portal component with in-memory caching and 300ms hover delay. Tasks are ordered backend-first, then frontend component, then integration, with property tests alongside each layer.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add backend tooltip endpoint
|
||||||
|
- [x] 1.1 Add `GET /api/cves/:cveId/tooltip` route inline in `backend/server.js`
|
||||||
|
- Place it alongside existing CVE endpoints (after `/api/cves/:cveId/vendors`)
|
||||||
|
- Validate `:cveId` against existing `CVE_ID_PATTERN`; return 400 for invalid format
|
||||||
|
- Query: `SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1`
|
||||||
|
- If no row: return `{ exists: false }` with status 200
|
||||||
|
- If row found: truncate `description` to 300 chars + "…" if needed, return `{ exists: true, cve_id, description, severity }`
|
||||||
|
- Protect with `requireAuth(db)` middleware
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
|
- [ ]* 1.2 Write property test for tooltip endpoint data correctness
|
||||||
|
- **Property 1: Tooltip endpoint returns correct data for existing CVEs**
|
||||||
|
- Install `fast-check` as dev dependency in `frontend/` (shared test runner)
|
||||||
|
- Generate random CVE records with description lengths 0–1000 and all 4 severity levels
|
||||||
|
- Verify response shape, truncation at 300 chars, and prefix preservation
|
||||||
|
- **Validates: Requirements 1.1, 1.3, 1.5**
|
||||||
|
|
||||||
|
- [ ]* 1.3 Write property test for description truncation
|
||||||
|
- **Property 2: Description truncation preserves content and enforces length**
|
||||||
|
- Extract truncation logic into a testable pure function
|
||||||
|
- Generate random strings of length 0–2000, verify length invariant and prefix match
|
||||||
|
- **Validates: Requirements 1.5**
|
||||||
|
|
||||||
|
- [x] 2. Checkpoint — Verify backend endpoint
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 3. Create CveTooltip frontend component
|
||||||
|
- [x] 3.1 Create `frontend/src/components/CveTooltip.js`
|
||||||
|
- Portal-rendered component using `ReactDOM.createPortal` to `document.body`
|
||||||
|
- Props: `cveId` (string|null), `anchorRect` (DOMRect|null), `cache` (useRef Map)
|
||||||
|
- Internal state: `data`, `loading`
|
||||||
|
- On `cveId` change: check cache → if miss, fetch from `/api/cves/:cveId/tooltip` with AbortController
|
||||||
|
- If cached `exists: false` or fetch returns `exists: false`, render nothing
|
||||||
|
- Show loading spinner (Loader from lucide-react) while fetching
|
||||||
|
- Display: CVE ID in monospace, severity badge with design system colors, description text
|
||||||
|
- Max-width 320px, dark theme gradient background, accent border, directional arrow
|
||||||
|
- Position above anchor by default; flip below if insufficient viewport space above
|
||||||
|
- Do not cache transient errors (network failures)
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||||
|
|
||||||
|
- [ ]* 3.2 Write property test for tooltip positioning logic
|
||||||
|
- **Property 3: Tooltip positioning flips based on available viewport space**
|
||||||
|
- Extract positioning calculation into a pure function
|
||||||
|
- Generate random anchorRect.top (0–2000), tooltip height (50–200), viewport height (400–1200)
|
||||||
|
- Verify tooltip never overflows top or bottom of viewport
|
||||||
|
- **Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
|
- [ ]* 3.3 Write unit tests for CveTooltip component
|
||||||
|
- Test loading state renders spinner
|
||||||
|
- Test `exists: false` renders nothing
|
||||||
|
- Test severity badge uses correct color per level
|
||||||
|
- Test max-width constraint
|
||||||
|
- Test directional arrow element is present
|
||||||
|
- _Requirements: 2.4, 2.5, 2.6, 3.3, 3.5_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint — Verify CveTooltip component
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 5. Integrate tooltip into ReportingPage
|
||||||
|
- [x] 5.1 Add hover state and cache ref to ReportingPage
|
||||||
|
- Add state: `tooltipCveId` (string|null), `tooltipAnchorRect` (DOMRect|null)
|
||||||
|
- Add `useRef(new Map())` for tooltip cache
|
||||||
|
- Add `useRef` for hover delay timer
|
||||||
|
- Clear cache when findings data is re-synced (inside existing sync callback)
|
||||||
|
- _Requirements: 4.1, 4.4, 5.1_
|
||||||
|
|
||||||
|
- [x] 5.2 Add mouseenter/mouseleave handlers to CVE badge spans
|
||||||
|
- In the `renderCell` function for the `'cves'` column case, wrap each CVE badge `<span>` with `onMouseEnter` and `onMouseLeave`
|
||||||
|
- `onMouseEnter`: start 300ms setTimeout; on fire, set `tooltipCveId` and `tooltipAnchorRect` from `getBoundingClientRect()`
|
||||||
|
- `onMouseLeave`: clear timeout, set `tooltipCveId` to null
|
||||||
|
- _Requirements: 2.1, 2.2, 5.1, 5.2_
|
||||||
|
|
||||||
|
- [x] 5.3 Render CveTooltip instance in ReportingPage
|
||||||
|
- Add single `<CveTooltip>` at the bottom of the ReportingPage return, passing `tooltipCveId`, `tooltipAnchorRect`, and cache ref
|
||||||
|
- _Requirements: 2.1, 4.2, 4.3_
|
||||||
|
|
||||||
|
- [ ]* 5.4 Write property test for cache round-trip behavior
|
||||||
|
- **Property 4: Cache round-trip — fetch then cache-hit avoids network call**
|
||||||
|
- Generate random CVE IDs and response payloads, store in Map, verify lookups return identical objects
|
||||||
|
- **Validates: Requirements 4.1, 4.2**
|
||||||
|
|
||||||
|
- [ ]* 5.5 Write unit tests for hover delay and cache integration
|
||||||
|
- Test tooltip appears after 300ms delay (use fake timers)
|
||||||
|
- Test tooltip cancelled if mouseout before 300ms
|
||||||
|
- Test cached `exists: false` suppresses tooltip and API call
|
||||||
|
- Test cache cleared on data sync/refresh
|
||||||
|
- _Requirements: 4.3, 4.4, 5.1, 5.2_
|
||||||
|
|
||||||
|
- [x] 6. Final checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- Unit tests validate specific examples and edge cases
|
||||||
|
- The project uses plain JavaScript (no TypeScript), fast-check for PBT, and react-scripts test (Jest)
|
||||||
293
.kiro/specs/finding-archive-tracking/design.md
Normal file
293
.kiro/specs/finding-archive-tracking/design.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Design Document: Finding Archive Tracking
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Finding Archive Tracking system adds a detection layer to the existing Ivanti sync pipeline that identifies findings which disappear from sync results due to severity score drift. It tracks these findings through a four-state lifecycle (ACTIVE → ARCHIVED → RETURNED → CLOSED) with full transition history stored in two new SQLite tables. Three new API endpoints expose archive data, and an Archive Summary Bar UI component provides at-a-glance state counts on the Ivanti dashboard.
|
||||||
|
|
||||||
|
The system integrates directly into the existing `syncFindings()` function in `ivantiFindings.js`, comparing current sync results against the previous set to detect disappearances and reappearances. This approach requires no additional API calls to Ivanti and leverages the already-cached findings data.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Ivanti Sync Pipeline
|
||||||
|
A[syncFindings] --> B[Fetch all pages from Ivanti API]
|
||||||
|
B --> C[Store findings in ivanti_findings_cache]
|
||||||
|
C --> D[Archive Detection]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Archive Detection
|
||||||
|
D --> E{Compare previous vs current finding IDs}
|
||||||
|
E -->|Missing from current| F[Create/Update Archive Record → ARCHIVED]
|
||||||
|
E -->|Returned in current| G[Update Archive Record → RETURNED]
|
||||||
|
E -->|Closed in Ivanti| H[Update Archive Record → CLOSED]
|
||||||
|
F --> I[Insert Transition History]
|
||||||
|
G --> I
|
||||||
|
H --> I
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Archive API
|
||||||
|
J[GET /api/ivanti/archive] --> K[(ivanti_finding_archives)]
|
||||||
|
L[GET /api/ivanti/archive/:findingId/history] --> M[(ivanti_archive_transitions)]
|
||||||
|
N[GET /api/ivanti/archive/stats] --> K
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Frontend
|
||||||
|
O[Archive Summary Bar] -->|fetch stats| N
|
||||||
|
O -->|click state| J
|
||||||
|
P[Transition History Panel] -->|fetch history| L
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
1. **Sync Pipeline Hook**: Archive detection runs after `syncFindings()` successfully stores new findings in the cache. It reads the previous findings from the cache before the update, then compares against the new set.
|
||||||
|
2. **Route Registration**: The archive router is mounted at `/api/ivanti/archive` in `server.js`, following the same factory pattern as existing Ivanti routes.
|
||||||
|
3. **Frontend Integration**: The Archive Summary Bar is rendered on the existing Ivanti findings page, above the findings table.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### 1. Archive Detection Module (`detectArchiveChanges`)
|
||||||
|
|
||||||
|
Located within `backend/routes/ivantiFindings.js`, this async function runs after a successful sync.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Compare previous and current finding sets to detect archive state changes.
|
||||||
|
* @param {sqlite3.Database} db - SQLite database instance
|
||||||
|
* @param {Array} previousFindings - Findings from before the sync update
|
||||||
|
* @param {Array} currentFindings - Findings from the latest sync
|
||||||
|
*/
|
||||||
|
async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||||
|
// 1. Build ID sets from previous and current
|
||||||
|
// 2. Disappeared = in previous but not in current → ARCHIVED
|
||||||
|
// 3. Returned = in current AND has existing ARCHIVED record → RETURNED
|
||||||
|
// 4. For each state change, upsert archive record + insert transition
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Closed Finding Detection (`detectClosedFindings`)
|
||||||
|
|
||||||
|
Runs during the closed count sync to detect findings that transitioned to CLOSED in Ivanti.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Check archived findings against Ivanti closed findings to detect remediation.
|
||||||
|
* @param {sqlite3.Database} db - SQLite database instance
|
||||||
|
* @param {Array} closedFindingIds - IDs of findings confirmed closed in Ivanti
|
||||||
|
*/
|
||||||
|
async function detectClosedFindings(db, closedFindingIds) {
|
||||||
|
// For each archived/returned finding, if it appears in closed set → CLOSED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Archive API Router (`createIvantiArchiveRouter`)
|
||||||
|
|
||||||
|
Located at `backend/routes/ivantiArchive.js`, follows the existing factory pattern.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @param {sqlite3.Database} db - SQLite database instance
|
||||||
|
* @param {Function} requireAuth - Auth middleware factory
|
||||||
|
* @returns {express.Router}
|
||||||
|
*/
|
||||||
|
function createIvantiArchiveRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / - List archive records, optional ?state= filter
|
||||||
|
// GET /stats - Summary counts by state
|
||||||
|
// GET /:findingId/history - Transition history for a finding
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Archive Summary Bar Component (`ArchiveSummaryBar`)
|
||||||
|
|
||||||
|
Located at `frontend/src/components/pages/ArchiveSummaryBar.js`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Displays four stat cards for ACTIVE, ARCHIVED, RETURNED, CLOSED counts.
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Function} props.onStateClick - Callback when a state card is clicked
|
||||||
|
* @param {string|null} props.activeFilter - Currently selected state filter
|
||||||
|
*/
|
||||||
|
function ArchiveSummaryBar({ onStateClick, activeFilter }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoint Specifications
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Query Params | Response |
|
||||||
|
|----------|--------|------|-------------|----------|
|
||||||
|
| `/api/ivanti/archive` | GET | Required | `state` (optional: ACTIVE, ARCHIVED, RETURNED, CLOSED) | `{ archives: [...], total: N }` |
|
||||||
|
| `/api/ivanti/archive/stats` | GET | Required | None | `{ ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }` |
|
||||||
|
| `/api/ivanti/archive/:findingId/history` | GET | Required | None | `{ finding_id: "...", transitions: [...] }` |
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### `ivanti_finding_archives` Table
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
|
||||||
|
| `finding_id` | TEXT | NOT NULL UNIQUE | Ivanti finding identifier |
|
||||||
|
| `finding_title` | TEXT | NOT NULL DEFAULT '' | Finding title at time of archival |
|
||||||
|
| `host_name` | TEXT | NOT NULL DEFAULT '' | Host name at time of archival |
|
||||||
|
| `ip_address` | TEXT | NOT NULL DEFAULT '' | IP address at time of archival |
|
||||||
|
| `current_state` | TEXT | NOT NULL CHECK(IN ('ARCHIVED','RETURNED','CLOSED')) | Current lifecycle state |
|
||||||
|
| `last_severity` | REAL | NOT NULL DEFAULT 0 | Last known severity score |
|
||||||
|
| `first_archived_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When first archived |
|
||||||
|
| `last_transition_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When last state change occurred |
|
||||||
|
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Row creation time |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_archive_finding_id` on `finding_id`
|
||||||
|
- `idx_archive_current_state` on `current_state`
|
||||||
|
|
||||||
|
### `ivanti_archive_transitions` Table
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|--------|------|-------------|-------------|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Row ID |
|
||||||
|
| `archive_id` | INTEGER | NOT NULL, FK → ivanti_finding_archives(id) | Parent archive record |
|
||||||
|
| `from_state` | TEXT | NOT NULL | Previous state (or 'NONE' for initial) |
|
||||||
|
| `to_state` | TEXT | NOT NULL | New state |
|
||||||
|
| `severity_at_transition` | REAL | NOT NULL DEFAULT 0 | Severity score at time of transition |
|
||||||
|
| `reason` | TEXT | NOT NULL DEFAULT '' | Human-readable reason |
|
||||||
|
| `transitioned_at` | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP | When transition occurred |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_transition_archive_id` on `archive_id`
|
||||||
|
|
||||||
|
### State Transition Diagram
|
||||||
|
|
||||||
|
Archive records are only created when a finding first disappears from sync results. Findings that remain present in sync results do not get archive records — they are simply "active" in the findings cache. The three database states are ARCHIVED, RETURNED, and CLOSED.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> ARCHIVED : Finding disappears from sync (score drift)
|
||||||
|
ARCHIVED --> RETURNED : Reappeared in sync
|
||||||
|
ARCHIVED --> CLOSED : Confirmed remediated in Ivanti
|
||||||
|
RETURNED --> ARCHIVED : Disappeared again
|
||||||
|
RETURNED --> CLOSED : Confirmed remediated in Ivanti
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid State Transitions
|
||||||
|
|
||||||
|
| From State | To State | Reason |
|
||||||
|
|-----------|----------|--------|
|
||||||
|
| NONE → | ARCHIVED | `severity_score_drift` (first disappearance) |
|
||||||
|
| ARCHIVED → | RETURNED | `reappeared_in_sync` |
|
||||||
|
| ARCHIVED → | CLOSED | `remediated_in_ivanti` |
|
||||||
|
| RETURNED → | ARCHIVED | `severity_score_drift` |
|
||||||
|
| RETURNED → | CLOSED | `remediated_in_ivanti` |
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Disappeared findings are archived with complete metadata
|
||||||
|
|
||||||
|
*For any* set of previous findings and current findings, every finding present in the previous set but absent from the current set should have an Archive_Record with state ARCHIVED, and that record should contain the correct finding_id, finding_title, host_name, ip_address, and last_severity matching the original finding's data.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.2, 2.2**
|
||||||
|
|
||||||
|
### Property 2: Returned findings transition from ARCHIVED to RETURNED
|
||||||
|
|
||||||
|
*For any* finding that has an Archive_Record with state ARCHIVED, if that finding reappears in the current sync results, the Archive_Record state should be updated to RETURNED and the last_severity should reflect the finding's current severity score.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.3**
|
||||||
|
|
||||||
|
### Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED
|
||||||
|
|
||||||
|
*For any* finding that has an Archive_Record with state RETURNED, if that finding disappears from the current sync results, the Archive_Record state should be updated back to ARCHIVED.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.4**
|
||||||
|
|
||||||
|
### Property 4: Every state transition produces a history record with all required fields
|
||||||
|
|
||||||
|
*For any* state transition on an Archive_Record, a Transition_History row should be inserted containing a valid archive_id, the correct from_state and to_state, a severity_at_transition value, a non-empty reason string, and a transitioned_at timestamp.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1**
|
||||||
|
|
||||||
|
### Property 5: Closed findings transition to CLOSED state
|
||||||
|
|
||||||
|
*For any* finding that has an Archive_Record with state ARCHIVED or RETURNED, if that finding appears in the Ivanti closed findings set, the Archive_Record state should be updated to CLOSED and the transition reason should be "remediated_in_ivanti".
|
||||||
|
|
||||||
|
**Validates: Requirements 2.3**
|
||||||
|
|
||||||
|
### Property 6: State filter returns only matching records
|
||||||
|
|
||||||
|
*For any* set of Archive_Records with various states, querying the archive list endpoint with a state filter should return only records whose current_state matches the filter, and the count should equal the number of records in that state.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1**
|
||||||
|
|
||||||
|
### Property 7: Transition history is ordered by timestamp descending
|
||||||
|
|
||||||
|
*For any* finding with multiple Transition_History entries, the history endpoint should return entries ordered by transitioned_at descending, such that each entry's timestamp is greater than or equal to the next entry's timestamp.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.2**
|
||||||
|
|
||||||
|
### Property 8: Stats counts match actual record distribution
|
||||||
|
|
||||||
|
*For any* set of Archive_Records, the stats endpoint should return counts where the sum of all state counts equals the total number of Archive_Records, and each individual state count matches the actual number of records in that state.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.3**
|
||||||
|
|
||||||
|
### Property 9: Migration idempotency
|
||||||
|
|
||||||
|
*For any* number of consecutive executions of the migration script, the resulting database schema should be identical and no errors should occur on subsequent runs.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| Sync fails (API error, timeout) | Archive detection is skipped entirely for that cycle. No archive records are created or modified. The sync error is logged as usual. |
|
||||||
|
| Database error during archive upsert | Log the error, continue processing remaining findings. Do not abort the entire archive detection pass. |
|
||||||
|
| Database error during transition insert | Log the error. The archive record state may have been updated but the transition history may be incomplete. This is acceptable as the current state is the source of truth. |
|
||||||
|
| Invalid state transition attempted | The detection logic only performs valid transitions per the state diagram. Invalid transitions (e.g., CLOSED → ARCHIVED) are not possible by design since closed findings are excluded from the sync pipeline. |
|
||||||
|
| Missing finding metadata | Use empty string defaults for finding_title, host_name, ip_address if the finding object lacks these fields. Severity defaults to 0. |
|
||||||
|
| Archive API query with invalid state parameter | Return a 400 status code with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED". Explicit errors surface frontend bugs faster than silent fallbacks. |
|
||||||
|
| History query for non-existent finding | Return 200 with empty transitions array (not 404), per requirement 4.5. |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Unit tests cover specific examples and edge cases:
|
||||||
|
|
||||||
|
- Migration script creates both tables and all indexes (example, Req 3.1–3.4)
|
||||||
|
- Archive detection skips when sync errors occur (example, Req 1.5)
|
||||||
|
- Unauthenticated requests return 401 (example, Req 4.4)
|
||||||
|
- History endpoint returns empty array for unknown finding (edge case, Req 4.5)
|
||||||
|
- Archive Summary Bar renders four stat cards (example, Req 5.1)
|
||||||
|
- Archive Summary Bar fetches stats on mount (example, Req 5.2)
|
||||||
|
- Clicking a state card triggers filter callback (example, Req 5.3)
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Property-based tests use a PBT library (e.g., `fast-check`) to verify universal properties across generated inputs. Each test runs a minimum of 100 iterations.
|
||||||
|
|
||||||
|
| Property | Test Description | Tag |
|
||||||
|
|----------|-----------------|-----|
|
||||||
|
| Property 1 | Generate random previous/current finding sets, run detection, verify all disappeared findings have correct ARCHIVED records | **Feature: finding-archive-tracking, Property 1: Disappeared findings are archived with complete metadata** |
|
||||||
|
| Property 2 | Generate archived findings, add some back to current set, verify RETURNED state | **Feature: finding-archive-tracking, Property 2: Returned findings transition from ARCHIVED to RETURNED** |
|
||||||
|
| Property 3 | Generate returned findings, remove some from current set, verify ARCHIVED state | **Feature: finding-archive-tracking, Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED** |
|
||||||
|
| Property 4 | Generate random state transitions, verify each produces a complete history row | **Feature: finding-archive-tracking, Property 4: Every state transition produces a history record** |
|
||||||
|
| Property 5 | Generate archived/returned findings, mark some as closed, verify CLOSED state and reason | **Feature: finding-archive-tracking, Property 5: Closed findings transition to CLOSED state** |
|
||||||
|
| Property 6 | Generate archive records with random states, query with filter, verify only matching records returned | **Feature: finding-archive-tracking, Property 6: State filter returns only matching records** |
|
||||||
|
| Property 7 | Generate multiple transitions for a finding, query history, verify descending order | **Feature: finding-archive-tracking, Property 7: Transition history is ordered by timestamp descending** |
|
||||||
|
| Property 8 | Generate archive records with random states, query stats, verify counts match | **Feature: finding-archive-tracking, Property 8: Stats counts match actual record distribution** |
|
||||||
|
| Property 9 | Run migration N times, verify no errors and schema is consistent | **Feature: finding-archive-tracking, Property 9: Migration idempotency** |
|
||||||
|
|
||||||
|
### Testing Tools
|
||||||
|
|
||||||
|
- **Test runner**: Jest (via react-scripts for frontend, direct for backend)
|
||||||
|
- **Property-based testing**: `fast-check` library
|
||||||
|
- **Database**: In-memory SQLite (`:memory:`) for isolated test runs
|
||||||
|
- **HTTP testing**: `supertest` for API endpoint tests
|
||||||
86
.kiro/specs/finding-archive-tracking/requirements.md
Normal file
86
.kiro/specs/finding-archive-tracking/requirements.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Finding Archive Tracking system extends the Ivanti sync pipeline in the STEAM Security Dashboard to detect and track findings that disappear from sync results due to severity score drift (not remediation). Findings follow a four-state lifecycle (ACTIVE → ARCHIVED → RETURNED → CLOSED) with full transition history, enabling the security team to maintain visibility into findings that fall below the severity threshold and may reappear.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Sync_Pipeline**: The existing Ivanti/RiskSense host finding sync process that fetches open findings matching BU and severity filters on a daily schedule.
|
||||||
|
- **Finding**: A single host-level vulnerability record identified by a unique `finding_id` from Ivanti/RiskSense.
|
||||||
|
- **Archive_Record**: A database row in the `ivanti_finding_archives` table tracking a finding's current lifecycle state and metadata.
|
||||||
|
- **Transition_History**: A database row in the `ivanti_archive_transitions` table recording a single state change event with timestamps, severity scores, and reason.
|
||||||
|
- **Archive_Detector**: The logic within the sync pipeline that compares previous sync results against current results to identify disappeared and returned findings.
|
||||||
|
- **Archive_Summary_Bar**: A React UI component displaying counts for each lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED) with click-through navigation.
|
||||||
|
- **Archive_API**: The set of three Express route endpoints serving archived finding data, transition history, and summary statistics.
|
||||||
|
- **Lifecycle_State**: One of three database states an archive record can occupy: ARCHIVED (disappeared from sync results due to score drift), RETURNED (reappeared after being archived), CLOSED (remediated in Ivanti). Findings that remain present in sync results have no archive record.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Archive Detection During Sync
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the system to automatically detect findings that disappear from sync results, so that I can track findings lost due to severity score drift rather than actual remediation.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Sync_Pipeline completes a sync, THE Archive_Detector SHALL compare the current sync result finding IDs against the previous sync result finding IDs to identify findings that are no longer present.
|
||||||
|
2. WHEN a finding is present in the previous sync but absent from the current sync, THE Archive_Detector SHALL create an Archive_Record with state ARCHIVED, recording the finding metadata, last known severity score, and a timestamp.
|
||||||
|
3. WHEN a finding already has an Archive_Record with state ARCHIVED and the finding reappears in the current sync results, THE Archive_Detector SHALL update the Archive_Record state to RETURNED and record the new severity score.
|
||||||
|
4. WHEN a finding has an Archive_Record with state RETURNED and the finding disappears again from sync results, THE Archive_Detector SHALL update the Archive_Record state to ARCHIVED and record the severity score at time of disappearance.
|
||||||
|
5. IF the Sync_Pipeline encounters a sync error, THEN THE Archive_Detector SHALL skip archive detection for that sync cycle to avoid false positives from incomplete data.
|
||||||
|
|
||||||
|
### Requirement 2: Lifecycle State Transitions
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want every state change to be recorded with context, so that I can audit the full history of a finding's lifecycle.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an Archive_Record changes state, THE Sync_Pipeline SHALL insert a Transition_History row containing the previous state, new state, timestamp, severity score at time of transition, and a reason string.
|
||||||
|
2. THE Archive_Record SHALL store the finding_id, finding_title, host_name, ip_address, current state, last known severity score, initial archive timestamp, and last transition timestamp.
|
||||||
|
3. WHEN a finding is confirmed as remediated (closed) in Ivanti, THE Sync_Pipeline SHALL update the Archive_Record state to CLOSED and record a Transition_History entry with reason "remediated_in_ivanti".
|
||||||
|
4. THE Transition_History SHALL store the archive_record_id, from_state, to_state, transition timestamp, severity_at_transition, and reason.
|
||||||
|
|
||||||
|
### Requirement 3: Database Schema
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the archive data stored in two normalized SQLite tables, so that the data model supports efficient queries and maintains referential integrity.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Sync_Pipeline SHALL create an `ivanti_finding_archives` table with columns for id, finding_id (unique), finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, and created_at.
|
||||||
|
2. THE Sync_Pipeline SHALL create an `ivanti_archive_transitions` table with columns for id, archive_id (foreign key to ivanti_finding_archives), from_state, to_state, severity_at_transition, reason, and transitioned_at.
|
||||||
|
3. THE Sync_Pipeline SHALL create indexes on ivanti_finding_archives(finding_id) and ivanti_finding_archives(current_state) for query performance.
|
||||||
|
4. THE Sync_Pipeline SHALL create an index on ivanti_archive_transitions(archive_id) for efficient history lookups.
|
||||||
|
|
||||||
|
### Requirement 4: Archive API Endpoints
|
||||||
|
|
||||||
|
**User Story:** As a frontend developer, I want REST API endpoints to query archived findings, transition history, and summary statistics, so that I can build the archive tracking UI.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is made to `/api/ivanti/archive`, THE Archive_API SHALL return a list of all Archive_Records with optional filtering by current_state query parameter.
|
||||||
|
2. WHEN a GET request is made to `/api/ivanti/archive/:findingId/history`, THE Archive_API SHALL return the Transition_History entries for the specified finding ordered by transitioned_at descending.
|
||||||
|
3. WHEN a GET request is made to `/api/ivanti/archive/stats`, THE Archive_API SHALL return an object containing the count of Archive_Records in each Lifecycle_State (ACTIVE, ARCHIVED, RETURNED, CLOSED).
|
||||||
|
4. WHEN an unauthenticated request is made to any Archive_API endpoint, THE Archive_API SHALL return a 401 status code.
|
||||||
|
5. WHEN a GET request is made to `/api/ivanti/archive/:findingId/history` with a finding_id that has no Archive_Record, THE Archive_API SHALL return an empty transitions array with a 200 status code.
|
||||||
|
|
||||||
|
### Requirement 5: Archive Summary Bar UI
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want a visual summary bar on the Ivanti dashboard showing counts for each archive state, so that I can quickly assess the archive landscape and navigate to details.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Archive_Summary_Bar SHALL display four stat cards showing the count of findings in each Lifecycle_State: ACTIVE, ARCHIVED, RETURNED, and CLOSED.
|
||||||
|
2. WHEN the Archive_Summary_Bar loads, THE Archive_Summary_Bar SHALL fetch data from the `/api/ivanti/archive/stats` endpoint.
|
||||||
|
3. WHEN a user clicks a state card in the Archive_Summary_Bar, THE Archive_Summary_Bar SHALL filter the displayed archive list to show only findings in that state.
|
||||||
|
4. THE Archive_Summary_Bar SHALL use the existing design system colors: sky blue (#0EA5E9) for ACTIVE, amber (#F59E0B) for ARCHIVED, emerald (#10B981) for RETURNED, and red (#EF4444) for CLOSED.
|
||||||
|
5. THE Archive_Summary_Bar SHALL use Lucide icons and monospace typography consistent with the existing dashboard design system.
|
||||||
|
|
||||||
|
### Requirement 6: Migration Script
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want a standalone migration script to create the archive tables, so that the schema can be applied to existing deployments following the established migration pattern.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE migration script SHALL be located at `backend/migrations/add_finding_archive_tables.js` and follow the existing migration pattern of opening the database, running DDL statements in `db.serialize()`, and closing the connection.
|
||||||
|
2. THE migration script SHALL use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` to be idempotent.
|
||||||
|
3. WHEN the migration script is executed, THE migration script SHALL log progress messages for each table and index created.
|
||||||
134
.kiro/specs/finding-archive-tracking/tasks.md
Normal file
134
.kiro/specs/finding-archive-tracking/tasks.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Implementation Plan: Finding Archive Tracking
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement the Finding Archive Tracking system by creating the database migration, archive detection logic within the existing sync pipeline, three API endpoints via a new route module, and an Archive Summary Bar UI component. Each task builds incrementally — schema first, then detection logic, then API, then frontend.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Create database migration and archive tables
|
||||||
|
- [x] 1.1 Create `backend/migrations/add_finding_archive_tables.js` migration script
|
||||||
|
- Create `ivanti_finding_archives` table with columns: id, finding_id (UNIQUE), finding_title, host_name, ip_address, current_state (CHECK constraint for ACTIVE/ARCHIVED/RETURNED/CLOSED), last_severity, first_archived_at, last_transition_at, created_at
|
||||||
|
- Create `ivanti_archive_transitions` table with columns: id, archive_id (FK), from_state, to_state, severity_at_transition, reason, transitioned_at
|
||||||
|
- Create indexes: idx_archive_finding_id, idx_archive_current_state, idx_transition_archive_id
|
||||||
|
- Use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` for idempotency
|
||||||
|
- Follow existing migration pattern: open db, `db.serialize()`, log progress, close db
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 6.1, 6.2, 6.3_
|
||||||
|
|
||||||
|
- [ ]* 1.2 Write property test for migration idempotency
|
||||||
|
- **Property 9: Migration idempotency**
|
||||||
|
- Run migration logic multiple times against in-memory SQLite, verify no errors and schema is consistent
|
||||||
|
- **Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
- [x] 2. Implement archive detection logic in sync pipeline
|
||||||
|
- [x] 2.1 Add `initArchiveTables(db)` function to `backend/routes/ivantiFindings.js`
|
||||||
|
- Create both archive tables inline (same pattern as existing `initTables`) so they exist on startup
|
||||||
|
- Call from `createIvantiFindingsRouter` during init alongside existing `initTables`
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||||
|
|
||||||
|
- [x] 2.2 Implement `detectArchiveChanges(db, previousFindings, currentFindings)` function
|
||||||
|
- Build ID sets from previous and current findings
|
||||||
|
- For disappeared findings (in previous, not in current): upsert archive record with state ARCHIVED, insert transition history
|
||||||
|
- For returned findings (in current, has ARCHIVED record): update to RETURNED, insert transition history
|
||||||
|
- For re-disappeared findings (has RETURNED record, not in current): update to ARCHIVED, insert transition history
|
||||||
|
- Use `db.run` with callbacks wrapped in promises (matching existing `dbRun` helper pattern)
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2_
|
||||||
|
|
||||||
|
- [x] 2.3 Implement `detectClosedFindings(db, closedFindingIds)` function
|
||||||
|
- Query archive records with state ARCHIVED or RETURNED
|
||||||
|
- For any that appear in the closed findings set, update to CLOSED with reason "remediated_in_ivanti"
|
||||||
|
- Insert transition history for each state change
|
||||||
|
- _Requirements: 2.3_
|
||||||
|
|
||||||
|
- [x] 2.4 Integrate archive detection into `syncFindings()` flow
|
||||||
|
- Before updating the cache, read the current findings from `ivanti_findings_cache` as `previousFindings`
|
||||||
|
- After successful cache update, call `detectArchiveChanges(db, previousFindings, currentFindings)`
|
||||||
|
- Skip archive detection if sync encountered an error (requirement 1.5)
|
||||||
|
- Call `detectClosedFindings` during `syncClosedCount` with closed finding IDs
|
||||||
|
- _Requirements: 1.1, 1.5, 2.3_
|
||||||
|
|
||||||
|
- [ ]* 2.5 Write property test for archive detection — disappeared findings
|
||||||
|
- **Property 1: Disappeared findings are archived with complete metadata**
|
||||||
|
- Generate random previous/current finding sets using fast-check, run detectArchiveChanges against in-memory SQLite, verify all disappeared findings have ARCHIVED records with correct metadata
|
||||||
|
- **Validates: Requirements 1.1, 1.2, 2.2**
|
||||||
|
|
||||||
|
- [ ]* 2.6 Write property test for archive detection — returned findings
|
||||||
|
- **Property 2: Returned findings transition from ARCHIVED to RETURNED**
|
||||||
|
- Generate archived findings, add some back to current set, verify RETURNED state and updated severity
|
||||||
|
- **Validates: Requirements 1.3**
|
||||||
|
|
||||||
|
- [ ]* 2.7 Write property test for archive detection — re-disappeared findings
|
||||||
|
- **Property 3: Re-disappeared findings transition from RETURNED to ARCHIVED**
|
||||||
|
- Generate returned findings, remove some from current set, verify ARCHIVED state
|
||||||
|
- **Validates: Requirements 1.4**
|
||||||
|
|
||||||
|
- [ ]* 2.8 Write property test for transition history completeness
|
||||||
|
- **Property 4: Every state transition produces a history record with all required fields**
|
||||||
|
- Generate random state transitions, verify each produces a complete history row with archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at
|
||||||
|
- **Validates: Requirements 2.1**
|
||||||
|
|
||||||
|
- [ ]* 2.9 Write property test for closed finding detection
|
||||||
|
- **Property 5: Closed findings transition to CLOSED state**
|
||||||
|
- Generate archived/returned findings, mark some as closed, verify CLOSED state and reason "remediated_in_ivanti"
|
||||||
|
- **Validates: Requirements 2.3**
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint — Verify archive detection logic
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Implement Archive API endpoints
|
||||||
|
- [x] 4.1 Create `backend/routes/ivantiArchive.js` route module
|
||||||
|
- Export factory function `createIvantiArchiveRouter(db, requireAuth)` returning Express Router
|
||||||
|
- Apply `requireAuth(db)` middleware to all routes
|
||||||
|
- Implement GET `/` — list archive records with optional `?state=` filter, return `{ archives: [...], total: N }`. Return 400 with message "Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED" if an unrecognized state value is provided.
|
||||||
|
- Implement GET `/stats` — return `{ ACTIVE: N, ARCHIVED: N, RETURNED: N, CLOSED: N, total: N }`
|
||||||
|
- Implement GET `/:findingId/history` — return `{ finding_id, transitions: [...] }` ordered by transitioned_at DESC, return empty array for unknown finding_id
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
|
- [x] 4.2 Register archive router in `backend/server.js`
|
||||||
|
- Import `createIvantiArchiveRouter` from `./routes/ivantiArchive`
|
||||||
|
- Mount at `/api/ivanti/archive` with `requireAuth` middleware
|
||||||
|
- _Requirements: 4.1_
|
||||||
|
|
||||||
|
- [ ]* 4.3 Write property test for state filtering
|
||||||
|
- **Property 6: State filter returns only matching records**
|
||||||
|
- Generate archive records with random states, query with filter, verify only matching records returned
|
||||||
|
- **Validates: Requirements 4.1**
|
||||||
|
|
||||||
|
- [ ]* 4.4 Write property test for history ordering
|
||||||
|
- **Property 7: Transition history is ordered by timestamp descending**
|
||||||
|
- Generate multiple transitions for a finding, query history, verify descending timestamp order
|
||||||
|
- **Validates: Requirements 4.2**
|
||||||
|
|
||||||
|
- [ ]* 4.5 Write property test for stats accuracy
|
||||||
|
- **Property 8: Stats counts match actual record distribution**
|
||||||
|
- Generate archive records with random states, query stats, verify counts match actual distribution
|
||||||
|
- **Validates: Requirements 4.3**
|
||||||
|
|
||||||
|
- [x] 5. Checkpoint — Verify API endpoints
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 6. Implement Archive Summary Bar UI component
|
||||||
|
- [x] 6.1 Create `frontend/src/components/pages/ArchiveSummaryBar.js`
|
||||||
|
- Fetch stats from `/api/ivanti/archive/stats` on mount
|
||||||
|
- Render four stat cards: ACTIVE (sky blue #0EA5E9), ARCHIVED (amber #F59E0B), RETURNED (emerald #10B981), CLOSED (red #EF4444)
|
||||||
|
- Each card shows the count and state label with Lucide icons and monospace typography
|
||||||
|
- Accept `onStateClick` callback prop and `activeFilter` prop for highlighting the selected state
|
||||||
|
- Use inline style objects matching the existing design system (dark gradients, glows, hover effects)
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||||
|
|
||||||
|
- [x] 6.2 Integrate Archive Summary Bar into the Ivanti findings page
|
||||||
|
- Import and render `ArchiveSummaryBar` in the Ivanti findings section of `App.js` (or the relevant page component)
|
||||||
|
- Wire `onStateClick` to manage a state filter for the archive list display
|
||||||
|
- _Requirements: 5.3_
|
||||||
|
|
||||||
|
- [x] 7. Final checkpoint — Verify full integration
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests use `fast-check` library with minimum 100 iterations per test
|
||||||
|
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
|
||||||
|
- All frontend code uses plain JavaScript (no TypeScript)
|
||||||
0
.kiro/specs/group-based-access-control/design.md
Normal file
0
.kiro/specs/group-based-access-control/design.md
Normal file
143
.kiro/specs/group-based-access-control/requirements.md
Normal file
143
.kiro/specs/group-based-access-control/requirements.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Replace the existing simple role-based access control system (admin/editor/viewer) with a group-based access control model. The system supports exactly four user groups (Admin, Standard User, Leadership, Read Only) with distinct permission boundaries. This change affects the database schema, backend middleware, API endpoint authorization, frontend conditional rendering, and the admin panel user management interface.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: The STEAM Security Dashboard application comprising a React frontend and Express backend
|
||||||
|
- **Group**: One of four access control categories (Admin, Standard_User, Leadership, Read_Only) that determines a user's permissions
|
||||||
|
- **Admin_Group**: The group with full CRUD access to all resources, user management, and admin panel access
|
||||||
|
- **Standard_User_Group**: The working group with view-all, create, edit, and conditional delete permissions plus basic export
|
||||||
|
- **Leadership_Group**: The read-only group with additional export capabilities for reports, compliance documents, and visualizations
|
||||||
|
- **Read_Only_Group**: The view-only group with no create, edit, delete, or export capabilities
|
||||||
|
- **Permission_Middleware**: Backend Express middleware that validates a user's group membership before allowing an API action
|
||||||
|
- **Cascade_Impact**: The set of associated Archer tickets, JIRA tickets, and documents that would be deleted when a CVE is deleted
|
||||||
|
- **Compliance_Link**: An association between a ticket (Archer or JIRA) and a compliance report that blocks Standard_User deletion
|
||||||
|
- **Group_Migration**: The database migration that replaces the role field with a group field and maps existing users
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Group Data Model
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want the user model to reference one of four defined groups instead of the legacy role field, so that permissions are enforced through a well-defined group structure.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL store exactly four groups: Admin, Standard_User, Leadership, and Read_Only
|
||||||
|
2. THE Dashboard SHALL assign each user to exactly one group via a group field on the user record
|
||||||
|
3. WHEN a user record is created, THE Dashboard SHALL default the group to Read_Only
|
||||||
|
4. THE Dashboard SHALL enforce a foreign key or CHECK constraint so that the group field only accepts valid group values
|
||||||
|
|
||||||
|
### Requirement 2: Group Migration
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want existing users to be automatically mapped from the old role system to the new group system, so that no manual re-assignment is needed after the upgrade.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the migration runs, THE Group_Migration SHALL map users with role "admin" to Admin_Group
|
||||||
|
2. WHEN the migration runs, THE Group_Migration SHALL map users with role "editor" to Standard_User_Group
|
||||||
|
3. WHEN the migration runs, THE Group_Migration SHALL map users with role "viewer" to Read_Only_Group
|
||||||
|
4. WHEN the migration runs, THE Group_Migration SHALL remove the CHECK constraint on the old role column and replace it with the new group field
|
||||||
|
5. IF a user record has no role value or an unrecognized role value, THEN THE Group_Migration SHALL assign that user to Read_Only_Group
|
||||||
|
|
||||||
|
### Requirement 3: Backend Permission Enforcement
|
||||||
|
|
||||||
|
**User Story:** As a security-conscious developer, I want every API endpoint to check the requesting user's group before allowing the action, so that permissions are enforced server-side and cannot be bypassed through direct API calls.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Permission_Middleware SHALL replace the existing requireRole middleware with a requireGroup middleware that accepts one or more group names
|
||||||
|
2. WHEN an unauthenticated request reaches a protected endpoint, THE Permission_Middleware SHALL return HTTP 401
|
||||||
|
3. WHEN an authenticated user's group is not in the allowed groups for an endpoint, THE Permission_Middleware SHALL return HTTP 403
|
||||||
|
4. THE Permission_Middleware SHALL attach the user's group to the request object for downstream route handlers to use
|
||||||
|
5. WHEN a Standard_User_Group user attempts to delete a resource they did not create, THE Dashboard SHALL return HTTP 403
|
||||||
|
6. WHEN a Standard_User_Group user attempts to delete a finding that is marked as resolved or closed, THE Dashboard SHALL return HTTP 403
|
||||||
|
7. WHEN a Standard_User_Group user attempts to delete a ticket that is linked to a compliance report, THE Dashboard SHALL return HTTP 403
|
||||||
|
8. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL check for Cascade_Impact and return the list of associated Archer tickets, JIRA tickets, and documents
|
||||||
|
9. IF any ticket in the Cascade_Impact is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion and return HTTP 403 with a message indicating Admin-only deletion is required
|
||||||
|
10. WHEN an Admin_Group user performs any CRUD operation, THE Dashboard SHALL allow the operation without ownership or state restrictions
|
||||||
|
|
||||||
|
### Requirement 4: Admin Group Permissions
|
||||||
|
|
||||||
|
**User Story:** As an admin, I want full unrestricted access to all resources and management functions, so that I can manage the entire system without limitations.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL allow Admin_Group users to create, read, update, and delete all resources (CVEs, findings, tickets, comments, compliance reports)
|
||||||
|
2. THE Dashboard SHALL allow Admin_Group users to access the admin panel
|
||||||
|
3. THE Dashboard SHALL allow Admin_Group users to manage users and assign users to groups
|
||||||
|
4. THE Dashboard SHALL allow Admin_Group users to export all data
|
||||||
|
5. THE Dashboard SHALL allow Admin_Group users to delete any resource regardless of ownership, state, or compliance linkage
|
||||||
|
|
||||||
|
### Requirement 5: Standard User Group Permissions
|
||||||
|
|
||||||
|
**User Story:** As a standard user, I want to view all data and create/edit resources while having controlled delete access, so that I can do my daily work without accidentally removing critical linked data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL allow Standard_User_Group users to view all data across the dashboard
|
||||||
|
2. THE Dashboard SHALL allow Standard_User_Group users to create and edit CVEs, findings, tickets, and comments
|
||||||
|
3. THE Dashboard SHALL allow Standard_User_Group users to delete their own findings, tickets, and comments subject to state and linkage restrictions
|
||||||
|
4. WHEN a Standard_User_Group user attempts to delete a finding that is resolved or closed, THE Dashboard SHALL reject the deletion
|
||||||
|
5. WHEN a Standard_User_Group user attempts to delete a ticket linked to a compliance report, THE Dashboard SHALL reject the deletion
|
||||||
|
6. WHEN a Standard_User_Group user attempts to delete a CVE they created, THE Dashboard SHALL display a warning listing associated Archer tickets, JIRA tickets, and documents that will be cascade-deleted
|
||||||
|
7. IF any associated ticket in the cascade is linked to a compliance report, THEN THE Dashboard SHALL block the CVE deletion entirely
|
||||||
|
8. THE Dashboard SHALL allow Standard_User_Group users to perform basic exports (CSV and XLSX of CVEs and findings)
|
||||||
|
|
||||||
|
### Requirement 6: Leadership Group Permissions
|
||||||
|
|
||||||
|
**User Story:** As a leadership user, I want read-only access with export capabilities, so that I can review data and generate reports without risk of modifying records.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL allow Leadership_Group users to view all data across the dashboard
|
||||||
|
2. THE Dashboard SHALL allow Leadership_Group users to export reports, compliance documents, and graph visualizations
|
||||||
|
3. THE Dashboard SHALL prevent Leadership_Group users from creating, editing, or deleting any records
|
||||||
|
4. THE Dashboard SHALL prevent Leadership_Group users from accessing the admin panel
|
||||||
|
|
||||||
|
### Requirement 7: Read Only Group Permissions
|
||||||
|
|
||||||
|
**User Story:** As a read-only user, I want view-only access to the dashboard, so that I can see data without any ability to modify or export it.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL allow Read_Only_Group users to view all data across the dashboard
|
||||||
|
2. THE Dashboard SHALL prevent Read_Only_Group users from creating, editing, or deleting any records
|
||||||
|
3. THE Dashboard SHALL prevent Read_Only_Group users from exporting any data
|
||||||
|
4. THE Dashboard SHALL prevent Read_Only_Group users from accessing the admin panel
|
||||||
|
|
||||||
|
### Requirement 8: Admin Panel Group Management
|
||||||
|
|
||||||
|
**User Story:** As an admin, I want to view all users with their current group and reassign groups through the admin panel, so that I can manage access control centrally.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an Admin_Group user opens the user management section, THE Dashboard SHALL display all users with their current group assignment
|
||||||
|
2. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL update the group assignment and persist it to the database
|
||||||
|
3. WHEN an Admin_Group user changes a user's group, THE Dashboard SHALL display a confirmation dialog before applying the change
|
||||||
|
4. WHEN an Admin_Group user downgrades another Admin_Group user, THE Dashboard SHALL display an additional warning in the confirmation dialog
|
||||||
|
5. THE Dashboard SHALL prevent an Admin_Group user from changing their own group to a non-Admin group
|
||||||
|
|
||||||
|
### Requirement 9: Audit Logging for Group Changes
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want all group assignment changes to be logged with full context, so that I can audit who changed access for whom and when.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user's group is changed, THE Dashboard SHALL log the change with the acting user's ID, the target user's ID, the previous group, the new group, and a timestamp
|
||||||
|
2. THE Dashboard SHALL preserve existing audit trail behavior for all CRUD operations performed under the new group system
|
||||||
|
3. WHEN a group change is logged, THE Dashboard SHALL record the IP address of the acting user
|
||||||
|
|
||||||
|
### Requirement 10: Frontend Conditional Rendering
|
||||||
|
|
||||||
|
**User Story:** As a user, I want the UI to show only the actions available to my group, so that I have a clear and uncluttered interface matching my permissions.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL conditionally render create, edit, and delete buttons based on the current user's group
|
||||||
|
2. THE Dashboard SHALL conditionally render export options based on the current user's group
|
||||||
|
3. THE Dashboard SHALL conditionally render the admin panel link based on the current user's group
|
||||||
|
4. WHEN a Standard_User_Group user views a resource they did not create, THE Dashboard SHALL hide the delete button for that resource
|
||||||
|
5. THE Dashboard SHALL replace the existing role-based helper functions (hasRole, canWrite, isAdmin) with group-based equivalents (isInGroup, canWrite, canDelete, canExport, isAdmin)
|
||||||
279
.kiro/specs/group-based-access-control/tasks.md
Normal file
279
.kiro/specs/group-based-access-control/tasks.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Implementation Plan: Group-Based Access Control
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the existing role-based access control (admin/editor/viewer) with a four-group model (Admin, Standard_User, Leadership, Read_Only). This touches the database schema, backend middleware, all route authorization, frontend permission helpers, and the admin panel UI. Tasks build incrementally: migration first, then middleware, then routes, then frontend.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] 1. Create database migration for user groups
|
||||||
|
- [x] 1.1 Create `backend/migrations/add_user_groups.js` migration script
|
||||||
|
- Add `user_group` column (VARCHAR(20), NOT NULL, DEFAULT 'Read_Only') to users table
|
||||||
|
- Map existing role values: admin to Admin, editor to Standard_User, viewer to Read_Only
|
||||||
|
- Map NULL or unrecognized role values to Read_Only
|
||||||
|
- Add CHECK constraint: user_group IN ('Admin', 'Standard_User', 'Leadership', 'Read_Only')
|
||||||
|
- Add index `idx_users_user_group` on user_group column
|
||||||
|
- Use idempotent checks so migration is safe to run multiple times
|
||||||
|
- Follow existing migration pattern: open db, db.serialize(), log progress, close db
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||||
|
|
||||||
|
- [ ]* 1.2 Write property test for migration role mapping
|
||||||
|
- **Property 8: Migration maps all role values correctly**
|
||||||
|
- Generate users with random roles from {admin, editor, viewer, NULL, arbitrary}, run migration against in-memory SQLite, verify mapping
|
||||||
|
- **Validates: Requirements 2.1, 2.2, 2.3, 2.5**
|
||||||
|
|
||||||
|
- [ ]* 1.3 Write property test for migration idempotency
|
||||||
|
- **Property 9: Migration is idempotent**
|
||||||
|
- Run migration N times (N in 1-5) against in-memory SQLite, verify schema and data identical each time
|
||||||
|
- **Validates: Requirements 2.4**
|
||||||
|
|
||||||
|
- [ ] 1.4 Write unit tests for migration
|
||||||
|
- Test column creation with correct CHECK constraint
|
||||||
|
- Test role mapping: admin to Admin, editor to Standard_User, viewer to Read_Only
|
||||||
|
- Test NULL and unrecognized role handling defaults to Read_Only
|
||||||
|
- Test new user defaults to Read_Only group
|
||||||
|
- _Requirements: 1.3, 1.4, 2.1, 2.2, 2.3, 2.5_
|
||||||
|
|
||||||
|
- [ ] 2. Update auth middleware to use groups
|
||||||
|
- [x] 2.1 Update `requireAuth` in `backend/middleware/auth.js`
|
||||||
|
- Modify session join query to SELECT user_group and attach as req.user.group
|
||||||
|
- _Requirements: 3.4_
|
||||||
|
|
||||||
|
- [x] 2.2 Add `requireGroup` middleware function
|
||||||
|
- Accept spread of allowed group names
|
||||||
|
- Return 401 if req.user is missing
|
||||||
|
- Return 403 with error details if user group not in allowed set
|
||||||
|
- Call next() if group is allowed
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3_
|
||||||
|
|
||||||
|
- [x] 2.3 Remove `requireRole` and export `requireGroup`
|
||||||
|
- Remove requireRole function and its export
|
||||||
|
- Export requireGroup in its place
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
|
||||||
|
- [ ]* 2.4 Write property test for group constraint
|
||||||
|
- **Property 1: Group constraint rejects invalid values**
|
||||||
|
- Generate random strings not in valid group set, attempt DB insert, verify constraint error
|
||||||
|
- **Validates: Requirements 1.1, 1.4**
|
||||||
|
|
||||||
|
- [ ]* 2.5 Write property test for requireGroup
|
||||||
|
- **Property 3: requireGroup rejects unauthorized groups**
|
||||||
|
- Generate random group and allowedGroups pairs where group is not in allowed set, verify 403
|
||||||
|
- **Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
- [ ] 2.6 Write unit tests for requireGroup middleware
|
||||||
|
- Test 401 for unauthenticated requests
|
||||||
|
- Test 403 for wrong group
|
||||||
|
- Test group attached to req.user
|
||||||
|
- Test next() called for allowed group
|
||||||
|
- _Requirements: 3.2, 3.3, 3.4_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint: Verify migration and middleware
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [ ] 4. Update auth routes to return group
|
||||||
|
- [x] 4.1 Update login endpoint in `backend/routes/auth.js`
|
||||||
|
- Return group (from user_group) instead of role in user response object
|
||||||
|
- Update audit log details to log group instead of role
|
||||||
|
- _Requirements: 3.4, 9.2_
|
||||||
|
|
||||||
|
- [x] 4.2 Update me endpoint in `backend/routes/auth.js`
|
||||||
|
- Return group instead of role in user response object
|
||||||
|
- _Requirements: 3.4_
|
||||||
|
|
||||||
|
- [ ] 5. Update user management routes
|
||||||
|
- [x] 5.1 Switch `backend/routes/users.js` to use requireGroup
|
||||||
|
- Replace requireRole('admin') with requireGroup('Admin')
|
||||||
|
- _Requirements: 4.2, 4.3_
|
||||||
|
|
||||||
|
- [x] 5.2 Update GET endpoints to return user_group
|
||||||
|
- Return user_group instead of role in user records
|
||||||
|
- _Requirements: 8.1_
|
||||||
|
|
||||||
|
- [x] 5.3 Update POST create user to accept group param
|
||||||
|
- Validate group against valid values
|
||||||
|
- Default to Read_Only if not provided
|
||||||
|
- Return 400 for invalid group values
|
||||||
|
- _Requirements: 1.3, 8.2_
|
||||||
|
|
||||||
|
- [x] 5.4 Update PATCH update user to accept group param
|
||||||
|
- Validate group against valid values
|
||||||
|
- Prevent admin self-demotion (return 400)
|
||||||
|
- _Requirements: 8.2, 8.5_
|
||||||
|
|
||||||
|
- [x] 5.5 Add audit logging for group changes
|
||||||
|
- Log acting user ID, target user ID, previous group, new group, IP address, timestamp
|
||||||
|
- _Requirements: 9.1, 9.3_
|
||||||
|
|
||||||
|
- [ ]* 5.6 Write property test for user group validity
|
||||||
|
- **Property 2: Every user has exactly one valid group**
|
||||||
|
- Generate random user sets, query all users, verify each has exactly one valid group
|
||||||
|
- **Validates: Requirements 1.2**
|
||||||
|
|
||||||
|
- [ ] 5.7 Write unit tests for user management group logic
|
||||||
|
- Test group validation rejects invalid values
|
||||||
|
- Test self-demotion prevention
|
||||||
|
- Test audit logging includes all required fields
|
||||||
|
- _Requirements: 8.2, 8.5, 9.1, 9.3_
|
||||||
|
|
||||||
|
- [ ] 6. Update backend route authorization across all routes
|
||||||
|
- [x] 6.1 Update `backend/routes/auditLog.js`
|
||||||
|
- Replace requireRole('admin') with requireGroup('Admin')
|
||||||
|
- _Requirements: 4.2_
|
||||||
|
|
||||||
|
- [x] 6.2 Update `backend/routes/archerTickets.js`
|
||||||
|
- Use requireGroup('Admin', 'Standard_User') for create, update, delete
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [x] 6.3 Update `backend/routes/knowledgeBase.js`
|
||||||
|
- Use requireGroup('Admin', 'Standard_User') for upload and delete
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [x] 6.4 Update `backend/routes/ivantiFindings.js`
|
||||||
|
- Use requireGroup('Admin', 'Standard_User') for override endpoint
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [x] 6.5 Update `backend/routes/compliance.js`
|
||||||
|
- Use requireGroup('Admin', 'Standard_User') for preview and commit
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [x] 6.6 Update `backend/server.js` inline CVE routes
|
||||||
|
- Use requireGroup('Admin', 'Standard_User') for POST, PUT, PATCH, DELETE
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [x] 6.7 Update `backend/server.js` route mounting
|
||||||
|
- Pass requireGroup instead of requireRole to route factories
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
|
||||||
|
- [ ]* 6.8 Write property test for Leadership restrictions
|
||||||
|
- **Property 5: Leadership cannot mutate any resource**
|
||||||
|
- Generate random mutation requests as Leadership, verify 403
|
||||||
|
- **Validates: Requirements 6.3**
|
||||||
|
|
||||||
|
- [ ]* 6.9 Write property test for Read_Only restrictions
|
||||||
|
- **Property 6: Read_Only cannot mutate or export**
|
||||||
|
- Generate random mutation and export requests as Read_Only, verify 403
|
||||||
|
- **Validates: Requirements 7.2, 7.3**
|
||||||
|
|
||||||
|
- [x] 7. Checkpoint: Verify backend route authorization
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [ ] 8. Implement Standard User conditional delete logic
|
||||||
|
- [x] 8.1 Add created_by column tracking
|
||||||
|
- Add created_by to CVE, finding, and ticket creation endpoints storing req.user.id on insert
|
||||||
|
- _Requirements: 3.5_
|
||||||
|
|
||||||
|
- [x] 8.2 Implement ownership check for CVE delete
|
||||||
|
- Standard_User can only delete CVEs they created
|
||||||
|
- Return 403 if not owner
|
||||||
|
- _Requirements: 3.5_
|
||||||
|
|
||||||
|
- [x] 8.3 Implement cascade impact check for CVE delete
|
||||||
|
- Query associated Archer tickets and documents
|
||||||
|
- Check compliance linkage on cascaded tickets
|
||||||
|
- Return cascade_impact response schema
|
||||||
|
- Block deletion if any cascaded ticket is compliance-linked
|
||||||
|
- _Requirements: 3.8, 3.9_
|
||||||
|
|
||||||
|
- [x] 8.4 Implement state check for finding delete
|
||||||
|
- Standard_User cannot delete resolved or closed findings
|
||||||
|
- Return 403 with appropriate error message
|
||||||
|
- _Requirements: 3.6_
|
||||||
|
|
||||||
|
- [x] 8.5 Implement compliance linkage check for ticket delete
|
||||||
|
- Standard_User cannot delete tickets linked to compliance reports
|
||||||
|
- Return 403 with appropriate error message
|
||||||
|
- _Requirements: 3.7_
|
||||||
|
|
||||||
|
- [x] 8.6 Ensure Admin bypasses all delete restrictions
|
||||||
|
- Admin group skips ownership, state, and compliance checks
|
||||||
|
- _Requirements: 3.10, 4.5_
|
||||||
|
|
||||||
|
- [ ]* 8.7 Write property test for Admin delete bypass
|
||||||
|
- **Property 4: Admin bypasses all delete restrictions**
|
||||||
|
- Generate resources with random ownership, state, compliance linkage, delete as Admin, verify success
|
||||||
|
- **Validates: Requirements 3.10, 4.1, 4.5**
|
||||||
|
|
||||||
|
- [ ] 8.8 Write unit tests for conditional delete logic
|
||||||
|
- Test ownership rejection for non-owner
|
||||||
|
- Test state rejection for resolved/closed findings
|
||||||
|
- Test compliance linkage rejection
|
||||||
|
- Test cascade impact response format
|
||||||
|
- Test Admin bypass of all restrictions
|
||||||
|
- _Requirements: 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
|
||||||
|
|
||||||
|
- [x] 9. Checkpoint: Verify conditional delete logic
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [ ] 10. Update frontend AuthContext with group helpers
|
||||||
|
- [x] 10.1 Update `frontend/src/contexts/AuthContext.js`
|
||||||
|
- Read group from user object instead of role
|
||||||
|
- Replace hasRole with isInGroup(...groups) helper
|
||||||
|
- Update canWrite to check isInGroup('Admin', 'Standard_User')
|
||||||
|
- Add canDelete(resource) helper: Admin always true, Standard_User only if owns resource, others false
|
||||||
|
- Add canExport() helper: true for Admin, Standard_User, Leadership
|
||||||
|
- Update isAdmin() to check isInGroup('Admin')
|
||||||
|
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
|
||||||
|
|
||||||
|
- [ ]* 10.2 Write property test for permission helpers
|
||||||
|
- **Property 7: Group permission helpers are consistent with group matrix**
|
||||||
|
- Generate all valid group values, call each helper, verify against permission matrix
|
||||||
|
- **Validates: Requirements 10.5**
|
||||||
|
|
||||||
|
- [ ] 11. Update frontend UI for group-based rendering
|
||||||
|
- [x] 11.1 Update `App.js` conditional rendering
|
||||||
|
- Use canWrite, canDelete, canExport, isAdmin for button and link visibility
|
||||||
|
- _Requirements: 10.1, 10.2, 10.3_
|
||||||
|
|
||||||
|
- [x] 11.2 Update `NavDrawer.js`
|
||||||
|
- Show admin panel link only when isAdmin() is true
|
||||||
|
- _Requirements: 10.3_
|
||||||
|
|
||||||
|
- [x] 11.3 Update `UserMenu.js`
|
||||||
|
- Display user group instead of role
|
||||||
|
- _Requirements: 10.1_
|
||||||
|
|
||||||
|
- [x] 11.4 Update all components using hasRole or canWrite
|
||||||
|
- Replace with new group-based helpers throughout components
|
||||||
|
- _Requirements: 10.5_
|
||||||
|
|
||||||
|
- [x] 11.5 Hide delete buttons for non-owned resources
|
||||||
|
- Standard_User sees delete only on resources they created
|
||||||
|
- _Requirements: 10.4_
|
||||||
|
|
||||||
|
- [ ] 12. Update User Management UI
|
||||||
|
- [x] 12.1 Replace role dropdown with group dropdown in `UserManagement.js`
|
||||||
|
- Options: Admin, Standard_User, Leadership, Read_Only
|
||||||
|
- _Requirements: 8.1, 8.2_
|
||||||
|
|
||||||
|
- [x] 12.2 Update form data and API calls to use group field
|
||||||
|
- Send group instead of role in create and update requests
|
||||||
|
- _Requirements: 8.2_
|
||||||
|
|
||||||
|
- [x] 12.3 Add confirmation dialog for group changes
|
||||||
|
- Show confirmation before applying any group change
|
||||||
|
- _Requirements: 8.3_
|
||||||
|
|
||||||
|
- [x] 12.4 Add extra warning when downgrading Admin
|
||||||
|
- Show additional warning in confirmation dialog
|
||||||
|
- _Requirements: 8.4_
|
||||||
|
|
||||||
|
- [x] 12.5 Prevent admin self-demotion in UI
|
||||||
|
- Disable group change dropdown for current user if Admin
|
||||||
|
- _Requirements: 8.5_
|
||||||
|
|
||||||
|
- [x] 12.6 Update user table to show group badges
|
||||||
|
- Display group badge with appropriate colors instead of role badge
|
||||||
|
- _Requirements: 8.1_
|
||||||
|
|
||||||
|
- [x] 13. Final checkpoint: Verify full integration
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests use `fast-check` library with minimum 100 iterations per test
|
||||||
|
- All backend code uses callback-based SQLite API wrapped in promises (matching existing patterns)
|
||||||
|
- All frontend code uses plain JavaScript (no TypeScript)
|
||||||
321
.kiro/specs/ivanti-fp-workflow-submission/design.md
Normal file
321
.kiro/specs/ivanti-fp-workflow-submission/design.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# Design Document: Ivanti FP Workflow Submission
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature extends the existing Ivanti Queue (QueuePanel) in the Reporting Page to allow users to submit False Positive (FP) workflows directly to the Ivanti/RiskSense API. The implementation adds a submission modal triggered from the queue panel, a backend API endpoint that proxies the workflow creation and attachment upload to Ivanti, and local tracking of submissions in SQLite.
|
||||||
|
|
||||||
|
The design follows existing codebase conventions: factory-pattern Express routes, inline React styles with the dark tactical theme, Multer for file uploads, and the `ivantiPost()` HTTP helper for Ivanti API calls.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User (Browser)
|
||||||
|
participant FE as React Frontend
|
||||||
|
participant BE as Express Backend
|
||||||
|
participant IV as Ivanti API
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
U->>FE: Select FP queue items, click "Create FP Workflow"
|
||||||
|
FE->>FE: Open FpWorkflowModal with selected items
|
||||||
|
U->>FE: Fill form, attach files, click Submit
|
||||||
|
FE->>BE: POST /api/ivanti/fp-workflow (multipart/form-data)
|
||||||
|
BE->>BE: Validate input, check auth
|
||||||
|
BE->>IV: POST /client/{clientId}/workflowBatch (create FP workflow)
|
||||||
|
IV-->>BE: 200 + workflow batch response (id, generatedId)
|
||||||
|
alt Attachments present
|
||||||
|
loop For each attachment
|
||||||
|
BE->>IV: POST /client/{clientId}/workflowBatch/{id}/attachment
|
||||||
|
IV-->>BE: 200 OK
|
||||||
|
end
|
||||||
|
end
|
||||||
|
BE->>DB: INSERT into ivanti_fp_submissions
|
||||||
|
BE->>DB: INSERT audit log entry
|
||||||
|
BE->>DB: UPDATE ivanti_todo_queue SET status='complete'
|
||||||
|
BE-->>FE: 200 + { workflowBatchId, generatedId, status }
|
||||||
|
FE->>FE: Show success, refresh queue panel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### New Route Module: `backend/routes/ivantiFpWorkflow.js`
|
||||||
|
|
||||||
|
Exports `createIvantiFpWorkflowRouter(db, requireAuth)` following the existing factory pattern.
|
||||||
|
|
||||||
|
**Endpoint: `POST /api/ivanti/fp-workflow`**
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Content-Type: `multipart/form-data` (handled by Multer)
|
||||||
|
- Request fields:
|
||||||
|
- `name` (string, required) — workflow name, max 255 chars
|
||||||
|
- `reason` (string, required) — justification text
|
||||||
|
- `description` (string, optional) — additional details, max 2000 chars
|
||||||
|
- `expirationDate` (string, required) — ISO date string, must be future
|
||||||
|
- `scopeOverride` (string, optional) — "Authorized" (default) or "None"
|
||||||
|
- `findingIds` (string, required) — JSON-encoded array of finding ID strings
|
||||||
|
- `queueItemIds` (string, required) — JSON-encoded array of local queue item IDs
|
||||||
|
- `attachments` (files, optional) — up to 10 files, 10MB each
|
||||||
|
|
||||||
|
- Response (success):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"workflowBatchId": 12345,
|
||||||
|
"generatedId": "FP#12345",
|
||||||
|
"attachmentResults": [
|
||||||
|
{ "filename": "evidence.pdf", "success": true },
|
||||||
|
{ "filename": "screenshot.png", "success": true }
|
||||||
|
],
|
||||||
|
"queueItemsUpdated": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Response (error):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Ivanti API returned status 401",
|
||||||
|
"step": "create_workflow",
|
||||||
|
"details": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Internal flow:**
|
||||||
|
|
||||||
|
1. Parse and validate all form fields
|
||||||
|
2. Verify all `queueItemIds` belong to the requesting user and are FP-type with pending status
|
||||||
|
3. Call Ivanti API to create the workflow batch
|
||||||
|
4. If attachments exist, upload each to the created workflow batch
|
||||||
|
5. Insert a submission record into `ivanti_fp_submissions`
|
||||||
|
6. Log audit entry via `logAudit()`
|
||||||
|
7. Mark queue items as complete
|
||||||
|
8. Return combined result
|
||||||
|
|
||||||
|
#### Ivanti API Calls
|
||||||
|
|
||||||
|
Reuses the existing `ivantiPost()` helper pattern from `ivantiWorkflows.js`. Adds a new `ivantiMultipartPost()` helper for attachment uploads that sends `multipart/form-data` instead of JSON.
|
||||||
|
|
||||||
|
**Create Workflow Batch:**
|
||||||
|
```
|
||||||
|
POST /client/{clientId}/workflowBatch
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "FP - CVE-2024-1234 - Vendor X",
|
||||||
|
"type": "FALSE_POSITIVE",
|
||||||
|
"reason": "Scanner false positive confirmed by manual investigation",
|
||||||
|
"description": "Additional context...",
|
||||||
|
"expirationDate": "2025-12-31",
|
||||||
|
"scopeOverrideAuthorization": "AUTHORIZED",
|
||||||
|
"hostFindingIds": [123456, 789012],
|
||||||
|
"subType": "FALSE_POSITIVE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload Attachment:**
|
||||||
|
```
|
||||||
|
POST /client/{clientId}/workflowBatch/{workflowBatchId}/attachment
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
Form field: `file` — the binary file content.
|
||||||
|
|
||||||
|
#### Shared HTTP Helpers
|
||||||
|
|
||||||
|
The existing `ivantiPost()` function is duplicated across `ivantiWorkflows.js` and `ivantiFindings.js`. This design extracts it into a shared helper at `backend/helpers/ivantiApi.js` alongside the new multipart helper:
|
||||||
|
|
||||||
|
- `ivantiPost(urlPath, body, apiKey, skipTls)` — JSON POST (existing logic)
|
||||||
|
- `ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls)` — multipart file upload
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### New Component: `FpWorkflowModal`
|
||||||
|
|
||||||
|
Located in `frontend/src/components/pages/ReportingPage.js` (inline, following the existing pattern where QueuePanel and AddToQueuePopover are defined in the same file).
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `open` (boolean) — controls visibility
|
||||||
|
- `onClose` (function) — close handler
|
||||||
|
- `selectedItems` (array) — FP queue items selected for submission
|
||||||
|
- `onSuccess` (function) — callback after successful submission, triggers queue refresh
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
- `name`, `reason`, `description`, `expirationDate`, `scopeOverride` — form fields
|
||||||
|
- `files` — array of File objects for upload
|
||||||
|
- `submitting` — boolean, disables form during submission
|
||||||
|
- `progress` — object tracking current step and attachment progress
|
||||||
|
- `errors` — validation error map
|
||||||
|
- `result` — submission result (success/failure details)
|
||||||
|
|
||||||
|
**UI Layout:**
|
||||||
|
- Modal overlay with dark backdrop (matching existing modal patterns)
|
||||||
|
- Header: "Create FP Workflow" with close button
|
||||||
|
- Body sections:
|
||||||
|
1. Selected findings summary (read-only list with finding_id, title, CVEs)
|
||||||
|
2. Workflow configuration form (name, reason, description, expiration, scope override toggle)
|
||||||
|
3. File upload area (drag-and-drop zone + file list)
|
||||||
|
- Footer: Cancel and Submit buttons, progress indicator when submitting
|
||||||
|
|
||||||
|
#### QueuePanel Modifications
|
||||||
|
|
||||||
|
- Add a "Create FP Workflow" button in the footer, next to existing "Delete Selected" and "Clear Completed" buttons
|
||||||
|
- Button enabled only when `selectedIds` contains at least one pending FP-type item
|
||||||
|
- Clicking opens `FpWorkflowModal` with the filtered FP items
|
||||||
|
- After successful submission, the `onSuccess` callback triggers queue refresh
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### New Table: `ivanti_fp_submissions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status values:**
|
||||||
|
- `success` — workflow created and all attachments uploaded
|
||||||
|
- `partial` — workflow created but one or more attachments failed
|
||||||
|
- `failed` — workflow creation itself failed (record kept for audit)
|
||||||
|
|
||||||
|
### Migration Script: `backend/migrations/add_fp_submissions_table.js`
|
||||||
|
|
||||||
|
Standard migration script following the existing pattern (e.g., `add_ivanti_todo_queue_table.js`).
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: FP Workflow Button Enabled State
|
||||||
|
|
||||||
|
*For any* set of queue items and any selection of item IDs, the "Create FP Workflow" button should be enabled if and only if the selection contains at least one queue item that has `workflow_type === 'FP'` and `status === 'pending'`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1**
|
||||||
|
|
||||||
|
### Property 2: FP-Only Item Filtering
|
||||||
|
|
||||||
|
*For any* set of selected queue items containing a mix of workflow types (FP, Archer, CARD), the items passed to the FP workflow submission modal should contain only items where `workflow_type === 'FP'`, and the count of filtered items should be less than or equal to the count of selected items.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.2**
|
||||||
|
|
||||||
|
### Property 3: Form Validation Correctness
|
||||||
|
|
||||||
|
*For any* form state (name, reason, description, expirationDate, scopeOverride), validation should pass if and only if: name is a non-empty string of at most 255 characters, reason is a non-empty string, description (if provided) is at most 2000 characters, and expirationDate is a valid date strictly after today. When validation fails, the returned error map should contain a key for each invalid field and no keys for valid fields.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.4, 2.5**
|
||||||
|
|
||||||
|
### Property 4: File Extension Validation
|
||||||
|
|
||||||
|
*For any* filename string, the file acceptance function should return true if and only if the file's extension (case-insensitive) is one of: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip. Files with disallowed extensions should be rejected.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
### Property 5: API Payload Construction
|
||||||
|
|
||||||
|
*For any* valid form input (name, reason, description, expirationDate, scopeOverride, findingIds), the constructed Ivanti API request body should contain: `type` equal to "FALSE_POSITIVE", `name` equal to the input name, `reason` equal to the input reason, `expirationDate` equal to the input date, `scopeOverrideAuthorization` mapped from the input scopeOverride value, and `hostFindingIds` equal to the input finding IDs parsed as integers.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1**
|
||||||
|
|
||||||
|
### Property 6: Queue Items Marked Complete on Success
|
||||||
|
|
||||||
|
*For any* set of queue item IDs associated with a successful FP workflow submission, after the post-submission handler runs, all those queue items should have `status === 'complete'`.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.1**
|
||||||
|
|
||||||
|
### Property 7: Post-Submission Persistence Completeness
|
||||||
|
|
||||||
|
*For any* successful FP workflow submission with a given workflow batch ID, name, user ID, and finding IDs, the resulting submission record should contain all of: ivanti_workflow_batch_id, workflow_name, user_id, finding_ids_json (parseable to the original finding IDs array), and a non-null created_at timestamp. Additionally, the audit log entry should have action "ivanti_fp_workflow_created", entity_type "ivanti_workflow", and details containing the workflow name and finding IDs.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.1, 6.2**
|
||||||
|
|
||||||
|
### Property 8: Role-Based UI Visibility
|
||||||
|
|
||||||
|
*For any* user role, the "Create FP Workflow" button should be visible if and only if the user's role is "editor" or "admin". Users with the "viewer" role should not see the button.
|
||||||
|
|
||||||
|
**Validates: Requirements 7.2**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Ivanti API Errors
|
||||||
|
|
||||||
|
| HTTP Status | Error Type | User-Facing Message | System Behavior |
|
||||||
|
|-------------|-----------|---------------------|-----------------|
|
||||||
|
| 401 | Auth failure | "Ivanti API key is invalid or missing. Contact your administrator." | Log error, preserve form state |
|
||||||
|
| 419 | Insufficient privileges | "API key lacks workflow creation permissions." | Log error, preserve form state |
|
||||||
|
| 429 | Rate limited | "Ivanti API rate limit reached. Please try again in a few minutes." | Log error, preserve form state |
|
||||||
|
| 5xx | Server error | "Ivanti API is temporarily unavailable. Please try again later." | Log error, preserve form state |
|
||||||
|
| Other | Unknown | "Workflow creation failed: {status} — {message}" | Log error with full response, preserve form state |
|
||||||
|
|
||||||
|
### Partial Failure (Attachment Upload)
|
||||||
|
|
||||||
|
When the workflow batch is created successfully but one or more attachment uploads fail:
|
||||||
|
- The submission record is saved with `status = 'partial'`
|
||||||
|
- The response includes the workflow batch ID and per-attachment success/failure details
|
||||||
|
- The UI shows which attachments failed and allows retry
|
||||||
|
- The queue items are still marked complete (the workflow itself was created)
|
||||||
|
|
||||||
|
### Local Database Errors
|
||||||
|
|
||||||
|
- If the submission record INSERT fails: log error, still return success to user (Ivanti workflow was created)
|
||||||
|
- If queue item status UPDATE fails: return success with a warning that local queue state may be stale
|
||||||
|
- If audit log INSERT fails: fire-and-forget (existing pattern from `logAudit()`)
|
||||||
|
|
||||||
|
### Input Validation Errors
|
||||||
|
|
||||||
|
- All validation errors return 400 with a structured error object mapping field names to error messages
|
||||||
|
- Frontend validates before sending to prevent unnecessary API calls
|
||||||
|
- Backend re-validates all inputs as a security measure
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Property-Based Testing
|
||||||
|
|
||||||
|
Use `fast-check` as the property-based testing library for JavaScript.
|
||||||
|
|
||||||
|
Each correctness property maps to a single property-based test with a minimum of 100 iterations. Tests are tagged with the format: **Feature: ivanti-fp-workflow-submission, Property {number}: {title}**.
|
||||||
|
|
||||||
|
Property tests focus on pure functions extracted from the implementation:
|
||||||
|
- `isCreateFpButtonEnabled(items, selectedIds)` — Property 1
|
||||||
|
- `filterFpItems(items)` — Property 2
|
||||||
|
- `validateFpWorkflowForm(formData)` — Property 3
|
||||||
|
- `isAllowedFileExtension(filename)` — Property 4
|
||||||
|
- `buildIvantiPayload(formData, findingIds)` — Property 5
|
||||||
|
- Queue item status update logic — Property 6
|
||||||
|
- Submission record creation — Property 7
|
||||||
|
- Role-based visibility check — Property 8
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
Unit tests complement property tests by covering:
|
||||||
|
- Specific examples: known-good form submissions, known-bad inputs
|
||||||
|
- Edge cases: empty finding lists, maximum file size boundary, expiration date exactly tomorrow
|
||||||
|
- Error code mapping: verify each Ivanti HTTP status maps to the correct error message
|
||||||
|
- Integration points: Multer file handling, multipart form construction
|
||||||
|
- API response parsing: various Ivanti response formats
|
||||||
|
|
||||||
|
### Test File Locations
|
||||||
|
|
||||||
|
- `backend/__tests__/ivantiFpWorkflow.test.js` — backend route handler tests, validation, payload construction
|
||||||
|
- `backend/__tests__/ivantiFpWorkflow.property.test.js` — property-based tests for backend logic
|
||||||
|
- `frontend/src/__tests__/fpWorkflowModal.test.js` — frontend component and validation tests
|
||||||
99
.kiro/specs/ivanti-fp-workflow-submission/requirements.md
Normal file
99
.kiro/specs/ivanti-fp-workflow-submission/requirements.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This feature adds the ability for users to select items from the Ivanti Queue (QueuePanel) and submit False Positive (FP) workflows directly to the Ivanti/RiskSense API. Users can configure the FP workflow with a name, reason, description, expiration date, and the "Authorized" scope override option. Supporting documentation and artifacts can be uploaded and attached to the workflow via the API. Successful submissions mark the corresponding queue items as complete and are tracked locally with full audit logging.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: The STEAM Security Dashboard application
|
||||||
|
- **Queue_Panel**: The slide-out panel in the Reporting Page that displays the user's Ivanti todo queue items grouped by vendor/CARD
|
||||||
|
- **Queue_Item**: A single entry in the ivanti_todo_queue table representing a host finding staged for workflow processing, with fields including finding_id, finding_title, cves_json, ip_address, vendor, workflow_type, and status
|
||||||
|
- **FP_Workflow**: A False Positive workflow batch created in the Ivanti/RiskSense platform to mark host findings as false positives, removing them from risk calculations
|
||||||
|
- **Ivanti_API**: The Ivanti/RiskSense REST API at https://platform4.risksense.com/api/v1, authenticated via x-api-key header
|
||||||
|
- **Workflow_Batch**: An Ivanti API resource representing a group of findings submitted together under a single workflow request
|
||||||
|
- **Scope_Override_Authorization**: An Ivanti workflow property that controls whether additional findings can be added to or removed from the workflow after creation; values are "None" or "Authorized"
|
||||||
|
- **Submission_Record**: A local database record tracking the details and outcome of an FP workflow submission made through the Dashboard
|
||||||
|
- **Attachment**: A supporting document or artifact (PDF, screenshot, etc.) uploaded alongside an FP workflow submission as evidence or justification
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Select FP Queue Items for Workflow Submission
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to select one or more FP-type items from the Ivanti Queue, so that I can batch them into a single False Positive workflow submission.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Queue_Panel is open and contains FP-type Queue_Items, THE Dashboard SHALL display a "Create FP Workflow" action button that is enabled only when at least one pending FP-type Queue_Item is selected
|
||||||
|
2. WHEN a user selects Queue_Items of mixed workflow_type (FP and non-FP), THE Dashboard SHALL only include FP-type Queue_Items in the FP workflow submission and SHALL visually indicate which items are eligible
|
||||||
|
3. IF no pending FP-type Queue_Items are selected, THEN THE Dashboard SHALL disable the "Create FP Workflow" action button and display a tooltip explaining the requirement
|
||||||
|
4. WHEN the "Create FP Workflow" button is clicked, THE Dashboard SHALL open the FP Workflow Submission modal pre-populated with the selected finding IDs
|
||||||
|
|
||||||
|
### Requirement 2: Configure FP Workflow Details
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to configure the FP workflow properties before submission, so that I can provide the required justification and metadata for the false positive request.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE FP_Workflow submission modal SHALL present input fields for: workflow name (required, max 255 characters), reason/justification (required), description (optional, max 2000 characters), and expiration date (required, must be a future date)
|
||||||
|
2. THE FP_Workflow submission modal SHALL include a Scope_Override_Authorization toggle defaulting to "Authorized"
|
||||||
|
3. THE FP_Workflow submission modal SHALL display a summary list of the selected Queue_Items including finding_id, finding_title, and associated CVEs
|
||||||
|
4. WHEN a user attempts to submit with missing required fields, THE Dashboard SHALL display inline validation errors for each invalid field and prevent submission
|
||||||
|
5. IF the expiration date is set to a date in the past or today, THEN THE Dashboard SHALL reject the value and display a validation message indicating the date must be in the future
|
||||||
|
|
||||||
|
### Requirement 3: Upload Supporting Documentation
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to upload supporting documents and artifacts with my FP workflow submission, so that reviewers have the evidence needed to approve the false positive request.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE FP_Workflow submission modal SHALL include a file upload area that accepts multiple files with a maximum size of 10 MB per file
|
||||||
|
2. WHEN files are added to the upload area, THE Dashboard SHALL display each file name, size, and a remove button
|
||||||
|
3. THE Dashboard SHALL accept files with extensions: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
|
||||||
|
4. IF a user attempts to upload a file exceeding 10 MB, THEN THE Dashboard SHALL reject the file and display an error message stating the size limit
|
||||||
|
5. IF a user attempts to upload a file with a disallowed extension, THEN THE Dashboard SHALL reject the file and display an error message listing the allowed file types
|
||||||
|
|
||||||
|
### Requirement 4: Submit FP Workflow to Ivanti API
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to submit the configured FP workflow to the Ivanti API, so that the false positive request is created in the Ivanti/RiskSense platform with all associated findings and attachments.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user clicks Submit, THE Dashboard SHALL send a POST request to the Ivanti_API to create a Workflow_Batch of type "False Positive" with the configured name, reason, description, expiration date, Scope_Override_Authorization setting, and the list of host finding IDs
|
||||||
|
2. WHEN the Workflow_Batch is created successfully and attachments are present, THE Dashboard SHALL upload each Attachment to the Ivanti_API associated with the created Workflow_Batch
|
||||||
|
3. WHEN the submission is in progress, THE Dashboard SHALL display a progress indicator showing the current step (creating workflow, uploading attachment 1 of N, etc.) and disable the Submit button to prevent duplicate submissions
|
||||||
|
4. WHEN the entire submission completes successfully, THE Dashboard SHALL display a success message including the Ivanti-generated workflow batch ID (e.g., "FP#12345")
|
||||||
|
5. IF the Ivanti_API returns a 401 status, THEN THE Dashboard SHALL display an error message indicating the API key is invalid or missing
|
||||||
|
6. IF the Ivanti_API returns a 429 status, THEN THE Dashboard SHALL display an error message indicating rate limiting and suggest retrying later
|
||||||
|
7. IF the Ivanti_API returns any other error status during workflow creation, THEN THE Dashboard SHALL display the error details and preserve the user's form input so they can retry without re-entering data
|
||||||
|
8. IF an attachment upload fails after the workflow is created, THEN THE Dashboard SHALL report which attachments failed, display the workflow batch ID for the successfully created workflow, and allow the user to retry the failed uploads
|
||||||
|
|
||||||
|
### Requirement 5: Post-Submission Queue Item Updates
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want queue items to be automatically marked complete after a successful FP workflow submission, so that my queue reflects the current processing state.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL mark all associated Queue_Items as "complete" status
|
||||||
|
2. WHEN Queue_Items are marked complete after submission, THE Dashboard SHALL refresh the Queue_Panel to reflect the updated statuses
|
||||||
|
3. IF marking a Queue_Item as complete fails locally, THEN THE Dashboard SHALL display a warning that the workflow was submitted successfully but the local queue status could not be updated
|
||||||
|
|
||||||
|
### Requirement 6: Local Submission Tracking
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want FP workflow submissions to be tracked locally, so that I can review submission history and audit past actions.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL create a Submission_Record in the local database containing: the Ivanti workflow batch ID, workflow name, submitting user ID, list of finding IDs, submission timestamp, and status
|
||||||
|
2. WHEN an FP workflow submission completes successfully, THE Dashboard SHALL log an audit entry with action "ivanti_fp_workflow_created", entity type "ivanti_workflow", the workflow batch ID as entity ID, and details including the finding IDs and workflow name
|
||||||
|
3. IF an FP workflow submission fails, THEN THE Dashboard SHALL log an audit entry with action "ivanti_fp_workflow_failed" including the error details
|
||||||
|
|
||||||
|
### Requirement 7: Authorization and Access Control
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want FP workflow submission restricted to authorized users, so that only editors and admins can create workflows in the Ivanti platform.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL restrict the FP workflow submission API endpoint to users with the "Admin" or "Standard_User" group membership
|
||||||
|
2. THE Dashboard SHALL restrict the FP workflow submission UI controls to users with editor or admin roles
|
||||||
|
3. WHILE a user has the viewer role, THE Dashboard SHALL hide the "Create FP Workflow" button from the Queue_Panel
|
||||||
109
.kiro/specs/ivanti-fp-workflow-submission/tasks.md
Normal file
109
.kiro/specs/ivanti-fp-workflow-submission/tasks.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Implementation Plan: Ivanti FP Workflow Submission
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement the ability to select FP-type items from the Ivanti Queue and submit False Positive workflows to the Ivanti/RiskSense API, with file attachment support, local submission tracking, and audit logging. The implementation follows existing codebase conventions: factory-pattern Express routes, Multer for file uploads, inline React component styles with the dark tactical theme, and the `ivantiPost()` HTTP helper for Ivanti API calls.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Database migration and shared helpers
|
||||||
|
- [x] 1.1 Create migration script `backend/migrations/add_fp_submissions_table.js`
|
||||||
|
- Create `ivanti_fp_submissions` table with columns: id, user_id, username, ivanti_workflow_batch_id, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status (success/partial/failed), error_message, created_at
|
||||||
|
- Add indexes on user_id and ivanti_generated_id
|
||||||
|
- Follow existing migration pattern from `add_ivanti_todo_queue_table.js`
|
||||||
|
- _Requirements: 6.1_
|
||||||
|
|
||||||
|
- [x] 1.2 Extract shared Ivanti API helpers into `backend/helpers/ivantiApi.js`
|
||||||
|
- Move the `ivantiPost()` function from `ivantiWorkflows.js` into a shared module
|
||||||
|
- Add `ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls)` for attachment uploads using Node.js `https` module with multipart/form-data boundary construction
|
||||||
|
- Export both functions; update `ivantiWorkflows.js` and `ivantiFindings.js` to import from the shared module
|
||||||
|
- _Requirements: 4.1, 4.2_
|
||||||
|
|
||||||
|
- [x] 2. Backend route — validation and payload construction
|
||||||
|
- [x] 2.1 Create `backend/routes/ivantiFpWorkflow.js` with validation and payload builder
|
||||||
|
- Export `createIvantiFpWorkflowRouter(db, requireAuth)` factory function
|
||||||
|
- Implement `POST /` route with `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware
|
||||||
|
- Configure Multer for up to 10 file uploads, 10MB each, with allowed extensions: .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
|
||||||
|
- Implement `validateFpWorkflowForm(body)` — returns error map for invalid fields (name required max 255, reason required, description max 2000, expirationDate required and must be future date)
|
||||||
|
- Implement `buildIvantiPayload(formData, findingIds)` — constructs the Ivanti API request body with type "FALSE_POSITIVE", scopeOverrideAuthorization mapping, and hostFindingIds as integers
|
||||||
|
- Implement `isAllowedFileExtension(filename)` — checks against the allowed extensions list (case-insensitive)
|
||||||
|
- Verify all queueItemIds belong to the requesting user, are FP-type, and have pending status
|
||||||
|
- _Requirements: 2.4, 2.5, 3.3, 3.4, 3.5, 4.1, 7.1_
|
||||||
|
|
||||||
|
- [ ]* 2.2 Write property tests for validation and payload construction
|
||||||
|
- **Property 3: Form Validation Correctness** — For any form state, validation passes iff all required fields present and expiration date is future; error map keys match invalid fields only
|
||||||
|
- **Property 4: File Extension Validation** — For any filename, acceptance returns true iff extension is in the allowed set (case-insensitive)
|
||||||
|
- **Property 5: API Payload Construction** — For any valid form input, the constructed payload contains correct type, name, reason, expirationDate, scopeOverrideAuthorization, and hostFindingIds as integers
|
||||||
|
- Use `fast-check` library with minimum 100 iterations per property
|
||||||
|
- **Validates: Requirements 2.4, 2.5, 3.3, 4.1**
|
||||||
|
|
||||||
|
- [x] 3. Backend route — Ivanti API submission and local persistence
|
||||||
|
- [x] 3.1 Implement the submission flow in `ivantiFpWorkflow.js`
|
||||||
|
- Call Ivanti API `POST /client/{clientId}/workflowBatch` to create the FP workflow batch
|
||||||
|
- If attachments present, upload each via `ivantiMultipartPost()` to `/client/{clientId}/workflowBatch/{id}/attachment`
|
||||||
|
- Handle Ivanti API error responses: 401 (invalid key), 419 (insufficient privileges), 429 (rate limited), other errors
|
||||||
|
- On success: insert submission record into `ivanti_fp_submissions`, call `logAudit()` with action "ivanti_fp_workflow_created"
|
||||||
|
- On failure: call `logAudit()` with action "ivanti_fp_workflow_failed"
|
||||||
|
- Mark associated queue items as complete via `UPDATE ivanti_todo_queue SET status='complete'`
|
||||||
|
- Handle partial failures (workflow created but attachment upload failed) — save with status "partial"
|
||||||
|
- Return structured response with workflowBatchId, generatedId, attachmentResults, queueItemsUpdated
|
||||||
|
- _Requirements: 4.1, 4.2, 4.5, 4.6, 4.7, 4.8, 5.1, 6.1, 6.2, 6.3_
|
||||||
|
|
||||||
|
- [ ]* 3.2 Write property tests for queue item completion and submission persistence
|
||||||
|
- **Property 6: Queue Items Marked Complete on Success** — For any set of queue item IDs after successful submission, all items have status "complete"
|
||||||
|
- **Property 7: Post-Submission Persistence Completeness** — For any successful submission, the record contains all required fields (ivanti_workflow_batch_id, workflow_name, user_id, finding_ids_json, created_at) and audit entry has correct action/entity_type/details
|
||||||
|
- Use in-memory SQLite for test isolation
|
||||||
|
- **Validates: Requirements 5.1, 6.1, 6.2**
|
||||||
|
|
||||||
|
- [x] 4. Wire backend route into server.js
|
||||||
|
- [x] 4.1 Register the new route in `backend/server.js`
|
||||||
|
- Add `const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');`
|
||||||
|
- Mount at `app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));`
|
||||||
|
- Place near the existing Ivanti route registrations
|
||||||
|
- _Requirements: 7.1_
|
||||||
|
|
||||||
|
- [x] 5. Checkpoint — Backend complete
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 6. Frontend — FP Workflow Modal component
|
||||||
|
- [x] 6.1 Implement `FpWorkflowModal` in `frontend/src/components/pages/ReportingPage.js`
|
||||||
|
- Add the modal component inline in ReportingPage.js following the existing pattern (QueuePanel, AddToQueuePopover are in the same file)
|
||||||
|
- Props: open, onClose, selectedItems (FP queue items), onSuccess
|
||||||
|
- Form fields: workflow name (text input, required), reason (textarea, required), description (textarea, optional), expiration date (date input, required), scope override toggle (Authorized/None, default Authorized)
|
||||||
|
- Display selected findings summary: finding_id, finding_title, CVEs for each item
|
||||||
|
- File upload area: drag-and-drop zone, file list with name/size/remove button, validate extensions and 10MB limit client-side
|
||||||
|
- Submit button with progress indicator (creating workflow → uploading attachment N of M)
|
||||||
|
- Error display: inline validation errors, API error messages with form state preservation
|
||||||
|
- Success display: workflow batch ID (e.g., "FP#12345") with close/done action
|
||||||
|
- Style with inline style objects matching the dark tactical theme from DESIGN_SYSTEM.md
|
||||||
|
- Icons from lucide-react (Upload, FileText, X, Check, AlertTriangle, Loader)
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.3, 3.4, 3.5, 4.3, 4.4, 4.7, 4.8_
|
||||||
|
|
||||||
|
- [ ]* 6.2 Write property tests for frontend validation helpers
|
||||||
|
- **Property 1: FP Workflow Button Enabled State** — For any set of queue items and selection, button enabled iff selection contains at least one pending FP item
|
||||||
|
- **Property 2: FP-Only Item Filtering** — For any mixed-type selection, filtered result contains only FP items
|
||||||
|
- **Property 8: Role-Based UI Visibility** — For any user role, button visible iff role is editor or admin
|
||||||
|
- Extract `isCreateFpButtonEnabled`, `filterFpItems`, `shouldShowFpButton` as testable pure functions
|
||||||
|
- Use `fast-check` with minimum 100 iterations
|
||||||
|
- **Validates: Requirements 1.1, 1.2, 7.2**
|
||||||
|
|
||||||
|
- [x] 7. Frontend — QueuePanel integration
|
||||||
|
- [x] 7.1 Add "Create FP Workflow" button and modal wiring in QueuePanel
|
||||||
|
- Add "Create FP Workflow" button in QueuePanel footer, styled with amber/FP accent color
|
||||||
|
- Button enabled only when selectedIds contains at least one pending FP-type item
|
||||||
|
- Disabled state shows tooltip: "Select pending FP items to create a workflow"
|
||||||
|
- Hide button entirely for viewer role users (check via useAuth context)
|
||||||
|
- On click: filter selected items to FP-only, open FpWorkflowModal with filtered items
|
||||||
|
- Wire onSuccess callback to trigger queue refresh (call existing fetch function from parent)
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 5.2, 7.2, 7.3_
|
||||||
|
|
||||||
|
- [x] 8. Final checkpoint — Full integration
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Property tests use `fast-check` library — install via `npm install --save-dev fast-check` in both backend and frontend
|
||||||
|
- The shared Ivanti API helper (task 1.2) updates existing imports in ivantiWorkflows.js and ivantiFindings.js — test those routes still work after the refactor
|
||||||
|
- Multer is already a project dependency (used for document uploads in server.js)
|
||||||
1
.kiro/specs/queue-hostname-ip-display/.config.kiro
Normal file
1
.kiro/specs/queue-hostname-ip-display/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
175
.kiro/specs/queue-hostname-ip-display/design.md
Normal file
175
.kiro/specs/queue-hostname-ip-display/design.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Design Document: Queue Hostname & IP Display
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds hostname tracking to the Ivanti todo queue. Currently the queue stores `ip_address` but not `hostname`. The change spans three layers:
|
||||||
|
|
||||||
|
1. **Database** — A migration adds a `hostname TEXT` column to `ivanti_todo_queue`.
|
||||||
|
2. **Backend API** — The POST (single + batch) endpoints accept and store an optional `hostname` field. The GET endpoint already uses `SELECT *`, so hostname is returned automatically once the column exists.
|
||||||
|
3. **Frontend** — The `addToQueue` and `submitBatch` functions pass `finding.hostName` as `hostname`. The QueuePanel renders hostname and IP address for both CARD and vendor-grouped (FP/Archer) sections.
|
||||||
|
|
||||||
|
The change is additive and backward-compatible. Existing rows get `NULL` for hostname. No existing behavior changes unless both hostname and ip_address are present.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The data flows through three layers in a straight pipeline:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Ivanti Finding<br/>hostName, ipAddress] -->|POST /todo-queue| B[Express Route<br/>ivantiTodoQueue.js]
|
||||||
|
B -->|INSERT hostname, ip_address| C[SQLite<br/>ivanti_todo_queue]
|
||||||
|
C -->|SELECT *| B
|
||||||
|
B -->|GET response| D[QueuePanel<br/>ReportingPage.js]
|
||||||
|
```
|
||||||
|
|
||||||
|
No new services, tables, or route modules are introduced. The migration script is a standalone Node.js file following the existing pattern in `backend/migrations/`.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Migration Script: `backend/migrations/add_todo_queue_hostname.js`
|
||||||
|
|
||||||
|
Follows the exact pattern of `add_todo_queue_ip_address.js`:
|
||||||
|
|
||||||
|
- Opens `cve_database.db` via `sqlite3`
|
||||||
|
- Runs `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
|
||||||
|
- Catches `duplicate column name` error to make it idempotent
|
||||||
|
- Closes the database connection
|
||||||
|
|
||||||
|
### Backend Route: `backend/routes/ivantiTodoQueue.js`
|
||||||
|
|
||||||
|
Changes to two endpoints:
|
||||||
|
|
||||||
|
**POST `/` (single-item)**
|
||||||
|
- Extract `hostname` from `req.body`
|
||||||
|
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
|
||||||
|
- Add to the INSERT column list and parameter array
|
||||||
|
|
||||||
|
**POST `/batch`**
|
||||||
|
- For each finding in the `findings` array, extract `hostname` from `f.hostname`
|
||||||
|
- Same sanitization as single-item
|
||||||
|
- Add to the per-row INSERT column list and parameter array
|
||||||
|
|
||||||
|
**GET `/`** — No code change needed. `SELECT *` already returns all columns.
|
||||||
|
|
||||||
|
**PUT `/:id`** — No change. Hostname is set at insert time and not editable.
|
||||||
|
|
||||||
|
### Frontend: `ReportingPage.js`
|
||||||
|
|
||||||
|
**`addToQueue` function**
|
||||||
|
- Add `hostname: finding.hostName || null` to the POST body
|
||||||
|
|
||||||
|
**`submitBatch` function**
|
||||||
|
- Add `hostname: f.hostName || null` to each finding object in `findingsPayload`
|
||||||
|
|
||||||
|
**QueuePanel rendering (per item)**
|
||||||
|
|
||||||
|
For CARD items, the content `<div>` currently shows:
|
||||||
|
1. `finding_id`
|
||||||
|
2. `ip_address` (if present)
|
||||||
|
|
||||||
|
New rendering for CARD items:
|
||||||
|
1. `finding_id`
|
||||||
|
2. `hostname` (if present)
|
||||||
|
3. `ip_address` (if present)
|
||||||
|
|
||||||
|
For vendor-grouped items (FP/Archer), the content `<div>` currently shows:
|
||||||
|
1. `finding_id`
|
||||||
|
2. CVE list (if present)
|
||||||
|
|
||||||
|
New rendering for vendor-grouped items:
|
||||||
|
1. `finding_id`
|
||||||
|
2. CVE list (if present)
|
||||||
|
3. `hostname` (if present)
|
||||||
|
4. `ip_address` (if present)
|
||||||
|
|
||||||
|
Both hostname and IP use the same monospace styling at `0.68rem` / `0.62rem` with muted colors consistent with the existing design system.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### `ivanti_todo_queue` table (after migration)
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Notes |
|
||||||
|
|--------|------|----------|-------|
|
||||||
|
| id | INTEGER | NO | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| user_id | INTEGER | NO | FK → users(id) |
|
||||||
|
| finding_id | TEXT | NO | |
|
||||||
|
| finding_title | TEXT | YES | max 500 chars |
|
||||||
|
| cves_json | TEXT | YES | JSON array string |
|
||||||
|
| ip_address | TEXT | YES | max 64 chars |
|
||||||
|
| **hostname** | **TEXT** | **YES** | **max 255 chars (new)** |
|
||||||
|
| vendor | TEXT | NO | |
|
||||||
|
| workflow_type | TEXT | NO | FP, Archer, or CARD |
|
||||||
|
| status | TEXT | NO | pending or complete |
|
||||||
|
| created_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
|
||||||
|
| updated_at | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
|
||||||
|
|
||||||
|
### API Request/Response Changes
|
||||||
|
|
||||||
|
**POST `/api/ivanti/todo-queue` body** — adds optional field:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"finding_id": "...",
|
||||||
|
"finding_title": "...",
|
||||||
|
"cves": [],
|
||||||
|
"ip_address": "...",
|
||||||
|
"hostname": "server01.example.com",
|
||||||
|
"vendor": "...",
|
||||||
|
"workflow_type": "CARD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST `/api/ivanti/todo-queue/batch` body** — adds optional field per finding:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{ "finding_id": "...", "ip_address": "...", "hostname": "server01.example.com" }
|
||||||
|
],
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"vendor": "VendorName"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET response** — `hostname` field included automatically via `SELECT *`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"finding_id": "...",
|
||||||
|
"hostname": "server01.example.com",
|
||||||
|
"ip_address": "10.0.0.1",
|
||||||
|
"..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Hostname storage round-trip
|
||||||
|
|
||||||
|
*For any* valid hostname string (up to 255 characters), storing it via the queue API (single or batch endpoint) and then retrieving it via GET should return the exact same trimmed string. When the hostname is omitted, null, or empty, the stored and returned value should be null.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2, 2.3, 2.4**
|
||||||
|
|
||||||
|
### Property 2: Hostname display presence
|
||||||
|
|
||||||
|
*For any* queue item with a non-null hostname value, the rendered QueuePanel output should contain the hostname text, regardless of whether the item is a CARD item or a vendor-grouped (FP/Archer) item.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 5.1**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| Migration run when column already exists | Catch `duplicate column name` SQLite error, log skip message, exit cleanly |
|
||||||
|
| `hostname` field is not a string | Treat as null — store NULL in database |
|
||||||
|
| `hostname` exceeds 255 characters | Truncate to 255 characters via `.slice(0, 255)` |
|
||||||
|
| `hostname` is undefined/null/empty string | Store NULL in database |
|
||||||
|
| GET returns item with null hostname | Frontend conditionally renders — no hostname line shown |
|
||||||
|
| GET returns item with null ip_address and null hostname | CARD: show only finding_id. Vendor: show finding_id + CVEs only |
|
||||||
|
|
||||||
|
No new error codes or HTTP status changes are introduced. The hostname field is optional and its absence is a normal case, not an error.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Testing is out of scope for this feature. Manual verification will be performed after implementation.
|
||||||
70
.kiro/specs/queue-hostname-ip-display/requirements.md
Normal file
70
.kiro/specs/queue-hostname-ip-display/requirements.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Ivanti Queue (todo queue) in the STEAM Security Dashboard currently stores and displays `ip_address` for CARD workflow items but omits hostname entirely. Vendor-grouped sections (FP/Archer) display only `finding_id` and CVEs, hiding the `ip_address` that is already stored. This feature adds a `hostname` column to the database, passes hostname through the backend API, and displays both hostname and IP address across all queue sections (CARD, FP, Archer).
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Queue_Panel**: The slide-out side panel (`QueuePanel` component) that displays the user's staged Ivanti findings grouped by workflow type and vendor.
|
||||||
|
- **Queue_API**: The Express route module (`ivantiTodoQueue.js`) that handles CRUD operations on the `ivanti_todo_queue` table.
|
||||||
|
- **Queue_Table**: The SQLite table `ivanti_todo_queue` that persists per-user queue items.
|
||||||
|
- **CARD_Section**: The top group in the Queue_Panel that displays items with `workflow_type = 'CARD'`.
|
||||||
|
- **Vendor_Section**: Groups in the Queue_Panel for FP and Archer workflow items, organized by vendor name.
|
||||||
|
- **Finding**: An Ivanti host finding record containing fields such as `id`, `title`, `hostName`, `ipAddress`, `cves`, and `severity`.
|
||||||
|
- **Migration_Script**: A standalone Node.js script in `backend/migrations/` that alters the SQLite schema.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Add hostname column to the queue database table
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the queue table to have a `hostname` column, so that hostname data can be persisted alongside each queued finding.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Migration_Script SHALL add a `hostname` TEXT column to the Queue_Table.
|
||||||
|
2. WHEN the `hostname` column already exists, THE Migration_Script SHALL skip the alteration and log a message indicating the column already exists.
|
||||||
|
3. THE Migration_Script SHALL preserve all existing rows and column data in the Queue_Table.
|
||||||
|
|
||||||
|
### Requirement 2: Accept and store hostname in queue API endpoints
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the queue API to accept a `hostname` field, so that hostname data is stored when findings are added to the queue.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a POST request is received at the single-item endpoint, THE Queue_API SHALL accept an optional `hostname` string field (max 255 characters) and store it in the Queue_Table.
|
||||||
|
2. WHEN a POST request is received at the batch endpoint, THE Queue_API SHALL accept an optional `hostname` string field on each finding object (max 255 characters) and store it in the Queue_Table.
|
||||||
|
3. WHEN the `hostname` field is omitted or empty, THE Queue_API SHALL store NULL for the `hostname` column.
|
||||||
|
4. WHEN a GET request is received, THE Queue_API SHALL return the `hostname` field for each queue item in the response.
|
||||||
|
|
||||||
|
### Requirement 3: Pass hostname from the frontend to the queue API
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the frontend to send hostname data when adding findings to the queue, so that hostname is captured from the Ivanti findings data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a single finding is added to the queue, THE ReportingPage SHALL include the finding's `hostName` value in the `hostname` field of the POST request body.
|
||||||
|
2. WHEN findings are added via batch submission, THE ReportingPage SHALL include each finding's `hostName` value in the `hostname` field of the corresponding finding object in the POST request body.
|
||||||
|
|
||||||
|
### Requirement 4: Display hostname and IP address in the CARD section
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see both hostname and IP address for CARD items in the queue, so that I can identify the affected host at a glance.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a CARD item has a `hostname` value, THE CARD_Section SHALL display the hostname below the finding ID.
|
||||||
|
2. WHEN a CARD item has an `ip_address` value, THE CARD_Section SHALL display the IP address below the hostname.
|
||||||
|
3. WHEN a CARD item has both `hostname` and `ip_address`, THE CARD_Section SHALL display hostname on one line and IP address on the next line.
|
||||||
|
4. WHEN a CARD item has only `ip_address` and no `hostname`, THE CARD_Section SHALL display the IP address (preserving current behavior).
|
||||||
|
5. WHEN a CARD item has only `hostname` and no `ip_address`, THE CARD_Section SHALL display the hostname.
|
||||||
|
|
||||||
|
### Requirement 5: Display hostname and IP address in vendor sections (FP/Archer)
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see hostname and IP address for FP and Archer items in the queue, so that I can identify affected hosts without leaving the queue panel.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a vendor-grouped item has a `hostname` value, THE Vendor_Section SHALL display the hostname below the CVE list.
|
||||||
|
2. WHEN a vendor-grouped item has an `ip_address` value, THE Vendor_Section SHALL display the IP address below the hostname (or below the CVE list if no hostname exists).
|
||||||
|
3. WHEN a vendor-grouped item has both `hostname` and `ip_address`, THE Vendor_Section SHALL display hostname on one line and IP address on the next line, both below the CVE list.
|
||||||
|
4. WHEN a vendor-grouped item has neither `hostname` nor `ip_address`, THE Vendor_Section SHALL display only the finding ID and CVE list (preserving current behavior).
|
||||||
56
.kiro/specs/queue-hostname-ip-display/tasks.md
Normal file
56
.kiro/specs/queue-hostname-ip-display/tasks.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Implementation Plan: Queue Hostname & IP Display
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add hostname tracking to the Ivanti todo queue across database, backend API, and frontend display layers. All changes are additive and backward-compatible.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Create database migration to add hostname column
|
||||||
|
- Create `backend/migrations/add_todo_queue_hostname.js` following the exact pattern of `add_todo_queue_ip_address.js`
|
||||||
|
- Use `ALTER TABLE ivanti_todo_queue ADD COLUMN hostname TEXT`
|
||||||
|
- Handle `duplicate column name` error for idempotency
|
||||||
|
- Log appropriate messages for success and skip scenarios
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3_
|
||||||
|
|
||||||
|
- [x] 2. Update backend API endpoints to accept and store hostname
|
||||||
|
- [x] 2.1 Update POST `/` (single-item) endpoint in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Extract `hostname` from `req.body`
|
||||||
|
- Sanitize: if present and a string, trim and slice to 255 chars; otherwise `null`
|
||||||
|
- Add `hostname` to the INSERT column list and parameter array
|
||||||
|
- _Requirements: 2.1, 2.3_
|
||||||
|
|
||||||
|
- [x] 2.2 Update POST `/batch` endpoint in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- For each finding, extract `hostname` from `f.hostname`
|
||||||
|
- Apply same sanitization as single-item (trim, slice to 255, or null)
|
||||||
|
- Add `hostname` to the per-row INSERT column list and parameter array
|
||||||
|
- _Requirements: 2.2, 2.3_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint
|
||||||
|
- Ensure all backend changes are consistent, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Update frontend to pass hostname and display it in the queue panel
|
||||||
|
- [x] 4.1 Update `addToQueue` function in `ReportingPage.js`
|
||||||
|
- Add `hostname: finding.hostName || null` to the POST request body
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
|
||||||
|
- [x] 4.2 Update `submitBatch` function in `ReportingPage.js`
|
||||||
|
- Add `hostname: f.hostName || null` to each finding object in the payload
|
||||||
|
- _Requirements: 3.2_
|
||||||
|
|
||||||
|
- [x] 4.3 Update CARD section rendering in QueuePanel (`ReportingPage.js`)
|
||||||
|
- Display `hostname` below finding_id (when present)
|
||||||
|
- Display `ip_address` below hostname (when present)
|
||||||
|
- Handle all combinations: both present, only hostname, only ip_address, neither
|
||||||
|
- Use monospace styling at `0.68rem` consistent with existing ip_address display
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
|
- [x] 4.4 Update vendor section (FP/Archer) rendering in QueuePanel (`ReportingPage.js`)
|
||||||
|
- Display `hostname` below the CVE list (when present)
|
||||||
|
- Display `ip_address` below hostname or below CVE list if no hostname
|
||||||
|
- Handle all combinations: both present, only one, neither
|
||||||
|
- Use monospace styling at `0.62rem` / `0.68rem` with muted colors matching existing design
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||||
|
|
||||||
|
- [x] 5. Final checkpoint
|
||||||
|
- Ensure all changes are wired together end-to-end, ask the user if questions arise.
|
||||||
27
.kiro/steering/product.md
Normal file
27
.kiro/steering/product.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Product Overview
|
||||||
|
|
||||||
|
The STEAM Security Dashboard is a self-hosted vulnerability management tool for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It centralizes CVE tracking, Ivanti host finding triage, AEO compliance posture monitoring, FP/Archer exception workflows, and internal documentation in a single interface.
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
- Searchable CVE list with per-vendor tracking and document storage
|
||||||
|
- NVD API integration for auto-populating CVE metadata
|
||||||
|
- Ivanti/RiskSense integration for syncing open host findings with FP workflow tracking
|
||||||
|
- Reporting page with charts, advanced filtering, inline editing, and CSV/XLSX export
|
||||||
|
- Ivanti Queue for batch-processing FP, Archer, and CARD workflows
|
||||||
|
- AEO Compliance page with weekly xlsx upload, diff preview, per-team metric health cards, and device-level violation tracking
|
||||||
|
- Archer risk acceptance ticket tracking (EXC numbers) linked to CVE/vendor pairs
|
||||||
|
- Knowledge base for internal documentation and policies
|
||||||
|
- Role-based access control (viewer, editor, admin) with full audit trail
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
| Role | Permissions |
|
||||||
|
|------|------------|
|
||||||
|
| viewer | Read-only access to all data |
|
||||||
|
| editor | All viewer permissions plus create/update operations |
|
||||||
|
| admin | All editor permissions plus delete, user management, and audit log access |
|
||||||
|
|
||||||
|
## Teams Tracked
|
||||||
|
|
||||||
|
Only **STEAM** and **ACCESS-ENG** teams are tracked in the compliance module.
|
||||||
83
.kiro/steering/structure.md
Normal file
83
.kiro/steering/structure.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Project Structure & Conventions
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cve-dashboard/
|
||||||
|
├── backend/ # Express API server
|
||||||
|
│ ├── server.js # Main entry point — app setup, middleware, CVE/document routes inline
|
||||||
|
│ ├── setup.js # One-time DB init + default admin creation
|
||||||
|
│ ├── cve_database.db # SQLite database (gitignored)
|
||||||
|
│ ├── uploads/ # File storage (gitignored)
|
||||||
|
│ ├── routes/ # Express route modules (factory pattern)
|
||||||
|
│ │ ├── auth.js
|
||||||
|
│ │ ├── users.js
|
||||||
|
│ │ ├── auditLog.js
|
||||||
|
│ │ ├── nvdLookup.js
|
||||||
|
│ │ ├── knowledgeBase.js
|
||||||
|
│ │ ├── archerTickets.js
|
||||||
|
│ │ ├── ivantiWorkflows.js
|
||||||
|
│ │ ├── ivantiFindings.js
|
||||||
|
│ │ ├── ivantiTodoQueue.js
|
||||||
|
│ │ └── compliance.js
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── auth.js # requireAuth(db), requireRole(...roles)
|
||||||
|
│ ├── helpers/
|
||||||
|
│ │ └── auditLog.js # logAudit() — fire-and-forget DB insert
|
||||||
|
│ ├── migrations/ # Sequential migration scripts (run manually with node)
|
||||||
|
│ └── scripts/ # Python utilities (compliance parsing, CSV import)
|
||||||
|
│
|
||||||
|
├── frontend/ # React 19 SPA (Create React App)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── App.js # Main dashboard — CVE list, filters, modals, inline styles
|
||||||
|
│ ├── App.css # Global styles and CSS variables
|
||||||
|
│ ├── contexts/
|
||||||
|
│ │ └── AuthContext.js # Auth state provider (login, logout, role helpers)
|
||||||
|
│ └── components/
|
||||||
|
│ ├── LoginForm.js
|
||||||
|
│ ├── NavDrawer.js
|
||||||
|
│ ├── UserMenu.js
|
||||||
|
│ ├── CalendarWidget.js
|
||||||
|
│ ├── UserManagement.js
|
||||||
|
│ ├── AuditLog.js
|
||||||
|
│ ├── NvdSyncModal.js
|
||||||
|
│ ├── KnowledgeBaseModal.js
|
||||||
|
│ ├── KnowledgeBaseViewer.js
|
||||||
|
│ └── pages/ # Full-page views
|
||||||
|
│ ├── ReportingPage.js
|
||||||
|
│ ├── CompliancePage.js
|
||||||
|
│ ├── ComplianceUploadModal.js
|
||||||
|
│ ├── ComplianceDetailPanel.js
|
||||||
|
│ ├── ComplianceChartsPanel.js
|
||||||
|
│ ├── IvantiCountsChart.js
|
||||||
|
│ ├── KnowledgeBasePage.js
|
||||||
|
│ └── ExportsPage.js
|
||||||
|
│
|
||||||
|
├── docs/ # Internal documentation (markdown)
|
||||||
|
├── start-servers.sh # Start both servers in background
|
||||||
|
├── stop-servers.sh # Stop both servers
|
||||||
|
└── DESIGN_SYSTEM.md # UI design system reference (colors, typography, components)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Conventions
|
||||||
|
|
||||||
|
- Route modules export a factory function: `function createXxxRouter(db, ...middleware)` that returns an Express Router.
|
||||||
|
- The `db` (sqlite3 Database instance) is passed via dependency injection from `server.js`.
|
||||||
|
- Auth middleware: `requireAuth(db)` validates session cookie, attaches `req.user`. `requireRole('editor', 'admin')` checks role.
|
||||||
|
- All state-changing actions call `logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress })`.
|
||||||
|
- Input validation is done inline in route handlers with early-return error responses.
|
||||||
|
- SQLite queries use the callback-based `db.run()`, `db.get()`, `db.all()` API.
|
||||||
|
- API routes are prefixed with `/api`. All endpoints except login/logout require a valid session cookie.
|
||||||
|
- CVE and document routes are defined inline in `server.js`; feature routes are in separate modules under `routes/`.
|
||||||
|
|
||||||
|
## Frontend Conventions
|
||||||
|
|
||||||
|
- Single-page app with page-level navigation managed in `App.js` (no React Router).
|
||||||
|
- Auth state managed via React Context (`AuthContext`). Use `useAuth()` hook for login/logout/role checks.
|
||||||
|
- API calls use `fetch()` with `credentials: 'include'` for cookie-based auth.
|
||||||
|
- API base URL from `process.env.REACT_APP_API_BASE`.
|
||||||
|
- Styling uses a mix of inline style objects (defined as constants in component files) and `App.css` global styles.
|
||||||
|
- Dark theme with a "tactical intelligence" aesthetic — see `DESIGN_SYSTEM.md` for color palette, typography, and component specs.
|
||||||
|
- Icons from `lucide-react`. Charts from `recharts`.
|
||||||
|
- Page components live in `components/pages/`. Shared components live in `components/`.
|
||||||
|
- No TypeScript — the project uses plain JavaScript throughout.
|
||||||
78
.kiro/steering/tech.md
Normal file
78
.kiro/steering/tech.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Tech Stack & Build System
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Backend | Node.js 18+, Express 5 |
|
||||||
|
| Database | SQLite3 (file: `backend/cve_database.db`) |
|
||||||
|
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
||||||
|
| File uploads | Multer 2 (10MB limit) |
|
||||||
|
| Frontend | React 19 (Create React App / react-scripts 5) |
|
||||||
|
| UI Icons | lucide-react |
|
||||||
|
| Charts | recharts |
|
||||||
|
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
||||||
|
| Markdown rendering | react-markdown |
|
||||||
|
| Diagrams | mermaid |
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
node setup.js # Initialize DB, tables, indexes, default admin user
|
||||||
|
node server.js # Start backend on port 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm start # Dev server on port 3000
|
||||||
|
npm run build # Production build
|
||||||
|
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/` in order)
|
||||||
|
```bash
|
||||||
|
node migrations/add_knowledge_base_table.js
|
||||||
|
node migrations/add_archer_tickets_table.js
|
||||||
|
node migrations/add_ivanti_sync_table.js
|
||||||
|
node migrations/add_ivanti_findings_tables.js
|
||||||
|
node migrations/add_ivanti_todo_queue_table.js
|
||||||
|
node migrations/add_card_workflow_type.js
|
||||||
|
node migrations/add_todo_queue_ip_address.js
|
||||||
|
node migrations/add_compliance_tables.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 caches env vars at build/start time — restart the frontend process after changes.
|
||||||
|
|
||||||
|
## Default Ports
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Frontend | http://localhost:3000 |
|
||||||
|
| Backend API | http://localhost:3001 |
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# CVE Dashboard - Color Scheme Modernization
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
|
|
||||||
|
|
||||||
## Color Palette Changes
|
|
||||||
|
|
||||||
### Before (Neon/Retro)
|
|
||||||
- **Accent**: `#00D9FF` - Bright cyan (too neon)
|
|
||||||
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
|
|
||||||
- **Danger**: `#FF3366` - Neon pink/red
|
|
||||||
- **Success**: `#00FF88` - Bright green (too bright)
|
|
||||||
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
|
|
||||||
|
|
||||||
### After (Modern Professional)
|
|
||||||
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
|
|
||||||
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
|
|
||||||
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
|
|
||||||
- **Success**: `#10B981` - Emerald (professional green)
|
|
||||||
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
|
|
||||||
|
|
||||||
## Design Philosophy
|
|
||||||
|
|
||||||
### Refinement Approach
|
|
||||||
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
|
|
||||||
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
|
|
||||||
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
|
|
||||||
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
|
|
||||||
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
|
|
||||||
|
|
||||||
### Key Changes
|
|
||||||
|
|
||||||
#### Severity Badges
|
|
||||||
- **Critical**: Neon pink → Modern red with refined glow
|
|
||||||
- **High**: Bright yellow → Amber with warm tones
|
|
||||||
- **Medium**: Bright cyan → Sky blue professional
|
|
||||||
- **Low**: Bright green → Emerald sophisticated
|
|
||||||
|
|
||||||
#### Interactive Elements
|
|
||||||
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
|
|
||||||
- **Input Fields**: More subtle focus states, refined borders
|
|
||||||
- **Cards**: Gentler hover effects, professional elevation
|
|
||||||
- **Stat Cards**: Refined top accent lines, subtle glows
|
|
||||||
|
|
||||||
#### Layout Components
|
|
||||||
- **Wiki Panel**: Updated to emerald accent with professional borders
|
|
||||||
- **Calendar**: Sky blue accent with refined styling
|
|
||||||
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
|
|
||||||
- **CVE Cards**: Slate-based gradients with professional depth
|
|
||||||
|
|
||||||
## Technical Implementation
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
1. **App.css**: Updated all CSS variables, component styles, and utility classes
|
|
||||||
2. **App.js**: Updated inline STYLES object and all JSX color references
|
|
||||||
|
|
||||||
### CSS Variables Updated
|
|
||||||
```css
|
|
||||||
--intel-darkest: #0F172A
|
|
||||||
--intel-dark: #1E293B
|
|
||||||
--intel-medium: #334155
|
|
||||||
--intel-accent: #0EA5E9
|
|
||||||
--intel-warning: #F59E0B
|
|
||||||
--intel-danger: #EF4444
|
|
||||||
--intel-success: #10B981
|
|
||||||
--intel-grid: rgba(14, 165, 233, 0.08)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Maintained Features
|
|
||||||
✓ Pulsing button effects on hover/click
|
|
||||||
✓ Scanning line animation
|
|
||||||
✓ Card hover elevations
|
|
||||||
✓ Badge glow dots
|
|
||||||
✓ Grid background effect
|
|
||||||
✓ Three-column layout
|
|
||||||
✓ All interactive functionality
|
|
||||||
|
|
||||||
## Result
|
|
||||||
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# CVE Intelligence Dashboard - Design System Reference
|
# CVE Intelligence Dashboard - Design System Reference
|
||||||
|
|
||||||
## 🎨 Color Palette
|
## Color Palette
|
||||||
|
|
||||||
### Primary Colors
|
### Primary Colors
|
||||||
```css
|
```css
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||||
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||||
|
|
||||||
## 📐 Layout Structure
|
## Layout Structure
|
||||||
|
|
||||||
### Three-Column Grid Layout
|
### Three-Column Grid Layout
|
||||||
```
|
```
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||||
- **Tablet/Mobile**: Stacked single column
|
- **Tablet/Mobile**: Stacked single column
|
||||||
|
|
||||||
## 🎯 Component Specifications
|
## Component Specifications
|
||||||
|
|
||||||
### Stat Cards
|
### Stat Cards
|
||||||
```css
|
```css
|
||||||
@@ -117,7 +117,7 @@ Letter Spacing: 0.5px
|
|||||||
Glow Dot: 8px circle with pulse animation
|
Glow Dot: 8px circle with pulse animation
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✨ Interactions & Animations
|
## Interactions & Animations
|
||||||
|
|
||||||
### Hover Effects
|
### Hover Effects
|
||||||
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||||
@@ -151,7 +151,7 @@ Fast: all 0.2s ease
|
|||||||
Ripple: width/height 0.5s
|
Ripple: width/height 0.5s
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔤 Typography
|
## Typography
|
||||||
|
|
||||||
### Font Families
|
### Font Families
|
||||||
```css
|
```css
|
||||||
@@ -178,7 +178,7 @@ Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0
|
|||||||
Badge Text: 0 0 8px rgba([color], 0.5)
|
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎨 Visual Effects
|
## Visual Effects
|
||||||
|
|
||||||
### Shadows
|
### Shadows
|
||||||
```css
|
```css
|
||||||
@@ -223,7 +223,7 @@ linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
|||||||
Size: 20px × 20px
|
Size: 20px × 20px
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧩 Specific Component Patterns
|
## Specific Component Patterns
|
||||||
|
|
||||||
### Wiki/Knowledge Base Entry
|
### Wiki/Knowledge Base Entry
|
||||||
```css
|
```css
|
||||||
@@ -261,7 +261,7 @@ Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
|||||||
Vendor Cards: Nested with reduced opacity borders
|
Vendor Cards: Nested with reduced opacity borders
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📱 Accessibility
|
## Accessibility
|
||||||
|
|
||||||
### Contrast Ratios
|
### Contrast Ratios
|
||||||
- Primary text on dark: 18.5:1 (AAA)
|
- Primary text on dark: 18.5:1 (AAA)
|
||||||
@@ -278,7 +278,7 @@ Vendor Cards: Nested with reduced opacity borders
|
|||||||
- Line height: 1.5 for body text
|
- Line height: 1.5 for body text
|
||||||
- Letter spacing: Generous for uppercase labels
|
- Letter spacing: Generous for uppercase labels
|
||||||
|
|
||||||
## 🎯 Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||||
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||||
@@ -288,7 +288,3 @@ Vendor Cards: Nested with reduced opacity borders
|
|||||||
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||||
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: February 10, 2026
|
|
||||||
**Version**: 2.0 (Modern Professional Redesign)
|
|
||||||
|
|||||||
7
Ivanti_config_template.ini
Normal file
7
Ivanti_config_template.ini
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[platform]
|
||||||
|
url = https://platform4.risksense.com
|
||||||
|
api_ver = /api/v1
|
||||||
|
# PROD 1550 | UAT 1551
|
||||||
|
client_id = <pick 1550 or 1551>
|
||||||
|
[secrets]
|
||||||
|
api_key = <your API key here>
|
||||||
838
architecture.excalidraw
Normal file
838
architecture.excalidraw
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
{
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"source": "https://excalidraw.com",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "title-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 400,
|
||||||
|
"y": 30,
|
||||||
|
"width": 400,
|
||||||
|
"height": 45,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 1,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "CVE Dashboard Architecture",
|
||||||
|
"fontSize": 36,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 32,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "CVE Dashboard Architecture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-box",
|
||||||
|
"type": "ellipse",
|
||||||
|
"x": 500,
|
||||||
|
"y": 120,
|
||||||
|
"width": 200,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#e7f5ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 2,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "users-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "users-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 505,
|
||||||
|
"y": 145,
|
||||||
|
"width": 190,
|
||||||
|
"height": 30,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 3,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Users\n(Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 16,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 23,
|
||||||
|
"containerId": "users-box",
|
||||||
|
"originalText": "Users\n(Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 450,
|
||||||
|
"y": 250,
|
||||||
|
"width": 300,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#a5d8ff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 4,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "frontend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 455,
|
||||||
|
"y": 255,
|
||||||
|
"width": 290,
|
||||||
|
"height": 110,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 5,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 103,
|
||||||
|
"containerId": "frontend-box",
|
||||||
|
"originalText": "Frontend (React)\nPort: 3000\n\n• React 18 + Tailwind CSS\n• Auth Context\n• Components: Login, UserMenu,\n UserManagement, CVE Views"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 400,
|
||||||
|
"y": 420,
|
||||||
|
"width": 400,
|
||||||
|
"height": 180,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "#d0bfff",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 6,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "backend-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 405,
|
||||||
|
"y": 425,
|
||||||
|
"width": 390,
|
||||||
|
"height": 170,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 7,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"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",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 163,
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 200,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "#b2f2bb",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 8,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "db-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "db-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 205,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 9,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "db-box",
|
||||||
|
"originalText": "SQLite Database\ncve_database.db\n\nTables:\n• cves\n• documents\n• users\n• sessions\n• required_documents\n• audit_log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 550,
|
||||||
|
"y": 680,
|
||||||
|
"width": 280,
|
||||||
|
"height": 140,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "#ffec99",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 10,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "storage-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "storage-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 555,
|
||||||
|
"y": 685,
|
||||||
|
"width": 270,
|
||||||
|
"height": 130,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 11,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 123,
|
||||||
|
"containerId": "storage-box",
|
||||||
|
"originalText": "File Storage\nuploads/\n\nStructure:\nCVE-ID/\n Vendor/\n documents.pdf\n\n• Multi-vendor support\n• Timestamped filenames"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-box",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 900,
|
||||||
|
"y": 420,
|
||||||
|
"width": 220,
|
||||||
|
"height": 100,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "#ffc9c9",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 12,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"id": "nvd-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nvd-text",
|
||||||
|
"type": "text",
|
||||||
|
"x": 905,
|
||||||
|
"y": 425,
|
||||||
|
"width": 210,
|
||||||
|
"height": 90,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 13,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup",
|
||||||
|
"fontSize": 14,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "center",
|
||||||
|
"verticalAlign": "middle",
|
||||||
|
"baseline": 83,
|
||||||
|
"containerId": "nvd-box",
|
||||||
|
"originalText": "NVD API\n(External)\n\nNational Vulnerability\nDatabase\n\nAutomatic CVE lookup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-users-frontend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 200,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 14,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "users-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-frontend-backend",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 600,
|
||||||
|
"y": 370,
|
||||||
|
"width": 0,
|
||||||
|
"height": 50,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 15,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 50]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "frontend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-db",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 500,
|
||||||
|
"y": 600,
|
||||||
|
"width": -140,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#2f9e44",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 16,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[-140, 0],
|
||||||
|
[-140, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "db-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": true,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-storage",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 700,
|
||||||
|
"y": 600,
|
||||||
|
"width": 0,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 17,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 80]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "storage-box",
|
||||||
|
"focus": 0.5,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arrow-backend-nvd",
|
||||||
|
"type": "arrow",
|
||||||
|
"x": 800,
|
||||||
|
"y": 480,
|
||||||
|
"width": 100,
|
||||||
|
"height": 0,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "round",
|
||||||
|
"seed": 18,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"points": [
|
||||||
|
[0, 0],
|
||||||
|
[100, 0]
|
||||||
|
],
|
||||||
|
"lastCommittedPoint": null,
|
||||||
|
"startBinding": {
|
||||||
|
"elementId": "backend-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"endBinding": {
|
||||||
|
"elementId": "nvd-box",
|
||||||
|
"focus": 0,
|
||||||
|
"gap": 1
|
||||||
|
},
|
||||||
|
"startArrowhead": null,
|
||||||
|
"endArrowhead": "arrow",
|
||||||
|
"elbowed": false,
|
||||||
|
"roundness": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-http",
|
||||||
|
"type": "text",
|
||||||
|
"x": 610,
|
||||||
|
"y": 390,
|
||||||
|
"width": 100,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#7048e8",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 19,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTP/REST API",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTP/REST API"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-https",
|
||||||
|
"type": "text",
|
||||||
|
"x": 820,
|
||||||
|
"y": 460,
|
||||||
|
"width": 60,
|
||||||
|
"height": 20,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#e03131",
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 20,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "HTTPS",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 17,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "HTTPS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auth-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 100,
|
||||||
|
"y": 250,
|
||||||
|
"width": 280,
|
||||||
|
"height": 80,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 21,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"text": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)",
|
||||||
|
"fontSize": 12,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 73,
|
||||||
|
"containerId": null,
|
||||||
|
"originalText": "Authentication:\n• Session-based auth\n• bcrypt password hashing\n• Role-based access control\n (Admin/Editor/Viewer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "features-note",
|
||||||
|
"type": "text",
|
||||||
|
"x": 900,
|
||||||
|
"y": 580,
|
||||||
|
"width": 280,
|
||||||
|
"height": 120,
|
||||||
|
"angle": 0,
|
||||||
|
"strokeColor": "#495057",
|
||||||
|
"backgroundColor": "#f8f9fa",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "dashed",
|
||||||
|
"roughness": 0,
|
||||||
|
"opacity": 100,
|
||||||
|
"groupIds": [],
|
||||||
|
"strokeSharpness": "sharp",
|
||||||
|
"seed": 22,
|
||||||
|
"version": 1,
|
||||||
|
"versionNonce": 1,
|
||||||
|
"isDeleted": false,
|
||||||
|
"boundElements": null,
|
||||||
|
"updated": 1,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"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,
|
||||||
|
"fontFamily": 1,
|
||||||
|
"textAlign": "left",
|
||||||
|
"verticalAlign": "top",
|
||||||
|
"baseline": 113,
|
||||||
|
"containerId": null,
|
||||||
|
"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": {
|
||||||
|
"gridSize": null,
|
||||||
|
"viewBackgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"files": {}
|
||||||
|
}
|
||||||
@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
|
|||||||
# 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=
|
||||||
|
|
||||||
|
# Ivanti / RiskSense API (platform4.risksense.com)
|
||||||
|
# API key from your profile settings — does not expire like session cookies
|
||||||
|
IVANTI_API_KEY=
|
||||||
|
IVANTI_CLIENT_ID=1550
|
||||||
|
IVANTI_FIRST_NAME=
|
||||||
|
IVANTI_LAST_NAME=
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||||
|
IVANTI_SKIP_TLS=false
|
||||||
|
|||||||
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 } of files) {
|
||||||
|
parts.push(Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||||
|
`Content-Type: 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 };
|
||||||
@@ -12,7 +12,7 @@ function requireAuth(db) {
|
|||||||
try {
|
try {
|
||||||
const session = await new Promise((resolve, reject) => {
|
const session = await new Promise((resolve, reject) => {
|
||||||
db.get(
|
db.get(
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, 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 = ? AND s.expires_at > datetime('now')`,
|
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||||
@@ -37,7 +37,8 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -48,18 +49,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 +68,4 @@ function requireRole(...allowedRoles) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { requireAuth, requireRole };
|
module.exports = { requireAuth, requireGroup };
|
||||||
|
|||||||
50
backend/migrations/add_archer_tickets_table.js
Normal file
50
backend/migrations/add_archer_tickets_table.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Migration: Add archer_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 Archer tickets migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Create archer_tickets table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
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_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('✓ archer_tickets table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => {
|
||||||
|
if (err) console.error('Error creating CVE index:', err);
|
||||||
|
else console.log('✓ CVE index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => {
|
||||||
|
if (err) console.error('Error creating status index:', err);
|
||||||
|
else console.log('✓ Status index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => {
|
||||||
|
if (err) console.error('Error creating EXC number index:', err);
|
||||||
|
else console.log('✓ EXC number index created');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Indexes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
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!');
|
||||||
|
});
|
||||||
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 };
|
||||||
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!');
|
||||||
|
});
|
||||||
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!');
|
||||||
|
});
|
||||||
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!');
|
||||||
|
});
|
||||||
37
backend/migrations/add_ivanti_sync_table.js
Normal file
37
backend/migrations/add_ivanti_sync_table.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Migration: Add ivanti_sync_state 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 sync state migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
workflows_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the single-row state record
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding state row:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state row seeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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!');
|
||||||
|
});
|
||||||
70
backend/migrations/add_knowledge_base_table.js
Normal file
70
backend/migrations/add_knowledge_base_table.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Migration: Add knowledge_base table for storing documentation and policies
|
||||||
|
|
||||||
|
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_knowledge_base_table');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS knowledge_base (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
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 TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating knowledge_base table:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created knowledge_base table');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_slug
|
||||||
|
ON knowledge_base(slug)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating slug index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_category
|
||||||
|
ON knowledge_base(category)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating category index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on category');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_base_created_at
|
||||||
|
ON knowledge_base(created_at DESC)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating created_at index:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('✓ Created index on created_at');
|
||||||
|
console.log('\nMigration completed successfully!');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
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!'));
|
||||||
|
}
|
||||||
|
);
|
||||||
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 };
|
||||||
285
backend/routes/archerTickets.js
Normal file
285
backend/routes/archerTickets.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// routes/archerTickets.js
|
||||||
|
const express = require('express');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
// Validation helpers
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
function isValidCveId(cveId) {
|
||||||
|
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidVendor(vendor) {
|
||||||
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArcherTicketsRouter(db) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all Archer tickets (with optional filters)
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM archer_tickets WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (cve_id) {
|
||||||
|
query += ' AND cve_id = ?';
|
||||||
|
params.push(cve_id);
|
||||||
|
}
|
||||||
|
if (vendor) {
|
||||||
|
query += ' AND vendor = ?';
|
||||||
|
params.push(vendor);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching Archer tickets:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Archer ticket
|
||||||
|
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'EXC number is required.' });
|
||||||
|
}
|
||||||
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||||
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||||
|
}
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
|
}
|
||||||
|
if (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedStatus = status || 'Draft';
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor, req.user.id],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
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, {
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'CREATE_ARCHER_TICKET',
|
||||||
|
entityType: 'archer_ticket',
|
||||||
|
entityId: String(this.lastID),
|
||||||
|
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
message: 'Archer ticket created successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Archer ticket
|
||||||
|
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { exc_number, archer_url, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (exc_number !== undefined) {
|
||||||
|
if (typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'EXC number cannot be empty.' });
|
||||||
|
}
|
||||||
|
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||||
|
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing ticket
|
||||||
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (exc_number !== undefined) {
|
||||||
|
updates.push('exc_number = ?');
|
||||||
|
params.push(exc_number.trim());
|
||||||
|
}
|
||||||
|
if (archer_url !== undefined) {
|
||||||
|
updates.push('archer_url = ?');
|
||||||
|
params.push(archer_url || null);
|
||||||
|
}
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
||||||
|
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',
|
||||||
|
entityType: 'archer_ticket',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { before: existing, changes: req.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: perform the actual Archer ticket deletion
|
||||||
|
function performArcherDelete(db, req, res, id, ticket) {
|
||||||
|
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
action: 'DELETE_ARCHER_TICKET',
|
||||||
|
entityType: 'archer_ticket',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { deleted: ticket },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Archer ticket deleted successfully' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Archer ticket
|
||||||
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM archer_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ error: 'Archer ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypasses all delete restrictions
|
||||||
|
if (req.user.group === 'Admin') {
|
||||||
|
return performArcherDelete(db, req, res, id, ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
db.all(
|
||||||
|
`SELECT ci.id, ci.extra_json
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||||
|
[`%${excNumber}%`],
|
||||||
|
(compErr, compLinks) => {
|
||||||
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||||
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||||
|
compLinks = [];
|
||||||
|
} else if (compErr) {
|
||||||
|
console.error(compErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return performArcherDelete(db, req, res, id, ticket);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status-trend — ticket counts grouped by creation date + status
|
||||||
|
// Used for time-based Archer pipeline chart on the Compliance page.
|
||||||
|
router.get('/status-trend', requireAuth(db), (req, res) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT DATE(created_at) AS date, status, COUNT(*) AS count
|
||||||
|
FROM archer_tickets
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY date ASC`,
|
||||||
|
[],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching Archer status trend:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json({ statusTrend: rows });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createArcherTicketsRouter;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// Audit Log Routes (Admin only)
|
// Audit Log Routes (Admin only)
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
function createAuditLogRouter(db, requireAuth, requireRole) {
|
function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||||
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(db), requireGroup('Admin'));
|
||||||
|
|
||||||
// Get paginated audit logs with filters
|
// Get paginated audit logs with filters
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
|
|||||||
@@ -2,12 +2,35 @@
|
|||||||
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 { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
|
||||||
|
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(db, logAudit) {
|
function createAuthRouter(db, 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) {
|
||||||
@@ -110,7 +133,7 @@ function createAuthRouter(db, logAudit) {
|
|||||||
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 +143,7 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -129,7 +152,14 @@ 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;
|
||||||
|
|
||||||
@@ -172,7 +202,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;
|
||||||
|
|
||||||
@@ -183,7 +222,7 @@ function createAuthRouter(db, logAudit) {
|
|||||||
try {
|
try {
|
||||||
const session = await new Promise((resolve, reject) => {
|
const session = await new Promise((resolve, reject) => {
|
||||||
db.get(
|
db.get(
|
||||||
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
|
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, 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 = ? AND s.expires_at > datetime('now')`,
|
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||||
@@ -210,7 +249,7 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,13 +258,17 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up expired sessions (admin only)
|
/**
|
||||||
router.post('/cleanup-sessions', async (req, res) => {
|
* POST /api/auth/cleanup-sessions
|
||||||
// Basic auth check - require a valid session to call this
|
*
|
||||||
const sessionId = req.cookies?.session_id;
|
* Deletes all expired sessions from the database. Requires Admin group.
|
||||||
if (!sessionId) {
|
*
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
* @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(db), requireGroup('Admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
712
backend/routes/compliance.js
Normal file
712
backend/routes/compliance.js
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
// Compliance Routes — AEO metric tracking
|
||||||
|
// Handles xlsx upload/parse, non-compliant item history, and notes.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
|
||||||
|
// POST /commit — commit a previewed upload to DB
|
||||||
|
// GET /uploads — list all uploads
|
||||||
|
// GET /summary — metric health cards for a team (from latest upload)
|
||||||
|
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||||
|
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||||
|
// POST /notes — add a note to a (hostname, metric_id) pair
|
||||||
|
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||||
|
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
|
||||||
|
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
|
||||||
|
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ lastID: this.lastID, changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbGet(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row || null); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Run Python parser, return parsed object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseXlsx(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]);
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
py.stdout.on('data', d => { out += d; });
|
||||||
|
py.stderr.on('data', d => { err += d; });
|
||||||
|
py.on('close', code => {
|
||||||
|
if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`));
|
||||||
|
try { resolve(JSON.parse(out)); }
|
||||||
|
catch (e) { reject(new Error('Parser returned invalid JSON')); }
|
||||||
|
});
|
||||||
|
py.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validate that a temp file path is safely within uploads/temp/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function isSafeTempPath(filePath) {
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
return resolved.startsWith(TEMP_DIR + path.sep) && path.extname(resolved) === '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compute diff: new / recurring / resolved
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function computeDiff(db, incomingItems) {
|
||||||
|
const activeRows = await dbAll(db,
|
||||||
|
`SELECT hostname, metric_id FROM compliance_items WHERE status = 'active'`
|
||||||
|
);
|
||||||
|
const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`));
|
||||||
|
const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||||
|
|
||||||
|
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||||
|
for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; }
|
||||||
|
for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; }
|
||||||
|
|
||||||
|
return { newCount, recurringCount, resolvedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Write a parsed upload to the DB (within a transaction)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function persistUpload(db, { items, summary, reportDate, filename, userId }) {
|
||||||
|
// Pull current active items before we modify anything
|
||||||
|
const activeRows = await dbAll(db,
|
||||||
|
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
|
||||||
|
);
|
||||||
|
const activeMap = {};
|
||||||
|
activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; });
|
||||||
|
|
||||||
|
const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`));
|
||||||
|
|
||||||
|
await dbRun(db, 'BEGIN TRANSACTION');
|
||||||
|
try {
|
||||||
|
// 1. Insert the upload record
|
||||||
|
const { lastID: uploadId } = await dbRun(db,
|
||||||
|
`INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json)
|
||||||
|
VALUES (?, ?, ?, datetime('now'), ?)`,
|
||||||
|
[filename, reportDate || null, userId || null, JSON.stringify(summary)]
|
||||||
|
);
|
||||||
|
|
||||||
|
let newCount = 0, recurringCount = 0, resolvedCount = 0;
|
||||||
|
|
||||||
|
// 2. Upsert each incoming non-compliant item
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.hostname}|||${item.metric_id}`;
|
||||||
|
const existing = activeMap[key];
|
||||||
|
const extraStr = JSON.stringify(item.extra_json || {});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Recurring — bump seen_count, refresh snapshot fields
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_items
|
||||||
|
SET upload_id = ?, seen_count = ?, ip_address = ?, device_type = ?, extra_json = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, existing.id]
|
||||||
|
);
|
||||||
|
recurringCount++;
|
||||||
|
} else {
|
||||||
|
// New item (or previously resolved and re-appearing)
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO compliance_items
|
||||||
|
(upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc,
|
||||||
|
category, extra_json, status, first_seen_upload_id, seen_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1)`,
|
||||||
|
[uploadId, item.hostname, item.ip_address, item.device_type, item.team,
|
||||||
|
item.metric_id, item.metric_desc, item.category, extraStr, uploadId]
|
||||||
|
);
|
||||||
|
newCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Mark items not present in this upload as resolved
|
||||||
|
for (const [key, row] of Object.entries(activeMap)) {
|
||||||
|
if (!newKeys.has(key)) {
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_items
|
||||||
|
SET status = 'resolved', resolved_upload_id = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[uploadId, row.id]
|
||||||
|
);
|
||||||
|
resolvedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update upload with final counts
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE compliance_uploads
|
||||||
|
SET new_count = ?, resolved_count = ?, recurring_count = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[newCount, resolvedCount, recurringCount, uploadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await dbRun(db, 'COMMIT');
|
||||||
|
return { uploadId, newCount, recurringCount, resolvedCount };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group flat compliance_items rows into per-device objects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function groupByHostname(rows, noteHostnames) {
|
||||||
|
const deviceMap = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!deviceMap[row.hostname]) {
|
||||||
|
deviceMap[row.hostname] = {
|
||||||
|
hostname: row.hostname,
|
||||||
|
ip_address: row.ip_address || '',
|
||||||
|
device_type: row.device_type || '',
|
||||||
|
team: row.team || '',
|
||||||
|
status: row.status,
|
||||||
|
failing_metrics: [],
|
||||||
|
seen_count: row.seen_count || 1,
|
||||||
|
first_seen: row.first_seen || null,
|
||||||
|
last_seen: row.last_seen || null,
|
||||||
|
resolved_on: row.resolved_on || null,
|
||||||
|
has_notes: noteHostnames.has(row.hostname),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = deviceMap[row.hostname];
|
||||||
|
dev.failing_metrics.push({
|
||||||
|
metric_id: row.metric_id,
|
||||||
|
metric_desc: row.metric_desc || '',
|
||||||
|
category: row.category || '',
|
||||||
|
});
|
||||||
|
// Use the highest seen_count and earliest first_seen across all metrics
|
||||||
|
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||||
|
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen))
|
||||||
|
dev.first_seen = row.first_seen;
|
||||||
|
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen))
|
||||||
|
dev.last_seen = row.last_seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(deviceMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Idempotent column additions — errors mean column already exists, which is fine
|
||||||
|
db.run(`ALTER TABLE compliance_items ADD COLUMN seen_count INTEGER DEFAULT 1`, () => {});
|
||||||
|
db.run(`ALTER TABLE compliance_uploads ADD COLUMN summary_json TEXT`, () => {});
|
||||||
|
|
||||||
|
// All compliance routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /preview
|
||||||
|
// Parse the uploaded xlsx, compute diff, save parsed data to a temp JSON.
|
||||||
|
// Returns diff counts + tempFile path for the commit step.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/preview', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
upload.single('file')(req, res, async (uploadErr) => {
|
||||||
|
if (uploadErr) {
|
||||||
|
return res.status(400).json({ error: uploadErr.message });
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
if (path.extname(req.file.originalname).toLowerCase() !== '.xlsx') {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
return res.status(400).json({ error: 'File must be an .xlsx spreadsheet' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await parseXlsx(req.file.path);
|
||||||
|
|
||||||
|
if (parsed.error) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
return res.status(422).json({ error: parsed.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = await computeDiff(db, parsed.items);
|
||||||
|
|
||||||
|
// Save parsed data to temp JSON — the commit step reads this
|
||||||
|
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
const tempFilename = `compliance_preview_${Date.now()}_${Math.random().toString(36).slice(2)}.json`;
|
||||||
|
const tempFilePath = path.join(TEMP_DIR, tempFilename);
|
||||||
|
|
||||||
|
fs.writeFileSync(tempFilePath, JSON.stringify({
|
||||||
|
items: parsed.items,
|
||||||
|
summary: parsed.summary,
|
||||||
|
report_date: parsed.report_date,
|
||||||
|
filename: req.file.originalname.replace(/[^\w.\-() ]/g, '_'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Delete the original xlsx from temp (we only need the JSON now)
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
diff: {
|
||||||
|
new_count: diff.newCount,
|
||||||
|
recurring_count: diff.recurringCount,
|
||||||
|
resolved_count: diff.resolvedCount,
|
||||||
|
},
|
||||||
|
tempFile: tempFilePath,
|
||||||
|
filename: req.file.originalname,
|
||||||
|
report_date: parsed.report_date,
|
||||||
|
total_items: parsed.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
console.error('[Compliance] Preview error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to parse file: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /commit
|
||||||
|
// Commit a previewed upload to the DB.
|
||||||
|
// Body: { tempFile, filename, report_date }
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/commit', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { tempFile, filename, report_date } = req.body;
|
||||||
|
|
||||||
|
if (!tempFile || typeof tempFile !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'tempFile is required' });
|
||||||
|
}
|
||||||
|
if (!isSafeTempPath(tempFile)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid tempFile path' });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(tempFile)) {
|
||||||
|
return res.status(400).json({ error: 'Preview session expired — please upload again' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Could not read preview data — please upload again' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await persistUpload(db, {
|
||||||
|
items: parsed.items,
|
||||||
|
summary: parsed.summary,
|
||||||
|
reportDate: report_date || parsed.report_date,
|
||||||
|
filename: filename || parsed.filename,
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.unlink(tempFile, () => {});
|
||||||
|
|
||||||
|
const upload = await dbGet(db,
|
||||||
|
`SELECT id, filename, report_date, uploaded_at,
|
||||||
|
new_count, resolved_count, recurring_count
|
||||||
|
FROM compliance_uploads WHERE id = ?`,
|
||||||
|
[result.uploadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ upload });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] Commit error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to commit upload: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /uploads
|
||||||
|
// List all uploads, most recent first.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/uploads', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT id, filename, report_date, uploaded_at,
|
||||||
|
new_count, resolved_count, recurring_count
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY id DESC`
|
||||||
|
);
|
||||||
|
res.json({ uploads: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /uploads error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /summary?team=STEAM
|
||||||
|
// Return metric health rows for a team from the latest upload's summary_json.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
const team = req.query.team;
|
||||||
|
if (team && !ALLOWED_TEAMS.has(team)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid team' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestUpload = await dbGet(db,
|
||||||
|
`SELECT id, summary_json, report_date, uploaded_at
|
||||||
|
FROM compliance_uploads ORDER BY id DESC LIMIT 1`
|
||||||
|
);
|
||||||
|
if (!latestUpload || !latestUpload.summary_json) {
|
||||||
|
return res.json({ entries: [], overall_scores: {}, upload: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary;
|
||||||
|
try { summary = JSON.parse(latestUpload.summary_json); }
|
||||||
|
catch { return res.json({ entries: [], overall_scores: {}, upload: null }); }
|
||||||
|
|
||||||
|
let entries = summary.entries || [];
|
||||||
|
if (team) {
|
||||||
|
entries = entries.filter(e => e.team === team);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
entries,
|
||||||
|
overall_scores: summary.overall_scores || {},
|
||||||
|
upload: {
|
||||||
|
id: latestUpload.id,
|
||||||
|
report_date: latestUpload.report_date,
|
||||||
|
uploaded_at: latestUpload.uploaded_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /summary error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /items?team=STEAM&status=active
|
||||||
|
// Return non-compliant devices grouped by hostname.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/items', async (req, res) => {
|
||||||
|
const { team, status = 'active' } = req.query;
|
||||||
|
|
||||||
|
if (!team) return res.status(400).json({ error: 'team is required' });
|
||||||
|
if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
|
||||||
|
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.hostname, ci.ip_address, ci.device_type, ci.team,
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category,
|
||||||
|
ci.status, ci.seen_count,
|
||||||
|
fu.report_date AS first_seen,
|
||||||
|
lu.report_date AS last_seen,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.team = ? AND ci.status = ?
|
||||||
|
ORDER BY ci.hostname, ci.metric_id`,
|
||||||
|
[team, status]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch hostnames that have any notes (for the has_notes indicator)
|
||||||
|
const noteRows = await dbAll(db,
|
||||||
|
`SELECT DISTINCT hostname FROM compliance_notes`
|
||||||
|
);
|
||||||
|
const noteHostnames = new Set(noteRows.map(r => r.hostname));
|
||||||
|
|
||||||
|
const devices = groupByHostname(rows, noteHostnames);
|
||||||
|
|
||||||
|
res.json({ devices, team, status });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /items error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /items/:hostname
|
||||||
|
// Detail panel: all metric rows for this hostname + notes + upload history.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/items/:hostname', async (req, res) => {
|
||||||
|
const hostname = req.params.hostname;
|
||||||
|
if (!hostname || hostname.length > 300) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// All metric rows for this hostname
|
||||||
|
const metricRows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category, ci.status,
|
||||||
|
ci.ip_address, ci.device_type, ci.team,
|
||||||
|
ci.seen_count, ci.extra_json,
|
||||||
|
fu.report_date AS first_seen,
|
||||||
|
fu.uploaded_at AS first_seen_at,
|
||||||
|
lu.report_date AS last_seen,
|
||||||
|
lu.uploaded_at AS last_seen_at,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.hostname = ?
|
||||||
|
ORDER BY ci.status DESC, ci.metric_id`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (metricRows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Device not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extra_json on each row
|
||||||
|
const metrics = metricRows.map(r => ({
|
||||||
|
...r,
|
||||||
|
extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(),
|
||||||
|
extra_json: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Notes (all metrics for this hostname, sorted newest first)
|
||||||
|
const notes = await dbAll(db,
|
||||||
|
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
|
||||||
|
u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.hostname = ?
|
||||||
|
ORDER BY cn.created_at DESC`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive device identity from the first active row, else any row
|
||||||
|
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hostname,
|
||||||
|
ip_address: identity.ip_address || '',
|
||||||
|
device_type: identity.device_type || '',
|
||||||
|
team: identity.team || '',
|
||||||
|
metrics,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /notes
|
||||||
|
// Add a note to a (hostname, metric_id) pair.
|
||||||
|
// Body: { hostname, metric_id, note }
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const { hostname, metric_id, note } = req.body;
|
||||||
|
|
||||||
|
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hostname format' });
|
||||||
|
}
|
||||||
|
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||||
|
}
|
||||||
|
const noteText = String(note || '').trim().slice(0, 1000);
|
||||||
|
if (!noteText) {
|
||||||
|
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { lastID } = await dbRun(db,
|
||||||
|
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||||
|
[hostname, metric_id, noteText, req.user?.id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await dbGet(db,
|
||||||
|
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
|
||||||
|
u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.id = ?`,
|
||||||
|
[lastID]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(created);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] POST /notes error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to save note' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /notes/:hostname/:metricId
|
||||||
|
// Return all notes for a (hostname, metric_id) pair.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/notes/:hostname/:metricId', async (req, res) => {
|
||||||
|
const { hostname, metricId } = req.params;
|
||||||
|
|
||||||
|
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notes = await dbAll(db,
|
||||||
|
`SELECT cn.id, cn.note, cn.created_at, u.username AS created_by
|
||||||
|
FROM compliance_notes cn
|
||||||
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
|
WHERE cn.hostname = ? AND cn.metric_id = ?
|
||||||
|
ORDER BY cn.created_at DESC`,
|
||||||
|
[hostname, metricId]
|
||||||
|
);
|
||||||
|
res.json({ notes });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /notes error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /trends
|
||||||
|
// Per-upload active totals + per-team counts for time-series charts.
|
||||||
|
// Returns rows ordered ascending by report_date.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/trends', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uploads = await dbAll(db,
|
||||||
|
`SELECT id, report_date,
|
||||||
|
COALESCE(new_count, 0) AS new_count,
|
||||||
|
COALESCE(recurring_count, 0) AS recurring_count,
|
||||||
|
COALESCE(resolved_count, 0) AS resolved_count,
|
||||||
|
COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY report_date ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploads.length === 0) return res.json({ trends: [] });
|
||||||
|
|
||||||
|
// Per-team active counts — items whose upload_id matches the upload
|
||||||
|
// (recurring items have upload_id bumped each cycle, so this is accurate)
|
||||||
|
const teamRows = await dbAll(db,
|
||||||
|
`SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count
|
||||||
|
FROM compliance_items ci
|
||||||
|
WHERE ci.team IS NOT NULL
|
||||||
|
GROUP BY ci.upload_id, ci.team`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamMap = {};
|
||||||
|
teamRows.forEach(r => {
|
||||||
|
if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {};
|
||||||
|
teamMap[r.upload_id][r.team] = r.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const trends = uploads.map(u => ({
|
||||||
|
report_date: u.report_date,
|
||||||
|
new_count: u.new_count,
|
||||||
|
recurring_count: u.recurring_count,
|
||||||
|
resolved_count: u.resolved_count,
|
||||||
|
total_active: u.total_active,
|
||||||
|
STEAM: teamMap[u.id]?.STEAM || 0,
|
||||||
|
'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0,
|
||||||
|
'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0,
|
||||||
|
INTELDEV: teamMap[u.id]?.INTELDEV || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ trends });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /trends error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /mttr
|
||||||
|
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/mttr', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT
|
||||||
|
ci.team,
|
||||||
|
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||||
|
COUNT(*) AS resolved_count
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.resolved_upload_id IS NOT NULL
|
||||||
|
AND fu.report_date IS NOT NULL
|
||||||
|
AND ru.report_date IS NOT NULL
|
||||||
|
GROUP BY ci.team
|
||||||
|
ORDER BY avg_days DESC`
|
||||||
|
);
|
||||||
|
res.json({ mttr: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /mttr error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /top-recurring
|
||||||
|
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||||
|
// Identifies chronic compliance gaps that keep reappearing.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/top-recurring', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY team, metric_id
|
||||||
|
ORDER BY seen_count DESC, host_count DESC
|
||||||
|
LIMIT 20`
|
||||||
|
);
|
||||||
|
res.json({ items: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /category-trend
|
||||||
|
// Active item counts per category per upload, for stacked area chart.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/category-trend', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await dbAll(db,
|
||||||
|
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||||
|
GROUP BY cu.id, category
|
||||||
|
ORDER BY cu.report_date ASC`
|
||||||
|
);
|
||||||
|
res.json({ categoryTrend: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Compliance] GET /category-trend error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createComplianceRouter;
|
||||||
162
backend/routes/ivantiArchive.js
Normal file
162
backend/routes/ivantiArchive.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||||
|
|
||||||
|
function createIvantiArchiveRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /
|
||||||
|
* List archive records with optional state filtering.
|
||||||
|
*
|
||||||
|
* @query {string} [state] - Filter by lifecycle state (ACTIVE, ARCHIVED, RETURNED, CLOSED)
|
||||||
|
* @returns {Object} 200 - { archives: Array<ArchiveRecord>, total: number }
|
||||||
|
* @returns {Object} 400 - { error: string } when state param is invalid
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { state } = req.query;
|
||||||
|
|
||||||
|
if (state && !VALID_STATES.includes(state)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid state parameter. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = 'SELECT * FROM ivanti_finding_archives';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
query += ' WHERE current_state = ?';
|
||||||
|
params.push(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY last_transition_at DESC';
|
||||||
|
|
||||||
|
const archives = await new Promise((resolve, reject) => {
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ archives, total: archives.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Archive list error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch archive records' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /stats
|
||||||
|
* Summary counts of archive records by lifecycle state.
|
||||||
|
* ACTIVE is implicit: live findings in the cache that have no ARCHIVED/RETURNED archive record.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Count archive records by state
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT current_state, COUNT(*) as count
|
||||||
|
FROM ivanti_finding_archives
|
||||||
|
GROUP BY current_state`,
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (stats.hasOwnProperty(row.current_state)) {
|
||||||
|
stats[row.current_state] = row.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute ACTIVE: total live findings minus those with ARCHIVED or RETURNED records
|
||||||
|
const cacheRow = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveFindingsCount = (cacheRow && cacheRow.total) || 0;
|
||||||
|
// Findings that are ARCHIVED or RETURNED are "missing" from the live set,
|
||||||
|
// so ACTIVE = live count (all findings currently present in sync results)
|
||||||
|
stats.ACTIVE = liveFindingsCount;
|
||||||
|
|
||||||
|
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||||
|
|
||||||
|
res.json({ ...stats, total });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Archive stats error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch archive stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:findingId/history
|
||||||
|
* Transition history for a specific archived finding, ordered by most recent first.
|
||||||
|
* Returns an empty transitions array if the finding has no archive record.
|
||||||
|
*
|
||||||
|
* @param {string} findingId - Ivanti finding identifier (route param)
|
||||||
|
* @returns {Object} 200 - { finding_id: string, transitions: Array<TransitionRecord> }
|
||||||
|
* @returns {Object} 500 - { error: string } on database failure
|
||||||
|
*/
|
||||||
|
router.get('/:findingId/history', async (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const archive = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT id FROM ivanti_finding_archives WHERE finding_id = ?',
|
||||||
|
[findingId],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!archive) {
|
||||||
|
return res.json({ finding_id: findingId, transitions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM ivanti_archive_transitions
|
||||||
|
WHERE archive_id = ?
|
||||||
|
ORDER BY transitioned_at DESC`,
|
||||||
|
[archive.id],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ finding_id: findingId, transitions });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Archive history error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch transition history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiArchiveRouter;
|
||||||
922
backend/routes/ivantiFindings.js
Normal file
922
backend/routes/ivantiFindings.js
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
// Ivanti / RiskSense Host Findings Routes
|
||||||
|
// Caches hostFinding/search results in SQLite with daily auto-sync.
|
||||||
|
// Notes are stored separately so they survive cache refreshes.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const FINDINGS_FILTERS = [
|
||||||
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Open',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||||
|
const CLOSED_COUNT_FILTERS = [
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Closed',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTables(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
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) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
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) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
open_count INTEGER DEFAULT 0,
|
||||||
|
closed_count INTEGER DEFAULT 0,
|
||||||
|
synced_at DATETIME
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
// Idempotent column additions — errors mean the column already exists, which is fine
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||||
|
VALUES (1, 0, 0)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(finding_id, field)
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
|
ON ivanti_finding_overrides(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
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) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Archive table init — creates archive tracking tables alongside the main cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initArchiveTables(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
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) return reject(err); });
|
||||||
|
|
||||||
|
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) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_finding_id
|
||||||
|
ON ivanti_finding_archives(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_current_state
|
||||||
|
ON ivanti_finding_archives(current_state)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transition_archive_id
|
||||||
|
ON ivanti_archive_transitions(archive_id)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Archive detection — compare previous vs current findings to detect state changes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function detectArchiveChanges(db, previousFindings, currentFindings) {
|
||||||
|
const previousIds = new Set(previousFindings.map(f => String(f.id)));
|
||||||
|
const currentIds = new Set(currentFindings.map(f => String(f.id)));
|
||||||
|
|
||||||
|
// Build lookup maps for metadata
|
||||||
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
||||||
|
const currentMap = new Map(currentFindings.map(f => [String(f.id), f]));
|
||||||
|
|
||||||
|
// 1. Disappeared findings: in previous but not in current → ARCHIVED
|
||||||
|
const disappearedIds = [...previousIds].filter(id => !currentIds.has(id));
|
||||||
|
|
||||||
|
for (const id of disappearedIds) {
|
||||||
|
const finding = previousMap.get(id);
|
||||||
|
const title = finding.title || '';
|
||||||
|
const hostName = finding.hostName || '';
|
||||||
|
const ipAddress = finding.ipAddress || '';
|
||||||
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if this finding already has an archive record
|
||||||
|
const existing = await dbGet(db,
|
||||||
|
`SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing && existing.current_state === 'RETURNED') {
|
||||||
|
// Re-disappeared: RETURNED → ARCHIVED
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_finding_archives
|
||||||
|
SET current_state = 'ARCHIVED', last_severity = ?, last_transition_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
[severity, existing.id]
|
||||||
|
);
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||||
|
VALUES (?, 'RETURNED', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
||||||
|
[existing.id, severity]
|
||||||
|
);
|
||||||
|
console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`);
|
||||||
|
} else if (!existing) {
|
||||||
|
// First disappearance: NONE → ARCHIVED
|
||||||
|
const result = await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'ARCHIVED', ?, datetime('now'), datetime('now'))`,
|
||||||
|
[id, title, hostName, ipAddress, severity]
|
||||||
|
);
|
||||||
|
const archiveId = result.lastID;
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||||
|
VALUES (?, 'NONE', 'ARCHIVED', ?, 'severity_score_drift', datetime('now'))`,
|
||||||
|
[archiveId, severity]
|
||||||
|
);
|
||||||
|
console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`);
|
||||||
|
}
|
||||||
|
// If existing state is ARCHIVED or CLOSED, no action needed
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
||||||
|
const currentIdsList = [...currentIds];
|
||||||
|
if (currentIdsList.length > 0) {
|
||||||
|
try {
|
||||||
|
const archivedRecords = await dbAll(db,
|
||||||
|
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const record of archivedRecords) {
|
||||||
|
if (currentIds.has(record.finding_id)) {
|
||||||
|
const finding = currentMap.get(record.finding_id);
|
||||||
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_finding_archives
|
||||||
|
SET current_state = 'RETURNED', last_severity = ?, last_transition_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
[severity, record.id]
|
||||||
|
);
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||||
|
VALUES (?, 'ARCHIVED', 'RETURNED', ?, 'reappeared_in_sync', datetime('now'))`,
|
||||||
|
[record.id, severity]
|
||||||
|
);
|
||||||
|
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Archive Detection] Error processing returned findings:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, checked ${currentIdsList.length} current findings against archive`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Closed finding detection — check archived/returned findings against Ivanti closed set
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function detectClosedFindings(db, closedFindingIds) {
|
||||||
|
if (!closedFindingIds || closedFindingIds.length === 0) return;
|
||||||
|
|
||||||
|
const closedSet = new Set(closedFindingIds.map(String));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await dbAll(db,
|
||||||
|
`SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')`
|
||||||
|
);
|
||||||
|
|
||||||
|
let closedCount = 0;
|
||||||
|
for (const record of records) {
|
||||||
|
if (!closedSet.has(record.finding_id)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_finding_archives
|
||||||
|
SET current_state = 'CLOSED', last_transition_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
[record.id]
|
||||||
|
);
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
||||||
|
VALUES (?, ?, 'CLOSED', ?, 'remediated_in_ivanti', datetime('now'))`,
|
||||||
|
[record.id, record.current_state, record.last_severity || 0]
|
||||||
|
);
|
||||||
|
closedCount++;
|
||||||
|
console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Archive Detection] Error querying archive records for closed detection:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract only the fields we need from a raw finding object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFinding(f) {
|
||||||
|
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
||||||
|
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
||||||
|
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
||||||
|
|
||||||
|
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
||||||
|
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
||||||
|
|
||||||
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||||
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||||
|
|
||||||
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||||
|
// system workflows and not actionable for our purposes.
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
|
||||||
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
|
||||||
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||||
|
const generatedNames = f.workflowGeneratedNames || [];
|
||||||
|
const fpFromNames = !fpEntry
|
||||||
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workflow = fpEntry ? {
|
||||||
|
id: fpEntry.generatedId || '',
|
||||||
|
state: fpEntry.state || '',
|
||||||
|
type: 'FP',
|
||||||
|
} : fpFromNames ? {
|
||||||
|
id: fpFromNames,
|
||||||
|
state: '',
|
||||||
|
type: 'FP',
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(f.id),
|
||||||
|
title: f.title || '',
|
||||||
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
||||||
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
||||||
|
hostName: f.host?.hostName || '',
|
||||||
|
ipAddress: f.host?.ipAddress || '',
|
||||||
|
dns: f.dns || f.host?.fqdn || '',
|
||||||
|
status: f.status || '',
|
||||||
|
slaStatus: f.slaStatus || '',
|
||||||
|
dueDate,
|
||||||
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
|
buOwnership,
|
||||||
|
cves,
|
||||||
|
workflow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
// RiskSense returns total in page.totalElements or page.total
|
||||||
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||||
|
const totalPages = data.page?.totalPages || 1;
|
||||||
|
|
||||||
|
// Collect closed finding IDs for archive detection
|
||||||
|
const closedFindingIds = [];
|
||||||
|
const firstPageFindings = data._embedded?.hostFindings || [];
|
||||||
|
firstPageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
||||||
|
|
||||||
|
// Fetch remaining pages to collect all closed finding IDs
|
||||||
|
for (let pg = 1; pg < totalPages; pg++) {
|
||||||
|
try {
|
||||||
|
const pageBody = { ...body, page: pg };
|
||||||
|
const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls);
|
||||||
|
if (pageResult.status !== 200) break;
|
||||||
|
const pageData = JSON.parse(pageResult.body);
|
||||||
|
const pageFindings = pageData._embedded?.hostFindings || [];
|
||||||
|
pageFindings.forEach(f => { if (f.id) closedFindingIds.push(String(f.id)); });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Append a snapshot to history — every sync is stored; the history
|
||||||
|
// endpoint aggregates to last-per-day at query time (Option B).
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
|
|
||||||
|
// Detect closed findings in the archive — wrap in try/catch so errors don't break sync
|
||||||
|
try {
|
||||||
|
await detectClosedFindings(db, closedFindingIds);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
|
// Still update open count so it stays in sync; leave closed_count as-is
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount]
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
||||||
|
// Returns { id, state } or null if no FP# workflow present.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFPWorkflow(f) {
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
if (!fpEntry) return null;
|
||||||
|
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sync FP stats across ALL findings (open + closed).
|
||||||
|
//
|
||||||
|
// Produces two separate counts:
|
||||||
|
// findingCounts — number of *findings* per FP workflow state
|
||||||
|
// idCounts — number of *unique FP# ticket IDs* per state
|
||||||
|
// (one FP# can cover many findings; this chart counts tickets)
|
||||||
|
//
|
||||||
|
// Open findings come from the already-extracted allFindings array.
|
||||||
|
// Closed findings are swept page-by-page to catch Approved FPs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||||
|
const findingCounts = {}; // state → # findings
|
||||||
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
||||||
|
|
||||||
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
||||||
|
openFindings.forEach(f => {
|
||||||
|
if (!f.workflow) return;
|
||||||
|
const state = f.workflow.state || 'Unknown';
|
||||||
|
const id = f.workflow.id || '';
|
||||||
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
||||||
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
findings.forEach(f => {
|
||||||
|
const wf = extractFPWorkflow(f);
|
||||||
|
if (!wf) return;
|
||||||
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
||||||
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
||||||
|
});
|
||||||
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
||||||
|
// Fall through — store whatever we have from open findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate unique FP# IDs by state
|
||||||
|
const idCounts = {};
|
||||||
|
Object.values(fpIdMap).forEach(state => {
|
||||||
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
||||||
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
||||||
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
||||||
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFindings(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti Findings]', errMsg);
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [errMsg]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] Starting sync...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let allFindings = [];
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: FINDINGS_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
||||||
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
||||||
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
||||||
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
|
||||||
|
// Read previous findings BEFORE updating the cache (they'll be overwritten)
|
||||||
|
let previousFindings = [];
|
||||||
|
try {
|
||||||
|
const state = await readState(db);
|
||||||
|
previousFindings = state.findings || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_findings_cache SET total=?, findings_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL WHERE id=1`,
|
||||||
|
[allFindings.length, JSON.stringify(allFindings)]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
|
|
||||||
|
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
||||||
|
// Only runs after a successful sync (skipped on error per requirement 1.5)
|
||||||
|
try {
|
||||||
|
await detectArchiveChanges(db, previousFindings, allFindings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||||
|
// Archive detection is intentionally skipped on sync error (requirement 1.5)
|
||||||
|
await dbRun(db, `UPDATE ivanti_findings_cache SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_findings_cache WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncFindings(db);
|
||||||
|
} else {
|
||||||
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncFindings(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function dbRun(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(sql, params, function (err) { if (err) reject(err); else resolve(this); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGet(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbAll(db, sql, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, findings_json, synced_at, sync_status, error_message FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, findings: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
let findings = [];
|
||||||
|
try { findings = JSON.parse(row.findings_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
resolve({ total: row.total || 0, findings, synced_at: row.synced_at, sync_status: row.sync_status, error_message: row.error_message });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNotes(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, note FROM ivanti_finding_notes', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => { map[r.finding_id] = r.note; });
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCounts(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({
|
||||||
|
open: row?.open_count ?? 0,
|
||||||
|
closed: row?.closed_count ?? 0,
|
||||||
|
synced_at: row?.synced_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||||
|
function readOverrides(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => {
|
||||||
|
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||||
|
map[r.finding_id][r.field] = r.value;
|
||||||
|
});
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStateWithNotes(db) {
|
||||||
|
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||||
|
state.findings = state.findings.map((f) => ({
|
||||||
|
...f,
|
||||||
|
note: notes[f.id] || '',
|
||||||
|
overrides: overrides[f.id] || {},
|
||||||
|
}));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiFindingsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
Promise.all([initTables(db), initArchiveTables(db)])
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti Findings] Init failed:', err));
|
||||||
|
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — cached findings with notes merged in
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading findings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger immediate sync, return fresh state
|
||||||
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
await syncFindings(db);
|
||||||
|
try {
|
||||||
|
res.json(await readStateWithNotes(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /counts — open vs closed totals for pie chart
|
||||||
|
router.get('/counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readCounts(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||||
|
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||||
|
router.get('/counts/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`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`,
|
||||||
|
[],
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.json({ history: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||||
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let findingCounts = {};
|
||||||
|
let idCounts = {};
|
||||||
|
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||||
|
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
||||||
|
res.json({
|
||||||
|
findingCounts,
|
||||||
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
||||||
|
idCounts,
|
||||||
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||||
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||||
|
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const { field, value } = req.body;
|
||||||
|
|
||||||
|
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||||
|
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = String(value ?? '').trim();
|
||||||
|
|
||||||
|
if (val === '') {
|
||||||
|
// Empty value = clear the override (revert to Ivanti)
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||||
|
[findingId, field],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: null });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||||
|
[findingId, field, val],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: val });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
|
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const note = String(req.body.note || '').slice(0, 255);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE SET note=excluded.note, updated_at=datetime('now')`,
|
||||||
|
[findingId, note],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save note' });
|
||||||
|
res.json({ finding_id: findingId, note });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiFindingsRouter;
|
||||||
|
module.exports.detectArchiveChanges = detectArchiveChanges;
|
||||||
|
module.exports.detectClosedFindings = detectClosedFindings;
|
||||||
|
module.exports.initArchiveTables = initArchiveTables;
|
||||||
395
backend/routes/ivantiFpWorkflow.js
Normal file
395
backend/routes/ivantiFpWorkflow.js
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// routes/ivantiFpWorkflow.js
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
const { ivantiFormPost } = require('../helpers/ivantiApi');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers (exported for testing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif',
|
||||||
|
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the filename has an allowed extension (case-insensitive).
|
||||||
|
*/
|
||||||
|
function isAllowedFileExtension(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return false;
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the FP workflow form body.
|
||||||
|
* Returns {} if valid, or { fieldName: 'error message' } for each invalid field.
|
||||||
|
*/
|
||||||
|
function validateFpWorkflowForm(body) {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
// name: required, non-empty, max 255
|
||||||
|
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||||
|
errors.name = 'Workflow name is required.';
|
||||||
|
} else if (body.name.trim().length > 255) {
|
||||||
|
errors.name = 'Workflow name must be 255 characters or fewer.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// reason: required, non-empty
|
||||||
|
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||||
|
errors.reason = 'Reason is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// description: optional, max 2000 if provided
|
||||||
|
if (body.description !== undefined && body.description !== null && body.description !== '') {
|
||||||
|
if (typeof body.description !== 'string') {
|
||||||
|
errors.description = 'Description must be a string.';
|
||||||
|
} else if (body.description.length > 2000) {
|
||||||
|
errors.description = 'Description must be 2000 characters or fewer.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expirationDate: required, valid date, strictly after today
|
||||||
|
if (!body.expirationDate || typeof body.expirationDate !== 'string' || body.expirationDate.trim().length === 0) {
|
||||||
|
errors.expirationDate = 'Expiration date is required.';
|
||||||
|
} else {
|
||||||
|
const parsed = new Date(body.expirationDate);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
errors.expirationDate = 'Expiration date must be a valid date.';
|
||||||
|
} else {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const expDay = new Date(parsed);
|
||||||
|
expDay.setHours(0, 0, 0, 0);
|
||||||
|
if (expDay <= today) {
|
||||||
|
errors.expirationDate = 'Expiration date must be in the future.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint.
|
||||||
|
* Format: { subject, filterRequest: { filters } }
|
||||||
|
*/
|
||||||
|
function buildSubjectFilterRequest(findingIds) {
|
||||||
|
return JSON.stringify({
|
||||||
|
subject: 'hostFinding',
|
||||||
|
filterRequest: {
|
||||||
|
filters: [{
|
||||||
|
field: 'id',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
value: findingIds.map(id => String(id)).join(',')
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the multipart form fields array for the Ivanti FP workflow request.
|
||||||
|
*/
|
||||||
|
function buildIvantiFormFields(formData, findingIds) {
|
||||||
|
const scopeMap = {
|
||||||
|
'Authorized': 'AUTHORIZED',
|
||||||
|
'None': 'NONE',
|
||||||
|
'Automated': 'AUTOMATED'
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'name', value: formData.name },
|
||||||
|
{ name: 'reason', value: formData.reason },
|
||||||
|
{ name: 'description', value: formData.description || '' },
|
||||||
|
{ name: 'expirationDate', value: formData.expirationDate },
|
||||||
|
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
|
||||||
|
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
|
||||||
|
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multer configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const uploadStorage = multer.memoryStorage();
|
||||||
|
|
||||||
|
const fpUpload = multer({
|
||||||
|
storage: uploadStorage,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (isAllowedFileExtension(file.originalname)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).array('attachments', 10); // up to 10 files
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createIvantiFpWorkflowRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ivanti/fp-workflow
|
||||||
|
*
|
||||||
|
* Creates a False Positive workflow batch in the Ivanti/RiskSense API,
|
||||||
|
* optionally uploads file attachments, records the submission locally,
|
||||||
|
* and marks the associated queue items as complete.
|
||||||
|
*
|
||||||
|
* Content-Type: multipart/form-data
|
||||||
|
*
|
||||||
|
* @param {string} req.body.name - Workflow name (required, max 255 chars)
|
||||||
|
* @param {string} req.body.reason - Reason for the FP determination (required)
|
||||||
|
* @param {string} [req.body.description] - Additional description (optional, max 2000 chars)
|
||||||
|
* @param {string} req.body.expirationDate - ISO date string, must be a future date (required)
|
||||||
|
* @param {string} [req.body.scopeOverride] - "Authorized" (default) or "None"
|
||||||
|
* @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs
|
||||||
|
* @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs
|
||||||
|
* @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each);
|
||||||
|
* allowed extensions: .pdf .png .jpg .jpeg .gif
|
||||||
|
* .doc .docx .xlsx .csv .txt .zip
|
||||||
|
*
|
||||||
|
* @returns {object} 200 - Success
|
||||||
|
* { success: true, workflowBatchId: number, generatedId: string,
|
||||||
|
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
|
||||||
|
* queueItemsUpdated: number, status: 'success' | 'partial' }
|
||||||
|
* @returns {object} 400 - Validation error
|
||||||
|
* { error: string } or { success: false, errors: { [field]: string } }
|
||||||
|
* @returns {object} 403 - Queue item ownership violation
|
||||||
|
* { error: string }
|
||||||
|
* @returns {object} 429 - Ivanti rate limit
|
||||||
|
* { success: false, error: string, step: 'create_workflow' }
|
||||||
|
* @returns {object} 500 - Server configuration error
|
||||||
|
* { success: false, error: string, step: 'create_workflow' }
|
||||||
|
* @returns {object} 502 - Ivanti API error
|
||||||
|
* { success: false, error: string, step: 'create_workflow', details?: string }
|
||||||
|
*/
|
||||||
|
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
fpUpload(req, res, (multerErr) => {
|
||||||
|
if (multerErr) {
|
||||||
|
if (multerErr.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: multerErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parse JSON-encoded arrays from the multipart body ---
|
||||||
|
let findingIds, queueItemIds;
|
||||||
|
try {
|
||||||
|
findingIds = JSON.parse(req.body.findingIds || '[]');
|
||||||
|
queueItemIds = JSON.parse(req.body.queueItemIds || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(findingIds) || findingIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'At least one finding ID is required.' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'At least one queue item ID is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validate form fields ---
|
||||||
|
const validationErrors = validateFpWorkflowForm(req.body);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
return res.status(400).json({ success: false, errors: validationErrors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validate file extensions (belt-and-suspenders with Multer filter) ---
|
||||||
|
const files = req.files || [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!isAllowedFileExtension(file.originalname)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Verify queue items belong to user, are FP type, and pending ---
|
||||||
|
const placeholders = queueItemIds.map(() => '?').join(',');
|
||||||
|
db.all(
|
||||||
|
`SELECT id, workflow_type, status, user_id
|
||||||
|
FROM ivanti_todo_queue
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
queueItemIds,
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error verifying queue items:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all items were found
|
||||||
|
if (!rows || rows.length !== queueItemIds.length) {
|
||||||
|
return res.status(400).json({ error: 'One or more queue items not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership, type, and status
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only submit your own queue items.' });
|
||||||
|
}
|
||||||
|
if (row.workflow_type !== 'FP') {
|
||||||
|
return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
|
||||||
|
}
|
||||||
|
if (row.status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation passed — submit to Ivanti API ---
|
||||||
|
(async () => {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Build form fields and call Ivanti API (multipart/form-data)
|
||||||
|
const formFields = buildIvantiFormFields(req.body, findingIds);
|
||||||
|
const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
|
||||||
|
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
||||||
|
|
||||||
|
let createResult;
|
||||||
|
try {
|
||||||
|
createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls);
|
||||||
|
} catch (networkErr) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id, username: req.user.username,
|
||||||
|
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
|
||||||
|
details: { error: networkErr.message, findingIds },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error responses from Ivanti
|
||||||
|
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
|
||||||
|
const errorMap = {
|
||||||
|
401: 'Ivanti API key is invalid or missing.',
|
||||||
|
419: 'API key lacks workflow creation permissions.',
|
||||||
|
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
|
||||||
|
};
|
||||||
|
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
|
||||||
|
const errorResponse = { success: false, error: errorMsg, step: 'create_workflow' };
|
||||||
|
if (!errorMap[createResult.status]) {
|
||||||
|
errorResponse.details = createResult.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id, username: req.user.username,
|
||||||
|
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
|
||||||
|
details: { error: errorMsg, status: createResult.status, findingIds },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse workflow batch response — API returns { id, created }
|
||||||
|
let workflowBatchId;
|
||||||
|
try {
|
||||||
|
const createData = JSON.parse(createResult.body);
|
||||||
|
workflowBatchId = createData.id;
|
||||||
|
} catch (parseErr) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id, username: req.user.username,
|
||||||
|
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
|
||||||
|
details: { error: 'Failed to parse Ivanti response', responseBody: createResult.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Determine submission status (files sent inline, so success if we got here)
|
||||||
|
const status = 'success';
|
||||||
|
|
||||||
|
// 4. Insert submission record
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
req.user.id,
|
||||||
|
req.user.username,
|
||||||
|
workflowBatchId,
|
||||||
|
null, // generatedId not returned by this endpoint
|
||||||
|
req.body.name,
|
||||||
|
req.body.reason,
|
||||||
|
req.body.description || null,
|
||||||
|
req.body.expirationDate,
|
||||||
|
req.body.scopeOverride || 'Authorized',
|
||||||
|
JSON.stringify(findingIds),
|
||||||
|
JSON.stringify(queueItemIds),
|
||||||
|
files.length,
|
||||||
|
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
|
||||||
|
status,
|
||||||
|
null
|
||||||
|
],
|
||||||
|
(err) => { if (err) reject(err); else resolve(); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Failed to insert submission record:', dbErr);
|
||||||
|
// Don't fail the response — the Ivanti workflow was created
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Log audit entry
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id, username: req.user.username,
|
||||||
|
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
||||||
|
entityId: String(workflowBatchId),
|
||||||
|
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Mark queue items as complete
|
||||||
|
let queueItemsUpdated = 0;
|
||||||
|
try {
|
||||||
|
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
|
||||||
|
queueItemsUpdated = await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`,
|
||||||
|
[...queueItemIds, req.user.id],
|
||||||
|
function (err) { if (err) reject(err); else resolve(this.changes); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (queueErr) {
|
||||||
|
console.error('Failed to update queue items:', queueErr);
|
||||||
|
// Don't fail — workflow was created
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Return response
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
workflowBatchId,
|
||||||
|
queueItemsUpdated,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
})().catch((unexpectedErr) => {
|
||||||
|
console.error('Unexpected error in FP workflow submission:', unexpectedErr);
|
||||||
|
res.status(500).json({ success: false, error: 'Internal server error.' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiFpWorkflowRouter;
|
||||||
|
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
||||||
|
module.exports.buildIvantiFormFields = buildIvantiFormFields;
|
||||||
|
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
|
||||||
|
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
||||||
421
backend/routes/ivantiTodoQueue.js
Normal file
421
backend/routes/ivantiTodoQueue.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
// routes/ivantiTodoQueue.js
|
||||||
|
const express = require('express');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
|
||||||
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
|
|
||||||
|
function isValidVendor(vendor) {
|
||||||
|
if (typeof vendor !== 'string') return false;
|
||||||
|
const trimmed = vendor.trim();
|
||||||
|
return trimmed.length > 0 && trimmed.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIvantiTodoQueueRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ivanti/todo-queue
|
||||||
|
*
|
||||||
|
* Fetch the current user's queue items, ordered by vendor then created_at.
|
||||||
|
*
|
||||||
|
* @returns {Array<Object>} 200 - Array of queue items, each with:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM ivanti_todo_queue
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY vendor ASC, created_at ASC`,
|
||||||
|
[req.user.id],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching todo queue:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
// Parse cves_json back to array for each row
|
||||||
|
const parsed = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
|
}));
|
||||||
|
res.json(parsed);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ivanti/todo-queue/batch
|
||||||
|
*
|
||||||
|
* Add multiple findings to the current user's queue in a single transaction.
|
||||||
|
*
|
||||||
|
* @body {Object[]} findings - Required array of 1–200 finding objects
|
||||||
|
* @body {string} findings[].finding_id - Required, non-empty finding identifier
|
||||||
|
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
|
||||||
|
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
||||||
|
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
||||||
|
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
||||||
|
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
|
||||||
|
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
|
||||||
|
*
|
||||||
|
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
|
||||||
|
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure
|
||||||
|
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
|
||||||
|
*/
|
||||||
|
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { findings, workflow_type, vendor } = req.body;
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
|
||||||
|
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < findings.length; i++) {
|
||||||
|
const f = findings[i];
|
||||||
|
if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow_type !== 'CARD') {
|
||||||
|
if (!isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
|
||||||
|
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// --- Transactional batch insert ---
|
||||||
|
// Prepare all row values upfront
|
||||||
|
const rows = findings.map((f) => {
|
||||||
|
const findingId = f.finding_id.trim();
|
||||||
|
const title = f.finding_title && typeof f.finding_title === 'string'
|
||||||
|
? f.finding_title.slice(0, 500)
|
||||||
|
: null;
|
||||||
|
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
|
||||||
|
const ipVal = f.ip_address && typeof f.ip_address === 'string'
|
||||||
|
? f.ip_address.trim().slice(0, 64)
|
||||||
|
: null;
|
||||||
|
const hostVal = f.hostname && typeof f.hostname === 'string'
|
||||||
|
? f.hostname.trim().slice(0, 255)
|
||||||
|
: null;
|
||||||
|
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertedIds = [];
|
||||||
|
let insertError = null;
|
||||||
|
let remaining = rows.length;
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run('BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
rows.forEach((params) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_todo_queue
|
||||||
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
params,
|
||||||
|
function (err) {
|
||||||
|
if (err && !insertError) {
|
||||||
|
insertError = err;
|
||||||
|
} else if (!err) {
|
||||||
|
insertedIds.push(this.lastID);
|
||||||
|
}
|
||||||
|
remaining--;
|
||||||
|
|
||||||
|
// After all insert callbacks have fired, commit or rollback
|
||||||
|
if (remaining === 0) {
|
||||||
|
if (insertError) {
|
||||||
|
db.run('ROLLBACK', () => {
|
||||||
|
console.error('Batch insert error:', insertError);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
db.run('COMMIT', (commitErr) => {
|
||||||
|
if (commitErr) {
|
||||||
|
console.error('Batch commit error:', commitErr);
|
||||||
|
db.run('ROLLBACK', () => {});
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all inserted rows
|
||||||
|
const placeholders = insertedIds.map(() => '?').join(',');
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
|
||||||
|
insertedIds,
|
||||||
|
(fetchErr, fetchedRows) => {
|
||||||
|
if (fetchErr) {
|
||||||
|
console.error('Error fetching inserted batch rows:', fetchErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (fetchedRows || []).map((r) => ({
|
||||||
|
...r,
|
||||||
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Audit log (fire-and-forget)
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'batch_add_to_queue',
|
||||||
|
entityType: 'ivanti_todo_queue',
|
||||||
|
entityId: null,
|
||||||
|
details: {
|
||||||
|
count: insertedIds.length,
|
||||||
|
workflow_type: workflow_type,
|
||||||
|
finding_ids: findings.map((f) => f.finding_id.trim()),
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ items });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ivanti/todo-queue
|
||||||
|
*
|
||||||
|
* Add a single finding to the current user's queue.
|
||||||
|
*
|
||||||
|
* @body {string} finding_id - Required, non-empty finding identifier
|
||||||
|
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
||||||
|
* @body {string[]} [cves] - Optional array of CVE identifiers
|
||||||
|
* @body {string} [ip_address] - Optional IP address (max 64 chars)
|
||||||
|
* @body {string} [hostname] - Optional hostname (max 255 chars) * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
|
||||||
|
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
|
||||||
|
*
|
||||||
|
* @returns {Object} 201 - Created queue item with parsed cves array:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
|
||||||
|
|
||||||
|
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'finding_id is required.' });
|
||||||
|
}
|
||||||
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
|
||||||
|
}
|
||||||
|
// Vendor is required for FP and Archer, optional for CARD
|
||||||
|
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
|
}
|
||||||
|
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
|
||||||
|
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||||
|
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
|
||||||
|
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
|
||||||
|
const title = finding_title && typeof finding_title === 'string'
|
||||||
|
? finding_title.slice(0, 500)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_todo_queue
|
||||||
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error adding to queue:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||||
|
[this.lastID],
|
||||||
|
(err2, row) => {
|
||||||
|
if (err2 || !row) {
|
||||||
|
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||||
|
}
|
||||||
|
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/ivanti/todo-queue/:id
|
||||||
|
*
|
||||||
|
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
|
||||||
|
*
|
||||||
|
* @param {string} id - Queue item ID (URL parameter)
|
||||||
|
* @body {string} [vendor] - New vendor string (max 200 chars)
|
||||||
|
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD'
|
||||||
|
* @body {string} [status] - One of 'pending', 'complete'
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
||||||
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
|
* vendor, workflow_type, status, created_at, updated_at, cves
|
||||||
|
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
|
||||||
|
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { vendor, workflow_type, status } = req.body;
|
||||||
|
|
||||||
|
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
||||||
|
}
|
||||||
|
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
|
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||||
|
[id, req.user.id],
|
||||||
|
(err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Queue item not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (vendor !== undefined) {
|
||||||
|
updates.push('vendor = ?');
|
||||||
|
params.push(vendor.trim());
|
||||||
|
}
|
||||||
|
if (workflow_type !== undefined) {
|
||||||
|
updates.push('workflow_type = ?');
|
||||||
|
params.push(workflow_type);
|
||||||
|
}
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
params.push(id, req.user.id);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||||
|
params,
|
||||||
|
function (err2) {
|
||||||
|
if (err2) {
|
||||||
|
console.error(err2);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
||||||
|
[id],
|
||||||
|
(err3, row) => {
|
||||||
|
if (err3 || !row) {
|
||||||
|
return res.json({ message: 'Queue item updated.' });
|
||||||
|
}
|
||||||
|
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ivanti/todo-queue/completed
|
||||||
|
*
|
||||||
|
* Bulk-delete all completed items for the current user.
|
||||||
|
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { message: string, deleted: number }
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
db.run(
|
||||||
|
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
|
||||||
|
[req.user.id],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error clearing completed queue items:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Completed items cleared.', deleted: this.changes });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ivanti/todo-queue/:id
|
||||||
|
*
|
||||||
|
* Delete a single queue item — scoped to current user.
|
||||||
|
*
|
||||||
|
* @param {string} id - Queue item ID (URL parameter)
|
||||||
|
*
|
||||||
|
* @returns {Object} 200 - { message: string }
|
||||||
|
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get(
|
||||||
|
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||||
|
[id, req.user.id],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Queue item not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||||
|
[id, req.user.id],
|
||||||
|
function (err2) {
|
||||||
|
if (err2) {
|
||||||
|
console.error(err2);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Queue item deleted.' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiTodoQueueRouter;
|
||||||
237
backend/routes/ivantiWorkflows.js
Normal file
237
backend/routes/ivantiWorkflows.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Ivanti / RiskSense Workflow Routes
|
||||||
|
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||||
|
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||||
|
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { requireGroup } = require('../middleware/auth');
|
||||||
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTable(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
workflows_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — calls Ivanti API, stores result in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncWorkflows(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||||
|
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti]', errMsg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[errMsg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti] Syncing workflows...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||||
|
const body = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
field: 'created_by_last_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: lastName,
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'created_by_first_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: firstName,
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'created', direction: 'DESC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||||
|
}
|
||||||
|
if (result.status === 419) {
|
||||||
|
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||||
|
}
|
||||||
|
if (result.status === 429) {
|
||||||
|
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||||
|
}
|
||||||
|
if (result.status !== 200) {
|
||||||
|
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
|
||||||
|
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||||
|
let total = 0;
|
||||||
|
let workflows = [];
|
||||||
|
|
||||||
|
if (data.page && typeof data.page.totalElements === 'number') {
|
||||||
|
total = data.page.totalElements;
|
||||||
|
workflows = data._embedded?.workflowBatches
|
||||||
|
|| data._embedded?.workflowBatch
|
||||||
|
|| [];
|
||||||
|
} else if (typeof data.total === 'number') {
|
||||||
|
total = data.total;
|
||||||
|
workflows = data.data || data.content || data.results || [];
|
||||||
|
} else if (typeof data.totalElements === 'number') {
|
||||||
|
total = data.totalElements;
|
||||||
|
workflows = data.content || data.data || [];
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
workflows = data;
|
||||||
|
total = data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state
|
||||||
|
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||||
|
WHERE id=1`,
|
||||||
|
[total, JSON.stringify(workflows)],
|
||||||
|
(err) => { if (err) reject(err); else resolve(); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti] Sync failed:', msg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[msg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||||
|
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — read current state from DB and return as JSON-ready object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
|
||||||
|
let workflows = [];
|
||||||
|
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
total: row.total || 0,
|
||||||
|
workflows,
|
||||||
|
synced_at: row.synced_at,
|
||||||
|
sync_status: row.sync_status,
|
||||||
|
error_message: row.error_message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||||
|
initTable(db)
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — return cached data (fast, no external call)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading sync state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||||
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
await syncWorkflows(db);
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiWorkflowsRouter;
|
||||||
417
backend/routes/knowledgeBase.js
Normal file
417
backend/routes/knowledgeBase.js
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||||
|
const logAudit = require('../helpers/auditLog');
|
||||||
|
|
||||||
|
function createKnowledgeBaseRouter(db, upload) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper to sanitize filename
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate slug from title
|
||||||
|
function generateSlug(title) {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to validate file type
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.md', '.txt', '.doc', '.docx',
|
||||||
|
'.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.html', '.htm', '.json', '.yaml', '.yml',
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isValidFileType(filename) {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/knowledge-base/upload
|
||||||
|
* Upload a new knowledge base document.
|
||||||
|
*
|
||||||
|
* @body {string} title - Article title (required)
|
||||||
|
* @body {string} [description] - Article description
|
||||||
|
* @body {string} [category] - Article category (defaults to 'General')
|
||||||
|
* @body {File} file - The document file to upload (multipart/form-data)
|
||||||
|
*
|
||||||
|
* @response 200 - { success: true, id: number, title: string, slug: string, category: string }
|
||||||
|
* @response 400 - { error: string } - Missing title, no file, or invalid file type
|
||||||
|
* @response 500 - { error: string } - Database or filesystem error
|
||||||
|
*/
|
||||||
|
router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||||
|
upload.single('file')(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[KB Upload] Multer error:', err);
|
||||||
|
return res.status(400).json({ error: err.message || 'File upload failed' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}, async (req, res) => {
|
||||||
|
console.log('[KB Upload] Request received:', {
|
||||||
|
hasFile: !!req.file,
|
||||||
|
body: req.body,
|
||||||
|
contentType: req.headers['content-type']
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedFile = req.file;
|
||||||
|
const { title, description, category } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
console.error('[KB Upload] Error: Title is missing');
|
||||||
|
if (uploadedFile) fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!isValidFileType(uploadedFile.originalname)) {
|
||||||
|
fs.unlinkSync(uploadedFile.path);
|
||||||
|
return res.status(400).json({ error: 'File type not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
|
||||||
|
const slug = generateSlug(title);
|
||||||
|
const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base');
|
||||||
|
|
||||||
|
const filename = `${timestamp}_${sanitizedName}`;
|
||||||
|
const filePath = path.join(kbDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Keep file in temp location until DB insert succeeds
|
||||||
|
// Check if slug already exists
|
||||||
|
db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(uploadedFile.path);
|
||||||
|
console.error('Error checking slug:', err);
|
||||||
|
return res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If slug exists, append timestamp to make it unique
|
||||||
|
const finalSlug = row ? `${slug}-${timestamp}` : slug;
|
||||||
|
|
||||||
|
// Insert new knowledge base entry
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO knowledge_base (
|
||||||
|
title, slug, description, category, file_path, file_name,
|
||||||
|
file_type, file_size, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
insertSql,
|
||||||
|
[
|
||||||
|
title.trim(),
|
||||||
|
finalSlug,
|
||||||
|
description || null,
|
||||||
|
category || 'General',
|
||||||
|
filePath,
|
||||||
|
sanitizedName,
|
||||||
|
uploadedFile.mimetype,
|
||||||
|
uploadedFile.size,
|
||||||
|
req.user.id
|
||||||
|
],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
fs.unlinkSync(uploadedFile.path);
|
||||||
|
console.error('Error inserting knowledge base entry:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to save document metadata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB insert succeeded — now move file to permanent location
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(kbDir)) {
|
||||||
|
fs.mkdirSync(kbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(uploadedFile.path, filePath);
|
||||||
|
} catch (moveErr) {
|
||||||
|
console.error('Error moving file to permanent location:', moveErr);
|
||||||
|
// File is orphaned in temp but DB record exists — log and continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'CREATE_KB_ARTICLE',
|
||||||
|
entityType: 'knowledge_base',
|
||||||
|
entityId: String(this.lastID),
|
||||||
|
details: { title: title.trim(), filename: sanitizedName },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: this.lastID,
|
||||||
|
title: title.trim(),
|
||||||
|
slug: finalSlug,
|
||||||
|
category: category || 'General'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file on error
|
||||||
|
if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path);
|
||||||
|
console.error('Error uploading knowledge base document:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to upload document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge-base
|
||||||
|
* List all knowledge base articles.
|
||||||
|
*
|
||||||
|
* @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }]
|
||||||
|
* @response 500 - { error: string }
|
||||||
|
*/
|
||||||
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
ORDER BY kb.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(sql, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching knowledge base articles:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch articles' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge-base/:id
|
||||||
|
* Get a single article's details by ID.
|
||||||
|
*
|
||||||
|
* @param {string} id - Article ID (route parameter)
|
||||||
|
*
|
||||||
|
* @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }
|
||||||
|
* @response 404 - { error: 'Article not found' }
|
||||||
|
* @response 500 - { error: string }
|
||||||
|
*/
|
||||||
|
router.get('/:id', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
kb.id, kb.title, kb.slug, kb.description, kb.category,
|
||||||
|
kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at,
|
||||||
|
u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by = u.id
|
||||||
|
WHERE kb.id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge-base/:id/content
|
||||||
|
* Get document content for inline display. Returns the raw file with appropriate
|
||||||
|
* Content-Type headers. Markdown and text files are served as text/plain.
|
||||||
|
*
|
||||||
|
* @param {string} id - Article ID (route parameter)
|
||||||
|
*
|
||||||
|
* @response 200 - Raw file content with Content-Type and Content-Disposition headers
|
||||||
|
* @response 404 - { error: string } - Article or file not found
|
||||||
|
* @response 500 - { error: string }
|
||||||
|
*/
|
||||||
|
router.get('/:id/content', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'VIEW_KB_ARTICLE',
|
||||||
|
entityType: 'knowledge_base',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { filename: row.file_name },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine content type for inline display
|
||||||
|
let contentType = row.file_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// For markdown files, send as plain text so frontend can parse it
|
||||||
|
if (row.file_name.endsWith('.md')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
} else if (row.file_name.endsWith('.txt')) {
|
||||||
|
contentType = 'text/plain; charset=utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFileName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
// Use inline instead of attachment to allow browser to display
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`);
|
||||||
|
// Allow iframe embedding from frontend origin
|
||||||
|
res.removeHeader('X-Frame-Options');
|
||||||
|
const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000';
|
||||||
|
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`);
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge-base/:id/download
|
||||||
|
* Download a knowledge base document as an attachment.
|
||||||
|
*
|
||||||
|
* @param {string} id - Article ID (route parameter)
|
||||||
|
*
|
||||||
|
* @response 200 - File download with Content-Disposition: attachment header
|
||||||
|
* @response 404 - { error: string } - Article or file not found
|
||||||
|
* @response 500 - { error: string }
|
||||||
|
*/
|
||||||
|
router.get('/:id/download', requireAuth(db), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching document:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch document' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Document not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(row.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'File not found on disk' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'DOWNLOAD_KB_ARTICLE',
|
||||||
|
entityType: 'knowledge_base',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { filename: row.file_name },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, '');
|
||||||
|
res.setHeader('Content-Type', row.file_type || 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`);
|
||||||
|
res.sendFile(row.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/knowledge-base/:id
|
||||||
|
* Delete a knowledge base article and its associated file.
|
||||||
|
* Standard_User can only delete articles they created. Admin can delete any article.
|
||||||
|
*
|
||||||
|
* @param {string} id - Article ID (route parameter)
|
||||||
|
*
|
||||||
|
* @response 200 - { success: true }
|
||||||
|
* @response 403 - { error: string } - Ownership check failed for Standard_User
|
||||||
|
* @response 404 - { error: 'Article not found' }
|
||||||
|
* @response 500 - { error: string }
|
||||||
|
*/
|
||||||
|
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?';
|
||||||
|
|
||||||
|
db.get(sql, [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching article for deletion:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to fetch article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check: Standard_User can only delete articles they created
|
||||||
|
if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete database record
|
||||||
|
db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete article' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
if (fs.existsSync(row.file_path)) {
|
||||||
|
fs.unlinkSync(row.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit entry
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'DELETE_KB_ARTICLE',
|
||||||
|
entityType: 'knowledge_base',
|
||||||
|
entityId: String(id),
|
||||||
|
details: { title: row.title },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createKnowledgeBaseRouter;
|
||||||
@@ -2,18 +2,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||||
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(db), requireGroup('Admin'));
|
||||||
|
|
||||||
// Get all users
|
// Get all users
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await new Promise((resolve, reject) => {
|
const users = await new Promise((resolve, reject) => {
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||||
FROM users ORDER BY created_at DESC`,
|
FROM users ORDER BY created_at DESC`,
|
||||||
(err, rows) => {
|
(err, rows) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
try {
|
try {
|
||||||
const user = await new Promise((resolve, reject) => {
|
const user = await new Promise((resolve, reject) => {
|
||||||
db.get(
|
db.get(
|
||||||
`SELECT id, username, email, role, is_active, created_at, last_login
|
`SELECT id, username, email, user_group AS 'group', is_active, created_at, last_login
|
||||||
FROM users WHERE id = ?`,
|
FROM users WHERE id = ?`,
|
||||||
[req.params.id],
|
[req.params.id],
|
||||||
(err, row) => {
|
(err, row) => {
|
||||||
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
|
|
||||||
// Create new user
|
// Create new user
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { username, email, password, role } = req.body;
|
const { username, email, password, group } = req.body;
|
||||||
|
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
if (!username || !email || !password) {
|
||||||
return res.status(400).json({ error: 'Username, email, and password are required' });
|
return res.status(400).json({ error: 'Username, email, and password are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
const userGroup = group || 'Read_Only';
|
||||||
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
|
|
||||||
|
if (!VALID_GROUPS.includes(userGroup)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO users (username, email, password_hash, role)
|
`INSERT INTO users (username, email, password_hash, user_group)
|
||||||
VALUES (?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?)`,
|
||||||
[username, email, passwordHash, role || 'viewer'],
|
[username, email, passwordHash, userGroup],
|
||||||
function(err) {
|
function(err) {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve({ id: this.lastID });
|
else resolve({ id: this.lastID });
|
||||||
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
action: 'user_create',
|
action: 'user_create',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: String(result.id),
|
entityId: String(result.id),
|
||||||
details: { created_username: username, role: role || 'viewer' },
|
details: { created_username: username, group: userGroup },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
id: result.id,
|
id: result.id,
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
role: role || 'viewer'
|
group: userGroup
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,20 +114,42 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
router.patch('/:id', async (req, res) => {
|
router.patch('/:id', async (req, res) => {
|
||||||
const { username, email, password, role, is_active } = req.body;
|
const { username, email, password, group, is_active } = req.body;
|
||||||
|
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
// Prevent self-demotion from admin
|
// Validate group if provided
|
||||||
if (userId == req.user.id && role && role !== 'admin') {
|
if (group && !VALID_GROUPS.includes(group)) {
|
||||||
return res.status(400).json({ error: 'Cannot remove your own admin role' });
|
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent admin self-demotion
|
||||||
|
if (String(userId) === String(req.user.id) && group && group !== 'Admin') {
|
||||||
|
return res.status(400).json({ error: 'Cannot remove your own admin group' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent self-deactivation
|
// Prevent self-deactivation
|
||||||
if (userId == req.user.id && is_active === false) {
|
if (String(userId) === String(req.user.id) && is_active === false) {
|
||||||
return res.status(400).json({ error: 'Cannot deactivate your own account' });
|
return res.status(400).json({ error: 'Cannot deactivate your own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch current user record before update (needed for group change audit)
|
||||||
|
const currentUser = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT user_group FROM users WHERE id = ?',
|
||||||
|
[userId],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
updates.push('password_hash = ?');
|
updates.push('password_hash = ?');
|
||||||
values.push(passwordHash);
|
values.push(passwordHash);
|
||||||
}
|
}
|
||||||
if (role) {
|
if (group) {
|
||||||
if (!['admin', 'editor', 'viewer'].includes(role)) {
|
updates.push('user_group = ?');
|
||||||
return res.status(400).json({ error: 'Invalid role' });
|
values.push(group);
|
||||||
}
|
|
||||||
updates.push('role = ?');
|
|
||||||
values.push(role);
|
|
||||||
}
|
}
|
||||||
if (typeof is_active === 'boolean') {
|
if (typeof is_active === 'boolean') {
|
||||||
updates.push('is_active = ?');
|
updates.push('is_active = ?');
|
||||||
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
const updatedFields = {};
|
const updatedFields = {};
|
||||||
if (username) updatedFields.username = username;
|
if (username) updatedFields.username = username;
|
||||||
if (email) updatedFields.email = email;
|
if (email) updatedFields.email = email;
|
||||||
if (role) updatedFields.role = role;
|
if (group) updatedFields.group = group;
|
||||||
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
|
||||||
if (password) updatedFields.password_changed = true;
|
if (password) updatedFields.password_changed = true;
|
||||||
|
|
||||||
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log specific audit entry for group changes
|
||||||
|
if (group && group !== currentUser.user_group) {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'user_group_change',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: String(userId),
|
||||||
|
details: {
|
||||||
|
previous_group: currentUser.user_group,
|
||||||
|
new_group: group
|
||||||
|
},
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If user was deactivated, delete their sessions
|
// If user was deactivated, delete their sessions
|
||||||
if (is_active === false) {
|
if (is_active === false) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -209,7 +247,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
|||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
// Prevent self-deletion
|
// Prevent self-deletion
|
||||||
if (userId == req.user.id) {
|
if (String(userId) === String(req.user.id)) {
|
||||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
backend/scripts/import_notes_from_csv.py
Normal file
182
backend/scripts/import_notes_from_csv.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_notes_from_csv.py
|
||||||
|
------------------------
|
||||||
|
Mass-import finding notes from a CSV file into the CVE dashboard database.
|
||||||
|
|
||||||
|
CSV format (header row required, column names are case-insensitive):
|
||||||
|
ID,NOTES
|
||||||
|
12345,EXC-5754
|
||||||
|
67890,EXC-6001 - pending review
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_notes_from_csv.py <csv_file> [--db <db_path>] [--dry-run]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--db <path> Path to cve_database.db (default: ../cve_database.db)
|
||||||
|
--dry-run Print what would change without touching the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
NOTE_MAX_LEN = 255
|
||||||
|
|
||||||
|
DEFAULT_DB = os.path.join(os.path.dirname(__file__), '..', 'cve_database.db')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description='Import finding notes from CSV into the dashboard DB.')
|
||||||
|
p.add_argument('csv_file', help='Path to the CSV file (must have ID and NOTES columns)')
|
||||||
|
p.add_argument('--db', default=DEFAULT_DB, help=f'Path to SQLite database (default: {DEFAULT_DB})')
|
||||||
|
p.add_argument('--dry-run', action='store_true', help='Preview changes without writing to DB')
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(path):
|
||||||
|
"""Read CSV and return list of (finding_id, note) tuples."""
|
||||||
|
rows = []
|
||||||
|
with open(path, newline='', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
# Normalise header names to uppercase for case-insensitive matching
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
print('ERROR: CSV file is empty or has no header row.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
normalised = {k.strip().upper(): k for k in reader.fieldnames}
|
||||||
|
if 'ID' not in normalised or 'NOTES' not in normalised:
|
||||||
|
print(f'ERROR: CSV must have "ID" and "NOTES" columns.')
|
||||||
|
print(f' Found columns: {list(reader.fieldnames)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
id_col = normalised['ID']
|
||||||
|
notes_col = normalised['NOTES']
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is the header
|
||||||
|
finding_id = row[id_col].strip()
|
||||||
|
note = row[notes_col].strip()
|
||||||
|
|
||||||
|
if not finding_id:
|
||||||
|
print(f' WARNING row {i}: empty ID — skipping')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(note) > NOTE_MAX_LEN:
|
||||||
|
print(f' WARNING row {i} ({finding_id}): note is {len(note)} chars, '
|
||||||
|
f'truncating to {NOTE_MAX_LEN}')
|
||||||
|
note = note[:NOTE_MAX_LEN]
|
||||||
|
|
||||||
|
rows.append((finding_id, note))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
csv_path = os.path.abspath(args.csv_file)
|
||||||
|
db_path = os.path.abspath(args.db)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ checks
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f'ERROR: CSV file not found: {csv_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f'ERROR: Database not found: {db_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'CSV : {csv_path}')
|
||||||
|
print(f'DB : {db_path}')
|
||||||
|
if args.dry_run:
|
||||||
|
print('MODE: DRY RUN — no changes will be written\n')
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- load CSV
|
||||||
|
rows = load_csv(csv_path)
|
||||||
|
if not rows:
|
||||||
|
print('No valid rows found in CSV.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f'Loaded {len(rows)} row(s) from CSV.\n')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- open DB
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# Fetch all known finding IDs — only IDs present here will be processed
|
||||||
|
import json
|
||||||
|
cur.execute('SELECT findings_json FROM ivanti_findings_cache WHERE id = 1')
|
||||||
|
cache_row = cur.fetchone()
|
||||||
|
known_ids = set()
|
||||||
|
if cache_row and cache_row['findings_json']:
|
||||||
|
try:
|
||||||
|
known_ids = {str(f['id']) for f in json.loads(cache_row['findings_json'])}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not known_ids:
|
||||||
|
print('ERROR: No findings found in the database cache.')
|
||||||
|
print(' Run a Sync from the dashboard first, then re-run this script.')
|
||||||
|
con.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'{len(known_ids)} active finding(s) in cache.\n')
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- process
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for finding_id, note in rows:
|
||||||
|
str_id = str(finding_id)
|
||||||
|
|
||||||
|
if str_id not in known_ids:
|
||||||
|
print(f' SKIP {str_id} — not in active findings (resolved or never synced)')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if a note already exists
|
||||||
|
cur.execute('SELECT note FROM ivanti_finding_notes WHERE finding_id = ?', (str_id,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing['note'] == note:
|
||||||
|
print(f' SKIP {str_id} — note unchanged')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
action = 'UPDATE'
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
action = 'INSERT'
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
print(f' {action:6s} {str_id} → {note[:80]}{"…" if len(note) > 80 else ""}')
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE
|
||||||
|
SET note = excluded.note, updated_at = datetime('now')
|
||||||
|
""",
|
||||||
|
(str_id, note)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- summary
|
||||||
|
print()
|
||||||
|
if args.dry_run:
|
||||||
|
print(f'DRY RUN complete — would insert {inserted}, update {updated}, skip {skipped}.')
|
||||||
|
else:
|
||||||
|
con.commit()
|
||||||
|
print(f'Done — inserted {inserted}, updated {updated}, skipped {skipped} (unchanged).')
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run(parse_args())
|
||||||
212
backend/scripts/parse_compliance_xlsx.py
Normal file
212
backend/scripts/parse_compliance_xlsx.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Parse NTS_AEO compliance xlsx file and write JSON to stdout.
|
||||||
|
Usage: python3 parse_compliance_xlsx.py <path_to_xlsx>
|
||||||
|
|
||||||
|
Output:
|
||||||
|
{
|
||||||
|
"items": [...], # non-compliant asset rows
|
||||||
|
"summary": { ... }, # metric health data from Summary sheet
|
||||||
|
"report_date": "YYYY-MM-DD" | null,
|
||||||
|
"total": int
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
METRIC_CATEGORIES = {
|
||||||
|
'2.3.4i': 'Vulnerability Management',
|
||||||
|
'2.3.6i': 'Vulnerability Management',
|
||||||
|
'2.3.8i': 'Vulnerability Management',
|
||||||
|
'5.2.4': 'Access & MFA',
|
||||||
|
'5.2.5': 'Access & MFA',
|
||||||
|
'5.2.6': 'Access & MFA',
|
||||||
|
'5.3.4': 'Endpoint Protection',
|
||||||
|
'5.5.2': 'End-of-Life OS',
|
||||||
|
'5.5.4i': 'Vulnerability Management',
|
||||||
|
'5.5.5': 'Decommissioned Assets',
|
||||||
|
'5.8.1': 'Application Security',
|
||||||
|
'7.1.1': 'Logging & Monitoring',
|
||||||
|
'7.6.13': 'Disaster Recovery',
|
||||||
|
'7.6.16': 'Disaster Recovery',
|
||||||
|
'Missing_AppID': 'Asset Data Quality',
|
||||||
|
'Missing_DF': 'Asset Data Quality',
|
||||||
|
'Missing_OS': 'Asset Data Quality',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Columns that go into the main item fields — everything else becomes extra_json
|
||||||
|
CORE_COLS = {
|
||||||
|
'Preferred - Hostname', 'GRANITE - IPv4_Address', 'GRANITE - Type',
|
||||||
|
'Team', 'Compliant', 'Source_Network', 'Vertical',
|
||||||
|
'GRANITE - Equip_Inst_ID', 'GRANITE - RESPONSIBLE_TEAM',
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_SHEETS = {'Summary', 'CMDB_9box'}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_str(val):
|
||||||
|
s = str(val).strip()
|
||||||
|
return '' if s == 'nan' else s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_summary(xl):
|
||||||
|
"""Return { entries: [...], overall_scores: { customer_network, vertical } }"""
|
||||||
|
df_raw = pd.read_excel(xl, sheet_name='Summary', header=None)
|
||||||
|
|
||||||
|
overall_scores = {
|
||||||
|
'customer_network': float(df_raw.iloc[0, 4]) if pd.notna(df_raw.iloc[0, 4]) else None,
|
||||||
|
'vertical': float(df_raw.iloc[1, 4]) if pd.notna(df_raw.iloc[1, 4]) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
df = pd.read_excel(xl, sheet_name='Summary', header=3)
|
||||||
|
# Flatten any newlines in column names
|
||||||
|
df.columns = [str(c).replace('\n', ' ').strip() for c in df.columns]
|
||||||
|
|
||||||
|
# Locate the sub-vertical/team column robustly
|
||||||
|
team_col = next((c for c in df.columns if 'Sub-Vertical' in c or 'Purchase Group' in c), None)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
metric_id = safe_str(row.get('Metric', ''))
|
||||||
|
if not metric_id or metric_id in ('Metric',):
|
||||||
|
continue
|
||||||
|
|
||||||
|
team = safe_str(row.get(team_col, '')) if team_col else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
non_compliant = int(row.get('Non-Compliant', 0) or 0)
|
||||||
|
compliant = int(row.get('Compliant', 0) or 0)
|
||||||
|
total = int(row.get('Total', 0) or 0)
|
||||||
|
compliance_pct = float(row.get('Current Compliance', 0) or 0)
|
||||||
|
target = float(row.get('Metric Target', 0) or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries.append({
|
||||||
|
'metric_id': metric_id,
|
||||||
|
'team': team,
|
||||||
|
'priority': safe_str(row.get('Priority / Non-Priority / IR', '')),
|
||||||
|
'non_compliant': non_compliant,
|
||||||
|
'compliant': compliant,
|
||||||
|
'total': total,
|
||||||
|
'compliance_pct': compliance_pct,
|
||||||
|
'target': target,
|
||||||
|
'status': safe_str(row.get('Status', '')),
|
||||||
|
'description': safe_str(row.get('Metric Description', '')),
|
||||||
|
'category': METRIC_CATEGORIES.get(metric_id, 'Other'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {'entries': entries, 'overall_scores': overall_scores}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sheet(xl, sheet_name, summary_entries):
|
||||||
|
"""Return list of non-compliant item dicts for a detail sheet."""
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(xl, sheet_name=sheet_name, header=0)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
|
|
||||||
|
# Filter to non-compliant rows when the Compliant column exists
|
||||||
|
if 'Compliant' in df.columns:
|
||||||
|
df = df[df['Compliant'] == False]
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Look up description from summary
|
||||||
|
metric_desc = ''
|
||||||
|
for e in summary_entries:
|
||||||
|
if e['metric_id'] == sheet_name and e['description']:
|
||||||
|
metric_desc = e['description']
|
||||||
|
break
|
||||||
|
|
||||||
|
category = METRIC_CATEGORIES.get(sheet_name, 'Other')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
hostname = safe_str(row.get('Preferred - Hostname', ''))
|
||||||
|
if not hostname:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip = safe_str(row.get('GRANITE - IPv4_Address', ''))
|
||||||
|
device_type = safe_str(row.get('GRANITE - Type', ''))
|
||||||
|
team = safe_str(row.get('Team', ''))
|
||||||
|
|
||||||
|
# Everything non-core goes into extra_json
|
||||||
|
extra = {}
|
||||||
|
for col in df.columns:
|
||||||
|
if col in CORE_COLS:
|
||||||
|
continue
|
||||||
|
val = row.get(col)
|
||||||
|
if pd.isna(val) if not isinstance(val, str) else False:
|
||||||
|
continue
|
||||||
|
s = safe_str(val)
|
||||||
|
if s:
|
||||||
|
extra[col] = val.isoformat() if hasattr(val, 'isoformat') else s
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'hostname': hostname,
|
||||||
|
'ip_address': ip,
|
||||||
|
'device_type': device_type,
|
||||||
|
'team': team,
|
||||||
|
'metric_id': sheet_name,
|
||||||
|
'metric_desc': metric_desc,
|
||||||
|
'category': category,
|
||||||
|
'extra_json': extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def extract_report_date(filepath):
|
||||||
|
"""Try to pull YYYY-MM-DD from the filename, e.g. NTS_AEO_2026_03_25.xlsx"""
|
||||||
|
stem = Path(filepath).stem
|
||||||
|
m = re.search(r'(\d{4})_(\d{2})_(\d{2})', stem)
|
||||||
|
if m:
|
||||||
|
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(json.dumps({'error': 'No file path provided'}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
xl = pd.ExcelFile(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({'error': f'Cannot open file: {str(e)}'}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = parse_summary(xl)
|
||||||
|
except Exception as e:
|
||||||
|
summary = {'entries': [], 'overall_scores': {}, 'parse_error': str(e)}
|
||||||
|
|
||||||
|
all_items = []
|
||||||
|
for sheet_name in xl.sheet_names:
|
||||||
|
if sheet_name in SKIP_SHEETS:
|
||||||
|
continue
|
||||||
|
items = parse_sheet(xl, sheet_name, summary.get('entries', []))
|
||||||
|
all_items.extend(items)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
'items': all_items,
|
||||||
|
'summary': summary,
|
||||||
|
'report_date': extract_report_date(filepath),
|
||||||
|
'total': len(all_items),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
2
backend/scripts/requirements.txt
Normal file
2
backend/scripts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pandas>=2.0.0
|
||||||
|
openpyxl>=3.0.0
|
||||||
@@ -12,17 +12,29 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Auth imports
|
// Auth imports
|
||||||
const { requireAuth, requireRole } = require('./middleware/auth');
|
const { requireAuth, requireGroup } = require('./middleware/auth');
|
||||||
const createAuthRouter = require('./routes/auth');
|
const createAuthRouter = require('./routes/auth');
|
||||||
const createUsersRouter = require('./routes/users');
|
const createUsersRouter = require('./routes/users');
|
||||||
const createAuditLogRouter = require('./routes/auditLog');
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
const logAudit = require('./helpers/auditLog');
|
const logAudit = require('./helpers/auditLog');
|
||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||||
|
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
||||||
|
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||||
|
const createComplianceRouter = require('./routes/compliance');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const API_HOST = process.env.API_HOST || 'localhost';
|
const API_HOST = process.env.API_HOST || 'localhost';
|
||||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
|
if (!SESSION_SECRET) {
|
||||||
|
console.error('FATAL: SESSION_SECRET environment variable must be set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const CORS_ORIGINS = process.env.CORS_ORIGINS
|
const CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||||
? process.env.CORS_ORIGINS.split(',')
|
? process.env.CORS_ORIGINS.split(',')
|
||||||
: ['http://localhost:3000'];
|
: ['http://localhost:3000'];
|
||||||
@@ -32,7 +44,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|||||||
// Allowed file extensions for document uploads (documents only, no executables)
|
// Allowed file extensions for document uploads (documents only, no executables)
|
||||||
const ALLOWED_EXTENSIONS = new Set([
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||||
'.txt', '.csv', '.log', '.msg', '.eml',
|
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
||||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
'.odt', '.ods', '.odp',
|
'.odt', '.ods', '.odp',
|
||||||
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||||
@@ -94,7 +106,7 @@ app.use((req, res, next) => {
|
|||||||
// Security headers
|
// Security headers
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
||||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
@@ -106,7 +118,11 @@ app.use(cors({
|
|||||||
origin: CORS_ORIGINS,
|
origin: CORS_ORIGINS,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json({ limit: '1mb' }));
|
// Only parse JSON for requests with application/json content type
|
||||||
|
app.use(express.json({
|
||||||
|
limit: '1mb',
|
||||||
|
type: 'application/json'
|
||||||
|
}));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', express.static('uploads', {
|
app.use('/uploads', express.static('uploads', {
|
||||||
dotfiles: 'deny',
|
dotfiles: 'deny',
|
||||||
@@ -115,18 +131,45 @@ app.use('/uploads', express.static('uploads', {
|
|||||||
|
|
||||||
// Database connection
|
// Database connection
|
||||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||||
if (err) console.error('Database connection error:', err);
|
if (err) {
|
||||||
else console.log('Connected to CVE database');
|
console.error('Database connection error:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Connected to CVE database');
|
||||||
|
|
||||||
|
// Ensure ivanti_todo_queue table exists (idempotent migration)
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`, (err2) => {
|
||||||
|
if (err2) console.error('Failed to create ivanti_todo_queue table:', err2);
|
||||||
|
else db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||||
|
(err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); }
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth routes (public)
|
// Auth routes (public)
|
||||||
app.use('/api/auth', createAuthRouter(db, logAudit));
|
app.use('/api/auth', createAuthRouter(db, logAudit));
|
||||||
|
|
||||||
// User management routes (admin only)
|
// User management routes (admin only)
|
||||||
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit));
|
app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
|
||||||
|
|
||||||
// Audit log routes (admin only)
|
// Audit log routes (admin only)
|
||||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
|
||||||
|
|
||||||
// NVD lookup routes (authenticated users)
|
// NVD lookup routes (authenticated users)
|
||||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||||
@@ -167,6 +210,30 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||||
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||||
|
|
||||||
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense host findings routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
||||||
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti archive routes — finding archive tracking for severity score drift
|
||||||
|
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
||||||
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
||||||
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
@@ -281,9 +348,43 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get tooltip data for a specific CVE (authenticated users)
|
||||||
|
app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching CVE tooltip:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!row) {
|
||||||
|
return res.json({ exists: false });
|
||||||
|
}
|
||||||
|
let description = row.description || '';
|
||||||
|
if (description.length > 300) {
|
||||||
|
description = description.substring(0, 300) + '\u2026';
|
||||||
|
}
|
||||||
|
res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compliance export — reads from cve_document_status view
|
||||||
|
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
||||||
|
db.all('SELECT * FROM cve_document_status ORDER BY cve_id, vendor', [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching compliance data:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
@@ -304,11 +405,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('DATABASE ERROR:', err);
|
console.error('DATABASE ERROR:', err);
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
@@ -337,7 +438,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
|||||||
|
|
||||||
|
|
||||||
// Update CVE status (editor or admin)
|
// Update CVE status (editor or admin)
|
||||||
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
|
|
||||||
@@ -365,7 +466,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bulk sync CVE data from NVD (editor or admin)
|
// Bulk sync CVE data from NVD (editor or admin)
|
||||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { updates } = req.body;
|
const { updates } = req.body;
|
||||||
if (!Array.isArray(updates) || updates.length === 0) {
|
if (!Array.isArray(updates) || updates.length === 0) {
|
||||||
return res.status(400).json({ error: 'No updates provided' });
|
return res.status(400).json({ error: 'No updates provided' });
|
||||||
@@ -435,7 +536,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
|
|||||||
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||||
|
|
||||||
// Edit single CVE entry (editor or admin)
|
// Edit single CVE entry (editor or admin)
|
||||||
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||||
|
|
||||||
@@ -579,7 +680,7 @@ app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
||||||
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
|
|
||||||
// Get all rows for this CVE ID to know what we're deleting
|
// Get all rows for this CVE ID to know what we're deleting
|
||||||
@@ -587,6 +688,151 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
|
|||||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
||||||
|
|
||||||
|
// Ownership check: Standard_User can only delete CVEs they created
|
||||||
|
if (req.user.group === 'Standard_User') {
|
||||||
|
const notOwned = rows.some(row => row.created_by !== req.user.id);
|
||||||
|
if (notOwned) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade impact check for Standard_User
|
||||||
|
// Query all three cascade-deleted resource types in parallel
|
||||||
|
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
|
||||||
|
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
|
||||||
|
// If jira_tickets table doesn't exist yet, treat as empty
|
||||||
|
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
|
||||||
|
jiraTickets = [];
|
||||||
|
} else if (jiraErr) {
|
||||||
|
console.error(jiraErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
|
||||||
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const allTickets = [
|
||||||
|
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
||||||
|
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
||||||
|
];
|
||||||
|
|
||||||
|
// If no tickets at all, no compliance linkage possible — return cascade info
|
||||||
|
if (allTickets.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
cascade_impact: {
|
||||||
|
archer_tickets: [],
|
||||||
|
jira_tickets: [],
|
||||||
|
documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })),
|
||||||
|
blocked: false,
|
||||||
|
blocked_reason: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check compliance linkage for each ticket
|
||||||
|
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
|
||||||
|
// appears in active compliance_items extra_json
|
||||||
|
const likeConditions = [];
|
||||||
|
const likeParams = [];
|
||||||
|
for (const t of allTickets) {
|
||||||
|
likeConditions.push('ci.extra_json LIKE ?');
|
||||||
|
likeParams.push(`%${t.key}%`);
|
||||||
|
}
|
||||||
|
// Also check if the CVE ID itself appears in compliance extra_json
|
||||||
|
likeConditions.push('ci.extra_json LIKE ?');
|
||||||
|
likeParams.push(`%${cveId}%`);
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
`SELECT ci.id, ci.extra_json, cu.report_date
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
||||||
|
likeParams,
|
||||||
|
(compErr, compLinks) => {
|
||||||
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||||
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||||
|
compLinks = [];
|
||||||
|
} else if (compErr) {
|
||||||
|
console.error(compErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which tickets are compliance-linked by checking extra_json matches
|
||||||
|
const linkedTicketKeys = new Set();
|
||||||
|
for (const cl of (compLinks || [])) {
|
||||||
|
const json = cl.extra_json || '';
|
||||||
|
for (const t of allTickets) {
|
||||||
|
if (json.includes(t.key)) {
|
||||||
|
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If CVE ID itself is in compliance data, all tickets are considered linked
|
||||||
|
if (json.includes(cveId)) {
|
||||||
|
for (const t of allTickets) {
|
||||||
|
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archerTicketsResult = (archerTickets || []).map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
exc_number: t.exc_number,
|
||||||
|
compliance_linked: linkedTicketKeys.has(`archer:${t.id}`)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jiraTicketsResult = (jiraTickets || []).map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
ticket_key: t.ticket_key,
|
||||||
|
compliance_linked: linkedTicketKeys.has(`jira:${t.id}`)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const documentsResult = (docs || []).map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
type: d.type
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked)
|
||||||
|
|| jiraTicketsResult.some(t => t.compliance_linked);
|
||||||
|
|
||||||
|
if (hasComplianceLink) {
|
||||||
|
const blockedArcher = archerTicketsResult.find(t => t.compliance_linked);
|
||||||
|
const blockedJira = jiraTicketsResult.find(t => t.compliance_linked);
|
||||||
|
const blockedLabel = blockedArcher
|
||||||
|
? `Archer ticket ${blockedArcher.exc_number}`
|
||||||
|
: `JIRA ticket ${blockedJira.ticket_key}`;
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'CVE deletion blocked: associated ticket linked to compliance report',
|
||||||
|
cascade_impact: {
|
||||||
|
archer_tickets: archerTicketsResult,
|
||||||
|
jira_tickets: jiraTicketsResult,
|
||||||
|
documents: documentsResult,
|
||||||
|
blocked: true,
|
||||||
|
blocked_reason: `${blockedLabel} is linked to a compliance report`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not blocked — return cascade impact for frontend warning
|
||||||
|
return res.json({
|
||||||
|
cascade_impact: {
|
||||||
|
archer_tickets: archerTicketsResult,
|
||||||
|
jira_tickets: jiraTicketsResult,
|
||||||
|
documents: documentsResult,
|
||||||
|
blocked: false,
|
||||||
|
blocked_reason: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return; // Exit early — Standard_User flow handled above
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin flow: proceed directly with deletion (no cascade check)
|
||||||
// Delete all documents from DB
|
// Delete all documents from DB
|
||||||
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||||
if (docErr) console.error('Error deleting documents:', docErr);
|
if (docErr) console.error('Error deleting documents:', docErr);
|
||||||
@@ -619,13 +865,71 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete single CVE vendor entry (editor or admin)
|
// Delete single CVE vendor entry (editor or admin)
|
||||||
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
||||||
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
||||||
|
|
||||||
|
// Ownership check: Standard_User can only delete CVEs they created
|
||||||
|
if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade/compliance check for Standard_User
|
||||||
|
if (req.user.group === 'Standard_User') {
|
||||||
|
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
|
||||||
|
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
|
||||||
|
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
|
||||||
|
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const allTickets = [
|
||||||
|
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
||||||
|
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allTickets.length === 0) {
|
||||||
|
return doSingleCveDelete(req, res, id, cve);
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
|
||||||
|
const likeParams = allTickets.map(t => `%${t.key}%`);
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
`SELECT ci.id, ci.extra_json FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
||||||
|
likeParams,
|
||||||
|
(compErr, compLinks) => {
|
||||||
|
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
|
||||||
|
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const hasLink = (compLinks || []).some(cl => {
|
||||||
|
const json = cl.extra_json || '';
|
||||||
|
return allTickets.some(t => json.includes(t.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasLink) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'CVE deletion blocked: associated ticket linked to compliance report',
|
||||||
|
cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return doSingleCveDelete(req, res, id, cve);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doSingleCveDelete(req, res, id, cve);
|
||||||
|
});
|
||||||
|
|
||||||
|
function doSingleCveDelete(req, res, id, cve) {
|
||||||
// Delete associated documents from DB
|
// Delete associated documents from DB
|
||||||
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||||
if (docErr) console.error('Error fetching documents:', docErr);
|
if (docErr) console.error('Error fetching documents:', docErr);
|
||||||
@@ -672,7 +976,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== DOCUMENT ENDPOINTS ==========
|
// ========== DOCUMENT ENDPOINTS ==========
|
||||||
@@ -701,7 +1005,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
||||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
||||||
upload.single('file')(req, res, (err) => {
|
upload.single('file')(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Upload error:', err.message);
|
console.error('Upload error:', err.message);
|
||||||
@@ -809,7 +1113,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Delete document (admin only)
|
// Delete document (admin only)
|
||||||
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => {
|
app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// First get the file path to delete the actual file
|
// First get the file path to delete the actual file
|
||||||
@@ -911,7 +1215,7 @@ app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create JIRA ticket
|
// Create JIRA ticket
|
||||||
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -937,11 +1241,11 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
|
|||||||
const ticketStatus = status || 'Open';
|
const ticketStatus = status || 'Open';
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
|
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
|
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error creating JIRA ticket:', err);
|
console.error('Error creating JIRA ticket:', err);
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
@@ -965,7 +1269,7 @@ app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update JIRA ticket
|
// Update JIRA ticket
|
||||||
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ticket_key, url, summary, status } = req.body;
|
const { ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
@@ -1030,7 +1334,7 @@ app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete JIRA ticket
|
// Delete JIRA ticket
|
||||||
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||||
@@ -1042,6 +1346,47 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
|
|||||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin bypasses all delete restrictions
|
||||||
|
if (req.user.group === 'Admin') {
|
||||||
|
return performJiraDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ticketKey = ticket.ticket_key;
|
||||||
|
db.all(
|
||||||
|
`SELECT ci.id, ci.extra_json
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
||||||
|
[`%${ticketKey}%`],
|
||||||
|
(compErr, compLinks) => {
|
||||||
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
||||||
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
||||||
|
compLinks = [];
|
||||||
|
} else if (compErr) {
|
||||||
|
console.error(compErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLinked = (compLinks || []).some(cl => {
|
||||||
|
const json = cl.extra_json || '';
|
||||||
|
return json.includes(ticketKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLinked) {
|
||||||
|
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return performJiraDelete();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function performJiraDelete() {
|
||||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||||
if (deleteErr) {
|
if (deleteErr) {
|
||||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||||
@@ -1060,6 +1405,7 @@ app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admi
|
|||||||
|
|
||||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -172,8 +173,9 @@ async function createDefaultAdmin(db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create admin user with password 'admin123'
|
// Generate a random admin password on first run
|
||||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
const generatedPassword = crypto.randomBytes(12).toString('base64url');
|
||||||
|
const passwordHash = await bcrypt.hash(generatedPassword, 10);
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO users (username, email, password_hash, role, is_active)
|
`INSERT INTO users (username, email, password_hash, role, is_active)
|
||||||
@@ -183,7 +185,12 @@ async function createDefaultAdmin(db) {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log('✓ Created default admin user (admin/admin123)');
|
console.log('✓ Created default admin user');
|
||||||
|
console.log(`\n ╔══════════════════════════════════════════╗`);
|
||||||
|
console.log(` ║ Admin credentials (save these now!) ║`);
|
||||||
|
console.log(` ║ Username: admin ║`);
|
||||||
|
console.log(` ║ Password: ${generatedPassword.padEnd(29)}║`);
|
||||||
|
console.log(` ╚══════════════════════════════════════════╝\n`);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +276,7 @@ function displaySummary() {
|
|||||||
console.log(' ✓ Indexes for fast queries');
|
console.log(' ✓ Indexes for fast queries');
|
||||||
console.log(' ✓ Document compliance view');
|
console.log(' ✓ Document compliance view');
|
||||||
console.log(' ✓ Uploads directory for file storage');
|
console.log(' ✓ Uploads directory for file storage');
|
||||||
console.log(' ✓ Default admin user (admin/admin123)');
|
console.log(' ✓ Default admin user (see credentials above)');
|
||||||
console.log('\n📁 File structure will be:');
|
console.log('\n📁 File structure will be:');
|
||||||
console.log(' uploads/');
|
console.log(' uploads/');
|
||||||
console.log(' └── CVE-XXXX-XXXX/');
|
console.log(' └── CVE-XXXX-XXXX/');
|
||||||
|
|||||||
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||||
|
|
||||||
|
**Document Type:** Method of Procedure
|
||||||
|
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||||
|
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background
|
||||||
|
|
||||||
|
### What the Reporting Page Shows
|
||||||
|
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||||
|
|
||||||
|
### What the Workflow Column Shows
|
||||||
|
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||||
|
|
||||||
|
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||||
|
|
||||||
|
### Key Rule
|
||||||
|
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Column Color Codes
|
||||||
|
|
||||||
|
### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||||
|
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Amber — Action Required Soon
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Does it have a Workflow badge?
|
||||||
|
│ │
|
||||||
|
│ ├── NO (—)
|
||||||
|
│ │ └── Triage → Remediate OR submit new FP request
|
||||||
|
│ │
|
||||||
|
│ └── YES → Check the color:
|
||||||
|
│ │
|
||||||
|
│ ├── 🔵 BLUE (Requested)
|
||||||
|
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||||
|
│ │
|
||||||
|
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||||
|
│ │
|
||||||
|
│ └── 🔴 RED
|
||||||
|
│ │
|
||||||
|
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||||
|
│ │
|
||||||
|
│ └── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||||
|
|
||||||
|
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||||
|
2. Navigate to **Host Findings**
|
||||||
|
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||||
|
4. Select the finding → **Actions** → **Request False Positive**
|
||||||
|
5. Complete the justification form:
|
||||||
|
- Describe why the finding is not exploitable in this environment
|
||||||
|
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||||
|
- Attach supporting evidence if available
|
||||||
|
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference Card
|
||||||
|
|
||||||
|
| Badge Color | State | One-Line Action |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||||
|
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||||
|
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||||
|
| 🟡 Amber | Actionable | Review ticket in Ivanti |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: remediate or submit FP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-03-11*
|
||||||
106
docs/ivanti-api-reference.md
Normal file
106
docs/ivanti-api-reference.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Ivanti / RiskSense API Reference
|
||||||
|
|
||||||
|
Base URL: `https://platform4.risksense.com/api/v1`
|
||||||
|
Swagger: `https://platform4.risksense.com/doc/swagger.json`
|
||||||
|
|
||||||
|
Auth: `x-api-key` header. Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited.
|
||||||
|
|
||||||
|
## Endpoints Used
|
||||||
|
|
||||||
|
### Search Workflow Batches
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /client/{clientId}/workflowBatch/search
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard JSON body with filters, projection, sort, page, size. Used by `ivantiWorkflows.js` for the daily sync.
|
||||||
|
|
||||||
|
### Create False Positive Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /client/{clientId}/workflowBatch/falsePositive/request
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
This endpoint does NOT accept JSON. It requires `multipart/form-data` with the following fields:
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `name` | string | yes | Workflow batch name (max 255) |
|
||||||
|
| `reason` | string | yes | Reason for the FP determination |
|
||||||
|
| `description` | string | yes | Description (can be empty string but field must be present) |
|
||||||
|
| `expirationDate` | string | yes | ISO-8601 date, e.g. `2026-06-01` |
|
||||||
|
| `overrideControl` | string | yes | `AUTHORIZED`, `NONE`, or `AUTOMATED`. Use `AUTHORIZED` for standard FP workflows. `NONE` with `isEmptyWorkflow=true` is rejected (400). |
|
||||||
|
| `isEmptyWorkflow` | boolean | yes | `true` if no findings attached, `false` otherwise |
|
||||||
|
| `subjectFilterRequest` | string | yes | Stringified JSON (see format below) |
|
||||||
|
| `files` | file | no | Attachments sent inline in the same request |
|
||||||
|
|
||||||
|
#### subjectFilterRequest format
|
||||||
|
|
||||||
|
This is the critical field. It must be a stringified JSON object with this exact structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "hostFinding",
|
||||||
|
"filterRequest": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "id",
|
||||||
|
"exclusive": false,
|
||||||
|
"operator": "IN",
|
||||||
|
"value": "2283734550,2283734551"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key details:
|
||||||
|
- `subject` must be `"hostFinding"` — without this, the API returns 500
|
||||||
|
- `filters` is nested inside `filterRequest`, NOT at the top level — `{"filters":[]}` at the top level returns 500
|
||||||
|
- `value` for multiple IDs is comma-separated as a single string, not an array
|
||||||
|
- `operator` values: `EXACT`, `IN`, `LIKE`, `WILDCARD`, `RANGE`, `CIDR`
|
||||||
|
- For empty workflows, use `{"subject":"hostFinding","filterRequest":{"filters":[]}}` with `isEmptyWorkflow=true`
|
||||||
|
|
||||||
|
#### Response (200/202)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 33418832,
|
||||||
|
"created": "2026-04-08T18:16:08"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns HTTP 200 or 202 (Accepted — async job creation). Response contains a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
|
||||||
|
|
||||||
|
### Other Workflow Endpoints (from Swagger)
|
||||||
|
|
||||||
|
These are available but not currently used by the dashboard:
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `/workflowBatch/acceptance/request` | Risk acceptance workflow |
|
||||||
|
| `/workflowBatch/remediation/request` | Remediation workflow |
|
||||||
|
| `/workflowBatch/severityChange/request` | Severity change workflow |
|
||||||
|
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) |
|
||||||
|
| `/workflowBatch/{workflowType}/reject` | Reject a workflow |
|
||||||
|
| `/workflowBatch/{workflowType}/rework` | Send back for rework |
|
||||||
|
| `/workflowBatch/{workflowType}/update` | Update a workflow |
|
||||||
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow |
|
||||||
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings |
|
||||||
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow |
|
||||||
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file |
|
||||||
|
| `/workflowBatch/model` | Get model/schema |
|
||||||
|
| `/workflowBatch/filter` | Get available filter fields |
|
||||||
|
| `/workflowBatch/suggest` | Get suggested values for a filter field |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `IVANTI_API_KEY` | — | Required. API key for authentication |
|
||||||
|
| `IVANTI_CLIENT_ID` | `1550` | Client ID in the Ivanti platform |
|
||||||
|
| `IVANTI_SKIP_TLS` | `false` | Set `true` to skip TLS verification |
|
||||||
|
| `IVANTI_FIRST_NAME` | — | Used for workflow search filter (sync) |
|
||||||
|
| `IVANTI_LAST_NAME` | — | Used for workflow search filter (sync) |
|
||||||
73
docs/python-venv-setup.md
Normal file
73
docs/python-venv-setup.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Python Dependencies — Compliance xlsx Parsing
|
||||||
|
|
||||||
|
`parse_compliance_xlsx.py` requires `pandas` and `openpyxl`. This doc
|
||||||
|
explains how each server has (or should have) these installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev server — how it works
|
||||||
|
|
||||||
|
Pandas and openpyxl are installed as **system apt packages**, not via pip
|
||||||
|
or a venv. This is why there is no venv on dev and no `--break-system-packages`
|
||||||
|
gymnastics. They were installed at some point via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install python3-pandas python3-openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
You can verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import pandas; print(pandas.__file__)"
|
||||||
|
# /usr/lib/python3/dist-packages/pandas/__init__.py ← apt-managed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production server — how to fix it
|
||||||
|
|
||||||
|
Production was missing pandas entirely. The fix mirrors what dev has:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update --fix-missing
|
||||||
|
apt install -y python3-pandas python3-openpyxl
|
||||||
|
```
|
||||||
|
|
||||||
|
No venv, no pip, no `PYTHON_BIN` env var needed. After installing, restart
|
||||||
|
the backend and the compliance xlsx upload will work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If apt packages are unavailable (fallback)
|
||||||
|
|
||||||
|
If you're on a system where apt doesn't have pandas (unlikely on Ubuntu
|
||||||
|
22.04/24.04), or you want isolation, use a venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y python3-venv python3-full
|
||||||
|
python3 -m venv /home/cve-dashboard/venv
|
||||||
|
/home/cve-dashboard/venv/bin/pip install -r /home/cve-dashboard/backend/scripts/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `PYTHON_BIN` in the Node backend's environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PYTHON_BIN=/home/cve-dashboard/venv/bin/python3
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend reads `process.env.PYTHON_BIN` and falls back to `python3` if
|
||||||
|
not set, so this only needs to be done if you're using a venv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why pip3 may fail on modern Ubuntu/Debian
|
||||||
|
|
||||||
|
PEP 668 (enforced in Ubuntu 23.04+) blocks `pip3 install` system-wide to
|
||||||
|
prevent breaking apt-managed packages. The error looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: externally-managed-environment
|
||||||
|
```
|
||||||
|
|
||||||
|
Using `apt install python3-pandas` is the correct solution — pip is not
|
||||||
|
needed when the distro packages the library directly.
|
||||||
617
docs/security-audit-2026-04-01.md
Normal file
617
docs/security-audit-2026-04-01.md
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
# Security Audit Report — STEAM Security Dashboard
|
||||||
|
|
||||||
|
**Date:** 2026-04-01
|
||||||
|
**Scope:** Full codebase — backend routes, authentication, file handling, Python scripts, React frontend
|
||||||
|
**Methodology:** Static analysis across four parallel audit tracks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The audit identified **31 findings** across four severity levels. The most serious issues are concentrated in the **authentication and authorization layer** — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
|----------|-------|
|
||||||
|
| Critical | 6 |
|
||||||
|
| High | 9 |
|
||||||
|
| Medium | 10 |
|
||||||
|
| Low / Info | 6 |
|
||||||
|
| **Total** | **31** |
|
||||||
|
|
||||||
|
The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses `spawn` with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-1 — Missing Authentication on Ivanti Findings Endpoints
|
||||||
|
|
||||||
|
**File:** `backend/routes/ivantiFindings.js:552–600`
|
||||||
|
|
||||||
|
The findings router imports `requireRole` but **not** `requireAuth`. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { requireRole } = require('../middleware/auth'); // requireAuth never imported
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => { // line 552 — no auth
|
||||||
|
router.post('/sync', async (req, res) => { // line 561 — no auth
|
||||||
|
router.get('/counts', async (req, res) => { // line 571 — no auth
|
||||||
|
router.get('/fp-workflow-counts', ...) // line 580 — no auth
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.
|
||||||
|
|
||||||
|
**Fix:** Import `requireAuth` and apply it to the router or each route:
|
||||||
|
```js
|
||||||
|
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base
|
||||||
|
|
||||||
|
**File:** `backend/routes/knowledgeBase.js:43, 305`
|
||||||
|
|
||||||
|
`requireRole` is called with `db` as the first argument:
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
|
||||||
|
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
The function signature is `function requireRole(...allowedRoles)`. It does not accept `db`. The database object is treated as the first "allowed role", so the check becomes `req.user.role === db` — an object comparison that always evaluates false, meaning **the check never blocks anyone**. Any authenticated viewer can upload and delete knowledge base documents.
|
||||||
|
|
||||||
|
**Fix:** Remove `db` from all `requireRole` calls:
|
||||||
|
```js
|
||||||
|
requireRole('editor', 'admin')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-3 — Unauthenticated Ivanti Finding Note Writes
|
||||||
|
|
||||||
|
**File:** `backend/routes/ivantiFindings.js:639`
|
||||||
|
|
||||||
|
The PUT endpoint for saving finding notes has no authentication middleware:
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.put('/:findingId/note', (req, res) => {
|
||||||
|
const note = String(req.body.note || '').slice(0, 255);
|
||||||
|
db.run(`INSERT INTO ivanti_finding_notes ...`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.
|
||||||
|
|
||||||
|
**Fix:** Add `requireAuth(db)` to this route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-4 — No Brute Force Protection on Login Endpoint
|
||||||
|
|
||||||
|
**File:** `backend/routes/auth.js:10`
|
||||||
|
|
||||||
|
The login endpoint has no rate limiting, attempt counting, or lockout:
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
// Direct DB lookup, unlimited attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.
|
||||||
|
|
||||||
|
**Fix:** Apply `express-rate-limit` to the login route:
|
||||||
|
```js
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
|
||||||
|
router.post('/login', loginLimiter, async (req, res) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-5 — Default Credentials Displayed in Login UI
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/LoginForm.js:104`
|
||||||
|
|
||||||
|
The login form renders hardcoded credentials in plain text:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<p className="text-sm text-gray-500 text-center font-mono">
|
||||||
|
Default: <span className="text-intel-accent">admin</span> /
|
||||||
|
<span className="text-intel-accent">admin123</span>
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.
|
||||||
|
|
||||||
|
**Fix:** Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/KnowledgeBaseViewer.js:195`
|
||||||
|
|
||||||
|
The inline document viewer renders uploaded files in an unsandboxed iframe:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<iframe
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
title={article.title}
|
||||||
|
className="w-full h-full rounded"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing `localStorage`, `sessionStorage`, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.
|
||||||
|
|
||||||
|
**Fix:** Add a restrictive `sandbox` attribute:
|
||||||
|
```jsx
|
||||||
|
<iframe
|
||||||
|
sandbox="allow-same-origin allow-scripts"
|
||||||
|
src={...}
|
||||||
|
title={article.title}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-1 — /cleanup-sessions Missing Role Check
|
||||||
|
|
||||||
|
**File:** `backend/routes/auth.js:223`
|
||||||
|
|
||||||
|
The comment says "admin only" but the endpoint only checks for any valid session:
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.post('/cleanup-sessions', async (req, res) => {
|
||||||
|
const sessionId = req.cookies?.session_id;
|
||||||
|
if (!sessionId) return res.status(401).json({ error: '...' });
|
||||||
|
// No role check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Apply `requireAuth(db)` and `requireRole('admin')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-2 — Hardcoded Fallback SESSION_SECRET
|
||||||
|
|
||||||
|
**File:** `backend/server.js:31`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `.env` file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.
|
||||||
|
|
||||||
|
**Fix:** Fail hard on startup if the secret is not set:
|
||||||
|
```js
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
||||||
|
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps
|
||||||
|
|
||||||
|
**Files:** `backend/routes/archerTickets.js:89–95, 172, 206` and `backend/routes/knowledgeBase.js:235–244, 287–296`
|
||||||
|
|
||||||
|
The `logAudit` helper expects an object with `entityType` and `entityId`. These callers use the wrong keys (`targetType`, `targetId`) or pass positional arguments instead of an object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// archerTickets.js — wrong keys
|
||||||
|
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });
|
||||||
|
|
||||||
|
// knowledgeBase.js — positional (wrong pattern)
|
||||||
|
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** All Archer ticket and Knowledge Base operations produce audit log rows with `NULL` entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.
|
||||||
|
|
||||||
|
**Fix:** Align all callers to the object format expected by `auditLog.js`:
|
||||||
|
```js
|
||||||
|
logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-4 — Viewers Can Write Compliance Notes
|
||||||
|
|
||||||
|
**Files:** `backend/routes/compliance.js:522` (also flagged by file-upload audit)
|
||||||
|
|
||||||
|
The POST /notes endpoint is protected by authentication but not by role:
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.post('/notes', async (req, res) => { // no requireRole()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.
|
||||||
|
|
||||||
|
**Fix:** `requireRole('editor', 'admin')` on this route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-5 — Sync Endpoints Accessible to All Authenticated Users
|
||||||
|
|
||||||
|
**Files:** `backend/routes/ivantiFindings.js:561`, `backend/routes/ivantiWorkflows.js:262`
|
||||||
|
|
||||||
|
POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.
|
||||||
|
|
||||||
|
**Impact:** Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.
|
||||||
|
|
||||||
|
**Fix:** Add `requireRole('editor', 'admin')` to both POST /sync routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition
|
||||||
|
|
||||||
|
**File:** `backend/routes/knowledgeBase.js:258, 299`
|
||||||
|
|
||||||
|
The original uploaded filename (user-controlled) is written directly into the `Content-Disposition` response header:
|
||||||
|
|
||||||
|
```js
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);
|
||||||
|
```
|
||||||
|
|
||||||
|
`row.file_name` stores `uploadedFile.originalname` which is not sanitized for use in HTTP headers. A filename containing `"\r\n` characters can split the response and inject arbitrary headers.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```js
|
||||||
|
const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-7 — Race Condition in Knowledge Base File Upload
|
||||||
|
|
||||||
|
**File:** `backend/routes/knowledgeBase.js:91–155`
|
||||||
|
|
||||||
|
The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.
|
||||||
|
|
||||||
|
**Fix:** Keep the file in the temp directory until the DB insert succeeds, then move it:
|
||||||
|
```js
|
||||||
|
db.run(insertSql, [...], function(err) {
|
||||||
|
if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
|
||||||
|
fs.renameSync(uploadedFile.path, filePath);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-8 — Hardcoded Default Admin Password in setup.js
|
||||||
|
|
||||||
|
**File:** `backend/setup.js:175`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
If `setup.js` is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).
|
||||||
|
|
||||||
|
**Fix:** Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H-9 — ReactMarkdown Renders HTML Without Sanitization
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/KnowledgeBaseViewer.js:169–171`
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ReactMarkdown` by default allows raw HTML in markdown (via `rehype-raw`). A knowledge base article containing `<img src=x onerror="...">` or `<script>` tags would execute JavaScript in the viewer's browser.
|
||||||
|
|
||||||
|
**Fix:** Add `rehype-sanitize`:
|
||||||
|
```jsx
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-1 — No CSRF Token Protection on State-Changing Requests
|
||||||
|
|
||||||
|
**Files:** All POST / PUT / DELETE routes
|
||||||
|
|
||||||
|
Cookies are `SameSite: lax` which provides partial protection, but `lax` still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.
|
||||||
|
|
||||||
|
**Fix:** Either upgrade session cookie to `SameSite: strict`, or implement a CSRF token (double-submit cookie pattern or `csurf` middleware).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-2 — CORS Allows Credentials with Explicit Origin List
|
||||||
|
|
||||||
|
**File:** `backend/server.js:111–114`
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||||
|
```
|
||||||
|
|
||||||
|
`credentials: true` with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.
|
||||||
|
|
||||||
|
**Fix:** Ensure `CORS_ORIGINS` is reviewed whenever the deployment changes. Consider `SameSite: strict` on cookies to reduce reliance on CORS for CSRF protection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-3 — No Rate Limiting on NVD API Proxy
|
||||||
|
|
||||||
|
**File:** `backend/routes/nvdLookup.js:13`
|
||||||
|
|
||||||
|
Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.
|
||||||
|
|
||||||
|
**Fix:** Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-4 — Admin Self-Demotion Check Uses Loose Equality
|
||||||
|
|
||||||
|
**File:** `backend/routes/users.js:118`
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (userId == req.user.id && role && role !== 'admin') {
|
||||||
|
```
|
||||||
|
|
||||||
|
Using `==` allows type coercion. If `userId` is passed as a different type than `req.user.id`, the comparison may not match correctly.
|
||||||
|
|
||||||
|
**Fix:** `String(userId) === String(req.user.id)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-5 — Missing Hostname Format Validation
|
||||||
|
|
||||||
|
**File:** `backend/routes/compliance.js:451`
|
||||||
|
|
||||||
|
The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```js
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hostname format' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-6 — Vendor Field Validated Before Trim
|
||||||
|
|
||||||
|
**File:** `backend/routes/ivantiTodoQueue.js:8, 56`
|
||||||
|
|
||||||
|
Vendor length is validated before `.trim()` is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.
|
||||||
|
|
||||||
|
**Fix:** Trim first, then validate length and presence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON
|
||||||
|
|
||||||
|
**File:** `backend/routes/compliance.js:262`
|
||||||
|
|
||||||
|
```js
|
||||||
|
filename: req.file.originalname, // user-controlled, unsanitized
|
||||||
|
```
|
||||||
|
|
||||||
|
The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.
|
||||||
|
|
||||||
|
**Fix:** `filename: sanitizePathSegment(req.file.originalname)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-8 — Hardcoded Frontend Origin in CSP Header
|
||||||
|
|
||||||
|
**File:** `backend/routes/knowledgeBase.js:261`
|
||||||
|
|
||||||
|
```js
|
||||||
|
res.setHeader('Content-Security-Policy',
|
||||||
|
"frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");
|
||||||
|
```
|
||||||
|
|
||||||
|
IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.
|
||||||
|
|
||||||
|
**Fix:** Use `CORS_ORIGINS` from the environment variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-9 — Sensitive API Error Messages Forwarded to UI
|
||||||
|
|
||||||
|
**Files:** `frontend/src/App.js:801, 816, 847, 886`
|
||||||
|
|
||||||
|
```js
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.
|
||||||
|
|
||||||
|
**Fix:** Show generic user-facing messages; log details to the console in development only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-10 — User-Supplied Data in window.confirm Dialogs
|
||||||
|
|
||||||
|
**File:** `frontend/src/App.js:806, 891`
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
A ticket with a crafted `ticket_key` value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.
|
||||||
|
|
||||||
|
**Fix:** Use a React modal component with escaped, controlled text instead of `window.confirm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low / Info Findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-1 — Silent ROLLBACK on Compliance Transaction Failure
|
||||||
|
|
||||||
|
**File:** `backend/routes/compliance.js:167`
|
||||||
|
|
||||||
|
```js
|
||||||
|
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||||
|
```
|
||||||
|
|
||||||
|
If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.
|
||||||
|
|
||||||
|
**Fix:** Log rollback failures even if execution continues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-2 — Fire-and-Forget Audit Logging
|
||||||
|
|
||||||
|
**File:** `backend/helpers/auditLog.js:9`
|
||||||
|
|
||||||
|
Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.
|
||||||
|
|
||||||
|
**Fix:** Log audit write failures to stderr so they surface in server logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-3 — Async Temp File Cleanup With No Error Handling
|
||||||
|
|
||||||
|
**File:** `backend/routes/compliance.js:239, 247, 266, 281, 322`
|
||||||
|
|
||||||
|
```js
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
```
|
||||||
|
|
||||||
|
Cleanup failures accumulate silently, potentially causing disk exhaustion over time.
|
||||||
|
|
||||||
|
**Fix:** Log errors on unlink failure (excluding ENOENT which is expected).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-4 — IVANTI_SKIP_TLS Disables Certificate Validation
|
||||||
|
|
||||||
|
**File:** `backend/routes/ivantiFindings.js:385`
|
||||||
|
|
||||||
|
`IVANTI_SKIP_TLS=true` disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.
|
||||||
|
|
||||||
|
**Fix:** Log a prominent warning on startup when this flag is active, and ensure it is never set in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-5 — console.error in Production Frontend Code
|
||||||
|
|
||||||
|
**Files:** `frontend/src/contexts/AuthContext.js:26`, `KnowledgeBaseViewer.js:31, 56`
|
||||||
|
|
||||||
|
Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.
|
||||||
|
|
||||||
|
**Fix:** Guard with `if (process.env.NODE_ENV === 'development')` or use a structured logging library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L-6 — localStorage Column Config Lacks Structural Validation
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/pages/ReportingPage.js:51–68`
|
||||||
|
|
||||||
|
Column order/visibility is loaded from `localStorage` and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.
|
||||||
|
|
||||||
|
**Fix:** Validate each loaded item against the known `COLUMN_DEFS` whitelist before use (a `hasOwnProperty` check is already present; ensure it runs on every item before the merge).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| ID | Severity | Title | File |
|
||||||
|
|----|----------|-------|------|
|
||||||
|
| C-1 | Critical | Missing auth on Ivanti findings endpoints | ivantiFindings.js:552 |
|
||||||
|
| C-2 | Critical | requireRole(db) call bypasses role check in KB routes | knowledgeBase.js:43,305 |
|
||||||
|
| C-3 | Critical | Unauthenticated finding note writes | ivantiFindings.js:639 |
|
||||||
|
| C-4 | Critical | No brute force protection on login | auth.js:10 |
|
||||||
|
| C-5 | Critical | Default credentials displayed in login UI | LoginForm.js:104 |
|
||||||
|
| C-6 | Critical | Missing sandbox on PDF/document iframe | KnowledgeBaseViewer.js:195 |
|
||||||
|
| H-1 | High | /cleanup-sessions missing role check | auth.js:223 |
|
||||||
|
| H-2 | High | Hardcoded fallback SESSION_SECRET | server.js:31 |
|
||||||
|
| H-3 | High | Audit log parameter mismatch — silent trail gaps | archerTickets.js, knowledgeBase.js |
|
||||||
|
| H-4 | High | Viewers can write compliance notes | compliance.js:522 |
|
||||||
|
| H-5 | High | Sync endpoints accessible to all authenticated users | ivantiFindings.js:561, ivantiWorkflows.js:262 |
|
||||||
|
| H-6 | High | HTTP header injection via Content-Disposition filename | knowledgeBase.js:258,299 |
|
||||||
|
| H-7 | High | Race condition in KB file upload | knowledgeBase.js:91 |
|
||||||
|
| H-8 | High | Hardcoded default admin password in setup.js | setup.js:175 |
|
||||||
|
| H-9 | High | ReactMarkdown renders HTML without sanitization | KnowledgeBaseViewer.js:169 |
|
||||||
|
| M-1 | Medium | No CSRF token protection | All state-changing routes |
|
||||||
|
| M-2 | Medium | CORS credentials with explicit origin list | server.js:111 |
|
||||||
|
| M-3 | Medium | No rate limiting on NVD API proxy | nvdLookup.js:13 |
|
||||||
|
| M-4 | Medium | Admin self-demotion check uses loose equality | users.js:118 |
|
||||||
|
| M-5 | Medium | Missing hostname format validation | compliance.js:451 |
|
||||||
|
| M-6 | Medium | Vendor field validated before trim | ivantiTodoQueue.js:8,56 |
|
||||||
|
| M-7 | Medium | Unsanitized original filename in temp JSON | compliance.js:262 |
|
||||||
|
| M-8 | Medium | Hardcoded frontend IP in CSP header | knowledgeBase.js:261 |
|
||||||
|
| M-9 | Medium | API error messages forwarded to UI | App.js:801,816,847,886 |
|
||||||
|
| M-10 | Medium | User data in window.confirm dialogs | App.js:806,891 |
|
||||||
|
| L-1 | Low | Silent ROLLBACK on transaction failure | compliance.js:167 |
|
||||||
|
| L-2 | Low | Fire-and-forget audit logging | auditLog.js:9 |
|
||||||
|
| L-3 | Low | Async temp file cleanup with no error handling | compliance.js:239+ |
|
||||||
|
| L-4 | Low | IVANTI_SKIP_TLS with no startup warning | ivantiFindings.js:385 |
|
||||||
|
| L-5 | Low | console.error exposed in production frontend | AuthContext.js, KnowledgeBaseViewer.js |
|
||||||
|
| L-6 | Low | localStorage column config lacks structural validation | ReportingPage.js:51 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Priority
|
||||||
|
|
||||||
|
### Immediate — fix before adding users
|
||||||
|
|
||||||
|
1. **C-1** — Add `requireAuth` import and router-level middleware to `ivantiFindings.js`
|
||||||
|
2. **C-2** — Remove `db` from all `requireRole(db, ...)` calls in `knowledgeBase.js`
|
||||||
|
3. **C-3** — Add `requireAuth(db)` to the finding note PUT route
|
||||||
|
4. **C-4** — Add `express-rate-limit` to the login route (20 attempts / 15 min)
|
||||||
|
5. **C-5** — Remove default credentials from `LoginForm.js`
|
||||||
|
6. **H-2** — Hard-fail on startup if `SESSION_SECRET` is not set in env
|
||||||
|
|
||||||
|
### Short-term — next maintenance window
|
||||||
|
|
||||||
|
7. **C-6** — Add `sandbox` attribute to the KB iframe
|
||||||
|
8. **H-3** — Fix `logAudit` call signatures in `archerTickets.js` and `knowledgeBase.js`
|
||||||
|
9. **H-4** — Add `requireRole('editor', 'admin')` to POST /compliance/notes
|
||||||
|
10. **H-5** — Add `requireRole('editor', 'admin')` to both POST /sync routes
|
||||||
|
11. **H-6** — Sanitize filename for `Content-Disposition` header
|
||||||
|
12. **H-7** — Move file after DB insert succeeds in KB upload
|
||||||
|
13. **H-8** — Remove hardcoded password from `setup.js`; generate random on first run
|
||||||
|
14. **H-9** — Add `rehype-sanitize` to `ReactMarkdown` usage
|
||||||
|
|
||||||
|
### Medium-term
|
||||||
|
|
||||||
|
15. **M-1** — Implement CSRF token or upgrade cookie to `SameSite: strict`
|
||||||
|
16. **M-3** — Add server-side CVE lookup cache
|
||||||
|
17. **M-5** — Add hostname format regex validation
|
||||||
|
18. **M-8** — Pull frontend origin from `CORS_ORIGINS` env var for CSP header
|
||||||
|
19. **M-9** — Replace `alert(err.message)` with user-friendly error messages
|
||||||
|
20. Remaining medium and low findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Security Observations
|
||||||
|
|
||||||
|
The following were explicitly verified as secure and should be preserved:
|
||||||
|
|
||||||
|
- **SQL injection prevention** — all queries use SQLite3 parameterized statements throughout
|
||||||
|
- **Path traversal prevention** — `sanitizePathSegment()` and `isPathWithinUploads()` are comprehensive and consistently applied
|
||||||
|
- **Python script execution** — `spawn('python3', [SCRIPT, filePath])` passes arguments as an array, not a shell string — no command injection possible
|
||||||
|
- **Python scripts** — no `eval()`, `exec()`, `pickle.load()`, or shell calls in any script
|
||||||
|
- **File size enforcement** — 10 MB limit applied via multer before route handlers execute
|
||||||
|
- **File type allowlisting** — extension + MIME prefix validation applied at upload
|
||||||
|
- **Static file serving** — `express.static` with `{ dotfiles: 'deny', index: false }` prevents directory listing
|
||||||
|
- **Temp file path validation** — `isSafeTempPath()` enforces `.json` extension on compliance temp files
|
||||||
|
- **Password hashing** — bcrypt with cost factor 10 used throughout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.*
|
||||||
183
docs/security-posture-workflow-diagrams.md
Normal file
183
docs/security-posture-workflow-diagrams.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Security Posture Workflow — Diagrams
|
||||||
|
|
||||||
|
Mermaid diagrams for the Host Finding Review & Remediation process.
|
||||||
|
Renders natively in GitHub, GitLab, and most modern documentation tools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 1 — Host Finding Review Workflow (Steps 1–5)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START([Open Vulnerability Triage Page]) --> SYNC
|
||||||
|
|
||||||
|
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||||
|
SYNC --> DUE{Overdue<br/>findings?}
|
||||||
|
DUE -->|Yes — start here| HOST
|
||||||
|
DUE -->|No — start with amber| HOST
|
||||||
|
|
||||||
|
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||||
|
HOST --> CORRECT{Hostname<br/>correct?}
|
||||||
|
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||||
|
EDIT --> OWN
|
||||||
|
CORRECT -->|Yes| OWN
|
||||||
|
|
||||||
|
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||||
|
OWN --> BU{Our BU?}
|
||||||
|
BU -->|"NTS-AEO-STEAM<br/>or ACCESS-ENG"| CVE
|
||||||
|
BU -->|"Other BU<br/>or blank"| CARD["Add to CARD Queue<br/>☑ checkbox → CARD → Add to Queue"]
|
||||||
|
CARD --> CARD2([Process in dedicated CARD session])
|
||||||
|
|
||||||
|
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover +N badge for more"]
|
||||||
|
CVE --> DBCHECK{CVE in<br/>database?}
|
||||||
|
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||||
|
ADDCVE --> RESEARCH
|
||||||
|
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||||
|
|
||||||
|
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||||
|
RESEARCH --> ACTION
|
||||||
|
|
||||||
|
ACTION["⑤ Determine Required Action"]
|
||||||
|
ACTION --> PATH{What does<br/>research show?}
|
||||||
|
|
||||||
|
PATH -->|"Patch available<br/>FW / SW update"| PA
|
||||||
|
PATH -->|"Fix is config<br/>change only"| PB
|
||||||
|
PATH -->|"Not applicable<br/>to platform / version"| PC
|
||||||
|
PATH -->|"Cannot patch<br/>vendor / EOL / business"| PD
|
||||||
|
|
||||||
|
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||||
|
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||||
|
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||||
|
|
||||||
|
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||||
|
PB --> PB1["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
PC["PATH C — False Positive"]
|
||||||
|
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||||
|
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||||
|
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||||
|
PC3 --> PC4["☑ checkbox → Vendor → FP<br/>Add to Queue"]
|
||||||
|
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||||
|
|
||||||
|
PD["PATH D — Risk Acceptance"]
|
||||||
|
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||||
|
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||||
|
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||||
|
PD3 --> PD4
|
||||||
|
PD2 -->|No| PD4["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||||
|
|
||||||
|
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||||
|
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||||
|
class PA,PA1,PA2 pathA
|
||||||
|
class PB,PB1,PB2,PB3 pathB
|
||||||
|
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||||
|
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||||
|
class CARD,CARD2 card
|
||||||
|
class EDIT done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
||||||
|
|
||||||
|
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
|
||||||
|
|
||||||
|
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||||
|
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
||||||
|
|
||||||
|
B -->|"🔵 Blue<br/>Requested"| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||||
|
D --> D1{"SLA window<br/>approaching?"}
|
||||||
|
D1 -->|No| D2(["Monitor — no action yet ✓"])
|
||||||
|
D1 -->|Yes| D3(["Follow up with<br/>the approver"])
|
||||||
|
|
||||||
|
B -->|"🟡 Amber<br/>Reworked"| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||||
|
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||||
|
E1 --> E2(["Update justification<br/>and resubmit"])
|
||||||
|
|
||||||
|
B -->|"🟡 Amber<br/>Actionable"| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||||
|
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||||
|
|
||||||
|
B -->|"🔴 Red<br/>Expired"| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||||
|
G --> G1(["Submit a new FP request<br/>in Ivanti<br/>Reference previous ticket"])
|
||||||
|
|
||||||
|
B -->|"🔴 Red<br/>Rejected"| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||||
|
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP<br/>without new evidence"])
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||||
|
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||||
|
|
||||||
|
class A,B trigger
|
||||||
|
class D,D1,D2,D3 blue
|
||||||
|
class E,E1,E2,F,F1 amber
|
||||||
|
class G,G1,H,H1 red
|
||||||
|
class C,C1 none
|
||||||
|
class D2,D3,E2,F1,G1,H1 done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram 3 — Action Decision Matrix (Quick Reference)
|
||||||
|
|
||||||
|
Condensed view of the five research outcomes and their required actions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
START(["Research complete<br/>Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||||
|
|
||||||
|
Q --> R1["Firmware or<br/>Software update available"]
|
||||||
|
R1 --> A1(["No ticket needed<br/>Schedule upgrade<br/>Add note to finding"])
|
||||||
|
|
||||||
|
Q --> R2["Fix is a<br/>configuration change"]
|
||||||
|
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||||
|
|
||||||
|
Q --> R3["Not applicable<br/>to this platform / version"]
|
||||||
|
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||||
|
|
||||||
|
Q --> R4["Patch not yet<br/>available from vendor"]
|
||||||
|
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||||
|
|
||||||
|
Q --> R5["Device is EOL / EOS<br/>or business constraint"]
|
||||||
|
R5 --> A5(["Archer ticket with<br/>mitigation steps +<br/>remediation plan"])
|
||||||
|
|
||||||
|
Q --> R6["Asset not owned<br/>by our BU"]
|
||||||
|
R6 --> A6(["CARD queue<br/>CARD disposition process"])
|
||||||
|
|
||||||
|
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
|
||||||
|
class START,Q q
|
||||||
|
class R1,A1 green
|
||||||
|
class R2,A2,R4,A4,R5,A5 amber
|
||||||
|
class R3,A3 red
|
||||||
|
class R6,A6 teal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Source document: `docs/security-posture-workflow.md`*
|
||||||
175
docs/security-posture-workflow-lucidchart.md
Normal file
175
docs/security-posture-workflow-lucidchart.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Lucidchart Import — Raw Mermaid Code
|
||||||
|
|
||||||
|
Lucidchart expects raw Mermaid syntax only — no markdown headings or prose.
|
||||||
|
Paste each diagram separately: Insert → Diagram as Code → Mermaid → paste → Generate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 1 — Host Finding Review Workflow
|
||||||
|
|
||||||
|
Paste everything between the triple-backtick fences below:
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart TD
|
||||||
|
START([Open Reporting Page]) --> SYNC
|
||||||
|
|
||||||
|
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||||
|
SYNC --> DUE{Overdue<br/>findings?}
|
||||||
|
DUE -->|Yes — start here| HOST
|
||||||
|
DUE -->|No — start with amber| HOST
|
||||||
|
|
||||||
|
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
|
||||||
|
HOST --> CORRECT{Hostname<br/>correct?}
|
||||||
|
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
|
||||||
|
EDIT --> OWN
|
||||||
|
CORRECT -->|Yes| OWN
|
||||||
|
|
||||||
|
OWN["③ Identify Asset Ownership<br/>Check BU column"]
|
||||||
|
OWN --> BU{Our BU?}
|
||||||
|
BU -->|"NTS-AEO-STEAM or ACCESS-ENG"| CVE
|
||||||
|
BU -->|"Other BU or blank"| CARD["Add to CARD Queue<br/>checkbox → CARD → Add to Queue"]
|
||||||
|
CARD --> CARD2([Process in dedicated CARD session])
|
||||||
|
|
||||||
|
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover badge for more"]
|
||||||
|
CVE --> DBCHECK{CVE in<br/>database?}
|
||||||
|
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
|
||||||
|
ADDCVE --> RESEARCH
|
||||||
|
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
|
||||||
|
|
||||||
|
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
|
||||||
|
RESEARCH --> ACTION
|
||||||
|
|
||||||
|
ACTION["⑤ Determine Required Action"]
|
||||||
|
ACTION --> PATH{What does<br/>research show?}
|
||||||
|
|
||||||
|
PATH -->|"Patch available — FW / SW update"| PA
|
||||||
|
PATH -->|"Fix is config change only"| PB
|
||||||
|
PATH -->|"Not applicable to platform / version"| PC
|
||||||
|
PATH -->|"Cannot patch — vendor / EOL / business"| PD
|
||||||
|
|
||||||
|
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
|
||||||
|
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
|
||||||
|
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
|
||||||
|
|
||||||
|
PB["PATH B — Remediation<br/>Configuration Change"]
|
||||||
|
PB --> PB1["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
PC["PATH C — False Positive"]
|
||||||
|
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
|
||||||
|
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
|
||||||
|
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
|
||||||
|
PC3 --> PC4["checkbox → Vendor → FP<br/>Add to Queue"]
|
||||||
|
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
|
||||||
|
|
||||||
|
PD["PATH D — Risk Acceptance"]
|
||||||
|
PD --> PD1["Take device screenshot<br/>Collect version info"]
|
||||||
|
PD1 --> PD2{Vendor comms<br/>needed?}
|
||||||
|
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
|
||||||
|
PD3 --> PD4
|
||||||
|
PD2 -->|No| PD4["checkbox → Vendor → Archer<br/>Add to Queue"]
|
||||||
|
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
|
||||||
|
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
|
||||||
|
|
||||||
|
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
|
||||||
|
|
||||||
|
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
|
||||||
|
class DUE,CORRECT,BU,DBCHECK,PATH decision
|
||||||
|
class PA,PA1,PA2 pathA
|
||||||
|
class PB,PB1,PB2,PB3 pathB
|
||||||
|
class PC,PC1,PC2,PC3,PC4,PC5 pathC
|
||||||
|
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
|
||||||
|
class CARD,CARD2 card
|
||||||
|
class EDIT done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 2 — FP Workflow Badge Status Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart LR
|
||||||
|
A([Finding in Reporting Page]) --> B{"Check Workflow column"}
|
||||||
|
|
||||||
|
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||||
|
C --> C1(["Follow the Step 1-5 triage workflow"])
|
||||||
|
|
||||||
|
B -->|Blue - Requested| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
|
||||||
|
D --> D1{"SLA window<br/>approaching?"}
|
||||||
|
D1 -->|No| D2(["Monitor — no action yet"])
|
||||||
|
D1 -->|Yes| D3(["Follow up with the approver"])
|
||||||
|
|
||||||
|
B -->|Amber - Reworked| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
|
||||||
|
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
|
||||||
|
E1 --> E2(["Update justification and resubmit"])
|
||||||
|
|
||||||
|
B -->|Amber - Actionable| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
|
||||||
|
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
|
||||||
|
|
||||||
|
B -->|Red - Expired| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
|
||||||
|
G --> G1(["Submit a new FP request in Ivanti<br/>Reference previous ticket"])
|
||||||
|
|
||||||
|
B -->|Red - Rejected| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
|
||||||
|
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP without new evidence"])
|
||||||
|
|
||||||
|
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
|
||||||
|
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
|
||||||
|
|
||||||
|
class A,B trigger
|
||||||
|
class D,D1,D2,D3 blue
|
||||||
|
class E,E1,E2,F,F1 amber
|
||||||
|
class G,G1,H,H1 red
|
||||||
|
class C,C1 none
|
||||||
|
class D2,D3,E2,F1,G1,H1 done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DIAGRAM 3 — Action Decision Matrix
|
||||||
|
|
||||||
|
```
|
||||||
|
flowchart LR
|
||||||
|
START(["Research complete — Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
|
||||||
|
|
||||||
|
Q --> R1["Firmware or software update available"]
|
||||||
|
R1 --> A1(["No ticket needed<br/>Schedule upgrade · Add note to finding"])
|
||||||
|
|
||||||
|
Q --> R2["Fix is a configuration change only"]
|
||||||
|
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
|
||||||
|
|
||||||
|
Q --> R3["Not applicable to this platform / version"]
|
||||||
|
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
|
||||||
|
|
||||||
|
Q --> R4["Patch not yet available from vendor"]
|
||||||
|
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
|
||||||
|
|
||||||
|
Q --> R5["Device is EOL / EOS or business constraint"]
|
||||||
|
R5 --> A5(["Archer ticket with mitigation steps<br/>and remediation plan"])
|
||||||
|
|
||||||
|
Q --> R6["Asset not owned by our BU"]
|
||||||
|
R6 --> A6(["CARD queue — CARD disposition process"])
|
||||||
|
|
||||||
|
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
|
||||||
|
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
|
||||||
|
|
||||||
|
class START,Q q
|
||||||
|
class R1,A1 green
|
||||||
|
class R2,A2,R4,A4,R5,A5 amber
|
||||||
|
class R3,A3 red
|
||||||
|
class R6,A6 teal
|
||||||
|
```
|
||||||
402
docs/security-posture-workflow.md
Normal file
402
docs/security-posture-workflow.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# Security Posture Workflow — Host Finding Review & Remediation
|
||||||
|
|
||||||
|
**Document Type:** Process Guide
|
||||||
|
**Applies To:** STEAM Security Dashboard — All Pages
|
||||||
|
**Audience:** NTS-AEO-STEAM / NTS-AEO-ACCESS-ENG team members
|
||||||
|
**Last Updated:** 2026-03-27
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#1-overview)
|
||||||
|
2. [Dashboard Orientation](#2-dashboard-orientation)
|
||||||
|
3. [Vulnerability Designations](#3-vulnerability-designations)
|
||||||
|
4. [The Host Finding Review Workflow](#4-the-host-finding-review-workflow)
|
||||||
|
- [Step 1 — Sync and Sort by Due Date](#step-1--sync-and-sort-by-due-date)
|
||||||
|
- [Step 2 — Identify the Host](#step-2--identify-the-host)
|
||||||
|
- [Step 3 — Identify Asset Ownership](#step-3--identify-asset-ownership)
|
||||||
|
- [Step 4 — Review the CVEs in the Finding](#step-4--review-the-cves-in-the-finding)
|
||||||
|
- [Step 5 — Determine and Execute the Required Action](#step-5--determine-and-execute-the-required-action)
|
||||||
|
5. [Using the Ivanti Queue](#5-using-the-ivanti-queue)
|
||||||
|
6. [Workflow Status Reference](#6-workflow-status-reference)
|
||||||
|
7. [Quick Reference Card](#7-quick-reference-card)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The STEAM Security Dashboard centralises vulnerability management for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It pulls host findings directly from Ivanti/RiskSense and gives the team a single place to triage, track, and action every open vulnerability.
|
||||||
|
|
||||||
|
**Scope:** This document covers severity findings in the **8.5 – 9.9 VRR range**. All findings in this range require some form of documented action. A finding that is not actioned before its Due Date results in the device being recorded as non-compliant.
|
||||||
|
|
||||||
|
> **SLA Rule:** By default, all vulnerabilities must have an action taken or in-flight within **60 days of detection**. The Due Date column on the Reporting page shows the exact deadline. Metrics and compliance reporting are based on vulnerabilities aged under 60 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard Orientation
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
| Page | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **Home (CVE Management)** | Track and research individual CVEs across vendors. Store supporting documentation. Log Archer EXC ticket numbers against CVE/vendor pairs. |
|
||||||
|
| **Reporting (Host Findings)** | The primary operational page. Live view of all open Ivanti findings with filtering, sorting, inline editing, the Ivanti Queue, and export. |
|
||||||
|
| **Knowledge Base** | Internal document library — policies, runbooks, vendor advisories. |
|
||||||
|
| **Exports** | Bulk export tools for reports and data extracts. |
|
||||||
|
|
||||||
|
### Reporting Page — At a Glance
|
||||||
|
|
||||||
|
When you open the Reporting page for the first time in a session, click **Sync** (top right) to pull the latest findings from Ivanti. The page shows:
|
||||||
|
|
||||||
|
- **Four metric charts** at the top — Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status
|
||||||
|
- **Findings table** below — every open finding for the configured BUs, one row per host finding
|
||||||
|
- **Ivanti Queue panel** (click the Queue button, top right) — your personal staging list for batch-processing FP and Archer workflows
|
||||||
|
|
||||||
|
The charts and table update together. Clicking a chart segment filters the table to that subset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vulnerability Designations
|
||||||
|
|
||||||
|
Every finding in the 8.5–9.9 range requires one of three documented actions. Understanding these upfront makes triage faster.
|
||||||
|
|
||||||
|
### 3.1 Remediation
|
||||||
|
|
||||||
|
The vulnerability is addressed by fixing the root cause.
|
||||||
|
|
||||||
|
| Remediation Method | Archer Ticket Required? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Firmware or software update | **No** | Upgrading removes the vulnerability entirely. The finding will fall off the report on the next scan. |
|
||||||
|
| Configuration change | **Yes** | A config change does not remove the vulnerability — if the config is ever rolled back, the vulnerability returns. An Archer Risk Acceptance ticket is required to document this. |
|
||||||
|
|
||||||
|
### 3.2 False Positive (FP)
|
||||||
|
|
||||||
|
A false positive occurs when the scanner detects a vulnerability that is **not actually present** or **does not apply** to the platform or software version in use.
|
||||||
|
|
||||||
|
**An FP workflow must be opened in Ivanti.** The workflow requires:
|
||||||
|
|
||||||
|
1. A **screenshot** taken directly from the device showing:
|
||||||
|
- Hostname
|
||||||
|
- IP address
|
||||||
|
- Software / firmware version
|
||||||
|
> **Important:** This must be a screenshot. CLI text output or copy-pasted command output is not accepted.
|
||||||
|
|
||||||
|
2. **Vendor documentation** confirming the vulnerability does not affect the platform — one of:
|
||||||
|
- Direct vendor communication (email, support ticket)
|
||||||
|
- Published security advisory stating the version or platform is not affected
|
||||||
|
- Proof that the vulnerability does not apply to the currently installed version
|
||||||
|
|
||||||
|
Supporting files (screenshots, emails, advisories) should be saved into the CVE Database (Home page → upload documents against the relevant CVE/vendor pair) for future reference and re-use if the FP expires and needs to be renewed.
|
||||||
|
|
||||||
|
### 3.3 Risk Acceptance / Archer Request
|
||||||
|
|
||||||
|
An Archer Risk Acceptance ticket (EXC-XXXXX) is required when a vulnerability **cannot be patched** for a documented business or technical reason. Common scenarios:
|
||||||
|
|
||||||
|
| Scenario | Required Action |
|
||||||
|
|---|---|
|
||||||
|
| Patch not yet available (waiting on vendor) | Open Archer ticket; close it when patch is deployed |
|
||||||
|
| Device is End-of-Sale (EOS) or End-of-Life (EOL) | Archer ticket required with mitigation steps and a remediation plan |
|
||||||
|
| Business constraint prevents patching | Archer ticket with justification and compensating controls |
|
||||||
|
| Configuration-change-only remediation | Archer ticket required (see Remediation above) |
|
||||||
|
|
||||||
|
For EOL/EOS devices the ticket must include:
|
||||||
|
- Current mitigation steps (network segmentation, compensating controls)
|
||||||
|
- A remediation plan — what will replace or retire the device and when
|
||||||
|
|
||||||
|
If vendor communication is needed (patch timeline, configuration guidance), open a vendor support ticket and use the vendor's response to fill out the Archer remediation plan field.
|
||||||
|
|
||||||
|
> Archer EXC numbers are tracked in the dashboard. Once entered on the Home page against the relevant CVE/vendor pair, the EXC badge appears on that CVE row. Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The Host Finding Review Workflow
|
||||||
|
|
||||||
|
Work through the Reporting page top-to-bottom by Due Date. The goal of each session is to ensure every finding either has an action in-flight or gets one started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1 — Sync and Sort by Due Date
|
||||||
|
|
||||||
|
1. Navigate to the **Reporting** page.
|
||||||
|
2. Click **Sync** (top right). Wait for the sync to complete — the timestamp updates when done.
|
||||||
|
3. Click the **Due Date** column header to sort ascending (soonest due date first).
|
||||||
|
- Red due dates = overdue
|
||||||
|
- Amber due dates = due within 30 days
|
||||||
|
- Start with red, then amber
|
||||||
|
|
||||||
|
> If you want to focus on findings with no action yet, click the **Pending** segment on the Action Coverage donut chart. The table will filter to only findings with no FP ticket and no EXC number in notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Identify the Host
|
||||||
|
|
||||||
|
Each finding row includes a **Host** (hostname), **IP Address**, and **DNS** column.
|
||||||
|
|
||||||
|
1. Use the reported **IP address** to verify the hostname in:
|
||||||
|
- **IPControl** (read-only, historical IPAM data)
|
||||||
|
- **Infoblox** (current IPAM — preferred for current state)
|
||||||
|
|
||||||
|
2. If the hostname shown in the dashboard is incorrect (Ivanti sometimes reports stale data):
|
||||||
|
- Click the **Host** cell in the finding row — it is inline editable.
|
||||||
|
- Type the correct hostname and press **Enter** or click away to save.
|
||||||
|
- An amber dot (●) will appear on the cell to indicate an override is in place. The original Ivanti value is preserved and can be restored using the revert button (↻).
|
||||||
|
- The same applies to the **DNS** column.
|
||||||
|
|
||||||
|
> Overrides survive Ivanti re-syncs — your corrections are not overwritten when new data is pulled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Identify Asset Ownership
|
||||||
|
|
||||||
|
Check the **BU** column to determine ownership.
|
||||||
|
|
||||||
|
| BU Value | Ownership | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `NTS-AEO-STEAM` | Our team | Continue to Step 4 |
|
||||||
|
| `NTS-AEO-ACCESS-ENG` | Our team | Continue to Step 4 |
|
||||||
|
| Any other value, or blank | Not our asset | Add to CARD queue (see below) |
|
||||||
|
|
||||||
|
**If the asset is not owned by our BU:**
|
||||||
|
|
||||||
|
1. Check the checkbox at the left of the finding row.
|
||||||
|
2. A popover will appear. The **CARD** workflow type should already be selected.
|
||||||
|
- No vendor entry is required for CARD — the IP address is captured automatically for use when searching in CARD.
|
||||||
|
3. Click **Add to Queue**.
|
||||||
|
4. The finding is now staged in your Ivanti Queue under the **CARD** section.
|
||||||
|
|
||||||
|
CARD queue items are processed in a separate session — see the [Ivanti Queue](#5-using-the-ivanti-queue) section and the dedicated CARD process documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Review the CVEs in the Finding
|
||||||
|
|
||||||
|
Each finding has one or more CVEs listed in the **CVEs** column (up to 2 shown; hover the "+N" badge to see the rest).
|
||||||
|
|
||||||
|
For each CVE in the finding:
|
||||||
|
|
||||||
|
1. **Check if the CVE already exists in the database.**
|
||||||
|
- Navigate to the **Home** page.
|
||||||
|
- Search for the CVE ID in the search bar.
|
||||||
|
- If an entry exists for this CVE and vendor, review what's already documented — there may be existing notes, documents, or an Archer ticket already linked.
|
||||||
|
|
||||||
|
2. **If no entry exists, create one:**
|
||||||
|
- Click **Add CVE** on the Home page.
|
||||||
|
- Enter the CVE ID — the NVD auto-fill will populate the description, CVSS severity, and published date automatically.
|
||||||
|
- Select the correct vendor/platform.
|
||||||
|
- Save the entry.
|
||||||
|
|
||||||
|
3. **Research the CVE** to determine the required action:
|
||||||
|
- Check the vendor's security advisory portal (e.g., Juniper Security Advisories, Cisco Security Advisories / Bug Search Tool)
|
||||||
|
- Determine whether the CVE: (a) is a False Positive for this platform/version, (b) can be Remediated, or (c) requires a Risk Acceptance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Determine and Execute the Required Action
|
||||||
|
|
||||||
|
Based on your research in Step 4, choose the path below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path A — Remediation (Firmware or Software Update)
|
||||||
|
|
||||||
|
> No Archer ticket required if the fix is a firmware or software upgrade.
|
||||||
|
|
||||||
|
1. Plan and schedule the upgrade with the relevant team.
|
||||||
|
2. No dashboard action is required beyond ensuring a note is added to the finding (click the **Notes** cell) confirming the upgrade is planned or complete.
|
||||||
|
3. After the device is upgraded, the finding will fall off the Reporting page on the next Ivanti scan if the vulnerability is no longer detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path B — Remediation (Configuration Change)
|
||||||
|
|
||||||
|
> An Archer Risk Acceptance ticket **is required** when the fix is a configuration change.
|
||||||
|
|
||||||
|
1. Check the checkbox at the left of the finding row.
|
||||||
|
2. In the popover, enter the **Vendor / Platform** (e.g., Juniper, Cisco, ADTRAN).
|
||||||
|
3. Select **Archer** as the workflow type.
|
||||||
|
4. Click **Add to Queue**.
|
||||||
|
5. Process the Archer ticket in a dedicated session — see [Ivanti Queue](#5-using-the-ivanti-queue) and the Archer process documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path C — False Positive
|
||||||
|
|
||||||
|
1. **Collect the required evidence:**
|
||||||
|
- Log into the device and **take a screenshot** showing the hostname, IP address, and software/firmware version.
|
||||||
|
- Obtain vendor documentation confirming the CVE does not affect this platform or version (security advisory, vendor email, etc.).
|
||||||
|
|
||||||
|
2. **Save supporting files to the database:**
|
||||||
|
- Go to the Home page and find (or create) the CVE entry for this vendor.
|
||||||
|
- Upload the screenshot as type `screenshot` and the vendor communication as type `advisory` or `email`.
|
||||||
|
- This ensures the evidence is accessible when the FP expires and needs to be renewed.
|
||||||
|
|
||||||
|
3. **Stage the finding in the queue:**
|
||||||
|
- Check the checkbox at the left of the finding row on the Reporting page.
|
||||||
|
- Enter the **Vendor / Platform**.
|
||||||
|
- Select **FP** as the workflow type.
|
||||||
|
- Click **Add to Queue**.
|
||||||
|
|
||||||
|
4. **Open the False Positive workflow in Ivanti:**
|
||||||
|
- Process queued FP items in a dedicated session.
|
||||||
|
- See the dedicated FP workflow documentation for the full Ivanti submission steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Path D — Risk Acceptance (Archer Ticket)
|
||||||
|
|
||||||
|
1. **Collect information** as you would for a False Positive (device screenshot, version info).
|
||||||
|
2. If vendor communication is required (patch timeline, EOL statement, recommended mitigations):
|
||||||
|
- Open a vendor support ticket requesting remediation steps, configuration guidance, or a patch commitment date.
|
||||||
|
- Use the vendor's response to fill out the Archer remediation plan.
|
||||||
|
3. **Stage the finding in the queue:**
|
||||||
|
- Check the checkbox on the finding row.
|
||||||
|
- Enter the **Vendor / Platform**.
|
||||||
|
- Select **Archer** as the workflow type.
|
||||||
|
- Click **Add to Queue**.
|
||||||
|
4. **Open the Archer Risk Acceptance ticket:**
|
||||||
|
- Process queued Archer items in a dedicated session.
|
||||||
|
- See the dedicated Archer process documentation for the full submission steps.
|
||||||
|
5. Once the EXC number is assigned, enter it in the finding's **Notes** cell on the Reporting page (format: `EXC-XXXXX`). The dashboard will recognise the pattern and include it in the Action Coverage chart under "Archer Exception".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Using the Ivanti Queue
|
||||||
|
|
||||||
|
The Ivanti Queue is a personal staging list built into the Reporting page. Rather than interrupting your review to context-switch into Ivanti, you tag findings as you go and then batch-process all the Ivanti work in one focused session.
|
||||||
|
|
||||||
|
### Adding Items to the Queue
|
||||||
|
|
||||||
|
1. On the Reporting page, check the **checkbox at the far left** of any finding row.
|
||||||
|
2. A popover appears anchored to the row.
|
||||||
|
3. For **FP** and **Archer** items: enter the **Vendor / Platform** (free text — e.g., "Juniper MX", "Cisco IOS-XE").
|
||||||
|
4. Select the **workflow type**:
|
||||||
|
- **FP** — False Positive request to be submitted in Ivanti
|
||||||
|
- **Archer** — Archer Risk Acceptance ticket to be opened
|
||||||
|
- **CARD** — Asset not owned by our BU; IP address is captured automatically
|
||||||
|
5. Click **Add to Queue**. The row checkbox turns solid blue to indicate it is queued.
|
||||||
|
|
||||||
|
### Opening the Queue Panel
|
||||||
|
|
||||||
|
Click the **Queue** button in the top-right of the Reporting page. A slide-out panel opens from the right showing all your queued items.
|
||||||
|
|
||||||
|
- **CARD** items appear at the top of the panel in their own green section, with the IP address displayed for easy CARD search.
|
||||||
|
- **FP and Archer** items are grouped alphabetically by vendor/platform below.
|
||||||
|
- Each item shows: Finding ID, CVEs (or IP for CARD), and the workflow type badge (amber = FP, sky = Archer, green = CARD).
|
||||||
|
|
||||||
|
### Working the Queue
|
||||||
|
|
||||||
|
**Marking items complete:**
|
||||||
|
Once you have submitted the FP or Archer ticket in Ivanti (or actioned the CARD item), check the item's green checkbox to mark it complete. Completed items are shown with a strikethrough at reduced opacity.
|
||||||
|
|
||||||
|
**Deleting items:**
|
||||||
|
- Click the trash icon on an individual item to remove it.
|
||||||
|
- To remove multiple items at once: check the small red selection checkbox on the left of each item you want to remove, then click **Delete (N)** in the footer.
|
||||||
|
|
||||||
|
**Clearing completed items:**
|
||||||
|
Click **Clear Completed** in the footer to remove all marked-complete items at once.
|
||||||
|
|
||||||
|
> Queue items are stored in the database and are **personal to your login** — they persist across sessions and page refreshes. Other team members see only their own queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Workflow Status Reference
|
||||||
|
|
||||||
|
The **Workflow** column on the Reporting page tracks FP# tickets — False Positive requests submitted in Ivanti. The badge shows the ticket ID and its current state, colour-coded by urgency.
|
||||||
|
|
||||||
|
> SYS# workflows are auto-generated system tracking records. They are not displayed and do not require team action.
|
||||||
|
|
||||||
|
### Status Colour Codes
|
||||||
|
|
||||||
|
#### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding has re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||||
|
| **Rejected** | The security team reviewed the FP and denied it. The finding is a confirmed, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||||
|
|
||||||
|
#### 🟡 Amber — Action Required Soon
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and returned for revision. | Open the ticket in Ivanti, review the feedback, update the justification, and **resubmit**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti and respond to what is required. |
|
||||||
|
|
||||||
|
#### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If the SLA window is approaching with no response, follow up with the approver. |
|
||||||
|
|
||||||
|
#### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | Meaning | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding using the workflow in Section 4. Determine whether to remediate, submit an FP, or open an Archer ticket. |
|
||||||
|
|
||||||
|
### Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Check the Workflow column
|
||||||
|
│
|
||||||
|
├── No badge (—)
|
||||||
|
│ └── Triage → follow Section 4 workflow
|
||||||
|
│
|
||||||
|
└── Has a badge → check the colour:
|
||||||
|
│
|
||||||
|
├── 🔵 BLUE (Requested)
|
||||||
|
│ └── Monitor. Follow up if SLA window is approaching.
|
||||||
|
│
|
||||||
|
├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ └── Open Ivanti ticket → review feedback → update → resubmit
|
||||||
|
│
|
||||||
|
└── 🔴 RED
|
||||||
|
│
|
||||||
|
├── Expired → Submit a new FP request in Ivanti
|
||||||
|
│
|
||||||
|
└── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Quick Reference Card
|
||||||
|
|
||||||
|
### Action Decision Matrix
|
||||||
|
|
||||||
|
| Research Outcome | Config Change? | Action Required |
|
||||||
|
|---|---|---|
|
||||||
|
| Can be patched (firmware/software) | N/A | Upgrade device — no ticket needed |
|
||||||
|
| Can be patched (configuration change only) | Yes | Archer Risk Acceptance ticket (EXC-XXXXX) |
|
||||||
|
| False Positive — not applicable to platform/version | N/A | FP workflow in Ivanti + evidence in CVE database |
|
||||||
|
| Cannot be patched — patch pending from vendor | N/A | Archer Risk Acceptance ticket (renew when patched) |
|
||||||
|
| Cannot be patched — EOL/EOS device | N/A | Archer ticket with mitigation steps + remediation plan |
|
||||||
|
| Asset not owned by our BU | N/A | CARD queue → CARD asset disposition process |
|
||||||
|
|
||||||
|
### Workflow Badge Quick Reference
|
||||||
|
|
||||||
|
| Badge | State | One-Line Action |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||||
|
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||||
|
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||||
|
| 🟡 Amber | Actionable | Review ticket in Ivanti and respond |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: follow Section 4 |
|
||||||
|
|
||||||
|
### Dashboard Shortcut Reference
|
||||||
|
|
||||||
|
| Task | How |
|
||||||
|
|---|---|
|
||||||
|
| See only untriaged findings | Click **Pending** segment on Action Coverage chart |
|
||||||
|
| See findings due this week | Click a date on the Home page calendar widget |
|
||||||
|
| See all findings for a specific Archer ticket | Click the EXC badge on the Home page CVE row |
|
||||||
|
| Correct a wrong hostname | Click the Host cell inline on the Reporting page |
|
||||||
|
| Save a screenshot or advisory to a CVE | Home page → CVE row → Upload document |
|
||||||
|
| Stage findings for a batch FP/Archer session | Use the Ivanti Queue (checkbox column on Reporting page) |
|
||||||
|
| Filter to a specific vendor or SLA status | Click the filter icon (⊙) on the relevant column header |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Related documentation: FP Workflow Submission (Ivanti) · Archer Risk Acceptance Process · CARD Asset Disposition Process · MOP: Workflow Status Colour Codes*
|
||||||
158
docs/team-training-agenda.md
Normal file
158
docs/team-training-agenda.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# STEAM Security Dashboard — Team Training Agenda
|
||||||
|
|
||||||
|
**Session length:** 30–40 minutes
|
||||||
|
**Format:** Live walkthrough (share your screen on the dashboard)
|
||||||
|
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-meeting prep
|
||||||
|
|
||||||
|
- Have the dashboard open and logged in before the meeting starts
|
||||||
|
- Sync Vulnerability Triage page so data is fresh when you get there
|
||||||
|
- Print or share `security-posture-workflow.md` as a take-home reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 1 — Why this tool exists (3 min)
|
||||||
|
|
||||||
|
**Talking points:**
|
||||||
|
- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for
|
||||||
|
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
|
||||||
|
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
|
||||||
|
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 2 — Dashboard orientation (4 min)
|
||||||
|
|
||||||
|
**Show on screen:** Navigate through each page in the nav drawer
|
||||||
|
|
||||||
|
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
|
||||||
|
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
|
||||||
|
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
|
||||||
|
- **Knowledge Base** — internal docs, runbooks, advisories
|
||||||
|
- **Exports** — bulk data extracts when needed
|
||||||
|
|
||||||
|
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 3 — The three things you can do with a finding (5 min)
|
||||||
|
|
||||||
|
**Talking points — before showing the table, set context:**
|
||||||
|
|
||||||
|
Every finding in our range gets one of three designations:
|
||||||
|
|
||||||
|
1. **Remediation** — you fix the root cause
|
||||||
|
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
|
||||||
|
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
|
||||||
|
|
||||||
|
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
|
||||||
|
- Requires an FP workflow opened in Ivanti
|
||||||
|
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
|
||||||
|
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
|
||||||
|
|
||||||
|
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
|
||||||
|
- Vendor hasn't released a patch yet
|
||||||
|
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
|
||||||
|
- Business constraint — needs justification and compensating controls
|
||||||
|
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
|
||||||
|
|
||||||
|
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
|
||||||
|
|
||||||
|
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
|
||||||
|
|
||||||
|
### Step 1 — Sync and sort (1 min)
|
||||||
|
- Click **Sync** top-right, wait for timestamp to update
|
||||||
|
- Click **Due Date** column to sort ascending — reds first, then ambers
|
||||||
|
- Red = overdue, Amber = due within 30 days — work these first
|
||||||
|
|
||||||
|
### Step 2 — Identify the host (3 min)
|
||||||
|
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
|
||||||
|
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
|
||||||
|
- An amber dot appears on overridden cells; original value is preserved and can be restored
|
||||||
|
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
|
||||||
|
|
||||||
|
### Step 3 — Check who owns the asset (2 min)
|
||||||
|
- Look at the **BU column**
|
||||||
|
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
|
||||||
|
- Anything else (or blank) → not ours → **CARD queue**
|
||||||
|
- Check the row checkbox, select CARD, click Add to Queue
|
||||||
|
- IP address is captured automatically for the CARD search
|
||||||
|
- Process CARD items in a separate session
|
||||||
|
|
||||||
|
### Step 4 — Look up the CVEs (4 min)
|
||||||
|
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
|
||||||
|
- Go to Home page, search for the CVE ID
|
||||||
|
- If it exists → review existing notes, docs, and any EXC numbers already linked
|
||||||
|
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
|
||||||
|
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
|
||||||
|
|
||||||
|
### Step 5 — Take action (5 min)
|
||||||
|
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
|
||||||
|
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
|
||||||
|
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
|
||||||
|
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 5 — The Ivanti Queue (5 min)
|
||||||
|
|
||||||
|
**Show on screen:** Click the Queue button, show the panel
|
||||||
|
|
||||||
|
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
|
||||||
|
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
|
||||||
|
- CARD items show the IP address so you can search directly in CARD
|
||||||
|
- Check the green checkbox on an item when the Ivanti/Archer action is done
|
||||||
|
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
|
||||||
|
- Queue is **personal to your login** — each person has their own; it persists across sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 6 — Workflow badge colours (3 min)
|
||||||
|
|
||||||
|
**Show on screen:** Workflow column on the Vulnerability Triage table
|
||||||
|
|
||||||
|
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
|
||||||
|
|
||||||
|
| Badge | What it means | What to do |
|
||||||
|
|---|---|---|
|
||||||
|
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
|
||||||
|
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
|
||||||
|
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
|
||||||
|
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
|
||||||
|
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
|
||||||
|
| No badge | Never been triaged | Run it through the 5-step workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 7 — Quick tips (2 min)
|
||||||
|
|
||||||
|
Quick features worth pointing out before Q&A:
|
||||||
|
|
||||||
|
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
|
||||||
|
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
|
||||||
|
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
|
||||||
|
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 8 — Q&A (remaining time)
|
||||||
|
|
||||||
|
Suggested prompts to open discussion if no questions come up:
|
||||||
|
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
|
||||||
|
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
|
||||||
|
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Takeaway for the team
|
||||||
|
|
||||||
|
Point them to:
|
||||||
|
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
|
||||||
|
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners
|
||||||
333
docs/time-based-reporting-recommendations.md
Normal file
333
docs/time-based-reporting-recommendations.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# Time-Based Reporting Recommendations
|
||||||
|
**Date:** 2026-04-02
|
||||||
|
**Author:** Engineering (Claude Code)
|
||||||
|
**Status:** Draft — for director review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document analyzes the current CVE Dashboard data model and recommends a set of time-based visualizations that can be added to the Reporting page. Recommendations are grouped by feasibility: **Tier 1** can be built with data already in the database, **Tier 2** requires a lightweight new tracking table, and **Tier 3** requires structural additions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Data Inventory
|
||||||
|
|
||||||
|
### What Already Has Time-Series History
|
||||||
|
|
||||||
|
| Source | Table | Date Fields | History? |
|
||||||
|
|--------|-------|-------------|----------|
|
||||||
|
| Compliance uploads | `compliance_uploads` | `report_date`, `uploaded_at` | **Yes** — one row per report cycle |
|
||||||
|
| Compliance items | `compliance_items` | `created_at`, `first_seen_upload_id`, `resolved_upload_id` | **Yes** — tracks lifecycle |
|
||||||
|
| Archer tickets | `archer_tickets` | `created_at`, `updated_at` | **Yes** — full history |
|
||||||
|
| Todo queue | `ivanti_todo_queue` | `created_at`, `updated_at` | **Yes** — by action |
|
||||||
|
| Finding notes | `ivanti_finding_notes` | `updated_at` | **Yes** — note activity |
|
||||||
|
|
||||||
|
### What Is Point-in-Time Only (no history yet)
|
||||||
|
|
||||||
|
| Source | Table | Problem |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| Ivanti findings | `ivanti_findings_cache` | Single-row cache — overwritten on every sync |
|
||||||
|
| Ivanti counts | `ivanti_counts_cache` | Single-row cache — no snapshots stored |
|
||||||
|
| FP workflow states | Computed from `findings_json` | Ephemeral — not persisted historically |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1 Recommendations — Build Now (No Schema Changes)
|
||||||
|
|
||||||
|
All of these use data that is already in the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 Compliance Trend Line — Total Active Findings Over Time
|
||||||
|
|
||||||
|
**Description:** A line chart showing the total number of active (non-compliant) items per compliance upload date. This directly answers "are we improving over time?"
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
COUNT(ci.id) AS active_count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
GROUP BY cu.id
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Line chart with data points per upload
|
||||||
|
**Axes:** X = Report Date, Y = Number of Active Findings
|
||||||
|
**Value-add:** Overlay a trend line (linear regression) to show trajectory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 New / Recurring / Resolved Bar Chart — Per Report Cycle
|
||||||
|
|
||||||
|
**Description:** A grouped or stacked bar chart showing the delta breakdown for each compliance upload: how many findings were newly introduced, how many recurred from a prior cycle, and how many were resolved.
|
||||||
|
|
||||||
|
**Data Source:** Already computed and stored in `compliance_uploads`:
|
||||||
|
```sql
|
||||||
|
SELECT report_date, new_count, recurring_count, resolved_count
|
||||||
|
FROM compliance_uploads
|
||||||
|
ORDER BY report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked bar chart (one bar per upload date)
|
||||||
|
**Legend:** New (red/amber), Recurring (yellow), Resolved (green)
|
||||||
|
**Value-add:** Shows whether each reporting cycle is improving (more resolved than new) or degrading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Team Compliance Health Over Time — Multi-Line Chart
|
||||||
|
|
||||||
|
**Description:** A multi-line chart showing the active finding count per team per upload date. Answers "which team is trending better or worse?"
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
ci.team,
|
||||||
|
COUNT(ci.id) AS active_count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
GROUP BY cu.id, ci.team
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Multi-line chart (one line per team)
|
||||||
|
**Teams:** STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV
|
||||||
|
**Value-add:** Immediately visible which team is outlier or improving fastest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Mean Time to Resolution (MTTR) — Per Team
|
||||||
|
|
||||||
|
**Description:** A bar chart showing average number of upload cycles between when a finding first appeared and when it was resolved, broken out by team.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
ci.team,
|
||||||
|
AVG(ci.resolved_upload_id - ci.first_seen_upload_id) AS avg_cycles_to_resolve,
|
||||||
|
COUNT(*) AS resolved_count
|
||||||
|
FROM compliance_items ci
|
||||||
|
WHERE ci.resolved_upload_id IS NOT NULL
|
||||||
|
GROUP BY ci.team;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Horizontal bar chart
|
||||||
|
**Axes:** Y = Team, X = Average Cycles to Resolution
|
||||||
|
**Value-add:** Normalize to calendar days by joining with upload dates for true MTTR in days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Recurring Findings Heatmap — Seen Count Distribution
|
||||||
|
|
||||||
|
**Description:** A heatmap or bubble chart showing findings grouped by how many times they have recurred (`seen_count`). Identifies chronic, long-standing compliance gaps.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
team,
|
||||||
|
metric_id,
|
||||||
|
metric_desc,
|
||||||
|
seen_count,
|
||||||
|
COUNT(*) AS host_count
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY team, metric_id
|
||||||
|
ORDER BY seen_count DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Horizontal bar chart sorted by `seen_count`, grouped by team
|
||||||
|
**Value-add:** Highlights the "chronic" findings that repeatedly appear — high priority for remediation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Archer Exception Ticket Status Over Time
|
||||||
|
|
||||||
|
**Description:** A line chart or cumulative area chart showing Archer ticket status transitions over time using `created_at` and `updated_at`.
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) AS date,
|
||||||
|
status,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM archer_tickets
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Statuses:** Draft, Open, Under Review, Accepted
|
||||||
|
**Value-add:** Tracks exception request pipeline velocity — are exceptions getting processed or stacking up?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 Compliance Category Breakdown Over Time
|
||||||
|
|
||||||
|
**Description:** A stacked area chart showing what categories of compliance failures are driving the total over time (if the `category` field in `compliance_items` is populated).
|
||||||
|
|
||||||
|
**Data Source:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cu.report_date,
|
||||||
|
ci.category,
|
||||||
|
COUNT(ci.id) AS count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
|
||||||
|
WHERE ci.category IS NOT NULL
|
||||||
|
GROUP BY cu.id, ci.category
|
||||||
|
ORDER BY cu.report_date ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Value-add:** Shows whether one category dominates or if failures are spread across areas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2 Recommendations — Lightweight Schema Addition Required
|
||||||
|
|
||||||
|
These require adding one new table to persist snapshots of data that is currently overwritten on each sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.1 Ivanti Findings Count Over Time — Open vs Closed Trend
|
||||||
|
|
||||||
|
**Description:** The single most-requested metric: "are we making progress on vulnerabilities?" A line chart showing open and closed Ivanti finding counts over time.
|
||||||
|
|
||||||
|
**Problem:** The current `ivanti_counts_cache` is a single-row table overwritten on each sync. No history is kept.
|
||||||
|
|
||||||
|
**Solution:** Add a `ivanti_counts_history` table and append a row on every successful sync:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend change:** In the sync route (`POST /api/ivanti/findings/sync`), after updating the cache, also `INSERT INTO ivanti_counts_history`.
|
||||||
|
|
||||||
|
**New API endpoint:** `GET /api/ivanti/findings/counts/history`
|
||||||
|
```sql
|
||||||
|
SELECT open_count, closed_count, recorded_at
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
ORDER BY recorded_at ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Dual-line chart
|
||||||
|
**Lines:** Open findings (red), Closed findings (green)
|
||||||
|
**Value-add:** Most direct measure of vulnerability remediation velocity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 FP Workflow State Snapshots Over Time
|
||||||
|
|
||||||
|
**Description:** A stacked area or line chart showing how FP workflow states (Actionable, Requested, Approved, Rejected, Expired) trend over sync cycles.
|
||||||
|
|
||||||
|
**Solution:** Add a `ivanti_fp_workflow_history` table:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ivanti_fp_workflow_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
finding_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
id_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Stacked area chart
|
||||||
|
**Value-add:** Shows whether FP requests are being worked through or stacking up in "Requested" state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Todo Queue Velocity — Items Added vs Completed Per Week
|
||||||
|
|
||||||
|
**Description:** A bar chart showing weekly queue throughput (items added vs items marked complete).
|
||||||
|
|
||||||
|
**Data Source:** Already available in `ivanti_todo_queue.created_at` and `updated_at` + `status = 'complete'`:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
STRFTIME('%Y-W%W', created_at) AS week,
|
||||||
|
COUNT(*) AS items_added,
|
||||||
|
SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS items_completed
|
||||||
|
FROM ivanti_todo_queue
|
||||||
|
GROUP BY week
|
||||||
|
ORDER BY week ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart Type:** Grouped bar chart (weekly)
|
||||||
|
**Value-add:** Measures operational pace of the team's workflow action throughput
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3 Recommendations — Structural Additions (Future Consideration)
|
||||||
|
|
||||||
|
These require more significant changes but would provide powerful long-term reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.1 Finding Age / Dwell Time Distribution
|
||||||
|
|
||||||
|
**Description:** A histogram showing how long open findings have been open (age in days). The `lastFoundOn` field exists in the Ivanti findings JSON but is not persisted to a structured table.
|
||||||
|
|
||||||
|
**Requirement:** Parse and store `lastFoundOn` from findings JSON into a structured column during sync.
|
||||||
|
|
||||||
|
**Value-add:** Highlights findings that have been open for 90+ days — high-priority remediation targets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SLA Breach Trends
|
||||||
|
|
||||||
|
**Description:** Track how many findings breach SLA (Due Date exceeded) over time. Currently SLA status is computed in the frontend on-the-fly.
|
||||||
|
|
||||||
|
**Requirement:** Add SLA breach tracking during sync — stamp findings that cross SLA date.
|
||||||
|
|
||||||
|
**Value-add:** Compliance and audit reporting for SLA adherence metrics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation Order
|
||||||
|
|
||||||
|
| Priority | Chart | Effort | Impact |
|
||||||
|
|----------|-------|--------|--------|
|
||||||
|
| 1 | 1.2 — New/Recurring/Resolved bar chart | Low (data ready) | High |
|
||||||
|
| 2 | 1.1 — Compliance trend line | Low (data ready) | High |
|
||||||
|
| 3 | 1.3 — Team health multi-line | Low (data ready) | High |
|
||||||
|
| 4 | 2.1 — Ivanti open/closed history | Medium (new table) | Very High |
|
||||||
|
| 5 | 1.4 — MTTR per team | Low (data ready) | Medium |
|
||||||
|
| 6 | 1.6 — Archer ticket pipeline | Low (data ready) | Medium |
|
||||||
|
| 7 | 2.3 — Queue velocity | Low (data ready) | Medium |
|
||||||
|
| 8 | 1.5 — Recurring findings heatmap | Low (data ready) | Medium |
|
||||||
|
| 9 | 2.2 — FP workflow snapshots | Medium (new table) | Medium |
|
||||||
|
| 10 | 1.7 — Category breakdown | Low (data ready) | Low–Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Charting Library Consideration
|
||||||
|
|
||||||
|
The current implementation uses **hand-rolled SVG donut charts** (no external library). For time-series line/bar/area charts, the team should decide:
|
||||||
|
|
||||||
|
| Option | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Continue hand-rolled SVG** | Zero dependencies, full style control | Significant effort for axes, labels, tooltips |
|
||||||
|
| **Recharts** (React-native) | Well-matched to React 19, composable, responsive | ~500KB dependency |
|
||||||
|
| **Chart.js via react-chartjs-2** | Mature, widely documented | Less React-idiomatic |
|
||||||
|
| **Lightweight: uPlot or Chart.xkcd** | Very small bundle | Less community support |
|
||||||
|
|
||||||
|
**Recommendation:** Recharts aligns best with the React 19 stack and allows declaring charts as JSX components consistent with the existing code style. It supports all chart types listed above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Director Review
|
||||||
|
|
||||||
|
- All **Tier 1** recommendations can be implemented with zero database migrations — the data is already there.
|
||||||
|
- The **single highest-value addition** is `2.1 — Ivanti open/closed count history`, as it captures the most direct remediation progress metric. It only requires one new table and one line added to the sync handler.
|
||||||
|
- **Compliance charts (1.1–1.5)** will only be meaningful once multiple compliance uploads have been committed. If only 1–2 uploads exist currently, the trend will not show much until more data accumulates — but building the charts now means data will automatically populate them.
|
||||||
|
- All queries listed above have been validated against the actual database schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Next step: Review with director, confirm priority order, then schedule sprint for implementation.*
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Getting Started with Create React App
|
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
|
||||||
|
|
||||||
The page will reload when you make changes.\
|
|
||||||
You may also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `npm run eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
|
||||||
|
|
||||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
|
||||||
|
|
||||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
|
|
||||||
### Code Splitting
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
|
||||||
|
|
||||||
### Analyzing the Bundle Size
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
|
||||||
|
|
||||||
### Making a Progressive Web App
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
|
||||||
|
|
||||||
### Advanced Configuration
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
|
||||||
|
|
||||||
### `npm run build` fails to minify
|
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
||||||
@@ -8,10 +8,15 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"recharts": "^3.8.1",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -647,3 +647,179 @@ h3.text-intel-accent {
|
|||||||
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
inset 0 2px 4px rgba(0, 0, 0, 0.25),
|
||||||
0 2px 8px rgba(14, 165, 233, 0.1);
|
0 2px 8px rgba(14, 165, 233, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Knowledge Base Content Area */
|
||||||
|
.kb-content-area {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styling */
|
||||||
|
.markdown-content {
|
||||||
|
color: #E2E8F0;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0EA5E9;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||||
|
font-family: monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #10B981;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F59E0B;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: #0EA5E9;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
color: #38BDF8;
|
||||||
|
border-bottom-color: #38BDF8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #10B981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid #0EA5E9;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th,
|
||||||
|
.markdown-content td {
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content th {
|
||||||
|
background: rgba(14, 165, 233, 0.1);
|
||||||
|
color: #0EA5E9;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content td {
|
||||||
|
color: #CBD5E1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content tr:hover {
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(14, 165, 233, 0.2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content strong {
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content em {
|
||||||
|
color: #CBD5E1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import AuditLog from './components/AuditLog';
|
import AuditLog from './components/AuditLog';
|
||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
|
import NavDrawer from './components/NavDrawer';
|
||||||
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
|
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
@@ -155,7 +162,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
|||||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth();
|
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, user } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||||
@@ -169,6 +176,10 @@ export default function App() {
|
|||||||
const [cveDocuments, setCveDocuments] = useState({});
|
const [cveDocuments, setCveDocuments] = useState({});
|
||||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
|
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
@@ -193,6 +204,7 @@ export default function App() {
|
|||||||
const [editNvdError, setEditNvdError] = useState(null);
|
const [editNvdError, setEditNvdError] = useState(null);
|
||||||
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
||||||
const [expandedCVEs, setExpandedCVEs] = useState({});
|
const [expandedCVEs, setExpandedCVEs] = useState({});
|
||||||
|
const [visibleCount, setVisibleCount] = useState(5);
|
||||||
const [jiraTickets, setJiraTickets] = useState([]);
|
const [jiraTickets, setJiraTickets] = useState([]);
|
||||||
const [showAddTicket, setShowAddTicket] = useState(false);
|
const [showAddTicket, setShowAddTicket] = useState(false);
|
||||||
const [showEditTicket, setShowEditTicket] = useState(false);
|
const [showEditTicket, setShowEditTicket] = useState(false);
|
||||||
@@ -203,6 +215,31 @@ export default function App() {
|
|||||||
// For adding ticket from within a CVE card
|
// For adding ticket from within a CVE card
|
||||||
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
|
// Archer tickets state
|
||||||
|
const [archerTickets, setArcherTickets] = useState([]);
|
||||||
|
const [showAddArcherTicket, setShowAddArcherTicket] = useState(false);
|
||||||
|
const [showEditArcherTicket, setShowEditArcherTicket] = useState(false);
|
||||||
|
const [editingArcherTicket, setEditingArcherTicket] = useState(null);
|
||||||
|
const [archerTicketForm, setArcherTicketForm] = useState({
|
||||||
|
exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: ''
|
||||||
|
});
|
||||||
|
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
|
// Ivanti workflows state
|
||||||
|
const [ivantiTotal, setIvantiTotal] = useState(null);
|
||||||
|
const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
|
||||||
|
const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
|
||||||
|
const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
|
||||||
|
const [ivantiSyncError, setIvantiSyncError] = useState(null);
|
||||||
|
const [ivantiLoading, setIvantiLoading] = useState(false);
|
||||||
|
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||||
|
|
||||||
|
// Archive filter state
|
||||||
|
const [archiveFilter, setArchiveFilter] = useState(null);
|
||||||
|
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
|
||||||
|
const [archiveList, setArchiveList] = useState([]);
|
||||||
|
const [archiveListLoading, setArchiveListLoading] = useState(false);
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
};
|
};
|
||||||
@@ -289,6 +326,72 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchArcherTickets = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch Archer tickets');
|
||||||
|
const data = await response.json();
|
||||||
|
setArcherTickets(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching Archer tickets:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyIvantiState = (data) => {
|
||||||
|
setIvantiTotal(data.total ?? 0);
|
||||||
|
setIvantiWorkflows(data.workflows || []);
|
||||||
|
setIvantiSyncedAt(data.synced_at || null);
|
||||||
|
setIvantiSyncStatus(data.sync_status || null);
|
||||||
|
setIvantiSyncError(data.error_message || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchIvantiWorkflows = async () => {
|
||||||
|
setIvantiLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncIvantiWorkflows = async () => {
|
||||||
|
setIvantiSyncing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error syncing Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiSyncing(false);
|
||||||
|
setArchiveRefreshKey(k => k + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveStateClick = (state) => {
|
||||||
|
const newFilter = archiveFilter === state ? null : state;
|
||||||
|
setArchiveFilter(newFilter);
|
||||||
|
if (newFilter) {
|
||||||
|
setArchiveListLoading(true);
|
||||||
|
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
|
.then(data => setArchiveList(data.archives || []))
|
||||||
|
.catch(() => setArchiveList([]))
|
||||||
|
.finally(() => setArchiveListLoading(false));
|
||||||
|
} else {
|
||||||
|
setArchiveList([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -686,12 +789,99 @@ export default function App() {
|
|||||||
setShowAddTicket(true);
|
setShowAddTicket(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== ARCHER TICKET HANDLERS ==========
|
||||||
|
|
||||||
|
const handleAddArcherTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(archerTicketForm)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to create Archer ticket');
|
||||||
|
}
|
||||||
|
alert('Archer ticket added successfully!');
|
||||||
|
setShowAddArcherTicket(false);
|
||||||
|
setAddArcherTicketContext(null);
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditArcherTicket = (ticket) => {
|
||||||
|
setEditingArcherTicket(ticket);
|
||||||
|
setArcherTicketForm({
|
||||||
|
exc_number: ticket.exc_number,
|
||||||
|
archer_url: ticket.archer_url || '',
|
||||||
|
status: ticket.status,
|
||||||
|
cve_id: ticket.cve_id,
|
||||||
|
vendor: ticket.vendor
|
||||||
|
});
|
||||||
|
setShowEditArcherTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateArcherTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
exc_number: archerTicketForm.exc_number,
|
||||||
|
archer_url: archerTicketForm.archer_url,
|
||||||
|
status: archerTicketForm.status
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to update Archer ticket');
|
||||||
|
}
|
||||||
|
alert('Archer ticket updated!');
|
||||||
|
setShowEditArcherTicket(false);
|
||||||
|
setEditingArcherTicket(null);
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArcherTicket = async (ticket) => {
|
||||||
|
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete Archer ticket');
|
||||||
|
alert('Archer ticket deleted');
|
||||||
|
fetchArcherTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||||
|
setAddArcherTicketContext({ cve_id, vendor });
|
||||||
|
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
|
||||||
|
setShowAddArcherTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch CVEs from API when authenticated
|
// Fetch CVEs from API when authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
fetchVendors();
|
fetchVendors();
|
||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
|
fetchArcherTickets();
|
||||||
|
fetchIvantiWorkflows();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
@@ -700,6 +890,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
|
setVisibleCount(5);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchQuery, selectedVendor, selectedSeverity]);
|
}, [searchQuery, selectedVendor, selectedSeverity]);
|
||||||
@@ -734,18 +925,39 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||||
|
<NavDrawer
|
||||||
|
isOpen={navOpen}
|
||||||
|
onClose={() => setNavOpen(false)}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
|
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
|
setCurrentPage(page);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto relative z-10">
|
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setNavOpen(true)}
|
||||||
|
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||||
|
title="Navigation"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||||
CVE INTEL
|
STEAM Security Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p>
|
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
@@ -770,8 +982,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Bar - Modern refined styling */}
|
{/* Stats Bar - only shown on Home page */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div style={STYLES.statCard}>
|
<div style={STYLES.statCard}>
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
||||||
@@ -792,8 +1004,19 @@ export default function App() {
|
|||||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
{currentPage === 'admin' && isAdmin() && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<UserManagement onClose={() => setCurrentPage('home')} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Management Modal */}
|
{/* User Management Modal */}
|
||||||
{showUserManagement && (
|
{showUserManagement && (
|
||||||
@@ -1255,52 +1478,156 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout */}
|
{/* Add Archer Ticket Modal */}
|
||||||
<div className="grid grid-cols-12 gap-6">
|
{showAddArcherTicket && (
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
<div className="p-6">
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
|
<div className="flex justify-between items-center mb-4">
|
||||||
Knowledge Base
|
<h2 className="text-xl font-bold text-purple-400 font-mono">Add Archer Risk Ticket</h2>
|
||||||
</h2>
|
<button onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddArcherTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="EXC-5754"
|
||||||
|
value={archerTicketForm.exc_number}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://archer.example.com/..."
|
||||||
|
value={archerTicketForm.archer_url}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={archerTicketForm.cve_id}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, cve_id: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!addArcherTicketContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Vendor name"
|
||||||
|
value={archerTicketForm.vendor}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, vendor: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!addArcherTicketContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={archerTicketForm.status}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Under Review">Under Review</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
Create Ticket
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Wiki/Blog Style Entries */}
|
{/* Edit Archer Ticket Modal */}
|
||||||
<div className="space-y-3">
|
{showEditArcherTicket && editingArcherTicket && (
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">CVE Response Procedures</h3>
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||||
<p className="text-gray-400 text-xs mb-2">Standard operating procedures for vulnerability response and escalation...</p>
|
<div className="p-6">
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-08</span>
|
<div className="flex justify-between items-center mb-4">
|
||||||
</div>
|
<h2 className="text-xl font-bold text-purple-400 font-mono">Edit Archer Risk Ticket</h2>
|
||||||
|
<button onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
<XCircle className="w-6 h-6" />
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Vendor Contact Matrix</h3>
|
</button>
|
||||||
<p className="text-gray-400 text-xs mb-2">Emergency contacts and escalation paths for security vendors...</p>
|
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-02-05</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Severity Classification Guide</h3>
|
|
||||||
<p className="text-gray-400 text-xs mb-2">Guidelines for assessing and classifying vulnerability severity levels...</p>
|
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-28</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Patching Policy</h3>
|
|
||||||
<p className="text-gray-400 text-xs mb-2">Enterprise patch management timelines and approval workflow...</p>
|
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-15</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }} className="hover:border-intel-success">
|
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">Documentation Standards</h3>
|
|
||||||
<p className="text-gray-400 text-xs mb-2">Required documentation for vulnerability tracking and audit compliance...</p>
|
|
||||||
<span className="text-xs text-intel-success font-mono">Last updated: 2024-01-10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
|
||||||
|
{editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdateArcherTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={archerTicketForm.exc_number}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={archerTicketForm.archer_url}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={archerTicketForm.status}
|
||||||
|
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Under Review">Under Review</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Two Column Layout - Home page only */}
|
||||||
|
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
||||||
{/* CENTER PANEL - Main Content */}
|
{/* CENTER PANEL - Main Content */}
|
||||||
<div className="col-span-12 lg:col-span-6 space-y-4">
|
<div className="col-span-12 lg:col-span-9 space-y-4">
|
||||||
|
<>
|
||||||
{/* Quick Check */}
|
{/* Quick Check */}
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
@@ -1424,7 +1751,7 @@ export default function App() {
|
|||||||
<span className="text-gray-500 mx-2">•</span>
|
<span className="text-gray-500 mx-2">•</span>
|
||||||
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
|
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
|
||||||
</p>
|
</p>
|
||||||
{selectedDocuments.length > 0 && (
|
{selectedDocuments.length > 0 && canExport() && (
|
||||||
<button
|
<button
|
||||||
onClick={exportSelectedDocuments}
|
onClick={exportSelectedDocuments}
|
||||||
className="intel-button intel-button-primary flex items-center gap-2"
|
className="intel-button intel-button-primary flex items-center gap-2"
|
||||||
@@ -1455,7 +1782,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
|
{Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
|
||||||
const isCVEExpanded = expandedCVEs[cveId];
|
const isCVEExpanded = expandedCVEs[cveId];
|
||||||
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
||||||
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
||||||
@@ -1511,7 +1838,7 @@ export default function App() {
|
|||||||
<span>Published: {vendorEntries[0].published_date}</span>
|
<span>Published: {vendorEntries[0].published_date}</span>
|
||||||
<span className="text-intel-accent">•</span>
|
<span className="text-intel-accent">•</span>
|
||||||
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||||
{canWrite() && vendorEntries.length >= 2 && (
|
{isAdmin() && vendorEntries.length >= 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
|
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
|
||||||
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
|
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
|
||||||
@@ -1572,7 +1899,7 @@ export default function App() {
|
|||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWrite() && (
|
{canDelete(cve) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteCVEEntry(cve)}
|
onClick={() => handleDeleteCVEEntry(cve)}
|
||||||
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
|
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
|
||||||
@@ -1704,9 +2031,11 @@ export default function App() {
|
|||||||
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
|
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{canDelete(ticket) && (
|
||||||
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
|
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1727,6 +2056,40 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Show more / pagination footer */}
|
||||||
|
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-gray-500 font-mono text-xs">
|
||||||
|
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(v => v + 5)}
|
||||||
|
className="intel-button intel-button-primary text-xs px-3 py-1"
|
||||||
|
>
|
||||||
|
Show 5 more
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(5)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1737,6 +2100,7 @@ export default function App() {
|
|||||||
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
|
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
{/* End Center Panel */}
|
{/* End Center Panel */}
|
||||||
|
|
||||||
@@ -1748,63 +2112,12 @@ export default function App() {
|
|||||||
Calendar
|
Calendar
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Simple Calendar Grid */}
|
<CalendarWidget
|
||||||
<div className="mb-2">
|
onDateClick={(dateStr) => {
|
||||||
<div className="text-center mb-3">
|
setCalendarFilter(dateStr);
|
||||||
<span className="text-white font-semibold font-mono">February 2024</span>
|
setCurrentPage('triage');
|
||||||
</div>
|
}}
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
|
/>
|
||||||
<div className="text-gray-400 font-mono">Su</div>
|
|
||||||
<div className="text-gray-400 font-mono">Mo</div>
|
|
||||||
<div className="text-gray-400 font-mono">Tu</div>
|
|
||||||
<div className="text-gray-400 font-mono">We</div>
|
|
||||||
<div className="text-gray-400 font-mono">Th</div>
|
|
||||||
<div className="text-gray-400 font-mono">Fr</div>
|
|
||||||
<div className="text-gray-400 font-mono">Sa</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center">
|
|
||||||
{/* Week 1 */}
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">28</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">30</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">31</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
|
|
||||||
{/* Week 2 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
|
|
||||||
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
|
|
||||||
{/* Week 3 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
|
|
||||||
{/* Week 4 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
|
|
||||||
{/* Week 5 */}
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
|
|
||||||
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">1</div>
|
|
||||||
<div className="text-gray-600 font-mono text-xs p-1">2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Vendor Tickets */}
|
{/* Open Vendor Tickets */}
|
||||||
@@ -1846,9 +2159,11 @@ export default function App() {
|
|||||||
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
|
<button onClick={() => handleEditTicket(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors">
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
{canDelete(ticket) && (
|
||||||
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1871,10 +2186,219 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archer Risk Acceptance Tickets */}
|
||||||
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
Archer Risk Tickets
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
|
||||||
|
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<a
|
||||||
|
href={ticket.archer_url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
{ticket.exc_number}
|
||||||
|
</a>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
|
||||||
|
title="View findings referencing this ticket"
|
||||||
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete(ticket) && (
|
||||||
|
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
|
||||||
|
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ivanti Workflows */}
|
||||||
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Ivanti Workflows
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={syncIvantiWorkflows}
|
||||||
|
disabled={ivantiSyncing || ivantiLoading}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
{ivantiSyncing ? 'Syncing…' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last synced line */}
|
||||||
|
<div className="text-xs text-gray-500 font-mono mb-4">
|
||||||
|
{ivantiSyncedAt
|
||||||
|
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||||
|
: 'Never synced'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive Summary Bar */}
|
||||||
|
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
|
||||||
|
|
||||||
|
{/* Archive list — shown when a state card is clicked */}
|
||||||
|
{archiveFilter && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{archiveFilter} findings
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
✕ Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{archiveListLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading…</div>
|
||||||
|
) : archiveList.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
|
||||||
|
No {archiveFilter.toLowerCase()} findings
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{archiveList.map((a) => (
|
||||||
|
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0' }}>{a.finding_title || a.finding_id}</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||||
|
{a.last_severity?.toFixed(1) ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||||
|
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ivantiLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400 font-mono">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : ivantiSyncStatus === 'error' ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiTotal ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
|
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-red-400 font-mono">{ivantiSyncError}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
|
||||||
|
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<span className="font-mono text-xs font-semibold text-teal-300">
|
||||||
|
{wf.id?.value || wf.uuid?.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{wf.currentState && (
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||||
|
{wf.currentState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{wf.type && (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>
|
||||||
|
)}
|
||||||
|
{wf.createdOn && (
|
||||||
|
<span className="text-xs text-gray-500">{wf.createdOn}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ivantiSyncStatus === 'never' && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* End Right Panel */}
|
{/* End Right Panel */}
|
||||||
|
|
||||||
</div>
|
</div>}
|
||||||
{/* End Three Column Layout */}
|
{/* End Three Column Layout */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
167
frontend/src/components/CalendarWidget.js
Normal file
167
frontend/src/components/CalendarWidget.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
function toLocalDateStr(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ onDateClick }) {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = toLocalDateStr(today);
|
||||||
|
|
||||||
|
const [calYear, setCalYear] = useState(today.getFullYear());
|
||||||
|
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
||||||
|
|
||||||
|
// Map of "YYYY-MM-DD" → count of findings due that day
|
||||||
|
const [dueDates, setDueDates] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (!data?.findings) return;
|
||||||
|
const counts = {};
|
||||||
|
data.findings.forEach((f) => {
|
||||||
|
if (f.dueDate) {
|
||||||
|
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDueDates(counts);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
||||||
|
else { setCalMonth((m) => m - 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
||||||
|
else { setCalMonth((m) => m + 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build cell array: null = padding, number = day of month
|
||||||
|
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
||||||
|
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||||
|
const cells = [
|
||||||
|
...Array(firstDow).fill(null),
|
||||||
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||||
|
];
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
||||||
|
|
||||||
|
const hasDueDatesThisMonth = cells.some((day) => {
|
||||||
|
if (!day) return false;
|
||||||
|
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return !!dueDates[ds];
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={prevMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||||
|
{MONTH_NAMES[calMonth]} {calYear}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMonth}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||||
|
>
|
||||||
|
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||||
|
{DAY_NAMES.map((d) => (
|
||||||
|
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
||||||
|
{cells.map((day, idx) => {
|
||||||
|
if (!day) return <div key={idx} />;
|
||||||
|
|
||||||
|
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const isToday = dateStr === todayStr;
|
||||||
|
const dueCount = dueDates[dateStr] || 0;
|
||||||
|
const hasDue = dueCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||||
|
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
gap: '2px', padding: '3px 1px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||||
|
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||||
|
cursor: hasDue ? 'pointer' : 'default',
|
||||||
|
transition: hasDue ? 'background 0.15s' : undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||||
|
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||||
|
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||||
|
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{/* Red dot indicator for due dates */}
|
||||||
|
{hasDue ? (
|
||||||
|
<div style={{
|
||||||
|
width: '4px', height: '4px', borderRadius: '50%',
|
||||||
|
background: '#EF4444',
|
||||||
|
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend — only shown when there are due dates this month */}
|
||||||
|
{hasDueDatesThisMonth && (
|
||||||
|
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Ivanti finding due
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
frontend/src/components/CveTooltip.js
Normal file
243
frontend/src/components/CveTooltip.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Severity color mapping — matches DESIGN_SYSTEM.md badge colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SEVERITY_COLORS = {
|
||||||
|
Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' },
|
||||||
|
High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' },
|
||||||
|
Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' },
|
||||||
|
Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure positioning function — exported for testability
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const TOOLTIP_GAP = 8;
|
||||||
|
const ARROW_SIZE = 6;
|
||||||
|
|
||||||
|
export function calcTooltipPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||||
|
const spaceAbove = anchorRect.top;
|
||||||
|
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||||
|
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||||
|
|
||||||
|
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
if (placeAbove) {
|
||||||
|
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||||
|
if (top < 0) top = 0;
|
||||||
|
} else {
|
||||||
|
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||||
|
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = anchorRect.left + anchorRect.width / 2;
|
||||||
|
|
||||||
|
return { top, left, placeAbove };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CveTooltip component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function CveTooltip({ cveId, anchorRect, cache }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cveId) {
|
||||||
|
setData(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (cache.current.has(cveId)) {
|
||||||
|
setData(cache.current.get(cveId));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — fetch from API
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setData(null);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
cache.current.set(cveId, payload);
|
||||||
|
setData(payload);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
// Do not cache transient errors
|
||||||
|
console.error('CveTooltip fetch error:', err);
|
||||||
|
setData(null);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [cveId, cache]);
|
||||||
|
|
||||||
|
// Nothing to show
|
||||||
|
if (!cveId || !anchorRect) return null;
|
||||||
|
if (!loading && !data) return null;
|
||||||
|
if (data && data.exists === false) return null;
|
||||||
|
|
||||||
|
const severity = data?.severity || '';
|
||||||
|
const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<TooltipBody
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
colors={colors}
|
||||||
|
severity={severity}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TooltipBody — inner component that measures itself for positioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function TooltipBody({ data, loading, anchorRect, colors, severity }) {
|
||||||
|
const tooltipRef = React.useRef(null);
|
||||||
|
const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true });
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!tooltipRef.current || !anchorRect) return;
|
||||||
|
const rect = tooltipRef.current.getBoundingClientRect();
|
||||||
|
const vp = window.innerHeight;
|
||||||
|
setPos(calcTooltipPosition(anchorRect, rect.height, vp));
|
||||||
|
}, [anchorRect, data, loading]);
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 99999,
|
||||||
|
top: pos.top,
|
||||||
|
left: pos.left,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
maxWidth: 320,
|
||||||
|
minWidth: 200,
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||||
|
border: `1.5px solid ${colors.border}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Directional arrow
|
||||||
|
const arrowStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||||
|
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||||
|
...(pos.placeAbove
|
||||||
|
? {
|
||||||
|
bottom: -ARROW_SIZE,
|
||||||
|
borderTop: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||||
|
borderBottom: 'none',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
top: -ARROW_SIZE,
|
||||||
|
borderBottom: `${ARROW_SIZE}px solid ${colors.border}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={tooltipRef} style={tooltipStyle} data-testid="cve-tooltip">
|
||||||
|
{/* Arrow */}
|
||||||
|
<div style={arrowStyle} data-testid="cve-tooltip-arrow" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||||
|
<Loader
|
||||||
|
style={{ width: 18, height: 18, color: '#0EA5E9', animation: 'spin 1s linear infinite' }}
|
||||||
|
data-testid="cve-tooltip-loader"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data && data.exists ? (
|
||||||
|
<>
|
||||||
|
{/* CVE ID header */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#E2E8F0',
|
||||||
|
marginBottom: '0.4rem',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
}}>
|
||||||
|
{data.cve_id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Severity badge */}
|
||||||
|
{severity && (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: `1.5px solid ${colors.border}`,
|
||||||
|
background: colors.bg,
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
{/* Glow dot */}
|
||||||
|
<span style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: colors.dot,
|
||||||
|
boxShadow: `0 0 6px ${colors.dot}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: colors.text,
|
||||||
|
}}>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{data.description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: '#CBD5E1',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{data.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
384
frontend/src/components/KnowledgeBaseModal.js
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, FileText, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, uploading, success, error
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('General');
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [existingArticles, setExistingArticles] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Fetch existing articles on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExistingArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchExistingArticles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||||
|
const data = await response.json();
|
||||||
|
setExistingArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching articles:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
const allowedExtensions = ['.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.json', '.yaml', '.yml'];
|
||||||
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(ext)) {
|
||||||
|
setError('File type not allowed. Please upload: PDF, Markdown, Text, Office docs, or HTML files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Auto-populate title from filename if empty
|
||||||
|
if (!title) {
|
||||||
|
const filename = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||||
|
setTitle(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!selectedFile || !title.trim()) {
|
||||||
|
setError('Please provide both a title and file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('title', title.trim());
|
||||||
|
formData.append('description', description.trim());
|
||||||
|
formData.append('category', category);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResult(data);
|
||||||
|
setPhase('success');
|
||||||
|
|
||||||
|
// Refresh the list of existing articles
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (id, filename) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id, articleTitle) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete "${articleTitle}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Delete failed');
|
||||||
|
|
||||||
|
// Refresh the list
|
||||||
|
await fetchExistingArticles();
|
||||||
|
|
||||||
|
// Notify parent to refresh
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
setError('Failed to delete article');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setPhase('idle');
|
||||||
|
setSelectedFile(null);
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setCategory('General');
|
||||||
|
setResult(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (!bytes) return 'Unknown size';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">Knowledge Base</h2>
|
||||||
|
<button onClick={onClose} className="modal-close">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Idle Phase - Upload Form */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Inventory Management Policy"
|
||||||
|
className="intel-input w-full"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this document..."
|
||||||
|
className="intel-input w-full"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="General">General</option>
|
||||||
|
<option value="Policy">Policy</option>
|
||||||
|
<option value="Procedure">Procedure</option>
|
||||||
|
<option value="Guide">Guide</option>
|
||||||
|
<option value="Reference">Reference</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
Document File *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||||
|
Selected: {selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!selectedFile || !title.trim()}
|
||||||
|
className={`intel-button w-full ${selectedFile && title.trim() ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<UploadIcon className="w-4 h-4 mr-2" />
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<p style={{ color: '#FCA5A5' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploading Phase */}
|
||||||
|
{phase === 'uploading' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Uploading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Phase */}
|
||||||
|
{phase === 'success' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
|
||||||
|
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
|
||||||
|
{result.title} has been added to the knowledge base.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Upload Another Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Phase */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={resetForm} className="intel-button w-full">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing Articles Section */}
|
||||||
|
{(phase === 'idle' || phase === 'success') && existingArticles.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Existing Documents ({existingArticles.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{existingArticles.map((article) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="intel-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<FileText className="w-4 h-4 flex-shrink-0" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<p className="font-medium truncate" style={{ color: '#E2E8F0' }}>
|
||||||
|
{article.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2 line-clamp-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>{formatDate(article.created_at)}</span>
|
||||||
|
<span>{formatFileSize(article.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(article.id, article.file_name)}
|
||||||
|
className="intel-button intel-button-small intel-button-success"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(article.id, article.title)}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
style={{ borderColor: '#EF4444', color: '#EF4444' }}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
frontend/src/components/KnowledgeBaseViewer.js
Normal file
335
frontend/src/components/KnowledgeBaseViewer.js
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import mermaid from 'mermaid';
|
||||||
|
import { X, Download, Loader, AlertCircle, FileText, File } from 'lucide-react';
|
||||||
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: 'dark',
|
||||||
|
darkMode: true,
|
||||||
|
themeVariables: {
|
||||||
|
background: '#0f172a',
|
||||||
|
primaryColor: '#1e3a5f',
|
||||||
|
primaryTextColor: '#e2e8f0',
|
||||||
|
primaryBorderColor: '#0ea5e9',
|
||||||
|
lineColor: '#475569',
|
||||||
|
secondaryColor: '#1a2e1a',
|
||||||
|
tertiaryColor: '#2d1f14',
|
||||||
|
edgeLabelBackground: '#1e293b',
|
||||||
|
clusterBkg: '#1e293b',
|
||||||
|
titleColor: '#e2e8f0',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mermaidCounter = 0;
|
||||||
|
|
||||||
|
function MermaidDiagram({ code }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [svgError, setSvgError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const id = `mermaid-kb-${++mermaidCounter}`;
|
||||||
|
mermaid.render(id, code)
|
||||||
|
.then(({ svg }) => {
|
||||||
|
if (!cancelled && ref.current) {
|
||||||
|
ref.current.innerHTML = svg;
|
||||||
|
// Make SVG responsive
|
||||||
|
const svgEl = ref.current.querySelector('svg');
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.removeAttribute('width');
|
||||||
|
svgEl.removeAttribute('height');
|
||||||
|
svgEl.style.width = '100%';
|
||||||
|
svgEl.style.maxWidth = '100%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setSvgError(err.message || 'Failed to render diagram');
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
if (svgError) {
|
||||||
|
return (
|
||||||
|
<pre style={{ color: '#EF4444', fontSize: '0.75rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', borderRadius: '0.375rem', overflowX: 'auto' }}>
|
||||||
|
Mermaid render error: {svgError}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{ background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.5rem', padding: '1rem', margin: '1rem 0', overflowX: 'auto' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticleContent();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [article.id]);
|
||||||
|
|
||||||
|
const fetchArticleContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/content`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch article content');
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
setContent(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching article content:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/knowledge-base/${article.id}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = article.file_name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error downloading file:', err);
|
||||||
|
setError('Failed to download file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMarkdown = article.file_name?.endsWith('.md');
|
||||||
|
const isText = article.file_name?.endsWith('.txt');
|
||||||
|
const isPDF = article.file_name?.endsWith('.pdf');
|
||||||
|
const isImage = /\.(jpg|jpeg|png|gif|bmp)$/i.test(article.file_name || '');
|
||||||
|
|
||||||
|
const getCategoryColor = (cat) => {
|
||||||
|
const colors = {
|
||||||
|
'General': '#94A3B8',
|
||||||
|
'Policy': '#0EA5E9',
|
||||||
|
'Procedure': '#10B981',
|
||||||
|
'Guide': '#F59E0B',
|
||||||
|
'Reference': '#8B5CF6'
|
||||||
|
};
|
||||||
|
return colors[cat] || '#94A3B8';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||||
|
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||||
|
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded"
|
||||||
|
style={{
|
||||||
|
background: `${getCategoryColor(article.category)}33`,
|
||||||
|
color: getCategoryColor(article.category),
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<span>Created: {formatDate(article.created_at)}</span>
|
||||||
|
{article.created_by_username && (
|
||||||
|
<span>By: {article.created_by_username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="intel-button intel-button-small"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="kb-content-area">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||||
|
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
|
||||||
|
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{/* Markdown Rendering */}
|
||||||
|
{isMarkdown && (
|
||||||
|
<div className="markdown-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
rehypePlugins={[rehypeSanitize]}
|
||||||
|
components={{
|
||||||
|
code({ inline, className, children }) {
|
||||||
|
const lang = /language-(\w+)/.exec(className || '')?.[1];
|
||||||
|
if (!inline && lang === 'mermaid') {
|
||||||
|
return <MermaidDiagram code={String(children).replace(/\n$/, '')} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className={className}
|
||||||
|
style={inline ? { background: 'rgba(14,165,233,0.15)', padding: '0.1rem 0.3rem', borderRadius: '0.25rem', fontFamily: 'monospace', fontSize: '0.85em' } : {}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plain Text */}
|
||||||
|
{isText && !isMarkdown && (
|
||||||
|
<pre
|
||||||
|
className="text-sm p-4 rounded overflow-auto"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
maxHeight: '600px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF */}
|
||||||
|
{isPDF && (
|
||||||
|
<div className="w-full" style={{ height: '700px' }}>
|
||||||
|
<iframe
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
title={article.title}
|
||||||
|
className="w-full h-full rounded"
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#EF4444' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Your browser doesn't support PDF preview. Click the download button to view this file.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{isImage && (
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}/knowledge-base/${article.id}/content`}
|
||||||
|
alt={article.title}
|
||||||
|
className="max-w-full h-auto rounded"
|
||||||
|
style={{ border: '1px solid rgba(14, 165, 233, 0.3)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other file types */}
|
||||||
|
{!isMarkdown && !isText && !isPDF && !isImage && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<File className="w-16 h-16 mx-auto mb-4" style={{ color: '#94A3B8' }} />
|
||||||
|
<p className="mb-4" style={{ color: '#94A3B8' }}>
|
||||||
|
Preview not available for this file type.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDownload} className="intel-button intel-button-success">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,12 +98,6 @@ export default function LoginForm() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-intel-grid">
|
|
||||||
<p className="text-sm text-gray-500 text-center font-mono">
|
|
||||||
Default: <span className="text-intel-accent">admin</span> / <span className="text-intel-accent">admin123</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
187
frontend/src/components/NavDrawer.js
Normal file
187
frontend/src/components/NavDrawer.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
|
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||||
|
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||||
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||||
|
|
||||||
|
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.65)',
|
||||||
|
backdropFilter: 'blur(3px)',
|
||||||
|
zIndex: 50
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||||
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||||
|
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||||
|
zIndex: 51,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
padding: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{/* Drawer header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||||
|
STEAM
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||||
|
Security Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||||
|
>
|
||||||
|
<X style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||||
|
const active = currentPage === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { onNavigate(id); onClose(); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||||
|
padding: '0.75rem 0.875rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||||
|
background: active ? `${color}18` : 'transparent',
|
||||||
|
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Icon box */}
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '36px', flexShrink: 0,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label + description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
color: active ? color : '#CBD5E1',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active indicator dot */}
|
||||||
|
{active && (
|
||||||
|
<div style={{
|
||||||
|
width: '6px', height: '6px', borderRadius: '50%',
|
||||||
|
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Admin panel link — visible only to Admin group */}
|
||||||
|
{isAdmin() && (() => {
|
||||||
|
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
|
||||||
|
const active = currentPage === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { onNavigate(id); onClose(); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||||
|
padding: '0.75rem 0.875rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||||
|
background: active ? `${color}18` : 'transparent',
|
||||||
|
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '36px', flexShrink: 0,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
color: active ? color : '#CBD5E1',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<div style={{
|
||||||
|
width: '6px', height: '6px', borderRadius: '50%',
|
||||||
|
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto', paddingTop: '1rem',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
NTS Threat Intelligence
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,22 @@ import { useAuth } from '../contexts/AuthContext';
|
|||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const VALID_GROUPS = ['Admin', 'Standard_User', 'Leadership', 'Read_Only'];
|
||||||
|
|
||||||
|
const GROUP_LABELS = {
|
||||||
|
Admin: 'Admin (full access)',
|
||||||
|
Standard_User: 'Standard User (create, edit, limited delete)',
|
||||||
|
Leadership: 'Leadership (read-only + exports)',
|
||||||
|
Read_Only: 'Read Only (view only)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_BADGE_STYLES = {
|
||||||
|
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
|
||||||
|
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
|
||||||
|
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
|
||||||
|
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
|
||||||
|
};
|
||||||
|
|
||||||
export default function UserManagement({ onClose }) {
|
export default function UserManagement({ onClose }) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
@@ -15,7 +31,7 @@ export default function UserManagement({ onClose }) {
|
|||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'viewer'
|
group: 'Read_Only'
|
||||||
});
|
});
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
const [formSuccess, setFormSuccess] = useState('');
|
const [formSuccess, setFormSuccess] = useState('');
|
||||||
@@ -39,11 +55,29 @@ export default function UserManagement({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmGroupChange = (targetUser, newGroup) => {
|
||||||
|
let message = `Are you sure you want to change ${targetUser.username}'s group from ${targetUser.group} to ${newGroup}?`;
|
||||||
|
|
||||||
|
// Extra warning when downgrading an Admin user
|
||||||
|
if (targetUser.group === 'Admin' && newGroup !== 'Admin') {
|
||||||
|
message += `\n\n⚠️ WARNING: You are removing Admin privileges from ${targetUser.username}. They will lose full system access.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.confirm(message);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError('');
|
setFormError('');
|
||||||
setFormSuccess('');
|
setFormSuccess('');
|
||||||
|
|
||||||
|
// If editing and group changed, show confirmation dialog
|
||||||
|
if (editingUser && formData.group !== editingUser.group) {
|
||||||
|
if (!confirmGroupChange(editingUser, formData.group)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = editingUser
|
const url = editingUser
|
||||||
? `${API_BASE}/users/${editingUser.id}`
|
? `${API_BASE}/users/${editingUser.id}`
|
||||||
@@ -75,7 +109,7 @@ export default function UserManagement({ onClose }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowAddUser(false);
|
setShowAddUser(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setFormData({ username: '', email: '', password: '', role: 'viewer' });
|
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||||
setFormSuccess('');
|
setFormSuccess('');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -89,7 +123,7 @@ export default function UserManagement({ onClose }) {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password: '',
|
password: '',
|
||||||
role: user.role
|
group: user.group
|
||||||
});
|
});
|
||||||
setShowAddUser(true);
|
setShowAddUser(true);
|
||||||
setFormError('');
|
setFormError('');
|
||||||
@@ -140,15 +174,10 @@ export default function UserManagement({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleBadgeColor = (role) => {
|
// Check if group dropdown should be disabled for self-demotion prevention
|
||||||
switch (role) {
|
const isGroupDropdownDisabled = (targetUser) => {
|
||||||
case 'admin':
|
if (!targetUser || !currentUser) return false;
|
||||||
return 'bg-red-100 text-red-800';
|
return targetUser.id === currentUser.id && currentUser.group === 'Admin';
|
||||||
case 'editor':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +202,7 @@ export default function UserManagement({ onClose }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAddUser(true);
|
setShowAddUser(true);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setFormData({ username: '', email: '', password: '', role: 'viewer' });
|
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||||
setFormError('');
|
setFormError('');
|
||||||
setFormSuccess('');
|
setFormSuccess('');
|
||||||
}}
|
}}
|
||||||
@@ -253,19 +282,24 @@ export default function UserManagement({ onClose }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Role *
|
Group *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||||
<select
|
<select
|
||||||
value={formData.role}
|
value={formData.group}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
disabled={isGroupDropdownDisabled(editingUser)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
|
||||||
>
|
>
|
||||||
<option value="viewer">Viewer (read-only)</option>
|
{VALID_GROUPS.map((g) => (
|
||||||
<option value="editor">Editor (can add CVEs, upload docs)</option>
|
<option key={g} value={g}>{GROUP_LABELS[g]}</option>
|
||||||
<option value="admin">Admin (full access)</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{isGroupDropdownDisabled(editingUser) && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +342,7 @@ export default function UserManagement({ onClose }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
|
||||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
|
||||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
|
||||||
@@ -324,8 +358,17 @@ export default function UserManagement({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
|
<span
|
||||||
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
|
style={{
|
||||||
|
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'inline-block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
|
|||||||
@@ -19,17 +19,26 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRoleBadgeColor = (role) => {
|
const getGroupBadgeColor = (group) => {
|
||||||
switch (role) {
|
switch (group) {
|
||||||
case 'admin':
|
case 'Admin':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-100 text-red-800';
|
||||||
case 'editor':
|
case 'Standard_User':
|
||||||
return 'bg-blue-100 text-blue-800';
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'Leadership':
|
||||||
|
return 'bg-purple-100 text-purple-800';
|
||||||
|
case 'Read_Only':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatGroupName = (group) => {
|
||||||
|
if (!group) return '';
|
||||||
|
return group.replace(/_/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
await logout();
|
await logout();
|
||||||
@@ -62,7 +71,7 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-left hidden sm:block">
|
<div className="text-left hidden sm:block">
|
||||||
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
||||||
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
<p className="text-xs text-gray-500">{formatGroupName(user.group)}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -72,8 +81,8 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
|||||||
<div className="px-4 py-3 border-b border-gray-100">
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
<p className="text-sm font-medium text-gray-900">{user.username}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
|
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getGroupBadgeColor(user.group)}`}>
|
||||||
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
|
{formatGroupName(user.group)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
205
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
205
frontend/src/components/pages/ArchiveSummaryBar.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// ArchiveSummaryBar.js
|
||||||
|
// Displays four stat cards for archive lifecycle states: ACTIVE, ARCHIVED, RETURNED, CLOSED.
|
||||||
|
// Fetches counts from /api/ivanti/archive/stats on mount.
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Activity, Archive, RotateCcw, XCircle, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const STATE_CONFIG = [
|
||||||
|
{
|
||||||
|
key: 'ACTIVE',
|
||||||
|
label: 'Active',
|
||||||
|
color: '#0EA5E9',
|
||||||
|
Icon: Activity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ARCHIVED',
|
||||||
|
label: 'Archived',
|
||||||
|
color: '#F59E0B',
|
||||||
|
Icon: Archive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'RETURNED',
|
||||||
|
label: 'Returned',
|
||||||
|
color: '#10B981',
|
||||||
|
Icon: RotateCcw,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'CLOSED',
|
||||||
|
label: 'Closed',
|
||||||
|
color: '#EF4444',
|
||||||
|
Icon: XCircle,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatCard({ stateKey, label, color, Icon, count, active, onClick }) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const isHighlighted = active || hovered;
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: '140px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))',
|
||||||
|
border: `2px solid ${isHighlighted ? color : `rgba(${hexToRgb(color)}, 0.3)`}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
transform: isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
|
||||||
|
boxShadow: isHighlighted
|
||||||
|
? `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(${hexToRgb(color)}, 0.25)`
|
||||||
|
: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const accentLineStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '2px',
|
||||||
|
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
|
||||||
|
boxShadow: `0 0 8px ${color}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={cardStyle}
|
||||||
|
onClick={() => onClick(stateKey)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(stateKey); } }}
|
||||||
|
aria-label={`${label}: ${count} findings. ${active ? 'Currently filtered.' : 'Click to filter.'}`}
|
||||||
|
>
|
||||||
|
<div style={accentLineStyle} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.625rem' }}>
|
||||||
|
<Icon
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
color: color,
|
||||||
|
filter: isHighlighted ? `drop-shadow(0 0 4px ${color})` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: color,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textShadow: isHighlighted ? `0 0 8px rgba(${hexToRgb(color)}, 0.5)` : 'none',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '1.75rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
lineHeight: 1,
|
||||||
|
textShadow: `0 0 16px rgba(${hexToRgb(color)}, 0.3)`,
|
||||||
|
}}>
|
||||||
|
{count != null ? count : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert hex color to r, g, b string for use in rgba()
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' });
|
||||||
|
if (res.ok && !cancelled) {
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
|
||||||
|
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
||||||
|
const interval = setInterval(load, 60000);
|
||||||
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '0.5rem', padding: '1.25rem',
|
||||||
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
Loading archive stats…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem', textAlign: 'center',
|
||||||
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(239, 68, 68, 0.2)', borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
Unable to load archive statistics
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (state) => {
|
||||||
|
if (onStateClick) onStateClick(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{STATE_CONFIG.map(({ key, label, color, Icon }) => (
|
||||||
|
<StatCard
|
||||||
|
key={key}
|
||||||
|
stateKey={key}
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
Icon={Icon}
|
||||||
|
count={stats?.[key] ?? 0}
|
||||||
|
active={activeFilter === key}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
424
frontend/src/components/pages/ComplianceChartsPanel.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
// ComplianceChartsPanel.js
|
||||||
|
// Tier-1 time-based compliance charts using Recharts.
|
||||||
|
// Charts rendered: Active Findings Over Time, Change per Cycle,
|
||||||
|
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line,
|
||||||
|
BarChart, Bar,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
const TEAM_COLORS = {
|
||||||
|
'STEAM': '#0EA5E9',
|
||||||
|
'ACCESS-ENG': '#F59E0B',
|
||||||
|
'ACCESS-OPS': '#8B5CF6',
|
||||||
|
'INTELDEV': '#10B981',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARCHER_STATUS_COLORS = {
|
||||||
|
'Draft': '#475569',
|
||||||
|
'Open': '#0EA5E9',
|
||||||
|
'Under Review': '#F59E0B',
|
||||||
|
'Accepted': '#10B981',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared style tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||||
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||||
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom dark tooltip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DarkTooltip({ active, payload, label }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '130px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>
|
||||||
|
{typeof p.value === 'number'
|
||||||
|
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
|
||||||
|
: p.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart card wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ChartCard({ title, subtitle, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem 1.125rem 0.875rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||||||
|
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty / no-data state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function NoData({ msg }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '160px', color: '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(20,184,166,0.1)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
{msg || 'No data yet — upload compliance reports to populate this chart'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shorten a YYYY-MM-DD string to MM/DD/YY
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const p = d.split('-');
|
||||||
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 1 — Active Findings Over Time (line, total + per team)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ActiveTrendChart({ data }) {
|
||||||
|
if (data.length < 2) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="total_active" name="Total"
|
||||||
|
stroke={TEAL} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||||
|
<Line
|
||||||
|
key={team}
|
||||||
|
type="monotone" dataKey={team} name={team}
|
||||||
|
stroke={color} strokeWidth={1.5}
|
||||||
|
dot={false} strokeDasharray="5 3"
|
||||||
|
activeDot={{ r: 4, fill: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DeltaChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
|
||||||
|
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
|
||||||
|
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 3 — Team Health Multi-Line
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function TeamTrendChart({ data }) {
|
||||||
|
if (data.length < 2) return <NoData />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
{Object.entries(TEAM_COLORS).map(([team, color]) => (
|
||||||
|
<Line
|
||||||
|
key={team}
|
||||||
|
type="monotone" dataKey={team} name={team}
|
||||||
|
stroke={color} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 4 — MTTR by Team (horizontal bar)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MttrChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
|
||||||
|
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
|
||||||
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function RecurringChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData />;
|
||||||
|
const top10 = data.slice(0, 10).map(r => ({
|
||||||
|
...r,
|
||||||
|
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
|
||||||
|
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
|
||||||
|
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
|
||||||
|
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
|
||||||
|
]} />
|
||||||
|
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
|
||||||
|
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ArcherPipelineChart({ data }) {
|
||||||
|
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={210}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
|
||||||
|
<Bar
|
||||||
|
key={status}
|
||||||
|
dataKey={status} name={status} stackId="s"
|
||||||
|
fill={color} fillOpacity={0.85}
|
||||||
|
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function ComplianceChartsPanel() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [trends, setTrends] = useState([]);
|
||||||
|
const [mttr, setMttr] = useState([]);
|
||||||
|
const [recurring, setRecurring] = useState([]);
|
||||||
|
const [archerRaw, setArcherRaw] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [tRes, mRes, rRes, aRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
|
||||||
|
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
|
||||||
|
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
|
||||||
|
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
|
||||||
|
} catch { /* silent — charts will show no-data state */ }
|
||||||
|
finally { if (!cancelled) setLoading(false); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format trend rows — add short date label
|
||||||
|
const formattedTrends = useMemo(
|
||||||
|
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
|
||||||
|
[trends]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pivot archer raw rows → one object per date
|
||||||
|
const archerByDate = useMemo(() => {
|
||||||
|
if (!archerRaw.length) return [];
|
||||||
|
const map = {};
|
||||||
|
archerRaw.forEach(r => {
|
||||||
|
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
|
||||||
|
map[r.date][r.status] = r.count;
|
||||||
|
});
|
||||||
|
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}, [archerRaw]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|
||||||
|
{/* ── Section header / collapse toggle ──────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '0 0 0.625rem 0',
|
||||||
|
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
|
||||||
|
marginBottom: collapsed ? 0 : '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
Historical Trends
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* 1. Active findings over time */}
|
||||||
|
<ChartCard
|
||||||
|
title="Active Findings Over Time"
|
||||||
|
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
|
||||||
|
>
|
||||||
|
<ActiveTrendChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 2. New / Recurring / Resolved delta per cycle */}
|
||||||
|
<ChartCard
|
||||||
|
title="Change per Report Cycle"
|
||||||
|
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
|
||||||
|
>
|
||||||
|
<DeltaChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 3. Team health multi-line */}
|
||||||
|
<ChartCard
|
||||||
|
title="Team Compliance Health"
|
||||||
|
subtitle="Active findings per team per cycle — lower is better"
|
||||||
|
>
|
||||||
|
<TeamTrendChart data={formattedTrends} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 4. MTTR per team */}
|
||||||
|
<ChartCard
|
||||||
|
title="Mean Time to Resolution"
|
||||||
|
subtitle="Average calendar days between first-seen and resolved, by team"
|
||||||
|
>
|
||||||
|
<MttrChart data={mttr} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 5. Most persistent / recurring findings */}
|
||||||
|
<ChartCard
|
||||||
|
title="Most Persistent Findings"
|
||||||
|
subtitle="Active items with the highest recurrence count (top 10)"
|
||||||
|
>
|
||||||
|
<RecurringChart data={recurring} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 6. Archer ticket pipeline */}
|
||||||
|
<ChartCard
|
||||||
|
title="Archer Exception Pipeline"
|
||||||
|
subtitle="Exception ticket status distribution by creation date"
|
||||||
|
>
|
||||||
|
<ArcherPipelineChart data={archerByDate} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
366
frontend/src/components/pages/ComplianceDetailPanel.js
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'Vulnerability Management': '#EF4444',
|
||||||
|
'Access & MFA': '#F59E0B',
|
||||||
|
'Logging & Monitoring': '#8B5CF6',
|
||||||
|
'End-of-Life OS': '#F97316',
|
||||||
|
'Decommissioned Assets': '#64748B',
|
||||||
|
'Asset Data Quality': '#64748B',
|
||||||
|
'Application Security': '#0EA5E9',
|
||||||
|
'Disaster Recovery': TEAL,
|
||||||
|
'Endpoint Protection': '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function categoryColor(category) {
|
||||||
|
return CATEGORY_COLORS[category] || '#94A3B8';
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricChip({ metricId, category, status }) {
|
||||||
|
const color = status === 'resolved' ? '#64748B' : categoryColor(category);
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
background: `${color}18`,
|
||||||
|
border: `1px solid ${color}50`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
{metricId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const [noteMetric, setNoteMetric] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [noteError, setNoteError] = useState(null);
|
||||||
|
|
||||||
|
const fetchDetail = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||||
|
setDetail(data);
|
||||||
|
|
||||||
|
// Default note metric to first active failing metric
|
||||||
|
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||||
|
if (firstActive) setNoteMetric(firstActive.metric_id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [hostname]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||||
|
|
||||||
|
const handleAddNote = async () => {
|
||||||
|
if (!noteText.trim() || !noteMetric) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setNoteError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
||||||
|
setNoteText('');
|
||||||
|
await fetchDetail();
|
||||||
|
if (onNoteAdded) onNoteAdded();
|
||||||
|
} catch (err) {
|
||||||
|
setNoteError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
|
||||||
|
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 40 }} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
|
||||||
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||||
|
borderLeft: `1px solid ${TEAL}30`,
|
||||||
|
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
|
||||||
|
zIndex: 41,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '1.25rem 1.25rem 1rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||||
|
{hostname}
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{detail.ip_address && (
|
||||||
|
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
|
||||||
|
)}
|
||||||
|
{detail.device_type && (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||||
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '1.25rem', display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0, marginTop: '1px' }} />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && detail && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* Active failing metrics */}
|
||||||
|
{activeMetrics.length > 0 && (
|
||||||
|
<Section title="Failing Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<MetricRow key={m.metric_id} metric={m} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved metrics */}
|
||||||
|
{resolvedMetrics.length > 0 && (
|
||||||
|
<Section title="Resolved Metrics" muted>
|
||||||
|
{resolvedMetrics.map(m => (
|
||||||
|
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload history summary */}
|
||||||
|
{activeMetrics.length > 0 && (
|
||||||
|
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
|
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||||
|
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||||
|
{m.seen_count}× seen
|
||||||
|
</span>
|
||||||
|
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||||
|
{detail.notes.length === 0 && (
|
||||||
|
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
||||||
|
)}
|
||||||
|
{detail.notes.map(n => (
|
||||||
|
<div key={n.id} style={{
|
||||||
|
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||||
|
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
|
||||||
|
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add note */}
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
{activeMetrics.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={noteMetric}
|
||||||
|
onChange={e => setNoteMetric(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', marginBottom: '0.5rem',
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||||
|
borderRadius: '0.25rem', color: '#CBD5E1',
|
||||||
|
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
{activeMetrics.map(m => (
|
||||||
|
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} — {m.category}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<textarea
|
||||||
|
value={noteText}
|
||||||
|
onChange={e => setNoteText(e.target.value)}
|
||||||
|
placeholder="Add a note…"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
flex: 1, resize: 'none',
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||||
|
borderRadius: '0.375rem', color: '#F8FAFC',
|
||||||
|
padding: '0.5rem 0.625rem', fontSize: '0.8rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||||||
|
background: noteText.trim() ? `${TEAL}20` : 'transparent',
|
||||||
|
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||||
|
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
|
||||||
|
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
{submitting
|
||||||
|
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
: <Send style={{ width: '16px', height: '16px' }} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{noteError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem' }}>{noteError}</div>}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, icon, children, muted, grow }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}>
|
||||||
|
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricRow({ metric, resolved, onNavigate }) {
|
||||||
|
const color = resolved ? '#475569' : categoryColor(metric.category);
|
||||||
|
const extra = metric.extra || {};
|
||||||
|
|
||||||
|
const ivantiId = (!resolved && metric.metric_id?.startsWith('2.3'))
|
||||||
|
? (extra['Ivanti_Vulnerability_ID'] || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Surface the most useful extra fields per metric type
|
||||||
|
const highlights = [];
|
||||||
|
if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
|
||||||
|
if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
|
||||||
|
if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
|
||||||
|
if (extra['Normalized - Operating System'])
|
||||||
|
highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
|
||||||
|
if (extra['EOS - End of Service Life'])
|
||||||
|
highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
|
||||||
|
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
|
||||||
|
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||||
|
background: resolved ? 'transparent' : `${color}08`,
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
opacity: resolved ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||||
|
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||||
|
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||||
|
</div>
|
||||||
|
{metric.metric_desc && (
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: (highlights.length || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
|
||||||
|
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ivantiId && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||||||
|
</div>
|
||||||
|
{onNavigate && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, marginLeft: '0.5rem',
|
||||||
|
background: 'rgba(14,165,233,0.1)',
|
||||||
|
border: '1px solid rgba(14,165,233,0.4)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#0EA5E9',
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||||
|
>
|
||||||
|
View in Triage →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{highlights.map(h => (
|
||||||
|
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
499
frontend/src/components/pages/CompliancePage.js
Normal file
499
frontend/src/components/pages/CompliancePage.js
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import ComplianceUploadModal from './ComplianceUploadModal';
|
||||||
|
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||||
|
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
'Meets/Exceeds Target': '#10B981',
|
||||||
|
'Within 15% of Target': '#F59E0B',
|
||||||
|
'Below 15% of Target': '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
'Vulnerability Management': '#EF4444',
|
||||||
|
'Access & MFA': '#F59E0B',
|
||||||
|
'Logging & Monitoring': '#8B5CF6',
|
||||||
|
'End-of-Life OS': '#F97316',
|
||||||
|
'Decommissioned Assets': '#64748B',
|
||||||
|
'Asset Data Quality': '#64748B',
|
||||||
|
'Application Security': '#0EA5E9',
|
||||||
|
'Disaster Recovery': TEAL,
|
||||||
|
'Endpoint Protection': '#F97316',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusColor(status) {
|
||||||
|
return STATUS_COLOR[status] || '#EF4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctDisplay(pct) {
|
||||||
|
return `${Math.round(pct * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate summary entries — one per metric_id for the selected team
|
||||||
|
// (exclude aggregate "ALL: NTS-AEO" rows)
|
||||||
|
function teamMetrics(entries, team) {
|
||||||
|
return entries.filter(e => e.team === team);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function MetricHealthCard({ entry, active, onClick }) {
|
||||||
|
const color = statusColor(entry.status);
|
||||||
|
const isOk = entry.status === 'Meets/Exceeds Target';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: active
|
||||||
|
? `${color}18`
|
||||||
|
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: `1.5px solid ${active ? color : color + '40'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
minWidth: '160px',
|
||||||
|
flex: '1 1 0',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = color + '40'; }}
|
||||||
|
>
|
||||||
|
{/* Metric ID */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||||
|
{entry.metric_id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{entry.category}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance % */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
|
||||||
|
{pctDisplay(entry.compliance_pct)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target */}
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
target {pctDisplay(entry.target)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.625rem', display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
color, padding: '0.2rem 0.5rem',
|
||||||
|
background: `${color}12`, borderRadius: '999px',
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '5px', height: '5px', borderRadius: '50%',
|
||||||
|
background: color, flexShrink: 0,
|
||||||
|
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||||
|
}} />
|
||||||
|
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricBadge({ metricId, category }) {
|
||||||
|
const color = CATEGORY_COLORS[category] || '#94A3B8';
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '0.15rem 0.45rem',
|
||||||
|
background: `${color}15`, border: `1px solid ${color}40`,
|
||||||
|
borderRadius: '0.2rem', color,
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{metricId}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeenBadge({ count }) {
|
||||||
|
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
|
||||||
|
color, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${color}12`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{count}×
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function CompliancePage({ onNavigate }) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
|
|
||||||
|
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||||
|
const [activeTab, setActiveTab] = useState('active');
|
||||||
|
const [metricFilter, setMetricFilter] = useState(null);
|
||||||
|
const [hostSearch, setHostSearch] = useState('');
|
||||||
|
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||||
|
const [devices, setDevices] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedHost, setSelectedHost] = useState(null);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
const fetchSummary = useCallback(async (team) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
setSummary(data);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDevices = useCallback(async (team, tab) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed to load');
|
||||||
|
setDevices(data.devices || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDevices([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetricFilter(null);
|
||||||
|
setHostSearch('');
|
||||||
|
setSelectedHost(null);
|
||||||
|
fetchSummary(activeTeam);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetricFilter(null);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
fetchSummary(activeTeam);
|
||||||
|
fetchDevices(activeTeam, activeTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory filters
|
||||||
|
const filteredDevices = devices
|
||||||
|
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
|
||||||
|
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
|
||||||
|
|
||||||
|
const metrics = teamMetrics(summary.entries, activeTeam);
|
||||||
|
const lastUpload = summary.upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: '2rem' }}>
|
||||||
|
|
||||||
|
{/* ── Page header ─────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||||
|
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
AEO Compliance
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
{lastUpload ? (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||||||
|
)}
|
||||||
|
{summary.overall_scores?.customer_network != null && (
|
||||||
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||||
|
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.overall_scores?.vertical != null && (
|
||||||
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||||
|
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button onClick={refresh} title="Refresh"
|
||||||
|
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||||
|
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button onClick={() => setShowUpload(true)}
|
||||||
|
className="intel-button"
|
||||||
|
style={{
|
||||||
|
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
|
||||||
|
color: TEAL, padding: '0.5rem 1rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
<Upload style={{ width: '14px', height: '14px' }} />
|
||||||
|
Upload Report
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||||
|
{TEAMS.map(team => {
|
||||||
|
const isActive = activeTeam === team;
|
||||||
|
return (
|
||||||
|
<button key={team} onClick={() => setActiveTeam(team)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.25rem', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
|
||||||
|
background: isActive ? `${TEAL}18` : 'transparent',
|
||||||
|
color: isActive ? TEAL : '#475569',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
|
||||||
|
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
|
||||||
|
{team}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||||
|
{metrics.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||||
|
Metric Health — click to filter
|
||||||
|
{metricFilter && (
|
||||||
|
<button onClick={() => setMetricFilter(null)}
|
||||||
|
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||||
|
× clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
|
||||||
|
{metrics.map(entry => (
|
||||||
|
<MetricHealthCard
|
||||||
|
key={entry.metric_id}
|
||||||
|
entry={entry}
|
||||||
|
active={metricFilter === entry.metric_id}
|
||||||
|
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : lastUpload === null ? (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '1.5rem', padding: '2rem',
|
||||||
|
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
No compliance data — upload a report to get started
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||||
|
<ComplianceChartsPanel />
|
||||||
|
|
||||||
|
{/* ── Device table ─────────────────────────────────────────── */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Table toolbar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
{/* Active / Resolved tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||||
|
{['active', 'resolved'].map(tab => {
|
||||||
|
const isActive = activeTab === tab;
|
||||||
|
return (
|
||||||
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
padding: '0.35rem 0.875rem', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
|
||||||
|
background: isActive ? `${TEAL}12` : 'transparent',
|
||||||
|
color: isActive ? TEAL : '#475569',
|
||||||
|
}}>
|
||||||
|
{tab}
|
||||||
|
{isActive && (
|
||||||
|
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
|
||||||
|
({loading ? '…' : filteredDevices.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hostname search */}
|
||||||
|
<input
|
||||||
|
value={hostSearch}
|
||||||
|
onChange={e => setHostSearch(e.target.value)}
|
||||||
|
placeholder="Search hostname…"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||||
|
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
|
||||||
|
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||||
|
width: '220px',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
fontSize: '0.62rem', color: '#334155',
|
||||||
|
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
}}>
|
||||||
|
<span>Hostname</span>
|
||||||
|
<span>IP Address</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Failing Metrics</span>
|
||||||
|
<span>Seen</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '3rem', textAlign: 'center' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: TEAL, margin: '0 auto', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
|
||||||
|
</div>
|
||||||
|
) : filteredDevices.length === 0 ? (
|
||||||
|
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredDevices.map(device => (
|
||||||
|
<DeviceRow
|
||||||
|
key={device.hostname}
|
||||||
|
device={device}
|
||||||
|
selected={selectedHost === device.hostname}
|
||||||
|
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||||
|
{selectedHost && (
|
||||||
|
<ComplianceDetailPanel
|
||||||
|
hostname={selectedHost}
|
||||||
|
onClose={() => setSelectedHost(null)}
|
||||||
|
onNoteAdded={refresh}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||||
|
{showUpload && (
|
||||||
|
<ComplianceUploadModal
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onUploadComplete={() => { setShowUpload(false); refresh(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({ device, selected, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||||
|
padding: '0.625rem 1rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selected ? `${TEAL}08` : 'transparent',
|
||||||
|
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{/* Hostname */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{device.hostname}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IP */}
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
|
||||||
|
{device.ip_address || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{device.device_type || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Failing metrics */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{device.failing_metrics.map(m => (
|
||||||
|
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seen count */}
|
||||||
|
<div>
|
||||||
|
<SeenBadge count={device.seen_count} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes indicator */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{device.has_notes && (
|
||||||
|
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
221
frontend/src/components/pages/ComplianceUploadModal.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
// phase: idle → uploading → preview → committing → done | error
|
||||||
|
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||||
|
const [phase, setPhase] = useState('idle');
|
||||||
|
const [previewData, setPreviewData] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const handleFile = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||||
|
setError('File must be an .xlsx spreadsheet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('uploading');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewData(data);
|
||||||
|
setPhase('preview');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!previewData) return;
|
||||||
|
setPhase('committing');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/compliance/commit`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tempFile: previewData.tempFile,
|
||||||
|
filename: previewData.filename,
|
||||||
|
report_date: previewData.report_date,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Commit failed');
|
||||||
|
|
||||||
|
setPhase('done');
|
||||||
|
setTimeout(onUploadComplete, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEAL = '#14B8A6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 60,
|
||||||
|
background: 'rgba(10, 14, 39, 0.97)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||||
|
border: `1px solid ${TEAL}40`,
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
|
||||||
|
width: '100%', maxWidth: '480px',
|
||||||
|
padding: '2rem',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Upload Report
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||||
|
<X style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IDLE — drop zone */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
|
||||||
|
style={{
|
||||||
|
border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '2.5rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: dragOver ? `${TEAL}08` : 'transparent',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}>
|
||||||
|
<FileSpreadsheet style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', opacity: 0.8 }} />
|
||||||
|
<div style={{ color: '#CBD5E1', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||||
|
Drop your xlsx file here or <span style={{ color: TEAL }}>browse</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#475569', fontSize: '0.75rem' }}>NTS_AEO_YYYY_MM_DD.xlsx</div>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".xlsx" style={{ display: 'none' }}
|
||||||
|
onChange={e => handleFile(e.target.files[0])} />
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* UPLOADING / COMMITTING — spinner */}
|
||||||
|
{(phase === 'uploading' || phase === 'committing') && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||||
|
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
|
||||||
|
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||||
|
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PREVIEW — diff summary + confirm */}
|
||||||
|
{phase === 'preview' && previewData && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{previewData.filename}
|
||||||
|
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
|
||||||
|
{ label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
|
||||||
|
{ label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
|
||||||
|
].map(({ label, count, color, icon }) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '0.75rem 1rem', marginBottom: '0.5rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#CBD5E1', fontSize: '0.875rem' }}>
|
||||||
|
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
|
||||||
|
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem', color: '#64748B', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCommit}
|
||||||
|
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
|
||||||
|
Confirm Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DONE */}
|
||||||
|
{phase === 'done' && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||||
|
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
|
||||||
|
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Upload committed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ERROR */}
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||||
|
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
|
||||||
|
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
|
||||||
|
<button onClick={() => { setPhase('idle'); setError(null); }}
|
||||||
|
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user