Compare commits
149 Commits
26abd55e0f
...
feature/mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f141fa58a1 | ||
|
|
e1b0236874 | ||
|
|
ed48522932 | ||
|
|
938dda400a | ||
|
|
732873dd6a | ||
|
|
0fe8e94d51 | ||
|
|
28bce28fc9 | ||
|
|
72fd79ea42 | ||
|
|
f63c286458 | ||
|
|
93c144576f | ||
|
|
fa3b045a2f | ||
|
|
4583d09750 | ||
|
|
75ac8c823a | ||
|
|
68e36b4bac | ||
|
|
d24b45b404 | ||
|
|
d64eb7eec4 | ||
|
|
6cb65fddc1 | ||
|
|
0ca83c6736 | ||
|
|
06268880da | ||
|
|
b4f0ddcb78 | ||
|
|
55e3e074a5 | ||
|
|
66bbeb84a5 | ||
|
|
4578f8cd85 | ||
|
|
5469a86e6e | ||
|
|
2b6db1f903 | ||
|
|
7c97bc3a84 | ||
|
|
835fbf26e7 | ||
|
|
c4aaeff2a1 | ||
|
|
df30430956 | ||
|
|
57f11c362b | ||
|
|
4df83d36dd | ||
|
|
0a7a7c2827 | ||
|
|
1963faf9b8 | ||
|
|
9b36a58959 | ||
|
|
690c30aac0 | ||
|
|
fc68097821 | ||
|
|
d9fdaf5cbb | ||
|
|
cb3da6980c | ||
|
|
ccc3576706 | ||
|
|
5405926550 | ||
|
|
328e48ea8c | ||
|
|
41f9c35586 | ||
|
|
729dada05c | ||
|
|
5d417edf82 | ||
|
|
03e60c9daf | ||
|
|
ee9403ab47 | ||
|
|
3d04cd393f | ||
|
|
382bc81a7e | ||
|
|
7302ece958 | ||
|
|
80d80c099f | ||
|
|
a2a43a8685 | ||
|
|
a711972054 | ||
|
|
8a6a3485e9 | ||
|
|
169a0d2337 | ||
|
|
c50fc5d8a8 | ||
|
|
e9e2c0961d | ||
|
|
d910af847e | ||
|
|
73fd747576 | ||
| 1ef57b0504 | |||
|
|
d1fe0bf455 | ||
|
|
3f7887eba6 | ||
|
|
9bd5a52661 | ||
|
|
2b4ec5d8e2 | ||
|
|
62592e9821 | ||
| 2fead2cfef | |||
| 7c0ba41514 | |||
| 9c6c03a518 | |||
| 0d48c109b3 | |||
| 18ad31228e | |||
| 3dcb91a1fc | |||
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 | |||
| b111273e5a | |||
| a7c74f625f | |||
| 8aef51b59a | |||
| d0087ba9b7 | |||
| 3d6062f3fa | |||
| 7af44608d0 | |||
| 3bb86e8369 | |||
| 4676279a72 | |||
| d3d86ddcf2 | |||
| 558c65807d | |||
| 518cb0a849 | |||
| b0adfa1bda | |||
| 7a2c56a11f | |||
| 89b1f57ef4 | |||
| 6bf6371e51 | |||
| 4d472b0aef | |||
| 887d11610e | |||
| 1520cc994b | |||
| 906066c7fa | |||
| b58bd0650a | |||
| ae04bc981e | |||
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a | |||
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a | |||
| 669396f635 | |||
| 8b3ea22fa0 | |||
| 75b8ecc61d | |||
| ade3cc25ad | |||
| 3fd6158eb3 | |||
| 5bbaaf5918 | |||
| 1f36d302ea | |||
| 8697ba4ef3 | |||
| d3806e8ce3 | |||
| 931c42faeb | |||
| ea3b72db5c | |||
| d63e7cc9b9 | |||
| 37e183543a | |||
| 337ffe6f35 | |||
| 08c8c8a2a1 | |||
| 4ed7721a71 | |||
| 3fb20c147d | |||
| f2e6069c08 | |||
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd | |||
| 112eb8dac1 | |||
| 3b37646b6d | |||
| 241ff16bb4 | |||
| 0e89251bac | |||
| fa9f4229a6 | |||
| eea226a9d5 | |||
| 79a1a23002 | |||
| 6fda7de7a3 | |||
| 0d67a99c7e | |||
| bf3d01becf | |||
| 9384ded04f | |||
| 0c9c3b5514 | |||
| 4a50cd100b | |||
| c22a3a70ab | |||
| 626d0cac3a | |||
| ba4d16396c | |||
| 83d944fa70 |
@@ -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,105 +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.
|
||||
|
||||
## 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.
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -37,10 +37,20 @@ frontend.pid
|
||||
|
||||
# Temporary files
|
||||
backend/uploads/temp/
|
||||
claude.md
|
||||
claude_status.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/fix_multivendor_constraint.js
|
||||
backend/server.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/archive-finding-clarity/.config.kiro
Normal file
1
.kiro/specs/archive-finding-clarity/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||
196
.kiro/specs/archive-finding-clarity/design.md
Normal file
196
.kiro/specs/archive-finding-clarity/design.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Design Document: Archive Finding Clarity
|
||||
|
||||
## Overview
|
||||
|
||||
This feature enhances the Ivanti Archive Findings panel on the STEAM Security Dashboard homepage to provide clearer context for archived findings. The changes span both backend (related active finding detection) and frontend (card rendering improvements).
|
||||
|
||||
The core additions are:
|
||||
1. **Finding ID display** — Show the Ivanti finding ID on each archive card for cross-referencing
|
||||
2. **Historical severity labeling** — Prefix severity with "Last seen:" to clarify it's a snapshot
|
||||
3. **Related active finding detection** — Server-side matching of archived findings against the current findings cache by hostname + title
|
||||
4. **Visual status indicators** — Icon and border color distinctions based on whether a related active finding exists
|
||||
|
||||
All matching is performed server-side in a single pass to avoid per-card API calls and keep the archive panel responsive.
|
||||
|
||||
## Architecture
|
||||
|
||||
The feature touches two layers:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Backend
|
||||
A[GET /api/ivanti/archive?state=X] --> B[Query ivanti_finding_archives]
|
||||
B --> C[Parse ivanti_findings_cache JSON once]
|
||||
C --> D[Match each archive record against active findings]
|
||||
D --> E[Return archives with related_active field]
|
||||
end
|
||||
subgraph Frontend
|
||||
E --> F[App.js archiveList.map]
|
||||
F --> G[Render enhanced Archive Cards]
|
||||
end
|
||||
```
|
||||
|
||||
**Key design decision:** The related finding lookup is embedded in the existing `GET /api/ivanti/archive` endpoint rather than exposed as a separate endpoint. This avoids N+1 API calls from the frontend and keeps the archive panel's fetch pattern unchanged (single request per state filter click).
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User clicks a state card in `ArchiveSummaryBar` → triggers `handleArchiveStateClick(state)` in `App.js`
|
||||
2. Frontend calls `GET /api/ivanti/archive?state={state}`
|
||||
3. Backend queries `ivanti_finding_archives` for matching state
|
||||
4. Backend reads `ivanti_findings_cache` row (id=1), parses `findings_json` once
|
||||
5. For each archive record, backend runs the matching function against the parsed active findings
|
||||
6. Backend returns `{ archives: [...], total: N }` where each archive object now includes a `related_active` field
|
||||
7. Frontend renders each archive card with the new fields: finding ID, "Last seen:" severity, optional badge, icon/border
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: Modified Archive Route (`backend/routes/ivantiArchive.js`)
|
||||
|
||||
**Changes to `GET /` handler:**
|
||||
|
||||
```javascript
|
||||
// New matching function added to the module
|
||||
function findRelatedActive(archive, activeFindings) {
|
||||
// Returns { id, title, severity } or null
|
||||
}
|
||||
```
|
||||
|
||||
**`findRelatedActive(archive, activeFindings)` logic:**
|
||||
- Input: one archive record, array of parsed active findings
|
||||
- Filter active findings where:
|
||||
- `hostName` exactly matches `archive.host_name` (case-sensitive, matching existing DB convention)
|
||||
- AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, OR vice versa
|
||||
- AND the active finding's `id` is NOT equal to `archive.finding_id`
|
||||
- If multiple matches, return the one with the highest `severity`
|
||||
- If no matches, return `null`
|
||||
|
||||
**Modified response shape:**
|
||||
```javascript
|
||||
// Before
|
||||
{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, ... }
|
||||
|
||||
// After — same fields plus:
|
||||
{ ...existing, related_active: null | { id: string, title: string, severity: number } }
|
||||
```
|
||||
|
||||
### Frontend: Modified Archive Card Rendering (`frontend/src/App.js`)
|
||||
|
||||
The `archiveList.map()` block in `App.js` is updated to render:
|
||||
|
||||
1. **Finding title** (existing, unchanged)
|
||||
2. **Finding ID** — new line below title, monospace, muted color (`#64748B`), font size `0.6rem`. Truncated with ellipsis at 20 characters, full value in `title` attribute for tooltip.
|
||||
3. **Severity badge** — changed from raw number to "Last seen: X.X" format. Null/zero shows "Last seen: —".
|
||||
4. **Related active badge** — conditional. When `related_active` is non-null, shows "Similar finding active" with the related finding's ID and severity, styled with accent color (`#0EA5E9`).
|
||||
5. **Icon** — `AlertTriangle` (from lucide-react) when `related_active` is non-null, `CheckCircle` when null.
|
||||
6. **Left border** — `#F59E0B` (amber) when `related_active` is non-null, `#10B981` (green) when null.
|
||||
|
||||
### No New Components
|
||||
|
||||
The archive card is rendered inline in `App.js` (not a separate component), consistent with the existing pattern. The changes modify the existing `archiveList.map()` JSX block. No new React components are introduced.
|
||||
|
||||
### No New API Endpoints
|
||||
|
||||
The related finding detection is added to the existing `GET /api/ivanti/archive` route. The `ArchiveSummaryBar` component and its `/stats` endpoint are unchanged.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Existing Tables (unchanged)
|
||||
|
||||
**`ivanti_finding_archives`**
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER PK | Auto-increment row ID |
|
||||
| finding_id | TEXT UNIQUE | Ivanti finding identifier |
|
||||
| finding_title | TEXT | Finding title at archive time |
|
||||
| host_name | TEXT | Hostname |
|
||||
| ip_address | TEXT | IP address |
|
||||
| current_state | TEXT | ARCHIVED, RETURNED, or CLOSED |
|
||||
| last_severity | REAL | Severity at last transition |
|
||||
| first_archived_at | DATETIME | First archive timestamp |
|
||||
| last_transition_at | DATETIME | Last state change timestamp |
|
||||
|
||||
**`ivanti_findings_cache`** (row id=1)
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| findings_json | TEXT | JSON array of active findings |
|
||||
| total | INTEGER | Count of cached findings |
|
||||
|
||||
Each entry in `findings_json` has the shape produced by `extractFinding()` in `ivantiFindings.js`:
|
||||
```javascript
|
||||
{ id, title, severity, vrrGroup, hostName, ipAddress, dns, status, slaStatus, dueDate, lastFoundOn, buOwnership, cves, workflow }
|
||||
```
|
||||
|
||||
### No Schema Changes
|
||||
|
||||
This feature requires no database migrations. All data needed for the matching logic already exists in the two tables above.
|
||||
|
||||
## 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: Finding ID display with truncation
|
||||
|
||||
*For any* archive record, the rendered card SHALL display the finding_id. If the finding_id is longer than 20 characters, the displayed text SHALL be truncated to 20 characters followed by an ellipsis. If the finding_id is 20 characters or fewer, it SHALL be displayed in full.
|
||||
|
||||
**Validates: Requirements 1.1, 1.2**
|
||||
|
||||
### Property 2: Historical severity labeling
|
||||
|
||||
*For any* archive record, the rendered severity display SHALL contain the text "Last seen:" followed by the severity value formatted to one decimal place. When the severity is null or zero, the display SHALL show "Last seen: —".
|
||||
|
||||
**Validates: Requirements 2.1, 2.3**
|
||||
|
||||
### Property 3: API response structure — related_active always present
|
||||
|
||||
*For any* request to the archive API and *for any* archive record in the response, the record SHALL contain a `related_active` field that is either `null` or an object with `id` (string), `title` (string), and `severity` (number) properties.
|
||||
|
||||
**Validates: Requirements 3.1, 3.4**
|
||||
|
||||
### Property 4: Matching logic — hostname and title substring
|
||||
|
||||
*For any* archived finding and *for any* active finding, the active finding is a related match if and only if: (a) the active finding's hostname exactly equals the archive's hostname, AND (b) the archive's title is a case-insensitive substring of the active finding's title OR the active finding's title is a case-insensitive substring of the archive's title, AND (c) the active finding's ID is not equal to the archive's finding_id.
|
||||
|
||||
**Validates: Requirements 3.2, 3.5**
|
||||
|
||||
### Property 5: Highest severity selection
|
||||
|
||||
*For any* archived finding with multiple matching active findings, the `related_active` field SHALL contain the match with the highest severity value.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 6: Badge visibility matches related_active presence
|
||||
|
||||
*For any* archive record, the "Similar finding active" badge SHALL be displayed if and only if the `related_active` field is non-null. When displayed, the badge SHALL include the related finding's ID and severity.
|
||||
|
||||
**Validates: Requirements 4.1, 4.3**
|
||||
|
||||
### Property 7: Icon and border determined by related_active, not lifecycle state
|
||||
|
||||
*For any* archive record, regardless of its lifecycle state (ARCHIVED, RETURNED, or CLOSED), the icon and left border color SHALL be determined solely by whether `related_active` is non-null (alert icon + amber border) or null (check icon + green border).
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| `findings_json` is malformed or unparseable | Catch JSON.parse error, log warning, treat as empty array (all `related_active` fields become `null`) |
|
||||
| `findings_json` column is NULL | Default to empty array |
|
||||
| `ivanti_findings_cache` row missing (id=1) | Default to empty array — no related matches |
|
||||
| Database query failure on archive records | Return 500 with `{ error: 'Failed to fetch archive records' }` (existing behavior) |
|
||||
| Database query failure on findings cache | Log error, continue with empty active findings (graceful degradation) |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| `related_active` field missing from response | Treat as `null` (no badge, green/check styling) |
|
||||
| `finding_id` is empty string | Display finding title only (existing fallback behavior) |
|
||||
| `last_severity` is undefined | Display "Last seen: —" |
|
||||
| API returns error | Existing error handling in `handleArchiveStateClick` already catches and shows empty state |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Testing is performed manually on the dev server. No automated tests are required for this feature.
|
||||
82
.kiro/specs/archive-finding-clarity/requirements.md
Normal file
82
.kiro/specs/archive-finding-clarity/requirements.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The Ivanti Archive Findings panel on the STEAM Security Dashboard homepage displays findings that have transitioned through the archive lifecycle (Active, Archived, Returned, Closed). The current archive cards show the finding title, hostname, IP address, and a raw severity number — but lack clarity in several areas. Users cannot see the Ivanti finding ID for cross-referencing, the severity score appears to be a current value when it is actually a historical snapshot, and there is no indication when a related finding with the same title still exists on the same host under a different Ivanti finding ID.
|
||||
|
||||
This feature improves archive card clarity by adding finding IDs, labeling severity as historical, introducing a "related active finding" indicator, and using visual icon distinctions to communicate resolution status at a glance.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Archive_Card**: A single rendered entry in the archive findings list on the homepage, representing one row from the `ivanti_finding_archives` table.
|
||||
- **Archive_Panel**: The section of the homepage that contains the ArchiveSummaryBar stat cards and the expandable archive findings list.
|
||||
- **Finding_ID**: The stable Ivanti-assigned identifier for a host finding (stored as `finding_id` in `ivanti_finding_archives`). Finding IDs do not change with score drift or rescoring.
|
||||
- **Last_Severity**: The severity score recorded at the time a finding was archived or last transitioned between states. It is a historical snapshot, not a live risk assessment.
|
||||
- **Current_Findings_Cache**: The `ivanti_findings_cache` table containing the latest synced findings as a JSON array. Each cached finding has fields including `id`, `title`, `severity`, `hostName`, and `ipAddress`.
|
||||
- **Related_Active_Finding**: A finding in the Current_Findings_Cache that shares the same hostname and a similar title with an archived finding but has a different Finding_ID, indicating a genuinely distinct but related finding is still open on the same host.
|
||||
- **Archive_API**: The backend endpoint `GET /api/ivanti/archive` that returns archive records filtered by lifecycle state.
|
||||
- **Related_Findings_Endpoint**: A new backend endpoint that accepts archived finding details and returns matching active findings from the Current_Findings_Cache.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Display Finding ID on Archive Cards
|
||||
|
||||
**User Story:** As a security analyst, I want to see the Ivanti finding ID on each archive card, so that I can cross-reference archived findings with the Reporting page.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Archive_Card SHALL display the Finding_ID in monospace font below the finding title.
|
||||
2. WHEN the Finding_ID is longer than 20 characters, THE Archive_Card SHALL truncate the Finding_ID with an ellipsis and display the full value in a tooltip on hover.
|
||||
3. THE Archive_Card SHALL render the Finding_ID with a visually distinct style (muted color, smaller font size) so it is clearly secondary to the finding title.
|
||||
|
||||
### Requirement 2: Historical Severity Labeling
|
||||
|
||||
**User Story:** As a security analyst, I want the severity score on archive cards to be clearly labeled as a historical value, so that I do not mistake it for a current risk assessment.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Archive_Card SHALL display the Last_Severity with a "Last seen:" prefix label (e.g., "Last seen: 9.4").
|
||||
2. THE Archive_Card SHALL render the severity label in a muted, secondary style that visually distinguishes it from live severity badges used elsewhere in the dashboard.
|
||||
3. WHEN the Last_Severity value is null or zero, THE Archive_Card SHALL display "Last seen: —" as a placeholder.
|
||||
|
||||
### Requirement 3: Related Active Finding Detection API
|
||||
|
||||
**User Story:** As a security analyst, I want the system to detect when an archived finding has a related active finding on the same host, so that I can understand whether similar risk still exists.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Archive_API SHALL return a `related_active` field for each archive record, containing either `null` (no match) or an object with the matching active finding's `id`, `title`, and `severity`.
|
||||
2. WHEN matching archived findings to active findings, THE Related_Findings_Endpoint SHALL compare by exact hostname match AND case-insensitive substring containment of the archived finding title within the active finding title (or vice versa).
|
||||
3. WHEN multiple active findings match a single archived finding, THE Related_Findings_Endpoint SHALL return the match with the highest severity.
|
||||
4. IF the Current_Findings_Cache contains no findings or is empty, THEN THE Related_Findings_Endpoint SHALL return `null` for all `related_active` fields.
|
||||
5. THE Related_Findings_Endpoint SHALL exclude matches where the active finding's `id` is identical to the archived finding's Finding_ID.
|
||||
|
||||
### Requirement 4: Related Active Finding Indicator on Archive Cards
|
||||
|
||||
**User Story:** As a security analyst, I want to see a visual indicator on archive cards when a related active finding exists on the same host, so that I can quickly identify findings that may still represent active risk.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display a badge reading "Similar finding active" with the related finding's ID and current severity.
|
||||
2. THE Archive_Card SHALL render the related active badge using the dashboard accent color (#0EA5E9) to distinguish it from the archive card's own severity display.
|
||||
3. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL not display any related-finding badge.
|
||||
|
||||
### Requirement 5: Visual Icon Distinction by Resolution Status
|
||||
|
||||
**User Story:** As a security analyst, I want archive cards to use different icons and border colors based on whether a related active finding exists, so that I can scan the list and quickly distinguish fully resolved findings from those with ongoing similar risk.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display an alert-style icon (e.g., `AlertTriangle` from lucide-react) and use a warning-toned left border color (#F59E0B).
|
||||
2. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL display a check-style icon (e.g., `CheckCircle` from lucide-react) and use a success-toned left border color (#10B981).
|
||||
3. THE Archive_Card SHALL apply the icon and border color consistently regardless of the archive lifecycle state (ARCHIVED, RETURNED, or CLOSED).
|
||||
|
||||
### Requirement 6: Performance of Related Finding Lookup
|
||||
|
||||
**User Story:** As a security analyst, I want the archive panel to load promptly even when checking for related active findings, so that the feature does not degrade the homepage experience.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Archive_API SHALL compute related active finding matches server-side within the existing archive list query, avoiding separate per-card API calls from the frontend.
|
||||
2. WHEN the Current_Findings_Cache JSON is parsed for matching, THE Archive_API SHALL parse the cache once per request and reuse the parsed result across all archive records in the response.
|
||||
3. THE Archive_API response time for a filtered archive list SHALL remain under 500ms for up to 200 archive records.
|
||||
70
.kiro/specs/archive-finding-clarity/tasks.md
Normal file
70
.kiro/specs/archive-finding-clarity/tasks.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Implementation Plan: Archive Finding Clarity
|
||||
|
||||
## Overview
|
||||
|
||||
Enhance the Ivanti Archive Findings panel to display finding IDs, label severity as historical, detect related active findings server-side, and apply visual icon/border distinctions based on resolution status. Changes span `backend/routes/ivantiArchive.js` (matching logic + enriched response) and `frontend/src/App.js` (card rendering updates). No new components, endpoints, or migrations.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add `findRelatedActive` function and enrich the GET `/` handler in `backend/routes/ivantiArchive.js`
|
||||
- [x] 1.1 Add the `findRelatedActive(archive, activeFindings)` helper function
|
||||
- Add function above `createIvantiArchiveRouter` or inside the module scope
|
||||
- Filter active findings where `hostName` exactly matches `archive.host_name`
|
||||
- AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, or vice versa
|
||||
- AND the active finding's `id` is NOT equal to `archive.finding_id`
|
||||
- If multiple matches, return the one with the highest `severity` as `{ id, title, severity }`
|
||||
- If no matches, return `null`
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.5_
|
||||
|
||||
- [x] 1.2 Modify the `GET /` handler to parse findings cache and enrich archive records
|
||||
- After fetching archive rows, query `ivanti_findings_cache` (id=1) for `findings_json`
|
||||
- Parse `findings_json` once with `JSON.parse`; default to empty array if NULL, missing row, or parse error
|
||||
- Log a warning on parse failure, do not throw
|
||||
- For each archive record, call `findRelatedActive(archive, parsedFindings)` and attach the result as `related_active`
|
||||
- Return the enriched archives array in the existing `{ archives, total }` response shape
|
||||
- _Requirements: 3.1, 3.4, 6.1, 6.2_
|
||||
|
||||
- [x] 2. Checkpoint — Verify backend changes
|
||||
- Ensure the backend starts without errors, ask the user if questions arise.
|
||||
|
||||
- [x] 3. Update archive card rendering in `frontend/src/App.js`
|
||||
- [x] 3.1 Add `AlertTriangle` and `CheckCircle` to the lucide-react import
|
||||
- Locate the existing lucide-react import statement in `App.js`
|
||||
- Add `AlertTriangle` and `CheckCircle` if not already imported
|
||||
- _Requirements: 5.1, 5.2_
|
||||
|
||||
- [x] 3.2 Add Finding ID display below the finding title
|
||||
- Inside the `archiveList.map()` block, add a new line below the title `<span>`
|
||||
- Render `a.finding_id` in monospace font, `0.6rem` size, muted color `#64748B`
|
||||
- If `finding_id` length exceeds 20 characters, truncate displayed text to 20 chars + ellipsis
|
||||
- Set the full `finding_id` as the `title` attribute for hover tooltip
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [x] 3.3 Change severity badge to "Last seen: X.X" format
|
||||
- In the severity `<span>` within the archive card, replace `{a.last_severity?.toFixed(1) ?? '—'}` with `Last seen: {a.last_severity?.toFixed(1) ?? '—'}`
|
||||
- Null or zero severity displays as "Last seen: —"
|
||||
- _Requirements: 2.1, 2.2, 2.3_
|
||||
|
||||
- [x] 3.4 Add conditional "Similar finding active" badge
|
||||
- When `a.related_active` is non-null, render a badge below the host info line
|
||||
- Badge text: "Similar finding active" with the related finding's ID and severity
|
||||
- Style with accent color `#0EA5E9`, monospace font, `0.6rem` size
|
||||
- When `a.related_active` is null, render nothing
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 3.5 Add icon and left border color based on `related_active`
|
||||
- When `a.related_active` is non-null: render `AlertTriangle` icon and set left border to `3px solid #F59E0B` (amber)
|
||||
- When `a.related_active` is null: render `CheckCircle` icon and set left border to `3px solid #10B981` (green)
|
||||
- Place the icon at the left side of the card header row, before the title
|
||||
- Apply consistently regardless of archive lifecycle state (ARCHIVED, RETURNED, CLOSED)
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 4. Final checkpoint — Verify full feature
|
||||
- Ensure the frontend compiles without errors, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- No automated tests — feature is validated manually on the dev server per user preference
|
||||
- No new components, endpoints, or database migrations required
|
||||
- The `findRelatedActive` function parses the findings cache once per request for performance (Requirement 6.2)
|
||||
- Each task references specific requirements for traceability
|
||||
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/compliance-multi-metric-notes/.config.kiro
Normal file
1
.kiro/specs/compliance-multi-metric-notes/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "8ec01dea-8d5c-40c1-8778-ec2992adb37f", "workflowType": "requirements-first", "specType": "feature"}
|
||||
290
.kiro/specs/compliance-multi-metric-notes/design.md
Normal file
290
.kiro/specs/compliance-multi-metric-notes/design.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Design Document: Multi-Metric Notes for Compliance Detail Panel
|
||||
|
||||
## Overview
|
||||
|
||||
This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the `ComplianceDetailPanel` uses a single-select `<select>` dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.
|
||||
|
||||
The change touches three layers:
|
||||
|
||||
1. **Database** — add a `group_id` column to `compliance_notes` so notes created together can be identified as a batch.
|
||||
2. **API** — extend `POST /api/compliance/notes` to accept `metric_ids` (array) alongside the existing `metric_id` (string), inserting one row per metric inside a transaction.
|
||||
3. **Frontend** — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by `group_id` in the display.
|
||||
|
||||
Backward compatibility is preserved: the existing `metric_id` field continues to work, and notes created before this feature (which lack a `group_id`) render exactly as they do today.
|
||||
|
||||
## Architecture
|
||||
|
||||
The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing `compliance.js` route file and `ComplianceDetailPanel.js` component.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant DetailPanel as ComplianceDetailPanel
|
||||
participant API as POST /api/compliance/notes
|
||||
participant DB as SQLite (compliance_notes)
|
||||
|
||||
User->>DetailPanel: Select multiple metrics via chip selector
|
||||
User->>DetailPanel: Type note text, click Send
|
||||
DetailPanel->>API: POST { hostname, metric_ids: [...], note }
|
||||
API->>API: Validate inputs (note text, metric IDs)
|
||||
API->>API: Generate group_id (UUID)
|
||||
API->>DB: BEGIN TRANSACTION
|
||||
loop For each metric_id in metric_ids
|
||||
API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
|
||||
end
|
||||
API->>DB: COMMIT
|
||||
API->>DetailPanel: 201 { notes: [...created rows] }
|
||||
DetailPanel->>DetailPanel: Group notes by group_id, refresh display
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend
|
||||
|
||||
**Modified: `POST /api/compliance/notes`**
|
||||
|
||||
Request body accepts either format:
|
||||
|
||||
```javascript
|
||||
// New multi-metric format
|
||||
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }
|
||||
|
||||
// Legacy single-metric format (still supported)
|
||||
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }
|
||||
```
|
||||
|
||||
Precedence: if both `metric_id` and `metric_ids` are present, `metric_ids` wins.
|
||||
|
||||
Validation rules:
|
||||
- `hostname` — required, string, 1–300 chars, matches `/^[a-zA-Z0-9._-]+$/` (unchanged)
|
||||
- `metric_ids` — array of strings, each non-empty and ≤50 chars, at least one entry
|
||||
- `note` — required, non-empty after trimming, max 1000 chars (unchanged)
|
||||
|
||||
On success, the endpoint returns all created rows (with `username` joined from `users`) so the frontend can update without a separate fetch.
|
||||
|
||||
**New: Migration script `backend/migrations/add_compliance_notes_group_id.js`**
|
||||
|
||||
Adds the `group_id` column and backfills existing rows:
|
||||
|
||||
```sql
|
||||
ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
|
||||
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
|
||||
-- Backfill: each existing row gets its own unique group_id
|
||||
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;
|
||||
```
|
||||
|
||||
The backfill ensures every row has a `group_id`, so the frontend grouping logic works uniformly without null checks.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Modified: `ComplianceDetailPanel.js`**
|
||||
|
||||
The notes section is updated with three changes:
|
||||
|
||||
1. **Multi-select metric selector** — replaces the `<select>` dropdown with a chip-based toggle list. Each active metric is rendered as a clickable `MetricChip`. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.
|
||||
|
||||
2. **Submission logic** — `handleAddNote` sends `metric_ids` (array of selected metric IDs) instead of `metric_id` (single string). The submit button is disabled when no metrics are selected or note text is empty.
|
||||
|
||||
3. **Note display grouping** — notes are grouped by `group_id` before rendering. Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges. Notes without a `group_id` (pre-migration legacy) render as individual entries, same as today.
|
||||
|
||||
**Component structure:**
|
||||
|
||||
```
|
||||
ComplianceDetailPanel
|
||||
├── Header (hostname, IP, device type, team)
|
||||
├── Section: Failing Metrics
|
||||
│ └── MetricRow (per active metric)
|
||||
├── Section: Resolved Metrics
|
||||
│ └── MetricRow (per resolved metric)
|
||||
├── Section: History
|
||||
│ └── MetricChip + seen count (per active metric)
|
||||
└── Section: Notes
|
||||
├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
|
||||
└── Add Note Form
|
||||
├── MetricChipSelector (multi-select chip toggles)
|
||||
│ ├── MetricChip (per active metric, clickable)
|
||||
│ └── Select All / Deselect All toggle
|
||||
├── Textarea (note text)
|
||||
└── Send button (disabled when no metrics selected or text empty)
|
||||
```
|
||||
|
||||
**MetricChipSelector behavior:**
|
||||
|
||||
| State | Behavior |
|
||||
|---|---|
|
||||
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
|
||||
| 2+ active metrics, panel just opened | First metric pre-selected. Select All toggle visible. |
|
||||
| User clicks unselected chip | Chip added to selection |
|
||||
| User clicks selected chip (2+ selected) | Chip removed from selection |
|
||||
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
|
||||
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
|
||||
| Deselect All clicked | All metrics deselected except the first (to maintain minimum selection) |
|
||||
|
||||
**Design rationale — minimum selection of 1:** The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.
|
||||
|
||||
## Data Models
|
||||
|
||||
### compliance_notes table (modified)
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | Auto-increment row ID |
|
||||
| `hostname` | TEXT NOT NULL | Device hostname |
|
||||
| `metric_id` | TEXT NOT NULL | Compliance metric ID |
|
||||
| `note` | TEXT NOT NULL | Note text (max 1000 chars) |
|
||||
| `group_id` | TEXT | Batch identifier — rows from the same submission share this value |
|
||||
| `created_by` | INTEGER FK | User ID of the note author |
|
||||
| `created_at` | DATETIME | Timestamp of creation |
|
||||
|
||||
The `group_id` is a UUID v4 string generated server-side via `crypto.randomUUID()`. Single-metric submissions also receive a `group_id` so the frontend grouping logic is uniform.
|
||||
|
||||
**Index:** `idx_compliance_notes_group ON compliance_notes(group_id)` — supports the frontend's grouping query.
|
||||
|
||||
### API Response Shape
|
||||
|
||||
`POST /api/compliance/notes` response (201):
|
||||
|
||||
```json
|
||||
{
|
||||
"notes": [
|
||||
{
|
||||
"id": 42,
|
||||
"hostname": "SERVER01",
|
||||
"metric_id": "2.1.1",
|
||||
"note": "Vendor ticket VT-1234 opened",
|
||||
"group_id": "a1b2c3d4-...",
|
||||
"created_at": "2025-01-15 14:30:00",
|
||||
"created_by": "jsmith"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"hostname": "SERVER01",
|
||||
"metric_id": "2.3.2",
|
||||
"note": "Vendor ticket VT-1234 opened",
|
||||
"group_id": "a1b2c3d4-...",
|
||||
"created_at": "2025-01-15 14:30:00",
|
||||
"created_by": "jsmith"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/compliance/items/:hostname` response — the existing `notes` array now includes `group_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"notes": [
|
||||
{ "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
|
||||
{ "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
|
||||
{ "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The frontend groups consecutive notes by `group_id` to render multi-metric notes as a single card.
|
||||
|
||||
## 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: Select All / Deselect All round-trip
|
||||
|
||||
*For any* set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
### Property 2: Toggle label reflects selection state
|
||||
|
||||
*For any* set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.
|
||||
|
||||
**Validates: Requirements 2.3**
|
||||
|
||||
### Property 3: Multi-metric submission creates correct rows with shared group_id
|
||||
|
||||
*For any* valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in `compliance_notes` (where N = length of the metric IDs array), all sharing the same `note` text, `created_by` user, `created_at` timestamp, and `group_id` value.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1**
|
||||
|
||||
### Property 4: Whitespace-only notes are rejected
|
||||
|
||||
*For any* string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 5: Atomic validation — invalid metric IDs reject the entire batch
|
||||
|
||||
*For any* array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.
|
||||
|
||||
**Validates: Requirements 5.2, 5.6**
|
||||
|
||||
### Property 6: Note grouping display
|
||||
|
||||
*For any* set of notes where multiple notes share the same `group_id`, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2, 6.4**
|
||||
|
||||
### Property 7: Reverse chronological note ordering
|
||||
|
||||
*For any* set of notes with varying `created_at` timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest `created_at` first), regardless of how many metrics each group covers.
|
||||
|
||||
**Validates: Requirements 4.3**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Backend
|
||||
|
||||
| Scenario | Response | Behavior |
|
||||
|---|---|---|
|
||||
| Empty or whitespace-only note text | 400 `{ error: "Note cannot be empty" }` | No rows inserted |
|
||||
| `metric_ids` is empty array | 400 `{ error: "At least one metric ID is required" }` | No rows inserted |
|
||||
| Any metric ID in array is empty or >50 chars | 400 `{ error: "Invalid metric_id at index N" }` | No rows inserted (atomic rejection) |
|
||||
| `metric_ids` is not an array (when provided) | 400 `{ error: "metric_ids must be an array" }` | Falls back to checking `metric_id` |
|
||||
| Neither `metric_id` nor `metric_ids` provided | 400 `{ error: "metric_id or metric_ids is required" }` | No rows inserted |
|
||||
| Database error during transaction | 500 `{ error: "Failed to save note" }` | Transaction rolled back, no partial inserts |
|
||||
| Invalid hostname format | 400 `{ error: "Invalid hostname format" }` | No rows inserted (unchanged) |
|
||||
|
||||
Transaction safety: all inserts for a multi-metric note happen inside `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the transaction is rolled back and no rows are persisted.
|
||||
|
||||
### Frontend
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| API returns 400 validation error | Display error message below the note input (existing `noteError` state) |
|
||||
| API returns 500 server error | Display error message below the note input |
|
||||
| Network failure | Display "Failed to save note" error |
|
||||
| No metrics selected | Submit button is disabled, no API call made |
|
||||
| Successful submission | Clear note text, refresh notes list, retain metric selection |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (example-based)
|
||||
|
||||
- **Backend:**
|
||||
- Legacy `metric_id` field still creates a single note row (backward compatibility)
|
||||
- Both `metric_id` and `metric_ids` provided — `metric_ids` takes precedence
|
||||
- Single active metric pre-selects and is non-removable
|
||||
- Response shape includes all created rows with `group_id` and `username`
|
||||
|
||||
- **Frontend:**
|
||||
- MetricChipSelector renders correct number of chips for given active metrics
|
||||
- Clicking a chip toggles its selection state
|
||||
- Submit button disabled when note text is empty or no metrics selected
|
||||
- Notes without `group_id` (legacy) render as individual entries
|
||||
- Single active metric auto-selects and hides Select All toggle
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Property-based tests use `fast-check` (JavaScript PBT library) with a minimum of 100 iterations per property.
|
||||
|
||||
Each property test is tagged with a comment referencing the design property:
|
||||
- **Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id**
|
||||
- **Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected**
|
||||
- **Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch**
|
||||
|
||||
Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
|
||||
- Migration script: verify `group_id` column exists and legacy rows are backfilled
|
||||
- Backward compatibility: existing `GET /items/:hostname` response includes `group_id` field on notes
|
||||
85
.kiro/specs/compliance-multi-metric-notes/requirements.md
Normal file
85
.kiro/specs/compliance-multi-metric-notes/requirements.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The compliance detail panel currently allows users to add notes to a single metric at a time via a dropdown selector. When a remediation action, vendor ticket, or status update applies to multiple metrics on the same device, users must repeat the same note for each metric individually. This feature adds multi-metric selection to the note creation flow so that a single note can be associated with multiple metrics in one action, while preserving the existing per-metric note history and display.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Detail_Panel**: The slide-out panel (`ComplianceDetailPanel.js`) that opens when a user clicks a device row on the Compliance page. It displays failing metrics, resolved metrics, upload history, and notes for a single hostname.
|
||||
- **Note**: A timestamped, user-attributed text entry stored in the `compliance_notes` table, keyed on `(hostname, metric_id)`. Notes persist across uploads and form a historical record.
|
||||
- **Metric_Selector**: The UI control in the Detail_Panel's notes section that allows the user to choose which metric(s) a note applies to. Currently a single-select dropdown; this feature replaces it with a multi-select control.
|
||||
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
|
||||
- **Notes_API**: The `POST /api/compliance/notes` endpoint that persists a note to the database.
|
||||
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — these are the metrics currently failing.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Multi-Metric Selection UI
|
||||
|
||||
**User Story:** As a compliance analyst, I want to select multiple metrics when adding a note, so that I can document a single remediation action that covers several metrics without repeating myself.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Metric_Selector SHALL display all Active_Metrics as individually selectable options.
|
||||
2. WHEN the user interacts with the Metric_Selector, THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
|
||||
3. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Metric_Selector SHALL pre-select that metric and remain visible as a single non-removable selection.
|
||||
4. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select the first Active_Metric by default.
|
||||
5. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
|
||||
|
||||
### Requirement 2: Select All / Deselect All
|
||||
|
||||
**User Story:** As a compliance analyst, I want a quick way to select or deselect all metrics, so that I can efficiently apply a note to every failing metric on a device.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
|
||||
2. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
|
||||
3. WHEN the user manually selects all Active_Metrics one by one, THE toggle label SHALL update to "Deselect All" to reflect the current state.
|
||||
|
||||
### Requirement 3: Multi-Metric Note Submission
|
||||
|
||||
**User Story:** As a compliance analyst, I want the system to save my note against all selected metrics in one action, so that the historical record accurately reflects which metrics the note covers.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user submits a note with multiple metrics selected, THE Notes_API SHALL create one `compliance_notes` row per selected metric, all sharing the same note text, `created_by`, and `created_at` timestamp.
|
||||
2. WHEN the user submits a note with a single metric selected, THE Notes_API SHALL create exactly one `compliance_notes` row, preserving backward compatibility with the existing behavior.
|
||||
3. IF the note text is empty or contains only whitespace, THEN THE Notes_API SHALL reject the submission and return a validation error.
|
||||
4. IF no metrics are selected, THEN THE Detail_Panel SHALL disable the submit button and prevent submission.
|
||||
5. WHEN a multi-metric note is successfully saved, THE Detail_Panel SHALL clear the note text field, refresh the notes list, and retain the current metric selection.
|
||||
|
||||
### Requirement 4: Multi-Metric Note Display
|
||||
|
||||
**User Story:** As a compliance analyst, I want to see which metrics a note was applied to, so that I can understand the scope of past remediation actions.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a note was submitted for multiple metrics simultaneously, THE Detail_Panel SHALL display all associated Metric_Chips together on that note entry, visually grouped.
|
||||
2. WHEN a note was submitted for a single metric, THE Detail_Panel SHALL continue to display a single Metric_Chip on that note entry, matching the current behavior.
|
||||
3. THE Detail_Panel SHALL display notes in reverse chronological order, with the newest note first, regardless of how many metrics each note covers.
|
||||
|
||||
### Requirement 5: Backend Multi-Metric Notes Endpoint
|
||||
|
||||
**User Story:** As a developer, I want the notes API to accept an array of metric IDs, so that the frontend can submit a note for multiple metrics in a single request.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Notes_API SHALL accept a `metric_ids` field (array of strings) in the request body as an alternative to the existing `metric_id` field (single string).
|
||||
2. WHEN `metric_ids` is provided, THE Notes_API SHALL validate that each entry is a non-empty string of 50 characters or fewer.
|
||||
3. WHEN `metric_ids` is provided, THE Notes_API SHALL insert one `compliance_notes` row per metric ID, all within the same database transaction, sharing the same `created_at` timestamp.
|
||||
4. WHEN the legacy `metric_id` field is provided instead of `metric_ids`, THE Notes_API SHALL continue to function as before, inserting a single row.
|
||||
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Notes_API SHALL use `metric_ids` and ignore `metric_id`.
|
||||
6. IF any metric ID in the `metric_ids` array fails validation, THEN THE Notes_API SHALL reject the entire request and return a 400 error without inserting any rows.
|
||||
7. THE Notes_API SHALL return all created note rows in the response, so the frontend can update the display without a separate fetch.
|
||||
|
||||
### Requirement 6: Note Grouping Identifier
|
||||
|
||||
**User Story:** As a developer, I want notes that were created together to share a group identifier, so that the frontend can visually group multi-metric notes in the display.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN multiple notes are created from a single submission, THE Notes_API SHALL assign the same `group_id` value to all rows in that batch.
|
||||
2. WHEN a single note is created, THE Notes_API SHALL assign a unique `group_id` to that row.
|
||||
3. THE `group_id` SHALL be stored as a text column in the `compliance_notes` table.
|
||||
4. THE Detail_Panel SHALL use the `group_id` to visually group notes that were submitted together, displaying them as a single note entry with multiple Metric_Chips rather than as separate entries.
|
||||
105
.kiro/specs/compliance-multi-metric-notes/tasks.md
Normal file
105
.kiro/specs/compliance-multi-metric-notes/tasks.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Implementation Plan: Multi-Metric Notes for Compliance Detail Panel
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the compliance notes system so a single note can be associated with multiple metrics in one action. Changes span three layers: a new migration script adding `group_id` to `compliance_notes`, updates to the `POST /notes` endpoint in `backend/routes/compliance.js` to accept `metric_ids` (array) and insert rows transactionally, and frontend changes in `ComplianceDetailPanel.js` to replace the single-select dropdown with a multi-select chip selector and group notes by `group_id` in the display.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Create database migration for `group_id` column
|
||||
- [x] 1.1 Create `backend/migrations/add_compliance_notes_group_id.js`
|
||||
- Add `group_id TEXT` column to `compliance_notes` table via `ALTER TABLE`
|
||||
- Create index `idx_compliance_notes_group` on `compliance_notes(group_id)`
|
||||
- Backfill existing rows: `UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`
|
||||
- Follow the existing migration pattern (sqlite3, serialize, console logging)
|
||||
- _Requirements: 6.1, 6.2, 6.3_
|
||||
|
||||
- [x] 2. Update `POST /notes` endpoint to support multi-metric submissions
|
||||
- [x] 2.1 Modify the `POST /notes` handler in `backend/routes/compliance.js` to accept `metric_ids` array
|
||||
- Accept `metric_ids` (array of strings) as an alternative to `metric_id` (single string)
|
||||
- When both are provided, `metric_ids` takes precedence
|
||||
- When neither is provided, return 400 with `"metric_id or metric_ids is required"`
|
||||
- When `metric_ids` is provided but is not an array, return 400 with `"metric_ids must be an array"`
|
||||
- Normalize single `metric_id` into a one-element array internally so the rest of the logic is uniform
|
||||
- _Requirements: 5.1, 5.4, 5.5_
|
||||
|
||||
- [x] 2.2 Add validation for `metric_ids` array entries
|
||||
- Validate that `metric_ids` has at least one entry; return 400 with `"At least one metric ID is required"` if empty
|
||||
- Validate each entry is a non-empty string of 50 characters or fewer; return 400 with `"Invalid metric_id at index N"` on failure
|
||||
- Reject the entire request if any entry fails validation (atomic rejection, no partial inserts)
|
||||
- _Requirements: 5.2, 5.6_
|
||||
|
||||
- [x] 2.3 Implement transactional multi-row insert with `group_id`
|
||||
- Generate a `group_id` using `crypto.randomUUID()` for each submission (single or multi)
|
||||
- Wrap all inserts in `BEGIN TRANSACTION` / `COMMIT` with `ROLLBACK` on error
|
||||
- Insert one `compliance_notes` row per metric ID, all sharing the same `note`, `group_id`, `created_by`, and `created_at`
|
||||
- _Requirements: 3.1, 3.2, 5.3, 6.1, 6.2_
|
||||
|
||||
- [x] 2.4 Update the response to return all created note rows
|
||||
- After commit, query all created rows (joined with `users` for `username`) and return as `{ notes: [...] }`
|
||||
- Each row includes `id`, `hostname`, `metric_id`, `note`, `group_id`, `created_at`, `created_by`
|
||||
- Return HTTP 201 status
|
||||
- _Requirements: 5.7_
|
||||
|
||||
- [x] 3. Update `GET /items/:hostname` to include `group_id` in notes response
|
||||
- Add `cn.group_id` to the SELECT in the notes query within the `GET /items/:hostname` handler
|
||||
- The existing query already fetches notes for the hostname; just add the column
|
||||
- No other changes to this endpoint
|
||||
- _Requirements: 6.3, 6.4_
|
||||
|
||||
- [x] 4. Checkpoint — Verify backend changes
|
||||
- Ensure all backend changes are syntactically correct, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Replace single-select dropdown with multi-select MetricChipSelector in `ComplianceDetailPanel.js`
|
||||
- [x] 5.1 Replace `noteMetric` (string) state with `selectedMetrics` (array) state
|
||||
- Initialize `selectedMetrics` with the first active metric's ID when detail loads (matching current default behavior)
|
||||
- When there is exactly one active metric, pre-select it as a non-removable selection
|
||||
- _Requirements: 1.3, 1.4_
|
||||
|
||||
- [x] 5.2 Build the multi-select chip-based metric selector UI
|
||||
- Replace the existing `<select>` dropdown with a row of clickable `MetricChip` components
|
||||
- Each active metric renders as a chip; selected chips get a highlighted border/background
|
||||
- Clicking an unselected chip adds it to `selectedMetrics`
|
||||
- Clicking a selected chip removes it, unless it is the only selected chip (minimum 1 selection)
|
||||
- Only show the chip selector when there are 2+ active metrics (single metric is auto-selected)
|
||||
- Style chips using existing `MetricChip` component patterns and category colors
|
||||
- _Requirements: 1.1, 1.2, 1.5_
|
||||
|
||||
- [x] 5.3 Add Select All / Deselect All toggle
|
||||
- Show a text toggle above or beside the chip row when there are 2+ active metrics
|
||||
- "Select All" selects all active metrics; label changes to "Deselect All"
|
||||
- "Deselect All" deselects all except the first metric (minimum selection invariant)
|
||||
- Toggle label is a pure function of whether all metrics are selected
|
||||
- Hide the toggle when there is only one active metric
|
||||
- _Requirements: 2.1, 2.2, 2.3_
|
||||
|
||||
- [x] 6. Update note submission logic to send `metric_ids` array
|
||||
- Modify `handleAddNote` to send `{ hostname, metric_ids: selectedMetrics, note }` instead of `{ hostname, metric_id: noteMetric, note }`
|
||||
- Disable the submit button when `selectedMetrics` is empty or note text is empty
|
||||
- On success, clear note text, refresh the detail panel, and retain the current metric selection
|
||||
- Handle the new response shape (`{ notes: [...] }`) from the updated API
|
||||
- _Requirements: 3.1, 3.4, 3.5_
|
||||
|
||||
- [x] 7. Update note display to group by `group_id`
|
||||
- [x] 7.1 Add note grouping logic
|
||||
- Group the `detail.notes` array by `group_id` before rendering
|
||||
- Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges
|
||||
- Notes without a `group_id` (pre-migration legacy, should not occur after backfill) render as individual entries
|
||||
- Maintain reverse chronological order (newest `created_at` first) across groups
|
||||
- _Requirements: 4.1, 4.2, 4.3, 6.4_
|
||||
|
||||
- [x] 7.2 Update the note card rendering
|
||||
- For grouped notes, display all associated `MetricChip` components in the card header
|
||||
- For single-metric notes, display one `MetricChip` (matching current behavior)
|
||||
- Preserve existing note card styling (background, border, padding, typography)
|
||||
- _Requirements: 4.1, 4.2_
|
||||
|
||||
- [x] 8. Final checkpoint — Verify full feature
|
||||
- Ensure frontend compiles without errors, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- No automated tests — feature is validated manually per user preference
|
||||
- No new components or route modules required; all changes are scoped to existing files plus one migration
|
||||
- The `group_id` backfill ensures legacy notes render correctly without null checks
|
||||
- Each task references specific requirements 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)
|
||||
1
.kiro/specs/fp-attachment-library/.config.kiro
Normal file
1
.kiro/specs/fp-attachment-library/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||
362
.kiro/specs/fp-attachment-library/design.md
Normal file
362
.kiro/specs/fp-attachment-library/design.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Design Document: FP Attachment Library
|
||||
|
||||
## Overview
|
||||
|
||||
This feature extends the FP submission workflow to let users attach documents from the existing CVE document library (the `documents` table) alongside traditional local file uploads. The core change is a new **Attachment Source Picker** component shared by both the create and edit modals, backed by a new **Document Search API** endpoint. On submission, the backend reads library files from disk and sends them to the Ivanti API identically to local uploads.
|
||||
|
||||
The design prioritizes minimal disruption to the existing codebase: one new GET endpoint, modifications to two existing POST endpoints, and a shared React component inserted into both modals.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Frontend
|
||||
A[FpWorkflowModal] --> C[AttachmentSourcePicker]
|
||||
B[FpEditModal] --> C
|
||||
C -->|local files| D[File objects in state]
|
||||
C -->|library docs| E[Document IDs in state]
|
||||
end
|
||||
subgraph Backend
|
||||
F[GET /api/documents/search] -->|SQLite| G[(documents table)]
|
||||
H[POST /api/ivanti/fp-workflow] -->|reads disk| I[uploads/]
|
||||
H -->|multipart| J[Ivanti API]
|
||||
K[POST .../attachments] -->|reads disk| I
|
||||
K -->|multipart| J
|
||||
end
|
||||
C -->|fetch| F
|
||||
A -->|FormData + libraryDocIds| H
|
||||
B -->|FormData + libraryDocIds| K
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. User opens FP Create or Edit modal
|
||||
2. Attachment Source Picker renders with two mode tabs: **Local Upload** and **Library**
|
||||
3. In Library mode, user types a search query → frontend debounces 300ms → calls `GET /api/documents/search?q=...`
|
||||
4. User selects library documents and/or local files
|
||||
5. On submit:
|
||||
- Frontend sends `FormData` with local files in `attachments` field and library document IDs in a `libraryDocIds` JSON field
|
||||
- Backend parses both, looks up library documents in the `documents` table, reads their files from disk
|
||||
- Backend combines local file buffers and library file buffers into a single `files` array
|
||||
- Backend calls `ivantiFormPost` with all files in one multipart request
|
||||
- Backend records results in `attachment_results_json` with a `source` field (`"local"` or `"library"`)
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Single shared component**: The `AttachmentSourcePicker` is used in both modals to avoid duplication. It receives callbacks for state management and renders the mode toggle, search UI, and unified attachment list.
|
||||
|
||||
2. **Library doc IDs sent as JSON field**: Rather than changing the multipart structure, library document IDs are sent as a JSON-encoded string field (`libraryDocIds`) alongside the existing `attachments` file field. This keeps the existing local upload path unchanged.
|
||||
|
||||
3. **Backend reads files from disk**: Library documents are read from disk using `fs.readFileSync(file_path)` at submission time. This avoids storing duplicate file buffers and keeps the Ivanti API call identical for both sources.
|
||||
|
||||
4. **No new database tables**: The feature uses the existing `documents` table for search and the existing `ivanti_fp_submissions` table for recording results. The only schema-level change is adding a `source` field to the JSON objects in `attachment_results_json`.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### New API Endpoint
|
||||
|
||||
#### `GET /api/documents/search`
|
||||
|
||||
Added to `backend/routes/ivantiFpWorkflow.js` (or as a new route in `server.js` alongside existing document routes).
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `q` | query string | No | Search term matched against `name`, `cve_id`, `vendor` using SQL `LIKE` |
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"cve_id": "CVE-2024-1234",
|
||||
"vendor": "Microsoft",
|
||||
"name": "advisory-2024-1234.pdf",
|
||||
"type": "Advisory",
|
||||
"file_size": "245760",
|
||||
"mime_type": "application/pdf",
|
||||
"uploaded_at": "2024-11-15T10:30:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```javascript
|
||||
// Pseudocode
|
||||
router.get('/documents/search', requireAuth(db), (req, res) => {
|
||||
const q = (req.query.q || '').trim();
|
||||
let sql, params;
|
||||
if (q) {
|
||||
const like = `%${q}%`;
|
||||
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
|
||||
FROM documents
|
||||
WHERE name LIKE ? OR cve_id LIKE ? OR vendor LIKE ?
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 50`;
|
||||
params = [like, like, like];
|
||||
} else {
|
||||
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
|
||||
FROM documents
|
||||
ORDER BY uploaded_at DESC
|
||||
LIMIT 50`;
|
||||
params = [];
|
||||
}
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: 'Database error.' });
|
||||
res.json(rows || []);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Modified API Endpoints
|
||||
|
||||
#### `POST /api/ivanti/fp-workflow` (create)
|
||||
|
||||
**New field in multipart body**:
|
||||
- `libraryDocIds` — JSON-encoded array of document IDs (integers) from the `documents` table
|
||||
|
||||
**Backend changes**:
|
||||
1. Parse `libraryDocIds` from `req.body` (default to `[]`)
|
||||
2. Validate each ID is a positive integer
|
||||
3. Query `documents` table for matching records
|
||||
4. Validate all IDs were found (400 if any missing)
|
||||
5. Read each file from disk using `file_path` (error if file missing on disk)
|
||||
6. Combine local file buffers (`req.files`) and library file buffers into a single `formFiles` array
|
||||
7. Pass combined array to `ivantiFormPost`
|
||||
8. Record results with `source: "local"` or `source: "library"` in `attachment_results_json`
|
||||
|
||||
#### `POST /api/ivanti/fp-workflow/submissions/:id/attachments` (edit)
|
||||
|
||||
Same changes as the create endpoint — accepts `libraryDocIds` alongside `attachments` files.
|
||||
|
||||
### New Frontend Component
|
||||
|
||||
#### `AttachmentSourcePicker`
|
||||
|
||||
Defined inline in `ReportingPage.js` (consistent with existing component patterns).
|
||||
|
||||
**Props**:
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `files` | `File[]` | Current local file attachments |
|
||||
| `onFilesChange` | `(files: File[]) => void` | Callback when local files change |
|
||||
| `libraryDocs` | `object[]` | Current selected library documents |
|
||||
| `onLibraryDocsChange` | `(docs: object[]) => void` | Callback when library selections change |
|
||||
| `disabled` | `boolean` | Disables all interactions (for approved submissions) |
|
||||
|
||||
**Internal state**:
|
||||
- `mode` — `'local'` or `'library'` (default: `'local'`)
|
||||
- `searchQuery` — current search input value
|
||||
- `searchResults` — array of document records from API
|
||||
- `searching` — loading state for search
|
||||
|
||||
**Behavior**:
|
||||
- Mode toggle renders two tab-style buttons at the top
|
||||
- Local mode shows the existing drag-and-drop zone
|
||||
- Library mode shows a search input + scrollable results list
|
||||
- Search is debounced at 300ms using `setTimeout`/`clearTimeout`
|
||||
- Selected library docs are tracked by `id` to prevent duplicates
|
||||
- Already-selected docs appear disabled/checked in search results
|
||||
- Unified attachment list below shows all attachments with source badges
|
||||
- Each attachment row shows: source badge, filename, file size, remove button
|
||||
- Library attachment rows additionally show CVE ID and vendor
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LocalMode
|
||||
LocalMode --> LibraryMode: Click "Library" tab
|
||||
LibraryMode --> LocalMode: Click "Local Upload" tab
|
||||
|
||||
state LibraryMode {
|
||||
[*] --> Idle
|
||||
Idle --> Searching: User types (after 300ms debounce)
|
||||
Searching --> ResultsShown: API responds
|
||||
ResultsShown --> Searching: User types again
|
||||
ResultsShown --> DocSelected: User clicks result
|
||||
DocSelected --> ResultsShown: Doc added to list
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### FpWorkflowModal (create)
|
||||
|
||||
- Replace the current file upload section with `<AttachmentSourcePicker>`
|
||||
- Add `libraryDocs` state array alongside existing `files` state
|
||||
- On submit, append `libraryDocIds` as JSON string to `FormData`:
|
||||
```javascript
|
||||
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||
```
|
||||
|
||||
#### FpEditModal (edit — attachments tab)
|
||||
|
||||
- Replace the static "upload in Ivanti" message with `<AttachmentSourcePicker>`
|
||||
- Keep existing attachment display above the picker
|
||||
- On submit, build `FormData` with both local files and `libraryDocIds`
|
||||
- Disable picker when `lifecycle_status === 'approved'`
|
||||
|
||||
## Data Models
|
||||
|
||||
### Existing: `documents` table (no changes)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | INTEGER PK | Auto-increment ID |
|
||||
| `cve_id` | VARCHAR(20) | Associated CVE identifier |
|
||||
| `vendor` | VARCHAR(100) | Vendor name |
|
||||
| `name` | VARCHAR(255) | Original filename |
|
||||
| `type` | VARCHAR(50) | Document type (Advisory, Patch, etc.) |
|
||||
| `file_path` | VARCHAR(500) | Relative path under `uploads/` |
|
||||
| `file_size` | VARCHAR(20) | Human-readable or byte size |
|
||||
| `mime_type` | VARCHAR(100) | MIME type |
|
||||
| `uploaded_at` | TIMESTAMP | Upload timestamp |
|
||||
| `notes` | TEXT | Optional notes |
|
||||
|
||||
### Modified: `attachment_results_json` shape
|
||||
|
||||
Current format per entry:
|
||||
```json
|
||||
{ "filename": "report.pdf", "success": true }
|
||||
```
|
||||
|
||||
New format per entry:
|
||||
```json
|
||||
{ "filename": "report.pdf", "success": true, "source": "local" }
|
||||
```
|
||||
or:
|
||||
```json
|
||||
{ "filename": "advisory-2024.pdf", "success": true, "source": "library", "documentId": 42 }
|
||||
```
|
||||
|
||||
The `source` field is added to distinguish attachment origins. The `documentId` field is included for library documents to enable traceability. Existing records without a `source` field are treated as `"local"` by the frontend for backward compatibility.
|
||||
|
||||
### Frontend State: Library Document Selection
|
||||
|
||||
```javascript
|
||||
// Shape of a selected library document in component state
|
||||
{
|
||||
id: 42, // documents.id
|
||||
cve_id: "CVE-2024-1234",
|
||||
vendor: "Microsoft",
|
||||
name: "advisory-2024-1234.pdf",
|
||||
file_size: "245760",
|
||||
mime_type: "application/pdf"
|
||||
}
|
||||
```
|
||||
|
||||
## 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: Search results match query
|
||||
|
||||
*For any* non-empty search query string `q` and any set of documents in the database, every document returned by the Document Search API SHALL have `q` as a case-insensitive substring of its `name`, `cve_id`, or `vendor` field.
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
### Property 2: Default results are ordered by recency
|
||||
|
||||
*For any* set of documents in the database, when the Document Search API is called with no query, the returned results SHALL be ordered by `uploaded_at` descending (most recent first).
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 3: Result set size is bounded
|
||||
|
||||
*For any* search query (including empty), the Document Search API SHALL return at most 50 records.
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 4: Library document ID validation rejects non-positive-integers
|
||||
|
||||
*For any* value that is not a positive integer (e.g., negative numbers, zero, floats, non-numeric strings, null), the backend validation SHALL reject it as an invalid library document ID.
|
||||
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
### Property 5: Combined attachments are all sent to Ivanti
|
||||
|
||||
*For any* combination of local file uploads and library document references in a submission, the backend SHALL produce a files array for the Ivanti API call whose length equals the count of local files plus the count of valid library documents, and each library file buffer SHALL match the content read from the document's `file_path`.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
### Property 6: Attachment results record source and filename correctly
|
||||
|
||||
*For any* mix of local and library attachments processed by the backend, each entry in `attachment_results_json` SHALL have a `source` field of `"local"` or `"library"`, and for library entries the `filename` SHALL equal the `name` field from the corresponding `documents` record.
|
||||
|
||||
**Validates: Requirements 4.6, 4.7**
|
||||
|
||||
### Property 7: No duplicate library documents in attachment list
|
||||
|
||||
*For any* sequence of library document selections applied to the Attachment Source Picker, the resulting attachment list SHALL contain at most one entry per document `id`.
|
||||
|
||||
**Validates: Requirements 5.1**
|
||||
|
||||
### Property 8: Attachment list displays all required fields per type
|
||||
|
||||
*For any* attachment in the list (local or library), the rendered display SHALL include the filename, file size, source indicator, and a remove action. *For any* library attachment, the display SHALL additionally include the CVE ID and vendor name.
|
||||
|
||||
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Document search DB error | Return 500 with `{ error: 'Database error.' }` |
|
||||
| Invalid `libraryDocIds` JSON | Return 400 with `{ error: 'libraryDocIds must be a valid JSON array.' }` |
|
||||
| Non-positive-integer document ID | Return 400 identifying the invalid ID |
|
||||
| Document ID not found in DB | Return 400 identifying the missing document ID |
|
||||
| Library file missing from disk | Log warning, skip that attachment, include `{ success: false, error: 'File not found on disk' }` in attachment results, continue with remaining files |
|
||||
| Ivanti API failure for attachment upload | Record `{ success: false, error: '...' }` per file in results, return partial success if some files succeeded |
|
||||
| Network error calling Document Search API (frontend) | Show inline error message in search results area, allow retry |
|
||||
| Empty search results | Show "No documents found" message with suggestion to refine search |
|
||||
| Unauthenticated request to search endpoint | Return 401 (handled by existing `requireAuth` middleware) |
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- Existing `attachment_results_json` entries without a `source` field are treated as `"local"` by the frontend
|
||||
- The `libraryDocIds` field is optional in both create and edit endpoints — omitting it preserves current behavior exactly
|
||||
- No database migrations required — the `documents` table already exists
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests (fast-check)
|
||||
|
||||
The project uses plain JavaScript with React 19. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) with the existing `react-scripts test` runner (Jest).
|
||||
|
||||
Each property test runs a minimum of 100 iterations and is tagged with a comment referencing its design property.
|
||||
|
||||
**Configuration**: `npm install --save-dev fast-check` in the frontend package (or backend if testing backend logic separately).
|
||||
|
||||
**Properties to test**:
|
||||
- Property 1: Search relevance — generate random documents and queries, verify all results match
|
||||
- Property 2: Default ordering — generate random documents, verify descending order
|
||||
- Property 3: Result limit — generate >50 documents, verify max 50 returned
|
||||
- Property 4: ID validation — generate random non-positive-integer values, verify rejection
|
||||
- Property 5: Combined attachment handling — generate random mixes, verify file array correctness
|
||||
- Property 6: Result record shape — generate random mixes, verify source and filename fields
|
||||
- Property 7: Duplicate prevention — generate random selection sequences, verify uniqueness
|
||||
- Property 8: Display completeness — generate random attachment lists, verify rendered fields
|
||||
|
||||
**Tag format**: `// Feature: fp-attachment-library, Property N: <property text>`
|
||||
|
||||
### Unit Tests (example-based)
|
||||
|
||||
- Authentication guard on search endpoint (1.5)
|
||||
- DB error handling returns 500 (1.6)
|
||||
- Mode toggle renders correctly in both modals (2.1, 2.2, 2.3)
|
||||
- Debounce behavior with fake timers (2.4)
|
||||
- Library doc selection adds to list with indicator (2.5)
|
||||
- Remove works for both types (2.6)
|
||||
- Mixed attachments in same submission (2.7)
|
||||
- Library doc displays name, size, CVE ID (2.8)
|
||||
- Edit modal replaces static message (3.1)
|
||||
- Existing attachments shown above picker (3.4)
|
||||
- Approved submission disables picker (3.5)
|
||||
- Missing file on disk returns error (4.3)
|
||||
- Invalid document ID returns 400 (4.4)
|
||||
- Already-selected docs shown as disabled (5.2)
|
||||
- Removed doc re-enabled in results (5.3)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- End-to-end create flow with mixed local + library attachments
|
||||
- End-to-end edit flow adding library attachments to existing submission
|
||||
- Search endpoint with real SQLite database
|
||||
94
.kiro/specs/fp-attachment-library/requirements.md
Normal file
94
.kiro/specs/fp-attachment-library/requirements.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The FP Attachment Library feature extends the FP submission workflow (both create and edit flows) to allow users to attach existing documents from the CVE document library stored in the `documents` table, in addition to the current local file upload capability. This eliminates the need to re-download and re-upload files that already exist in the system, streamlining the attachment workflow for FP submissions.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Dashboard**: The STEAM Security Dashboard application
|
||||
- **FP_Create_Modal**: The FpWorkflowModal component used to create new FP workflow submissions (in ReportingPage.js)
|
||||
- **FP_Edit_Modal**: The FpEditModal component used to edit existing FP workflow submissions (in ReportingPage.js)
|
||||
- **Document_Library**: The collection of files stored in the `documents` table, organized by CVE ID and vendor, with files on disk under `uploads/{cve_id}/{vendor}/`
|
||||
- **Attachment_Source_Picker**: The UI component that lets users choose between uploading a local file or selecting an existing document from the Document_Library
|
||||
- **Document_Search_API**: The backend endpoint that searches and returns documents from the Document_Library for selection
|
||||
- **Library_Document**: A document record from the `documents` table, containing id, cve_id, vendor, name, type, file_path, file_size, mime_type, uploaded_at, and notes
|
||||
- **Ivanti_API**: The external Ivanti/RiskSense API that receives FP workflow submissions and file attachments
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Document Search API
|
||||
|
||||
**User Story:** As an editor, I want to search the document library from within the FP workflow, so that I can find and attach existing documents without leaving the modal.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a search query is provided, THE Document_Search_API SHALL return Library_Document records whose name, cve_id, or vendor fields contain the query string
|
||||
2. WHEN no search query is provided, THE Document_Search_API SHALL return the most recent Library_Document records ordered by uploaded_at descending
|
||||
3. THE Document_Search_API SHALL limit results to a maximum of 50 records per request
|
||||
4. THE Document_Search_API SHALL return each Library_Document with its id, cve_id, vendor, name, type, file_size, mime_type, and uploaded_at fields
|
||||
5. THE Document_Search_API SHALL require an authenticated session before returning results
|
||||
6. IF the database query fails, THEN THE Document_Search_API SHALL return an error response with a 500 status code
|
||||
|
||||
### Requirement 2: Attachment Source Picker in FP Create Modal
|
||||
|
||||
**User Story:** As an editor, I want to choose between uploading a local file or selecting a document from the library when creating an FP submission, so that I can attach evidence without re-uploading files that already exist in the system.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE FP_Create_Modal SHALL display the Attachment_Source_Picker with two modes: local file upload and library document selection
|
||||
2. WHEN the user selects local file upload mode, THE FP_Create_Modal SHALL display the existing drag-and-drop zone and file picker
|
||||
3. WHEN the user selects library document selection mode, THE FP_Create_Modal SHALL display a search input and a scrollable list of matching Library_Document records
|
||||
4. WHEN the user types in the library search input, THE FP_Create_Modal SHALL query the Document_Search_API and display matching results within 300 milliseconds of the last keystroke (debounced)
|
||||
5. WHEN the user selects a Library_Document from the search results, THE FP_Create_Modal SHALL add the document to the attachment list with a visual indicator distinguishing it from locally uploaded files
|
||||
6. THE FP_Create_Modal SHALL allow the user to remove any attachment from the list, whether it is a local file or a Library_Document
|
||||
7. THE FP_Create_Modal SHALL allow mixing local file uploads and Library_Document selections in the same submission
|
||||
8. THE FP_Create_Modal SHALL display the file name, file size, and CVE ID for each selected Library_Document in the attachment list
|
||||
|
||||
### Requirement 3: Attachment Source Picker in FP Edit Modal
|
||||
|
||||
**User Story:** As an editor, I want to attach existing library documents to an FP submission I am editing, so that I can add supporting evidence after the initial submission without re-uploading files.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE FP_Edit_Modal SHALL replace the static "upload in Ivanti" message on the attachments tab with the Attachment_Source_Picker
|
||||
2. WHEN the user selects library document selection mode, THE FP_Edit_Modal SHALL display a search input and a scrollable list of matching Library_Document records
|
||||
3. WHEN the user selects local file upload mode, THE FP_Edit_Modal SHALL display a drag-and-drop zone and file picker for local files
|
||||
4. THE FP_Edit_Modal SHALL continue to display existing attachments from the initial submission above the Attachment_Source_Picker
|
||||
5. WHILE the submission lifecycle_status is "approved", THE FP_Edit_Modal SHALL disable the Attachment_Source_Picker and prevent adding new attachments
|
||||
6. THE FP_Edit_Modal SHALL allow the user to upload or attach selected documents by clicking a submit action button
|
||||
|
||||
### Requirement 4: Backend Handling of Library Document Attachments
|
||||
|
||||
**User Story:** As an editor, I want library documents to be sent to the Ivanti API the same way as local uploads, so that all attachments appear correctly on the Ivanti workflow.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the FP submission includes Library_Document references, THE Dashboard backend SHALL read the referenced files from disk using the file_path stored in the documents table
|
||||
2. WHEN the FP submission includes both local files and Library_Document references, THE Dashboard backend SHALL send all attachments to the Ivanti_API in a single multipart request
|
||||
3. IF a referenced Library_Document file_path does not exist on disk, THEN THE Dashboard backend SHALL return an error identifying the missing file and skip that attachment
|
||||
4. IF a referenced Library_Document id does not exist in the documents table, THEN THE Dashboard backend SHALL return a 400 error identifying the invalid document ID
|
||||
5. THE Dashboard backend SHALL validate that each referenced Library_Document id is a positive integer before querying the database
|
||||
6. THE Dashboard backend SHALL include Library_Document attachments in the attachment_results_json field of the submission record, with a source indicator distinguishing them from local uploads
|
||||
7. WHEN recording attachment results, THE Dashboard backend SHALL store the original document name from the Library_Document record as the filename
|
||||
|
||||
### Requirement 5: Duplicate Attachment Prevention
|
||||
|
||||
**User Story:** As an editor, I want the system to prevent me from attaching the same library document twice, so that I do not create redundant attachments on the Ivanti workflow.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user selects a Library_Document that is already in the attachment list, THE Attachment_Source_Picker SHALL not add a duplicate entry
|
||||
2. THE Attachment_Source_Picker SHALL visually indicate Library_Document records that are already attached by showing them as disabled or checked in the search results
|
||||
3. WHEN the user removes a previously selected Library_Document from the attachment list, THE Attachment_Source_Picker SHALL re-enable that document in the search results
|
||||
|
||||
### Requirement 6: Attachment List Display
|
||||
|
||||
**User Story:** As an editor, I want to clearly distinguish between local uploads and library documents in the attachment list, so that I know the source of each attachment before submitting.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Attachment_Source_Picker SHALL display a source badge or icon next to each attachment indicating whether it is a "Local Upload" or a "Library Document"
|
||||
2. THE Attachment_Source_Picker SHALL display the file name and file size for all attachments regardless of source
|
||||
3. WHEN displaying a Library_Document attachment, THE Attachment_Source_Picker SHALL also display the associated CVE ID and vendor name
|
||||
4. THE Attachment_Source_Picker SHALL display a remove button for each attachment in the list
|
||||
95
.kiro/specs/fp-attachment-library/tasks.md
Normal file
95
.kiro/specs/fp-attachment-library/tasks.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Implementation Plan: FP Attachment Library
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements the FP Attachment Library feature, which allows users to attach existing CVE document library files to FP workflow submissions alongside traditional local file uploads. The implementation adds a new Document Search API endpoint, modifies two existing backend endpoints to handle library document references, and creates a shared AttachmentSourcePicker component used in both the create and edit modals.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add Document Search API endpoint
|
||||
- [x] 1.1 Add `GET /api/documents/search` route in `backend/routes/ivantiFpWorkflow.js`
|
||||
- Add a new GET route handler for `/documents/search` inside `createIvantiFpWorkflowRouter`
|
||||
- Accept optional `q` query parameter for search term
|
||||
- When `q` is provided, query the `documents` table with `LIKE` matching against `name`, `cve_id`, and `vendor` columns (case-insensitive)
|
||||
- When `q` is empty or missing, return the most recent documents ordered by `uploaded_at DESC`
|
||||
- Limit results to 50 records maximum
|
||||
- Return each record with fields: `id`, `cve_id`, `vendor`, `name`, `type`, `file_size`, `mime_type`, `uploaded_at`
|
||||
- Protect with `requireAuth(db)` middleware
|
||||
- Return 500 with `{ error: 'Database error.' }` on DB failure
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||
|
||||
- [x] 2. Modify backend to handle library document attachments on create
|
||||
- [x] 2.1 Update `POST /api/ivanti/fp-workflow` in `backend/routes/ivantiFpWorkflow.js` to accept `libraryDocIds`
|
||||
- Parse `libraryDocIds` from `req.body` as a JSON-encoded array (default to `[]` if absent)
|
||||
- Return 400 if `libraryDocIds` is not valid JSON
|
||||
- Validate each ID is a positive integer; return 400 identifying any invalid ID
|
||||
- Query the `documents` table for all referenced IDs; return 400 if any ID is not found
|
||||
- Read each library file from disk using `fs.readFileSync(file_path)`; if a file is missing on disk, log a warning and include `{ success: false, error: 'File not found on disk', source: 'library', documentId: id }` in attachment results, skip that file
|
||||
- Combine local file buffers (`req.files`) and library file buffers into a single `formFiles` array passed to `ivantiFormPost`
|
||||
- Record attachment results with `source: "local"` for uploaded files and `source: "library"` plus `documentId` for library files
|
||||
- Use the `name` field from the `documents` record as the `filename` in attachment results for library files
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||
|
||||
- [x] 3. Modify backend to handle library document attachments on edit
|
||||
- [x] 3.1 Update `POST /api/ivanti/fp-workflow/submissions/:id/attachments` in `backend/routes/ivantiFpWorkflow.js` to accept `libraryDocIds`
|
||||
- Apply the same `libraryDocIds` parsing, validation, disk-read, and combined upload logic as task 2.1
|
||||
- Combine local file buffers and library file buffers into a single `formFiles` array for the Ivanti API call
|
||||
- Record attachment results with `source` and `documentId` fields matching the create endpoint behavior
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||
|
||||
- [x] 4. Checkpoint — Verify backend changes
|
||||
- Ensure all backend changes are syntactically correct and consistent with existing patterns. Ask the user if questions arise.
|
||||
|
||||
- [x] 5. Create AttachmentSourcePicker component
|
||||
- [x] 5.1 Implement `AttachmentSourcePicker` inline in `frontend/src/components/pages/ReportingPage.js`
|
||||
- Define the component above `FpWorkflowModal` in the file
|
||||
- Accept props: `files`, `onFilesChange`, `libraryDocs`, `onLibraryDocsChange`, `disabled`
|
||||
- Implement a mode toggle with two tab-style buttons: "Local Upload" and "Library" (default to "Local Upload")
|
||||
- In Local Upload mode, render the existing drag-and-drop zone with file input, file validation (extension + size), and file list
|
||||
- In Library mode, render a search input that queries `GET /api/documents/search?q=...` with 300ms debounce using `setTimeout`/`clearTimeout`
|
||||
- Display search results in a scrollable list showing document name, CVE ID, vendor, and file size
|
||||
- Show already-selected library documents as disabled/checked in search results to prevent duplicates
|
||||
- When a search result is clicked, add it to `libraryDocs` via `onLibraryDocsChange` (skip if already selected by `id`)
|
||||
- When a library doc is removed from the attachment list, re-enable it in search results
|
||||
- Render a unified attachment list below the mode-specific UI showing all attachments (local + library)
|
||||
- Each attachment row displays: source badge ("Local" or "Library"), filename, file size, and a remove button (Trash2 icon)
|
||||
- Library attachment rows additionally display CVE ID and vendor name
|
||||
- Disable all interactions when `disabled` prop is true
|
||||
- Style consistently with existing modal components using inline style objects, monospace font, dark theme colors from DESIGN_SYSTEM.md
|
||||
- Handle network errors on search by showing an inline error message in the results area
|
||||
- Show "No documents found" when search returns empty results
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 5.1, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4_
|
||||
|
||||
- [x] 6. Integrate AttachmentSourcePicker into FpWorkflowModal (create flow)
|
||||
- [x] 6.1 Replace the file upload section in `FpWorkflowModal` with `AttachmentSourcePicker`
|
||||
- Add `libraryDocs` state (`useState([])`) alongside existing `files` state
|
||||
- Reset `libraryDocs` to `[]` when modal opens (in the existing `useEffect` on `open`)
|
||||
- Replace the current drag-and-drop zone and file list section with `<AttachmentSourcePicker>` passing `files`, `setFiles`, `libraryDocs`, `setLibraryDocs`, and `disabled={submitting}`
|
||||
- Remove the inline `addFiles`, `removeFile`, `handleDrop`, `handleDragOver` functions and `fileInputRef`/`dropRef` refs (these are now handled inside AttachmentSourcePicker)
|
||||
- On submit, append `libraryDocIds` as a JSON string to the FormData: `formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)))`
|
||||
- Update the progress message to reflect combined attachment count
|
||||
- Update the result view to show source badges on attachment results (use `source` field, default to `"local"` for backward compatibility)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.5, 2.6, 2.7_
|
||||
|
||||
- [x] 7. Integrate AttachmentSourcePicker into FpEditModal (edit flow)
|
||||
- [x] 7.1 Replace the static "upload in Ivanti" message on the attachments tab with `AttachmentSourcePicker`
|
||||
- Add `libraryDocs` state (`useState([])`) alongside existing `files` state
|
||||
- Reset `libraryDocs` to `[]` when submission changes (in the existing `useEffect` on `submission`)
|
||||
- Keep the existing attachment display section (showing attachments from initial submission) above the picker
|
||||
- Render `<AttachmentSourcePicker>` below existing attachments, passing `files`, `setFiles`, `libraryDocs`, `setLibraryDocs`, and `disabled={isApproved}`
|
||||
- Update `handleUploadAttachments` to build FormData with both local files and `libraryDocIds` JSON field
|
||||
- Enable the upload button when either `files.length > 0` or `libraryDocs.length > 0`
|
||||
- Disable the picker when `lifecycle_status === 'approved'`
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [x] 8. Final checkpoint — Verify all changes
|
||||
- Ensure all changes are complete and consistent across backend and frontend. Ensure no hanging or orphaned code. Ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- No testing tasks included per user request — testing will be done on the dev server
|
||||
- The project uses plain JavaScript (no TypeScript) throughout
|
||||
- All frontend styling uses inline style objects consistent with the existing dark theme design system
|
||||
- The `documents` table already exists — no database migrations are needed
|
||||
- The `libraryDocIds` field is optional in both endpoints, preserving full backward compatibility
|
||||
- Existing `attachment_results_json` entries without a `source` field are treated as `"local"` by the frontend
|
||||
1
.kiro/specs/fp-submission-editing/.config.kiro
Normal file
1
.kiro/specs/fp-submission-editing/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a7e2c1f8-9b34-4d6a-b5e0-8f1c3a2d7e90", "workflowType": "requirements-first", "specType": "feature"}
|
||||
428
.kiro/specs/fp-submission-editing/design.md
Normal file
428
.kiro/specs/fp-submission-editing/design.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Design Document: FP Submission Editing
|
||||
|
||||
## Overview
|
||||
|
||||
This feature extends the existing FP workflow submission system to support viewing, editing, and resubmitting False Positive submissions. It adds lifecycle status tracking, an edit modal triggered from clickable workflow badges in the Reporting Table and from a submissions list in the Queue Panel, backend endpoints that proxy update/map/attach operations to the Ivanti API, and a submission history audit trail.
|
||||
|
||||
The design builds on the existing `ivantiFpWorkflow.js` route, `FpWorkflowModal` component, and `ivanti_fp_submissions` table. It follows the same conventions: factory-pattern Express routes, inline React components with the dark tactical theme, Multer for file uploads, and the `ivantiFormPost()` / `ivantiPost()` helpers for Ivanti API calls.
|
||||
|
||||
Key Ivanti API endpoints used for editing:
|
||||
- `POST /workflowBatch/falsePositive/update` — update workflow metadata
|
||||
- `POST /workflowBatch/falsePositive/{uuid}/map` — add findings to existing workflow
|
||||
- `POST /workflowBatch/falsePositive/{uuid}/attach` — upload additional attachments
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant FE as React Frontend
|
||||
participant BE as Express Backend
|
||||
participant IV as Ivanti API
|
||||
participant DB as SQLite
|
||||
|
||||
Note over U,FE: Entry Point A: Clickable Workflow Badge
|
||||
U->>FE: Click Reworked/Rejected/Expired badge in Reporting Table
|
||||
FE->>FE: Look up FP_Submission by workflow batch ID
|
||||
FE->>FE: Open FpEditModal pre-populated with submission data
|
||||
|
||||
Note over U,FE: Entry Point B: Queue Panel Submissions List
|
||||
U->>FE: Click submission in Queue Panel submissions list
|
||||
FE->>FE: Open FpEditModal pre-populated with submission data
|
||||
|
||||
Note over U,DB: Load Submission Data
|
||||
FE->>BE: GET /api/ivanti/fp-submissions
|
||||
BE->>DB: SELECT from ivanti_fp_submissions
|
||||
DB-->>BE: Submission records
|
||||
BE-->>FE: JSON array of submissions
|
||||
|
||||
Note over U,IV: Edit Form Fields
|
||||
U->>FE: Modify name/reason/description/expiration, click Save
|
||||
FE->>BE: PUT /api/ivanti/fp-submissions/:id
|
||||
BE->>BE: Validate input
|
||||
BE->>IV: POST /workflowBatch/falsePositive/update
|
||||
IV-->>BE: 200 OK
|
||||
BE->>DB: UPDATE ivanti_fp_submissions
|
||||
BE->>DB: INSERT ivanti_fp_submission_history
|
||||
BE->>DB: INSERT audit_log
|
||||
BE-->>FE: 200 + updated record
|
||||
|
||||
Note over U,IV: Add Findings
|
||||
U->>FE: Select additional FP queue items, click Add
|
||||
FE->>BE: POST /api/ivanti/fp-submissions/:id/findings
|
||||
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/map
|
||||
IV-->>BE: 200 OK
|
||||
BE->>DB: UPDATE finding_ids_json
|
||||
BE->>DB: UPDATE queue items → complete
|
||||
BE->>DB: INSERT history + audit
|
||||
BE-->>FE: 200 + updated record
|
||||
|
||||
Note over U,IV: Add Attachments
|
||||
U->>FE: Upload files, click Attach
|
||||
FE->>BE: POST /api/ivanti/fp-submissions/:id/attachments (multipart)
|
||||
loop Each file
|
||||
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/attach
|
||||
IV-->>BE: 200 OK
|
||||
end
|
||||
BE->>DB: UPDATE attachment_count, attachment_results_json
|
||||
BE->>DB: INSERT history + audit
|
||||
BE-->>FE: 200 + attachment results
|
||||
|
||||
Note over U,DB: Status Transition
|
||||
U->>FE: Change lifecycle status
|
||||
FE->>BE: PATCH /api/ivanti/fp-submissions/:id/status
|
||||
BE->>DB: UPDATE lifecycle_status, INSERT history + audit
|
||||
BE-->>FE: 200 OK
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend
|
||||
|
||||
#### Extended Route Module: `backend/routes/ivantiFpWorkflow.js`
|
||||
|
||||
Extends the existing `createIvantiFpWorkflowRouter(db, requireAuth)` with five new endpoints. All endpoints use `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')`, and verify the authenticated user owns the submission (returning 403 otherwise).
|
||||
|
||||
**Endpoint: `GET /api/ivanti/fp-submissions`**
|
||||
|
||||
Returns the authenticated user's FP submission records.
|
||||
|
||||
- Auth: `requireAuth(db)`, any authenticated user (viewers get read-only list)
|
||||
- Response:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 5,
|
||||
"username": "jdoe",
|
||||
"ivanti_workflow_batch_id": 33418832,
|
||||
"ivanti_workflow_batch_uuid": "abc-123-def",
|
||||
"workflow_name": "FP - CVE-2024-1234",
|
||||
"reason": "Scanner false positive",
|
||||
"description": "Confirmed by manual review",
|
||||
"expiration_date": "2026-12-31",
|
||||
"scope_override": "Authorized",
|
||||
"finding_ids_json": "[\"2283734550\",\"2283734551\"]",
|
||||
"queue_item_ids_json": "[1,2]",
|
||||
"attachment_count": 2,
|
||||
"attachment_results_json": "[{\"filename\":\"evidence.pdf\",\"success\":true}]",
|
||||
"status": "success",
|
||||
"lifecycle_status": "rework",
|
||||
"error_message": null,
|
||||
"created_at": "2026-04-08T18:16:08",
|
||||
"updated_at": "2026-04-10T12:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Endpoint: `PUT /api/ivanti/fp-submissions/:id`**
|
||||
|
||||
Updates form fields and proxies to Ivanti update endpoint.
|
||||
|
||||
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Ownership: verified via `user_id` match
|
||||
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||
- Request body:
|
||||
```json
|
||||
{
|
||||
"name": "Updated FP - CVE-2024-1234",
|
||||
"reason": "Updated reason",
|
||||
"description": "Updated description",
|
||||
"expirationDate": "2027-06-01",
|
||||
"scopeOverride": "Authorized"
|
||||
}
|
||||
```
|
||||
- Validation: same rules as creation form (`validateFpWorkflowForm`)
|
||||
- Ivanti call: `POST /client/{clientId}/workflowBatch/falsePositive/update` with JSON body containing `workflowBatchId` and updated fields
|
||||
- On success: updates local record, inserts history row, logs audit, sets `lifecycle_status` to `resubmitted` if previous status was `rejected` or `rework`
|
||||
- Response: `{ success: true, submission: { ...updatedRecord } }`
|
||||
|
||||
**Endpoint: `POST /api/ivanti/fp-submissions/:id/findings`**
|
||||
|
||||
Maps additional findings to the existing workflow batch.
|
||||
|
||||
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Ownership: verified
|
||||
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||
- Request body:
|
||||
```json
|
||||
{
|
||||
"findingIds": ["2283734552", "2283734553"],
|
||||
"queueItemIds": [3, 4]
|
||||
}
|
||||
```
|
||||
- Validates queue items belong to user, are FP type, and pending
|
||||
- Ivanti call: `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/map` with `subjectFilterRequest` containing the new finding IDs
|
||||
- On success: appends new IDs to `finding_ids_json`, marks queue items complete, inserts history + audit
|
||||
- Response: `{ success: true, addedFindings: [...], queueItemsUpdated: 2 }`
|
||||
|
||||
**Endpoint: `POST /api/ivanti/fp-submissions/:id/attachments`**
|
||||
|
||||
Uploads additional files to the existing workflow batch.
|
||||
|
||||
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Content-Type: `multipart/form-data` (Multer)
|
||||
- Ownership: verified
|
||||
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||
- File constraints: same as creation (10 MB, allowed extensions)
|
||||
- Ivanti call: for each file, `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attach`
|
||||
- On success: updates `attachment_count` and `attachment_results_json`, inserts history + audit
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"attachmentResults": [
|
||||
{ "filename": "new-evidence.pdf", "success": true },
|
||||
{ "filename": "screenshot.png", "success": false, "error": "Upload failed" }
|
||||
],
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoint: `PATCH /api/ivanti/fp-submissions/:id/status`**
|
||||
|
||||
Updates the lifecycle status of a submission.
|
||||
|
||||
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Ownership: verified
|
||||
- Request body:
|
||||
```json
|
||||
{
|
||||
"lifecycle_status": "rejected"
|
||||
}
|
||||
```
|
||||
- Validates status is one of: `submitted`, `approved`, `rejected`, `rework`, `resubmitted`
|
||||
- Validates transition is allowed (cannot transition FROM `approved`)
|
||||
- On success: updates `lifecycle_status` and `updated_at`, inserts history row with previous and new status, logs audit
|
||||
- Response: `{ success: true, previousStatus: "submitted", newStatus: "rejected" }`
|
||||
|
||||
#### Pure Helper Functions (exported for testing)
|
||||
|
||||
The following pure functions are extracted for testability:
|
||||
|
||||
- `validateFpWorkflowForm(body)` — already exists, reused for edit validation
|
||||
- `isAllowedFileExtension(filename)` — already exists, reused
|
||||
- `buildSubjectFilterRequest(findingIds)` — already exists, reused for map endpoint
|
||||
- `validateLifecycleTransition(currentStatus, newStatus)` — new, returns `{ valid: boolean, error?: string }`
|
||||
- `mergeFindings(existingJson, newIds)` — new, merges finding ID arrays, deduplicates, returns JSON string
|
||||
- `buildSubmissionHistoryEntry(changeType, details, userId, username)` — new, constructs a history record object
|
||||
|
||||
#### Ivanti API Calls
|
||||
|
||||
Uses existing helpers from `backend/helpers/ivantiApi.js`:
|
||||
|
||||
- **Update workflow**: `ivantiPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/update` with JSON body
|
||||
- **Map findings**: `ivantiFormPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/map` with `subjectFilterRequest`
|
||||
- **Attach file**: `ivantiMultipartPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attach` with file buffer
|
||||
|
||||
### Frontend
|
||||
|
||||
#### New Component: `FpEditModal`
|
||||
|
||||
Defined inline in `frontend/src/components/pages/ReportingPage.js`, following the existing `FpWorkflowModal` pattern.
|
||||
|
||||
**Props:**
|
||||
- `open` (boolean) — controls visibility
|
||||
- `onClose` (function) — close handler
|
||||
- `submission` (object) — the FP_Submission record to edit (null when closed)
|
||||
- `queueItems` (array) — user's current queue items (for adding findings)
|
||||
- `onSuccess` (function) — callback after successful edit, triggers data refresh
|
||||
|
||||
**State:**
|
||||
- `name`, `reason`, `description`, `expirationDate`, `scopeOverride` — editable form fields, initialized from `submission`
|
||||
- `files` — array of new File objects for upload
|
||||
- `additionalFindingIds` — selected queue items to add as findings
|
||||
- `saving` — boolean, disables form during save
|
||||
- `errors` — validation error map
|
||||
- `result` — operation result (success/failure)
|
||||
- `activeTab` — current tab: 'details' | 'findings' | 'attachments' | 'history'
|
||||
|
||||
**UI Layout:**
|
||||
- Modal overlay with dark backdrop (matching `FpWorkflowModal`)
|
||||
- Header: "Edit FP Workflow — {workflow_name}" with lifecycle status badge and close button
|
||||
- Tab bar: Details | Findings | Attachments | History
|
||||
- Details tab: editable form fields (name, reason, description, expiration, scope override) with Save button
|
||||
- Findings tab: current finding IDs list (read-only) + mechanism to select and add FP queue items
|
||||
- Attachments tab: existing attachments list + file upload area for new attachments
|
||||
- History tab: chronological list of changes from `ivanti_fp_submission_history`
|
||||
- Footer: contextual action buttons per tab
|
||||
- Approved submissions: all fields read-only with "This submission is finalized" message
|
||||
|
||||
#### Workflow Badge Modifications (Reporting Table)
|
||||
|
||||
The workflow column renderer (lines 1044–1070 of `ReportingPage.js`) is modified:
|
||||
|
||||
- For badges with state `reworked`, `rejected`, or `expired`:
|
||||
- Add `cursor: 'pointer'` and `onClick` handler
|
||||
- Append a small pencil icon (lucide `Edit3`, 10px) after the state text
|
||||
- On hover: increase border opacity and brighten background
|
||||
- On click: look up matching FP_Submission by `wf.id` (workflow batch ID), open `FpEditModal`
|
||||
- For badges with state `requested` or `approved`:
|
||||
- No changes — remain non-interactive (no cursor, no icon, no click handler)
|
||||
|
||||
#### QueuePanel Modifications
|
||||
|
||||
- Add a "Submissions" section below the existing queue items list
|
||||
- Fetches submissions via `GET /api/ivanti/fp-submissions` on panel open
|
||||
- Each submission row shows: workflow name, batch ID, lifecycle status badge, finding count, created date
|
||||
- Lifecycle status badges use color coding: submitted (sky blue), approved (emerald), rejected (red), rework (amber), resubmitted (sky blue)
|
||||
- Clicking a submission row opens `FpEditModal` with that submission's data
|
||||
- Viewers see the list but cannot click to edit
|
||||
|
||||
#### Lifecycle Status Badge Component
|
||||
|
||||
Inline helper function `lifecycleStatusBadge(status)` returning style object:
|
||||
|
||||
| Status | Border | Background | Text |
|
||||
|--------|--------|------------|------|
|
||||
| submitted | `rgba(14,165,233,0.4)` | `rgba(14,165,233,0.12)` | `#0EA5E9` |
|
||||
| approved | `rgba(16,185,129,0.4)` | `rgba(16,185,129,0.12)` | `#10B981` |
|
||||
| rejected | `rgba(239,68,68,0.4)` | `rgba(239,68,68,0.12)` | `#EF4444` |
|
||||
| rework | `rgba(245,158,11,0.4)` | `rgba(245,158,11,0.12)` | `#F59E0B` |
|
||||
| resubmitted | `rgba(14,165,233,0.4)` | `rgba(14,165,233,0.12)` | `#0EA5E9` |
|
||||
|
||||
## Data Models
|
||||
|
||||
### Schema Changes to `ivanti_fp_submissions`
|
||||
|
||||
Three new columns added to the existing table:
|
||||
|
||||
```sql
|
||||
ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted'
|
||||
CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'));
|
||||
|
||||
ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT;
|
||||
|
||||
ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
|
||||
```
|
||||
|
||||
### New Table: `ivanti_fp_submission_history`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||
'created', 'fields_updated', 'findings_added',
|
||||
'attachments_added', 'status_changed'
|
||||
)),
|
||||
change_details_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
|
||||
```
|
||||
|
||||
**change_details_json examples:**
|
||||
|
||||
- `fields_updated`: `{"changed": {"name": {"from": "old", "to": "new"}, "reason": {"from": "old", "to": "new"}}}`
|
||||
- `findings_added`: `{"addedFindingIds": ["123", "456"], "queueItemIds": [3, 4]}`
|
||||
- `attachments_added`: `{"files": [{"filename": "evidence.pdf", "success": true}]}`
|
||||
- `status_changed`: `{"from": "submitted", "to": "rejected"}`
|
||||
- `created`: `{"workflowBatchId": 33418832, "findingCount": 3, "attachmentCount": 1}`
|
||||
|
||||
### Migration Script: `backend/migrations/add_fp_submission_editing.js`
|
||||
|
||||
Applies all schema changes idempotently using `ALTER TABLE ... ADD COLUMN` wrapped in try/catch (SQLite throws if column already exists) and `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`.
|
||||
|
||||
|
||||
|
||||
## 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.*
|
||||
|
||||
Note: Properties for `validateFpWorkflowForm` and `isAllowedFileExtension` are already covered by the existing `ivanti-fp-workflow-submission` spec and are reused without modification. The properties below cover new pure functions introduced by this feature.
|
||||
|
||||
### Property 1: Finding Merge Preserves All IDs and Deduplicates
|
||||
|
||||
*For any* existing finding IDs JSON string (valid JSON array of strings) and any array of new finding ID strings, `mergeFindings(existingJson, newIds)` should produce a JSON string that, when parsed, contains every ID from the original array and every ID from the new array, contains no duplicate entries, and has a length less than or equal to the sum of the original and new array lengths.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 2: Lifecycle Transition Validation
|
||||
|
||||
*For any* pair of lifecycle status values (currentStatus, newStatus) drawn from the set {submitted, approved, rejected, rework, resubmitted}, `validateLifecycleTransition(currentStatus, newStatus)` should return `{ valid: false }` whenever currentStatus is "approved" (no transitions allowed from finalized state), and should return `{ valid: true }` for all other currentStatus values when newStatus is a valid lifecycle status. Additionally, when currentStatus is "rejected" or "rework" and newStatus is "resubmitted", the transition should always be valid.
|
||||
|
||||
**Validates: Requirements 5.4, 5.5**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Ivanti API Errors
|
||||
|
||||
| HTTP Status | Endpoint | User-Facing Message | System Behavior |
|
||||
|-------------|----------|---------------------|-----------------|
|
||||
| 401 | All | "Ivanti API key is invalid or missing. Contact your administrator." | Log error, preserve form state |
|
||||
| 419 | All | "API key lacks permissions for this operation." | Log error, preserve form state |
|
||||
| 429 | All | "Ivanti API rate limit reached. Please try again in a few minutes." | Log error, preserve form state |
|
||||
| 5xx | All | "Ivanti API is temporarily unavailable. Please try again later." | Log error, preserve form state |
|
||||
| Other | All | "Operation failed: {status} — {message}" | Log error with full response, preserve form state |
|
||||
|
||||
### Partial Failure (Attachment Upload)
|
||||
|
||||
When some attachment uploads succeed and others fail:
|
||||
- Response includes per-file success/failure details
|
||||
- Successfully uploaded files are recorded in `attachment_results_json`
|
||||
- Failed files are reported to the user with retry option
|
||||
- The submission record is updated with the successful uploads only
|
||||
|
||||
### Lifecycle Guard Errors
|
||||
|
||||
- Attempting to edit an "approved" submission returns 400: `"This submission is finalized and cannot be edited."`
|
||||
- Attempting an invalid status transition returns 400: `"Cannot transition from {current} to {new}."`
|
||||
|
||||
### Ownership Errors
|
||||
|
||||
- All edit endpoints verify `user_id` matches the authenticated user
|
||||
- Mismatch returns 403: `"You can only edit your own submissions."`
|
||||
|
||||
### Local Database Errors
|
||||
|
||||
- If history INSERT fails: log error, still return success (the Ivanti operation succeeded)
|
||||
- If audit log INSERT fails: fire-and-forget (existing `logAudit()` pattern)
|
||||
- If submission record UPDATE fails: return 500 with error message
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Testing
|
||||
|
||||
Use `fast-check` as the property-based testing library. Each correctness property maps to a single property-based test with a minimum of 100 iterations.
|
||||
|
||||
Property tests focus on the new pure functions:
|
||||
- `mergeFindings(existingJson, newIds)` — Property 1
|
||||
- `validateLifecycleTransition(currentStatus, newStatus)` — Property 2
|
||||
|
||||
Tag format: **Feature: fp-submission-editing, Property {number}: {title}**
|
||||
|
||||
Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Unit tests cover specific examples, edge cases, and integration points:
|
||||
|
||||
- **Validation reuse**: verify `validateFpWorkflowForm` is called correctly in the PUT endpoint
|
||||
- **Lifecycle badge styles**: verify each of the 5 statuses maps to the correct color scheme
|
||||
- **Clickable badge logic**: verify reworked/rejected/expired states produce clickable badges, requested/approved do not
|
||||
- **Ownership verification**: verify 403 when non-owner attempts edit
|
||||
- **Role guard**: verify non-Admin/Standard_User users are rejected
|
||||
- **Approved guard**: verify 400 when editing an approved submission
|
||||
- **Error mapping**: verify each Ivanti HTTP status maps to the correct error message
|
||||
- **History recording**: verify correct `change_type` and `change_details_json` for each operation type
|
||||
- **Migration idempotency**: verify migration can run multiple times without error
|
||||
|
||||
Test file: `backend/__tests__/fpSubmissionEditing.test.js`
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Integration tests verify the full request/response cycle with mocked Ivanti API:
|
||||
|
||||
- GET submissions returns correct records for authenticated user
|
||||
- PUT update proxies to Ivanti and updates local record
|
||||
- POST findings maps to Ivanti and merges finding IDs
|
||||
- POST attachments uploads to Ivanti and updates attachment records
|
||||
- PATCH status updates lifecycle and creates history entry
|
||||
- Queue items marked complete after successful finding addition
|
||||
|
||||
Test file: `backend/__tests__/fpSubmissionEditing.integration.test.js`
|
||||
122
.kiro/specs/fp-submission-editing/requirements.md
Normal file
122
.kiro/specs/fp-submission-editing/requirements.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
This feature adds the ability to view, edit, and resubmit existing False Positive (FP) workflow submissions in the STEAM Security Dashboard. Users need to update FP workflows when assets or findings must be added, when supporting documentation needs to be supplemented, or when submissions are rejected or returned for rework by Ivanti reviewers. The feature introduces lifecycle status tracking for FP submissions, an edit modal that loads existing submission data, and backend endpoints that proxy update, map, and attach operations to the Ivanti API.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Dashboard**: The STEAM Security Dashboard application
|
||||
- **FP_Submission**: A local database record in the `ivanti_fp_submissions` table tracking a False Positive workflow submission, including its Ivanti workflow batch ID, form data, finding IDs, attachment history, and lifecycle status
|
||||
- **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 FP workflow request, identified by a numeric ID and a UUID
|
||||
- **Lifecycle_Status**: The current state of an FP submission in its review lifecycle: submitted, approved, rejected, rework, or resubmitted
|
||||
- **Edit_Modal**: The UI modal that loads an existing FP submission's data and allows the user to modify form fields, add findings, and upload additional attachments
|
||||
- **Submission_History**: A chronological log of changes made to an FP submission, including edits, finding additions, attachment uploads, and status transitions
|
||||
- **Queue_Panel**: The slide-out panel in the Reporting Page that displays the user's Ivanti todo queue items
|
||||
- **Workflow_Badge**: The colored status badge displayed in the Workflow column of the Reporting Page findings table, showing the workflow ID and state (e.g., "FP#12345 REWORKED"). States include: expired (red), rejected (red), reworked (amber), actionable (amber), requested (sky blue)
|
||||
- **Reporting_Table**: The findings table on the Reporting Page that displays host findings with columns including a Workflow column showing Workflow_Badges
|
||||
- **Ivanti_Update_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/update` used to modify workflow batch metadata (name, reason, description, expiration date)
|
||||
- **Ivanti_Map_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/{workflowBatchUuid}/map` used to add additional findings to an existing workflow batch
|
||||
- **Ivanti_Attach_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/{workflowBatchUuid}/attach` used to upload additional file attachments to an existing workflow batch
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: View and Access Existing FP Submissions
|
||||
|
||||
**User Story:** As an editor or admin, I want to access my existing FP workflow submissions from the reporting table's workflow badges and from a submissions list, so that I can quickly identify and edit submissions that need attention.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL display a list of FP_Submissions for the authenticated user in the Queue_Panel, showing workflow name, Ivanti workflow batch ID, Lifecycle_Status, finding count, attachment count, and submission date
|
||||
2. WHEN the user clicks on an FP_Submission in the list, THE Dashboard SHALL open the Edit_Modal pre-populated with the submission's current data including form fields, associated finding IDs, and attachment history
|
||||
3. THE Dashboard SHALL visually distinguish FP_Submissions by Lifecycle_Status using color-coded status badges: submitted (sky blue), approved (emerald), rejected (red), rework (amber), resubmitted (sky blue)
|
||||
4. WHILE the user has the viewer role, THE Dashboard SHALL display the FP_Submission list in read-only mode with the edit action disabled
|
||||
5. WHEN a finding in the Reporting_Table has a Workflow_Badge with state "reworked", "rejected", or "expired", THE Dashboard SHALL render the Workflow_Badge as a clickable element with a pointer cursor and a subtle edit icon (pencil) appended to the badge
|
||||
6. WHEN the user clicks a clickable Workflow_Badge in the Reporting_Table, THE Dashboard SHALL look up the matching FP_Submission by the workflow batch ID displayed in the badge and open the Edit_Modal pre-populated with that submission's data
|
||||
7. WHEN the user hovers over a clickable Workflow_Badge, THE Dashboard SHALL display a hover effect (increased border opacity and slight background brightening) to indicate the badge is interactive
|
||||
8. WHILE a Workflow_Badge has state "requested" or "approved", THE Dashboard SHALL render the badge as non-interactive (no pointer cursor, no edit icon, no click handler) since those states do not require user action
|
||||
|
||||
### Requirement 2: Edit FP Workflow Form Fields
|
||||
|
||||
**User Story:** As an editor or admin, I want to update the name, reason, description, and expiration date of an existing FP submission, so that I can correct or supplement the justification when a submission is returned for rework.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Edit_Modal is opened for an existing FP_Submission, THE Dashboard SHALL load and display the current values for workflow name, reason, description, expiration date, and scope override authorization in editable form fields
|
||||
2. THE Dashboard SHALL apply the same validation rules to edited fields as the creation form: workflow name required and max 255 characters, reason required, description optional and max 2000 characters, expiration date required and must be a future date
|
||||
3. WHEN the user modifies form fields and clicks Save, THE Dashboard SHALL send the updated fields to the Ivanti_Update_Endpoint to modify the workflow batch metadata in the Ivanti platform
|
||||
4. IF the Ivanti_Update_Endpoint returns an error, THEN THE Dashboard SHALL display the error message and preserve the user's edits so the user can retry without re-entering data
|
||||
5. WHEN a form field update completes successfully, THE Dashboard SHALL update the local FP_Submission record with the new field values and record the change in Submission_History
|
||||
|
||||
### Requirement 3: Add Findings to Existing FP Submission
|
||||
|
||||
**User Story:** As an editor or admin, I want to add additional findings or assets to an existing FP submission, so that I can expand the scope of a false positive workflow when new related findings are identified.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Edit_Modal SHALL display the current list of finding IDs associated with the FP_Submission and provide a mechanism to add additional findings from the user's Ivanti queue
|
||||
2. WHEN the user selects additional FP-type queue items to add, THE Dashboard SHALL send the new finding IDs to the Ivanti_Map_Endpoint to map the findings to the existing Workflow_Batch
|
||||
3. WHEN findings are mapped successfully, THE Dashboard SHALL update the local FP_Submission record's finding_ids_json to include the newly added finding IDs
|
||||
4. WHEN findings are mapped successfully, THE Dashboard SHALL mark the corresponding queue items as complete and refresh the Queue_Panel
|
||||
5. IF the Ivanti_Map_Endpoint returns an error, THEN THE Dashboard SHALL display the error message and leave the queue items in their current status
|
||||
|
||||
### Requirement 4: Add Attachments to Existing FP Submission
|
||||
|
||||
**User Story:** As an editor or admin, I want to upload additional files and screenshots to an existing FP submission, so that I can provide supplementary evidence when reviewers request more documentation.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Edit_Modal SHALL display the list of previously uploaded attachments (filename and upload status) and provide a file upload area for adding new attachments
|
||||
2. THE Dashboard SHALL apply the same file constraints as the creation form: maximum 10 MB per file, allowed extensions .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
|
||||
3. WHEN the user uploads new files, THE Dashboard SHALL send each file to the Ivanti_Attach_Endpoint to attach the file to the existing Workflow_Batch
|
||||
4. WHEN an attachment upload completes successfully, THE Dashboard SHALL update the local FP_Submission record's attachment_count and attachment_results_json to include the new attachment
|
||||
5. IF an attachment upload fails, THEN THE Dashboard SHALL report which attachments failed and allow the user to retry the failed uploads without re-uploading successful attachments
|
||||
|
||||
### Requirement 5: FP Submission Lifecycle Status Tracking
|
||||
|
||||
**User Story:** As an editor or admin, I want the Dashboard to track the lifecycle status of my FP submissions, so that I can see which submissions are pending review, approved, rejected, or need rework.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL store a Lifecycle_Status field for each FP_Submission with allowed values: submitted, approved, rejected, rework, resubmitted
|
||||
2. WHEN a new FP workflow is created, THE Dashboard SHALL set the initial Lifecycle_Status to "submitted"
|
||||
3. WHEN the user manually updates the Lifecycle_Status of an FP_Submission (e.g., marking it as rejected or rework after receiving notification), THE Dashboard SHALL record the status change with a timestamp in Submission_History
|
||||
4. WHEN an FP_Submission with Lifecycle_Status "rejected" or "rework" is edited and resubmitted, THE Dashboard SHALL update the Lifecycle_Status to "resubmitted"
|
||||
5. THE Dashboard SHALL prevent editing of FP_Submissions with Lifecycle_Status "approved" and display a message indicating the submission is finalized
|
||||
|
||||
### Requirement 6: Submission History and Audit Trail
|
||||
|
||||
**User Story:** As an editor or admin, I want to see a history of changes made to an FP submission, so that I can track what was modified and when for audit purposes.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Edit_Modal SHALL display a Submission_History section showing a chronological list of changes made to the FP_Submission, including: initial creation, form field edits, finding additions, attachment uploads, and status transitions
|
||||
2. WHEN any modification is made to an FP_Submission, THE Dashboard SHALL log an audit entry with action "ivanti_fp_submission_edited", entity type "ivanti_workflow", the workflow batch ID as entity ID, and details including the type of change and changed values
|
||||
3. WHEN a Lifecycle_Status transition occurs, THE Dashboard SHALL log an audit entry with action "ivanti_fp_status_changed", entity type "ivanti_workflow", and details including the previous status and new status
|
||||
|
||||
### Requirement 7: Backend API Endpoints for FP Editing
|
||||
|
||||
**User Story:** As a system component, the backend needs API endpoints to retrieve, update, and extend existing FP submissions, so that the frontend can perform edit operations securely.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL provide a GET /api/ivanti/fp-submissions endpoint that returns the authenticated user's FP_Submission records with all stored fields and Lifecycle_Status
|
||||
2. THE Dashboard SHALL provide a PUT /api/ivanti/fp-submissions/:id endpoint that accepts updated form fields, validates the input, proxies the update to the Ivanti_Update_Endpoint, and updates the local record
|
||||
3. THE Dashboard SHALL provide a POST /api/ivanti/fp-submissions/:id/findings endpoint that accepts additional finding IDs, proxies the map operation to the Ivanti_Map_Endpoint, and updates the local record
|
||||
4. THE Dashboard SHALL provide a POST /api/ivanti/fp-submissions/:id/attachments endpoint that accepts file uploads, proxies each file to the Ivanti_Attach_Endpoint, and updates the local record
|
||||
5. THE Dashboard SHALL provide a PATCH /api/ivanti/fp-submissions/:id/status endpoint that accepts a new Lifecycle_Status value and updates the local record with the status transition
|
||||
6. THE Dashboard SHALL restrict all FP submission editing endpoints to users with "Admin" or "Standard_User" group membership
|
||||
7. THE Dashboard SHALL verify that the authenticated user owns the FP_Submission before allowing any edit operation, returning a 403 status if ownership verification fails
|
||||
|
||||
### Requirement 8: Database Schema Updates for Editing Support
|
||||
|
||||
**User Story:** As a system component, the database needs additional fields and tables to support FP submission editing, lifecycle tracking, and change history.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Dashboard SHALL add a lifecycle_status column to the ivanti_fp_submissions table with allowed values: submitted, approved, rejected, rework, resubmitted, defaulting to "submitted"
|
||||
2. THE Dashboard SHALL add an ivanti_workflow_batch_uuid column to the ivanti_fp_submissions table to store the UUID required by the Ivanti map and attach endpoints
|
||||
3. THE Dashboard SHALL add an updated_at column to the ivanti_fp_submissions table that is set to the current timestamp on each modification
|
||||
4. THE Dashboard SHALL create an ivanti_fp_submission_history table with columns: id, submission_id (foreign key), user_id, username, change_type, change_details_json, and created_at
|
||||
5. THE Dashboard SHALL provide a migration script at backend/migrations/add_fp_submission_editing.js that applies the schema changes idempotently
|
||||
182
.kiro/specs/fp-submission-editing/tasks.md
Normal file
182
.kiro/specs/fp-submission-editing/tasks.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Implementation Plan: FP Submission Editing
|
||||
|
||||
## Overview
|
||||
|
||||
Extends the existing FP workflow system with lifecycle status tracking, edit/resubmit capabilities, and a submission history audit trail. Implementation proceeds bottom-up: database migration → pure helpers → backend endpoints → frontend components → wiring and integration.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Database migration and schema changes
|
||||
- [x] 1.1 Create migration script `backend/migrations/add_fp_submission_editing.js`
|
||||
- Add `lifecycle_status` column to `ivanti_fp_submissions` with CHECK constraint and default `'submitted'`
|
||||
- Add `ivanti_workflow_batch_uuid` TEXT column to `ivanti_fp_submissions`
|
||||
- Add `updated_at` DATETIME column to `ivanti_fp_submissions` with default CURRENT_TIMESTAMP
|
||||
- Create `ivanti_fp_submission_history` table with columns: id, submission_id (FK), user_id, username, change_type (CHECK constraint), change_details_json, created_at
|
||||
- Create index `idx_fp_history_submission` on submission_id
|
||||
- Wrap ALTER TABLE statements in try/catch for idempotency; use CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 2. Implement pure helper functions in `backend/routes/ivantiFpWorkflow.js`
|
||||
- [x] 2.1 Implement `validateLifecycleTransition(currentStatus, newStatus)`
|
||||
- Accept two status strings from the set {submitted, approved, rejected, rework, resubmitted}
|
||||
- Return `{ valid: false, error }` when currentStatus is `'approved'` (finalized, no transitions allowed)
|
||||
- Return `{ valid: false, error }` when newStatus is not in the allowed set
|
||||
- Return `{ valid: true }` for all other valid transitions
|
||||
- Export from module for testing
|
||||
- _Requirements: 5.4, 5.5_
|
||||
|
||||
- [x] 2.2 Implement `mergeFindings(existingJson, newIds)`
|
||||
- Parse existingJson (JSON array of strings), concatenate with newIds array
|
||||
- Deduplicate by converting to Set, return JSON.stringify of the merged array
|
||||
- Handle edge cases: empty existing array, empty newIds, overlapping IDs
|
||||
- Export from module for testing
|
||||
- _Requirements: 3.3_
|
||||
|
||||
- [x] 2.3 Implement `buildSubmissionHistoryEntry(changeType, details, userId, username)`
|
||||
- Construct and return an object with: submission_id (to be set by caller), user_id, username, change_type, change_details_json (JSON.stringify of details), created_at (ISO string)
|
||||
- Export from module for testing
|
||||
- _Requirements: 6.1, 6.2_
|
||||
|
||||
- [ ]* 2.4 Write property test for `mergeFindings` — Property 1: Finding Merge Preserves All IDs and Deduplicates
|
||||
- **Property 1: Finding Merge Preserves All IDs and Deduplicates**
|
||||
- **Validates: Requirements 3.3**
|
||||
- Use fast-check to generate arbitrary arrays of string IDs for existing and new
|
||||
- Assert: parsed result contains every ID from both inputs, no duplicates, length ≤ sum of input lengths
|
||||
- Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||
|
||||
- [ ]* 2.5 Write property test for `validateLifecycleTransition` — Property 2: Lifecycle Transition Validation
|
||||
- **Property 2: Lifecycle Transition Validation**
|
||||
- **Validates: Requirements 5.4, 5.5**
|
||||
- Use fast-check to generate pairs from {submitted, approved, rejected, rework, resubmitted}
|
||||
- Assert: always invalid when currentStatus is 'approved'; always valid for other currentStatus values with valid newStatus; rejected/rework → resubmitted is always valid
|
||||
- Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||
|
||||
- [ ] 3. Checkpoint — Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Implement backend API endpoints in `backend/routes/ivantiFpWorkflow.js`
|
||||
- [x] 4.1 Implement `GET /api/ivanti/fp-submissions`
|
||||
- Add route with `requireAuth(db)` — any authenticated user
|
||||
- Query `ivanti_fp_submissions` filtered by `req.user.id`
|
||||
- Return JSON array of submission records including lifecycle_status and updated_at
|
||||
- _Requirements: 7.1, 1.1_
|
||||
|
||||
- [x] 4.2 Implement `PUT /api/ivanti/fp-submissions/:id`
|
||||
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Verify ownership (user_id match → 403 if not)
|
||||
- Lifecycle guard: reject if lifecycle_status is 'approved' → 400
|
||||
- Validate body with existing `validateFpWorkflowForm`
|
||||
- Proxy to Ivanti update endpoint via `ivantiPost()`
|
||||
- On success: UPDATE local record fields + updated_at, INSERT history row (change_type: 'fields_updated'), log audit
|
||||
- If previous status was 'rejected' or 'rework', set lifecycle_status to 'resubmitted'
|
||||
- _Requirements: 7.2, 2.1, 2.2, 2.3, 2.4, 2.5, 5.4, 5.5, 7.6, 7.7_
|
||||
|
||||
- [x] 4.3 Implement `POST /api/ivanti/fp-submissions/:id/findings`
|
||||
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Verify ownership, lifecycle guard (reject if approved)
|
||||
- Validate findingIds and queueItemIds from body; verify queue items belong to user, are FP type, and pending
|
||||
- Proxy to Ivanti map endpoint via `ivantiFormPost()` using `buildSubjectFilterRequest`
|
||||
- On success: merge finding IDs with `mergeFindings()`, mark queue items complete, INSERT history + audit
|
||||
- _Requirements: 7.3, 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||
|
||||
- [x] 4.4 Implement `POST /api/ivanti/fp-submissions/:id/attachments`
|
||||
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`, Multer middleware
|
||||
- Verify ownership, lifecycle guard (reject if approved)
|
||||
- Validate file constraints (10 MB, allowed extensions)
|
||||
- Loop each file: call `ivantiMultipartPost()` to Ivanti attach endpoint
|
||||
- Collect per-file success/failure results
|
||||
- Update attachment_count and attachment_results_json, INSERT history + audit
|
||||
- _Requirements: 7.4, 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||
|
||||
- [x] 4.5 Implement `PATCH /api/ivanti/fp-submissions/:id/status`
|
||||
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Verify ownership
|
||||
- Validate new status is in allowed set
|
||||
- Use `validateLifecycleTransition()` to check transition validity
|
||||
- UPDATE lifecycle_status and updated_at, INSERT history row (change_type: 'status_changed'), log audit
|
||||
- _Requirements: 7.5, 5.1, 5.2, 5.3, 7.6, 7.7_
|
||||
|
||||
- [ ]* 4.6 Write unit tests for backend endpoints
|
||||
- Test ownership verification returns 403 for non-owner
|
||||
- Test lifecycle guard returns 400 for approved submissions
|
||||
- Test role guard rejects non-Admin/Standard_User
|
||||
- Test Ivanti error status mapping (401, 419, 429, 5xx)
|
||||
- Test history recording produces correct change_type and change_details_json
|
||||
- Test migration idempotency (can run multiple times without error)
|
||||
- Test file: `backend/__tests__/fpSubmissionEditing.test.js`
|
||||
- _Requirements: 7.6, 7.7, 5.5_
|
||||
|
||||
- [ ]* 4.7 Write integration tests for backend endpoints
|
||||
- Test GET returns correct records for authenticated user
|
||||
- Test PUT proxies to Ivanti and updates local record
|
||||
- Test POST findings maps to Ivanti and merges finding IDs
|
||||
- Test POST attachments uploads to Ivanti and updates attachment records
|
||||
- Test PATCH status updates lifecycle and creates history entry
|
||||
- Test queue items marked complete after successful finding addition
|
||||
- Test file: `backend/__tests__/fpSubmissionEditing.integration.test.js`
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||
|
||||
- [ ] 5. Checkpoint — Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. Register new endpoints in `backend/server.js`
|
||||
- Wire the updated `ivantiFpWorkflow` router so the new GET/PUT/POST/PATCH routes are accessible under `/api/ivanti/fp-submissions`
|
||||
- Verify the existing POST `/api/ivanti/fp-workflow` route continues to work
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||
|
||||
- [x] 7. Implement frontend components in `frontend/src/components/pages/ReportingPage.js`
|
||||
- [x] 7.1 Implement `lifecycleStatusBadge(status)` helper function
|
||||
- Return inline style object with border, background, and text color per status
|
||||
- Color mapping: submitted/resubmitted (sky blue), approved (emerald), rejected (red), rework (amber)
|
||||
- _Requirements: 1.3_
|
||||
|
||||
- [x] 7.2 Implement `FpEditModal` component
|
||||
- Props: open, onClose, submission, queueItems, onSuccess
|
||||
- State: form fields initialized from submission, activeTab, saving, errors, result
|
||||
- Tab bar with 4 tabs: Details, Findings, Attachments, History
|
||||
- Details tab: editable form fields (name, reason, description, expirationDate, scopeOverride) with Save button; calls PUT endpoint
|
||||
- Findings tab: read-only current finding IDs list + mechanism to select and add FP queue items; calls POST findings endpoint
|
||||
- Attachments tab: existing attachments list + file upload area; calls POST attachments endpoint
|
||||
- History tab: chronological list fetched from submission history (included in GET response or separate query)
|
||||
- Approved submissions: all fields read-only with finalized message
|
||||
- Dark tactical theme matching existing FpWorkflowModal
|
||||
- _Requirements: 1.2, 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.4, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 5.5, 6.1_
|
||||
|
||||
- [x] 7.3 Modify workflow badge renderer for clickable badges
|
||||
- In the workflow column renderer (~lines 1044–1070), for badges with state reworked/rejected/expired:
|
||||
- Add `cursor: 'pointer'` and `onClick` handler
|
||||
- Append pencil icon (lucide `Edit3`, 10px) after state text
|
||||
- On hover: increase border opacity and brighten background
|
||||
- On click: look up matching FP_Submission by workflow batch ID, open FpEditModal
|
||||
- For badges with state requested/approved: no changes (remain non-interactive)
|
||||
- _Requirements: 1.5, 1.6, 1.7, 1.8_
|
||||
|
||||
- [x] 7.4 Add submissions list section to QueuePanel
|
||||
- Fetch submissions via GET /api/ivanti/fp-submissions on panel open
|
||||
- Display each submission: workflow name, batch ID, lifecycle status badge, finding count, created date
|
||||
- Clicking a submission row opens FpEditModal with that submission's data
|
||||
- Viewers see the list but cannot click to edit
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||
|
||||
- [x] 8. Wire frontend state and data flow
|
||||
- [x] 8.1 Add submissions state and fetch logic to ReportingPage
|
||||
- Add state for submissions array and selected submission
|
||||
- Fetch submissions on page load and after successful edits (onSuccess callback)
|
||||
- Pass submissions and queueItems to FpEditModal and QueuePanel
|
||||
- _Requirements: 1.1, 1.2_
|
||||
|
||||
- [x] 8.2 Connect FpEditModal callbacks to refresh data
|
||||
- On successful edit/findings/attachments/status change, call onSuccess to refresh submissions list, queue items, and reporting table data
|
||||
- _Requirements: 2.5, 3.4, 4.4, 5.3_
|
||||
|
||||
- [ ] 9. 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
|
||||
- The project uses plain JavaScript (no TypeScript) — all code should follow existing conventions
|
||||
- All new endpoints follow the existing factory-pattern router in `ivantiFpWorkflow.js`
|
||||
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/ivanti-queue-redirect/.config.kiro
Normal file
1
.kiro/specs/ivanti-queue-redirect/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||
362
.kiro/specs/ivanti-queue-redirect/design.md
Normal file
362
.kiro/specs/ivanti-queue-redirect/design.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Design Document: Ivanti Queue Redirect
|
||||
|
||||
## Overview
|
||||
|
||||
The Ivanti Queue Redirect feature adds an optional redirect action to completed queue items, allowing users to create a new pending queue item under a different workflow type from an existing completed item. This supports the common scenario where a CARD inventory fix is done but the finding still needs FP or Archer processing, where an item was assigned to the wrong workflow initially, or where a CARD item with a high asset score (90+) needs to go through the GRANITE program for reassignment or deletion.
|
||||
|
||||
The feature consists of five parts:
|
||||
1. A new backend API endpoint (`POST /api/ivanti/todo-queue/:id/redirect`) added to the existing `ivantiTodoQueue.js` route module
|
||||
2. GRANITE added as a fourth valid workflow type across all backend endpoints (`VALID_WORKFLOW_TYPES` constant)
|
||||
3. A redirect modal component in the frontend for collecting target workflow type and vendor
|
||||
4. A redirect button on completed queue items in the existing QueuePanel
|
||||
5. Updated QueuePanel grouping: CARD and GRANITE items grouped under an "Inventory" section, with GRANITE also available in the AddToQueue popover
|
||||
|
||||
There are four workflow types: FP, Archer, CARD, and GRANITE. FP and Archer require a vendor string; CARD and GRANITE do not. Any completed item can redirect to any other workflow type — there is no fixed ordering between types.
|
||||
|
||||
The redirect operation creates a new row in `ivanti_todo_queue` — it does not modify or delete the original completed item. This preserves the audit trail and allows the original item to remain visible as completed.
|
||||
|
||||
## Architecture
|
||||
|
||||
The feature follows the existing patterns in the codebase:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant QP as QueuePanel
|
||||
participant RM as RedirectModal
|
||||
participant API as POST /todo-queue/:id/redirect
|
||||
participant DB as SQLite (ivanti_todo_queue)
|
||||
participant AL as Audit Log
|
||||
|
||||
U->>QP: Clicks redirect button on completed item
|
||||
QP->>RM: Opens modal with item context
|
||||
U->>RM: Selects target workflow type + vendor
|
||||
RM->>API: POST /api/ivanti/todo-queue/:id/redirect
|
||||
API->>DB: SELECT original item (verify ownership + complete status)
|
||||
API->>DB: INSERT new pending item with target workflow_type
|
||||
API->>AL: logAudit (fire-and-forget)
|
||||
API-->>RM: 201 + new item JSON
|
||||
RM->>QP: Adds new item to list, closes modal, shows success
|
||||
```
|
||||
|
||||
No new database tables or schema changes are required. The redirect creates a standard `ivanti_todo_queue` row using the existing schema. Backend changes outside the new endpoint include: adding GRANITE to `VALID_WORKFLOW_TYPES`, updating all error messages to list four valid types, and treating GRANITE like CARD for vendor validation (no vendor required).
|
||||
|
||||
### QueuePanel Grouping (Layout)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph QueuePanel
|
||||
subgraph Inventory Section
|
||||
A[CARD items]
|
||||
B[Sub-divider - only when both exist]
|
||||
C[GRANITE items]
|
||||
end
|
||||
subgraph Vendor Groups
|
||||
D[Vendor A - FP/Archer items]
|
||||
E[Vendor B - FP/Archer items]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The QueuePanel groups items into two categories:
|
||||
- **Inventory section** (top): Contains both CARD and GRANITE items under a single "Inventory" heading. CARD items appear first, followed by a subtle sub-divider (only shown when both types are present), then GRANITE items. Each item retains its workflow type badge (CARD in green, GRANITE in warm slate).
|
||||
- **Vendor groups** (below): FP and Archer items grouped by vendor, same as current behavior.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Backend: VALID_WORKFLOW_TYPES Constant
|
||||
|
||||
Updated from `['FP', 'Archer', 'CARD']` to `['FP', 'Archer', 'CARD', 'GRANITE']`.
|
||||
|
||||
All endpoints that reference this constant (batch add, single add, PUT, redirect) automatically accept GRANITE. The vendor validation condition changes from `workflow_type !== 'CARD'` to `workflow_type !== 'CARD' && workflow_type !== 'GRANITE'` (or equivalently, checking if the type is FP or Archer). The `vendorVal` assignment similarly treats GRANITE like CARD: `vendorVal = (workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()`.
|
||||
|
||||
All error messages for invalid workflow_type are updated to: `"workflow_type must be FP, Archer, CARD, or GRANITE."`.
|
||||
|
||||
### Backend: Redirect Endpoint
|
||||
|
||||
Added to `backend/routes/ivantiTodoQueue.js` inside the existing `createIvantiTodoQueueRouter` factory function.
|
||||
|
||||
```
|
||||
POST /api/ivanti/todo-queue/:id/redirect
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"workflow_type": "FP" | "Archer" | "CARD" | "GRANITE",
|
||||
"vendor": "string (required for FP/Archer, omitted for CARD/GRANITE)"
|
||||
}
|
||||
```
|
||||
|
||||
**Success response (201):**
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"user_id": 1,
|
||||
"finding_id": "12345",
|
||||
"finding_title": "...",
|
||||
"cves_json": "[...]",
|
||||
"ip_address": "10.0.0.1",
|
||||
"hostname": "host.example.com",
|
||||
"vendor": "Cisco",
|
||||
"workflow_type": "FP",
|
||||
"status": "pending",
|
||||
"created_at": "...",
|
||||
"updated_at": "...",
|
||||
"cves": ["CVE-2024-1234"]
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| 400 | Item not in "complete" status |
|
||||
| 400 | Invalid workflow_type |
|
||||
| 400 | Missing/invalid vendor for FP/Archer |
|
||||
| 404 | Item not found or belongs to different user |
|
||||
| 500 | Database error |
|
||||
|
||||
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
|
||||
### Backend: Vendor Validation Logic
|
||||
|
||||
The vendor requirement is conditional on workflow type across all endpoints:
|
||||
|
||||
| Workflow Type | Vendor Required | vendorVal |
|
||||
|---------------|----------------|-----------|
|
||||
| FP | Yes — non-empty, ≤ 200 chars | `vendor.trim()` |
|
||||
| Archer | Yes — non-empty, ≤ 200 chars | `vendor.trim()` |
|
||||
| CARD | No | `''` (empty string) |
|
||||
| GRANITE | No | `''` (empty string) |
|
||||
|
||||
The condition for requiring vendor changes from `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` (or equivalently `!['CARD', 'GRANITE'].includes(workflow_type)`).
|
||||
|
||||
### Backend: PUT Validation Fix
|
||||
|
||||
In the existing PUT `/:id` handler, the error message for invalid `workflow_type` is updated to `"workflow_type must be FP, Archer, CARD, or GRANITE."`. The same update applies to the batch add, single add, and redirect endpoints.
|
||||
|
||||
### Frontend: RedirectModal Component
|
||||
|
||||
A modal component rendered inside the QueuePanel. It receives the item being redirected and collects:
|
||||
- Target workflow type (radio buttons: FP, Archer, CARD, GRANITE)
|
||||
- Vendor (text input, shown only when FP or Archer is selected)
|
||||
|
||||
The modal displays read-only context: finding title, finding ID, and current workflow type.
|
||||
|
||||
**WORKFLOW_OPTIONS constant** (updated to include GRANITE):
|
||||
```js
|
||||
const WORKFLOW_OPTIONS = [
|
||||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
];
|
||||
```
|
||||
|
||||
The `needsVendor` condition changes from `workflowType === 'FP' || workflowType === 'Archer'` — this remains the same since GRANITE, like CARD, does not need vendor.
|
||||
|
||||
Props:
|
||||
```js
|
||||
{
|
||||
item: Object, // The completed queue item being redirected
|
||||
onClose: Function, // Close the modal
|
||||
onRedirect: Function // Callback with the new item after successful redirect
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend: QueuePanel Changes
|
||||
|
||||
#### Grouping Logic
|
||||
|
||||
The current grouping logic filters `workflow_type === 'CARD'` into a separate top section. This changes to group both CARD and GRANITE into an "Inventory" section:
|
||||
|
||||
```js
|
||||
const grouped = useMemo(() => {
|
||||
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE');
|
||||
const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD');
|
||||
const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE');
|
||||
const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE');
|
||||
|
||||
// Vendor groups for FP/Archer items
|
||||
const map = {};
|
||||
otherItems.forEach((item) => {
|
||||
const v = item.vendor || 'Unknown';
|
||||
if (!map[v]) map[v] = [];
|
||||
map[v].push(item);
|
||||
});
|
||||
const vendorGroups = Object.keys(map).sort().map((vendor) => ({
|
||||
key: vendor, label: vendor, items: map[vendor], isInventory: false,
|
||||
}));
|
||||
|
||||
return inventoryItems.length > 0
|
||||
? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, items: inventoryItems, isInventory: true }, ...vendorGroups]
|
||||
: vendorGroups;
|
||||
}, [items]);
|
||||
```
|
||||
|
||||
#### Inventory Section Rendering
|
||||
|
||||
The Inventory section header uses the existing accent color for the section label. Within the section:
|
||||
1. CARD items render first
|
||||
2. A subtle sub-divider appears only when both CARD and GRANITE items exist
|
||||
3. GRANITE items render below the sub-divider
|
||||
|
||||
Each item retains its individual workflow type badge with distinct colors.
|
||||
|
||||
#### Workflow Type Color Mapping
|
||||
|
||||
Updated to include GRANITE:
|
||||
|
||||
```js
|
||||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||||
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
|
||||
: item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }
|
||||
: { col: '#10B981', rgb: '16,185,129' };
|
||||
```
|
||||
|
||||
#### GRANITE Item Rendering
|
||||
|
||||
GRANITE items render identically to CARD items — showing hostname and ip_address fields (not CVEs), since GRANITE is also an inventory-category workflow. The condition changes from `isCardItem` to `isInventoryItem`:
|
||||
|
||||
```js
|
||||
const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE';
|
||||
```
|
||||
|
||||
#### Redirect Button
|
||||
|
||||
A redirect icon button (`CornerUpRight` from lucide-react) on each completed queue item row, next to the existing delete button. Visible only when `item.status === 'complete'` and `canWrite` is true.
|
||||
|
||||
### Frontend: AddToQueue Popover
|
||||
|
||||
The AddToQueue popover (defined inline in `ReportingPage.js`) adds GRANITE as a fourth workflow type button:
|
||||
|
||||
```js
|
||||
const QUEUE_WORKFLOW_OPTIONS = [
|
||||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
];
|
||||
```
|
||||
|
||||
When GRANITE is selected, no vendor field is required — same behavior as CARD. The submit logic uses the same condition: `workflow_type === 'FP' || workflow_type === 'Archer'` to determine if vendor is needed.
|
||||
|
||||
### Frontend: API Call
|
||||
|
||||
```js
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${itemId}/redirect`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow_type, vendor })
|
||||
});
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
No schema changes. The redirect creates a standard `ivanti_todo_queue` row:
|
||||
|
||||
| Column | Source |
|
||||
|--------|--------|
|
||||
| user_id | `req.user.id` (current user) |
|
||||
| finding_id | Copied from original item |
|
||||
| finding_title | Copied from original item |
|
||||
| cves_json | Copied from original item |
|
||||
| ip_address | Copied from original item |
|
||||
| hostname | Copied from original item |
|
||||
| vendor | From request body (FP/Archer) or empty string (CARD/GRANITE) |
|
||||
| workflow_type | From request body (FP, Archer, CARD, or GRANITE) |
|
||||
| status | `'pending'` (always) |
|
||||
|
||||
The original completed item remains unchanged.
|
||||
|
||||
|
||||
## 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: Redirect preserves finding data
|
||||
|
||||
*For any* completed queue item with arbitrary finding_id, finding_title, cves_json, ip_address, and hostname values, and *for any* valid target workflow type (FP, Archer, CARD, or GRANITE), redirecting that item SHALL produce a new queue item where finding_id, finding_title, cves_json, ip_address, and hostname are identical to the original, status is "pending", and workflow_type matches the requested target.
|
||||
|
||||
**Validates: Requirements 1.1, 1.7**
|
||||
|
||||
### Property 2: Vendor requirement is conditional on workflow type
|
||||
|
||||
*For any* redirect request, if the target workflow_type is "FP" or "Archer", the request SHALL be accepted if and only if vendor is a non-empty string of 200 characters or fewer. If the target workflow_type is "CARD" or "GRANITE", the request SHALL be accepted regardless of whether vendor is provided.
|
||||
|
||||
**Validates: Requirements 1.2, 1.3, 6.2**
|
||||
|
||||
### Property 3: Successful redirect produces correct audit entry
|
||||
|
||||
*For any* successful redirect operation, the audit log entry SHALL contain action "queue_item_redirected", entityType "ivanti_todo_queue", the original item's ID as entityId, and details including the original workflow_type, the target workflow_type, the new item's ID, and the vendor.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | HTTP Status | Error Message | Behavior |
|
||||
|----------|-------------|---------------|----------|
|
||||
| Item not found or belongs to another user | 404 | "Queue item not found." | Consistent with existing DELETE/PUT pattern |
|
||||
| Item status is not "complete" | 400 | "Only completed queue items can be redirected." | Prevents redirecting pending items |
|
||||
| Invalid workflow_type | 400 | "workflow_type must be FP, Archer, CARD, or GRANITE." | Same message across all endpoints |
|
||||
| Missing/invalid vendor for FP/Archer | 400 | "vendor is required for FP and Archer workflows." | Same message as existing endpoints |
|
||||
| Vendor exceeds 200 chars | 400 | "vendor must be under 200 chars." | Same message as existing endpoints |
|
||||
| Database insert failure | 500 | "Internal server error." | Consistent with existing error pattern |
|
||||
| Frontend API error | — | Display error message from API in modal | Modal stays open so user can retry or cancel |
|
||||
|
||||
The redirect endpoint reuses the existing `isValidVendor()` helper and `VALID_WORKFLOW_TYPES` constant from `ivantiTodoQueue.js` for consistent validation. All error messages for invalid workflow_type now list all four valid options: FP, Archer, CARD, and GRANITE.
|
||||
|
||||
Audit logging uses the existing fire-and-forget pattern — a failed audit log write does not block or fail the redirect response.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
Backend:
|
||||
- Redirect a completed CARD item to FP with vendor → 201, new item returned
|
||||
- Redirect a completed FP item to CARD without vendor → 201, new item returned
|
||||
- Redirect a completed FP item to GRANITE without vendor → 201, new item returned
|
||||
- Redirect a completed GRANITE item to Archer with vendor → 201, new item returned
|
||||
- Redirect a pending item → 400
|
||||
- Redirect another user's item → 404
|
||||
- Redirect with invalid workflow_type → 400 with message listing FP, Archer, CARD, GRANITE
|
||||
- Redirect to FP without vendor → 400
|
||||
- Redirect to FP with vendor > 200 chars → 400
|
||||
- Redirect non-existent item → 404
|
||||
- PUT with invalid workflow_type returns error message "workflow_type must be FP, Archer, CARD, or GRANITE."
|
||||
- Batch add with workflow_type GRANITE and no vendor → 201
|
||||
- Single add with workflow_type GRANITE and no vendor → 201
|
||||
- Verify audit log is called with correct fields on successful redirect
|
||||
- Verify VALID_WORKFLOW_TYPES includes all four types
|
||||
|
||||
Frontend:
|
||||
- Redirect button visible on completed items, hidden on pending items
|
||||
- Clicking redirect button opens modal with correct item context
|
||||
- Modal shows all four workflow type options (FP, Archer, CARD, GRANITE)
|
||||
- Modal shows vendor field for FP/Archer, hides for CARD and GRANITE
|
||||
- Modal displays finding title, finding ID, current workflow type
|
||||
- Successful redirect closes modal, adds new item to list, shows notification
|
||||
- Failed redirect shows error message, modal stays open
|
||||
- QueuePanel groups CARD and GRANITE items under "Inventory" section
|
||||
- Sub-divider shown only when both CARD and GRANITE items exist in Inventory section
|
||||
- "Inventory" heading shown even when only one sub-type present
|
||||
- GRANITE badge uses warm slate color (#A1887F)
|
||||
- CARD badge uses green color (#10B981)
|
||||
- AddToQueue popover shows GRANITE as fourth option with warm slate color
|
||||
- Selecting GRANITE in AddToQueue popover does not require vendor
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
Library: `fast-check` (JavaScript property-based testing library)
|
||||
|
||||
Each property test runs a minimum of 100 iterations.
|
||||
|
||||
- **Property 1**: Generate random queue item data (finding_id, finding_title, cves_json, ip_address, hostname with varying lengths, special characters, null optionals) and random valid workflow_type from all four types (FP, Archer, CARD, GRANITE). Mock the database layer. Verify the INSERT parameters preserve all finding fields and set status to "pending".
|
||||
- Tag: `Feature: ivanti-queue-redirect, Property 1: Redirect preserves finding data`
|
||||
|
||||
- **Property 2**: Generate random (workflow_type, vendor) pairs where workflow_type is drawn from all four valid types and vendor is drawn from a mix of valid strings, empty strings, whitespace, strings of length 200, and strings of length 201. Verify that the validation logic accepts/rejects correctly: FP/Archer require non-empty vendor ≤ 200 chars; CARD/GRANITE accept without vendor.
|
||||
- Tag: `Feature: ivanti-queue-redirect, Property 2: Vendor requirement is conditional on workflow type`
|
||||
|
||||
- **Property 3**: Generate random successful redirect scenarios with varying item data and all four workflow types. Mock logAudit. Verify the audit call contains the correct action, entityType, entityId, and all required detail fields.
|
||||
- Tag: `Feature: ivanti-queue-redirect, Property 3: Successful redirect produces correct audit entry`
|
||||
107
.kiro/specs/ivanti-queue-redirect/requirements.md
Normal file
107
.kiro/specs/ivanti-queue-redirect/requirements.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The Ivanti Queue Redirect feature gives users the option to redirect any completed queue item to a different workflow type. Not every completed item needs a redirect — many items are fully resolved once their workflow completes. However, some findings require further action under a different workflow. The primary use case is CARD items where the inventory fix is done but the finding still needs an FP or Archer workflow. It also supports correcting items that were assigned to the wrong team by redirecting them after a CARD fix. Additionally, CARD items with high asset scores (90+) that cannot be edited in CARD need to go through the GRANITE program for reassignment or deletion — GRANITE is a first-class workflow type alongside FP, Archer, and CARD. Redirecting is always a user-initiated, optional action that creates a new pending queue item with the target workflow type, preserving the original finding data. Any completed item can redirect to GRANITE, and any completed GRANITE item can redirect to any other type — there is no fixed ordering between workflow types.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Queue_Panel**: The slide-out panel in the frontend that displays the user's Ivanti todo queue items. Items are grouped into an Inventory section (containing CARD and GRANITE sub-groups) at the top, followed by vendor-grouped sections for FP and Archer items.
|
||||
- **Queue_Item**: A row in the `ivanti_todo_queue` table representing a finding assigned to a workflow type (FP, Archer, CARD, or GRANITE) with a status of pending or complete.
|
||||
- **Redirect**: The action of creating a new pending Queue_Item from an existing completed Queue_Item, changing the workflow type and optionally setting a vendor.
|
||||
- **Workflow_Type**: One of four processing tracks for a finding: FP (False Positive), Archer (risk acceptance), CARD (inventory correction), or GRANITE (high-asset-score reassignment/deletion).
|
||||
- **GRANITE**: A workflow type for findings with high asset scores (90+) that cannot be edited in CARD and require reassignment or deletion through the GRANITE program. GRANITE behaves like CARD for validation — no vendor is required.
|
||||
- **Vendor**: The vendor string associated with a Queue_Item. Required for FP and Archer workflow types, not required for CARD or GRANITE.
|
||||
- **Redirect_API**: The backend endpoint `POST /api/ivanti/todo-queue/:id/redirect` that performs the redirect operation.
|
||||
- **Redirect_Modal**: The frontend dialog that collects the target workflow type and vendor from the user before executing a redirect.
|
||||
- **Inventory_Section**: The top section of the Queue_Panel that groups both CARD and GRANITE items under the heading "Inventory", with a sub-divider separating CARD items (first) from GRANITE items (below).
|
||||
- **AddToQueue_Popover**: The frontend popover that allows users to add findings to the queue by selecting a workflow type.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Redirect a Completed Queue Item via API
|
||||
|
||||
**User Story:** As an editor or admin, I want to redirect a completed queue item to a different workflow type, so that I can continue processing a finding under the correct workflow after initial work is done.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user submits a redirect request for a completed Queue_Item, THE Redirect_API SHALL create a new Queue_Item with status "pending", the specified target Workflow_Type, and the same finding_id, finding_title, cves_json, ip_address, and hostname as the original Queue_Item.
|
||||
2. WHEN a user submits a redirect request with a target Workflow_Type of "FP" or "Archer", THE Redirect_API SHALL require a non-empty vendor string of 200 characters or fewer.
|
||||
3. WHEN a user submits a redirect request with a target Workflow_Type of "CARD" or "GRANITE", THE Redirect_API SHALL accept the request without requiring a vendor.
|
||||
4. IF a user submits a redirect request for a Queue_Item that is not in "complete" status, THEN THE Redirect_API SHALL return a 400 error with a descriptive message.
|
||||
5. IF a user submits a redirect request for a Queue_Item that belongs to a different user, THEN THE Redirect_API SHALL return a 404 error.
|
||||
6. IF a user submits a redirect request with an invalid Workflow_Type, THEN THE Redirect_API SHALL return a 400 error indicating valid options are FP, Archer, CARD, or GRANITE.
|
||||
7. WHEN a redirect is successfully completed, THE Redirect_API SHALL return the newly created Queue_Item with a 201 status code.
|
||||
|
||||
### Requirement 2: Audit Logging for Redirects
|
||||
|
||||
**User Story:** As an admin, I want redirect actions to be recorded in the audit log, so that I can track workflow changes for compliance and accountability.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a redirect is successfully completed, THE Redirect_API SHALL log an audit entry with action "queue_item_redirected", the user's ID and username, the original Queue_Item ID as entityId, and details including the original Workflow_Type, the target Workflow_Type, the new Queue_Item ID, and the vendor.
|
||||
2. THE Redirect_API SHALL use entityType "ivanti_todo_queue" for all redirect audit entries.
|
||||
|
||||
### Requirement 3: Redirect UI in the Queue Panel
|
||||
|
||||
**User Story:** As a user, I want a redirect button on completed queue items, so that I can easily initiate a redirect without leaving the Queue_Panel.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE a Queue_Item has status "complete", THE Queue_Panel SHALL display a redirect button on that item.
|
||||
2. WHILE a Queue_Item has status "pending", THE Queue_Panel SHALL hide the redirect button on that item.
|
||||
3. WHEN the user clicks the redirect button on a completed Queue_Item, THE Queue_Panel SHALL open the Redirect_Modal pre-populated with the finding details from the selected item.
|
||||
|
||||
### Requirement 4: Redirect Modal Workflow
|
||||
|
||||
**User Story:** As a user, I want a modal dialog to select the target workflow type and vendor when redirecting, so that I can confirm the redirect details before submitting.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Redirect_Modal SHALL display a workflow type selector with options FP, Archer, CARD, and GRANITE.
|
||||
2. WHEN the user selects FP or Archer as the target Workflow_Type, THE Redirect_Modal SHALL display a required vendor input field.
|
||||
3. WHEN the user selects CARD or GRANITE as the target Workflow_Type, THE Redirect_Modal SHALL hide the vendor input field.
|
||||
4. THE Redirect_Modal SHALL display the finding title, finding ID, and current Workflow_Type of the item being redirected as read-only context.
|
||||
5. WHEN the user confirms the redirect in the Redirect_Modal, THE Queue_Panel SHALL call the Redirect_API and add the newly created Queue_Item to the displayed list without requiring a full page refresh.
|
||||
6. IF the Redirect_API returns an error, THEN THE Redirect_Modal SHALL display the error message to the user and remain open.
|
||||
7. WHEN the redirect succeeds, THE Redirect_Modal SHALL close and THE Queue_Panel SHALL display a success notification.
|
||||
|
||||
### Requirement 5: Fix PUT Endpoint Validation Message
|
||||
|
||||
**User Story:** As a developer, I want the PUT endpoint validation message to accurately list all valid workflow types, so that API consumers receive correct error guidance.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user submits an invalid workflow_type to the PUT /api/ivanti/todo-queue/:id endpoint, THE Redirect_API SHALL return an error message stating "workflow_type must be FP, Archer, CARD, or GRANITE".
|
||||
|
||||
### Requirement 6: GRANITE Backend Validation Support
|
||||
|
||||
**User Story:** As a developer, I want GRANITE recognized as a valid workflow type across all backend endpoints, so that users can add, update, and redirect GRANITE items through the existing API.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Redirect_API SHALL include "GRANITE" in the VALID_WORKFLOW_TYPES constant alongside "FP", "Archer", and "CARD".
|
||||
2. WHEN a user submits a request with Workflow_Type "GRANITE" to the batch add, single add, PUT, or redirect endpoints, THE Redirect_API SHALL accept the request without requiring a vendor, using the same validation rules as "CARD".
|
||||
3. WHEN any endpoint returns an error for an invalid Workflow_Type, THE Redirect_API SHALL list all four valid options: FP, Archer, CARD, and GRANITE.
|
||||
|
||||
### Requirement 7: Inventory Section Grouping in Queue Panel
|
||||
|
||||
**User Story:** As a user, I want CARD and GRANITE items grouped together under an "Inventory" heading in the Queue_Panel, so that I can see all inventory-category work in one place while distinguishing between the two sub-types.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Queue_Panel SHALL display a top section labeled "Inventory" that contains both CARD and GRANITE Queue_Items.
|
||||
2. WHILE the Inventory_Section contains both CARD and GRANITE items, THE Queue_Panel SHALL display a subtle sub-divider separating CARD items (listed first) from GRANITE items (listed below).
|
||||
3. THE Queue_Panel SHALL display a workflow type badge on each item showing "CARD" or "GRANITE" with distinct badge colors.
|
||||
4. THE Queue_Panel SHALL use a warm slate color (#A1887F or #8D6E63) for the GRANITE badge, distinct from the CARD green (#10B981).
|
||||
5. WHILE the Inventory_Section contains only CARD items or only GRANITE items, THE Queue_Panel SHALL still display the "Inventory" section heading without a sub-divider.
|
||||
|
||||
### Requirement 8: GRANITE Support in AddToQueue Popover
|
||||
|
||||
**User Story:** As a user, I want to add findings to the queue as GRANITE items directly from the AddToQueue_Popover, so that I can assign high-asset-score findings to the GRANITE workflow without needing to redirect from another type.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE AddToQueue_Popover SHALL display GRANITE as a fourth workflow type button alongside FP, Archer, and CARD.
|
||||
2. WHEN the user selects GRANITE in the AddToQueue_Popover, THE AddToQueue_Popover SHALL submit the request without requiring a vendor field, using the same behavior as CARD.
|
||||
3. THE AddToQueue_Popover SHALL use the warm slate color (#A1887F or #8D6E63) for the GRANITE button, consistent with the GRANITE badge color in the Queue_Panel.
|
||||
143
.kiro/specs/ivanti-queue-redirect/tasks.md
Normal file
143
.kiro/specs/ivanti-queue-redirect/tasks.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Implementation Plan: Ivanti Queue Redirect
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a redirect action for completed Ivanti queue items. The feature adds a `POST /api/ivanti/todo-queue/:id/redirect` endpoint to the existing route module, fixes the PUT validation message, creates a RedirectModal frontend component, and wires a redirect button into the QueuePanel for completed items. Tasks are ordered: backend bug fix, backend endpoint, frontend modal, frontend integration, with property tests alongside each layer.
|
||||
|
||||
Additionally, GRANITE is added as a fourth workflow type across the entire stack — backend validation, RedirectModal, QueuePanel grouping (Inventory section), and AddToQueue popover.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Fix PUT endpoint validation message
|
||||
- [x] 1.1 Update PUT `/:id` workflow_type error message in `backend/routes/ivantiTodoQueue.js`
|
||||
- Change `"workflow_type must be FP or Archer."` to `"workflow_type must be FP, Archer, or CARD."`
|
||||
- _Requirements: 5.1_
|
||||
|
||||
- [x] 2. Add redirect endpoint to backend
|
||||
- [x] 2.1 Add `POST /:id/redirect` route in `backend/routes/ivantiTodoQueue.js`
|
||||
- Place inside the existing `createIvantiTodoQueueRouter` factory, before the DELETE routes
|
||||
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||
- Validate `workflow_type` against existing `VALID_WORKFLOW_TYPES` constant
|
||||
- For FP/Archer: validate vendor using existing `isValidVendor()` helper; also check length ≤ 200
|
||||
- For CARD: accept without vendor
|
||||
- Fetch original item with `db.get()` scoped to `req.user.id`; return 404 if not found
|
||||
- Return 400 if original item status is not `"complete"`
|
||||
- INSERT new row copying finding_id, finding_title, cves_json, ip_address, hostname from original; set status `"pending"`, workflow_type and vendor from request body
|
||||
- Fetch the inserted row, parse cves_json, return 201 with the new item
|
||||
- Call `logAudit(db, ...)` fire-and-forget with action `"queue_item_redirected"`, entityType `"ivanti_todo_queue"`, entityId = original item ID, details: `{ original_workflow_type, target_workflow_type, new_item_id, vendor }`
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2_
|
||||
|
||||
- [x] 3. Checkpoint — Verify backend changes
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Create RedirectModal frontend component
|
||||
- [x] 4.1 Create `frontend/src/components/RedirectModal.js`
|
||||
- Props: `item` (the completed queue item), `onClose` (function), `onRedirect` (function called with new item)
|
||||
- Display read-only context: finding title, finding ID, current workflow type
|
||||
- Workflow type selector (radio buttons or select) with options FP, Archer, CARD
|
||||
- Vendor text input shown only when FP or Archer is selected; required for those types
|
||||
- Submit button calls `POST /api/ivanti/todo-queue/${item.id}/redirect` with `credentials: 'include'`
|
||||
- On success: call `onRedirect(newItem)`, close modal
|
||||
- On error: display error message from API response, keep modal open
|
||||
- Loading state on submit button to prevent double-clicks
|
||||
- Style with inline style objects following DESIGN_SYSTEM.md (dark theme, accent borders, gradient backgrounds)
|
||||
- Use lucide-react icons (e.g., `CornerUpRight` or `ArrowRightLeft`)
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||
|
||||
- [x] 5. Integrate redirect button and modal into QueuePanel
|
||||
- [x] 5.1 Add redirect button to completed items in QueuePanel (inside `frontend/src/components/pages/ReportingPage.js`)
|
||||
- Add a redirect icon button (lucide-react) on each completed queue item row, next to the existing delete button
|
||||
- Button visible only when `item.status === 'complete'`; hidden for pending items
|
||||
- _Requirements: 3.1, 3.2_
|
||||
|
||||
- [x] 5.2 Wire RedirectModal state and rendering in QueuePanel
|
||||
- Add `redirectItem` state (null or the item being redirected)
|
||||
- Clicking the redirect button sets `redirectItem` to that item, opening the modal
|
||||
- On successful redirect (`onRedirect` callback): append the new item to the queue items list, show a success notification, clear `redirectItem`
|
||||
- On close: clear `redirectItem`
|
||||
- Import and render `<RedirectModal>` conditionally when `redirectItem` is set
|
||||
- _Requirements: 3.3, 4.5, 4.7_
|
||||
|
||||
- [x] 6. Final checkpoint — Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 7. Add GRANITE to backend validation
|
||||
- [ ] 7.1 Update `VALID_WORKFLOW_TYPES` constant in `backend/routes/ivantiTodoQueue.js`
|
||||
- Change from `['FP', 'Archer', 'CARD']` to `['FP', 'Archer', 'CARD', 'GRANITE']`
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [ ] 7.2 Update vendor validation condition in POST `/` (single add) endpoint
|
||||
- Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` (or `!['CARD', 'GRANITE'].includes(workflow_type)`) for the vendor-required check
|
||||
- Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment
|
||||
- _Requirements: 6.2_
|
||||
|
||||
- [ ] 7.3 Update vendor validation condition in POST `/batch` endpoint
|
||||
- Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` for the vendor-required check
|
||||
- Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment
|
||||
- _Requirements: 6.2_
|
||||
|
||||
- [ ] 7.4 Update vendor validation condition in POST `/:id/redirect` endpoint
|
||||
- Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` for the vendor-required check
|
||||
- Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment
|
||||
- _Requirements: 6.2_
|
||||
|
||||
- [ ] 7.5 Update all error messages across all endpoints
|
||||
- Change `"workflow_type must be FP, Archer, or CARD."` to `"workflow_type must be FP, Archer, CARD, or GRANITE."` in POST `/`, POST `/batch`, PUT `/:id`, and POST `/:id/redirect`
|
||||
- _Requirements: 5.1, 6.3_
|
||||
|
||||
- [ ] 8. Add GRANITE to RedirectModal
|
||||
- [ ] 8.1 Update `WORKFLOW_OPTIONS` in `frontend/src/components/RedirectModal.js`
|
||||
- Add `{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }` as the fourth option
|
||||
- Vendor field already hidden for non-FP/Archer types via `needsVendor` check — no change needed there
|
||||
- _Requirements: 4.1, 4.3_
|
||||
|
||||
- [ ] 9. Update QueuePanel grouping for Inventory section
|
||||
- [ ] 9.1 Update the `grouped` useMemo in QueuePanel (`frontend/src/components/pages/ReportingPage.js`)
|
||||
- Change `items.filter((i) => i.workflow_type === 'CARD')` to filter both CARD and GRANITE into inventory items
|
||||
- Split inventory items into `cardItems` and `graniteItems` sub-arrays
|
||||
- Change `otherItems` filter from `i.workflow_type !== 'CARD'` to exclude both CARD and GRANITE
|
||||
- Rename group key from `__CARD__` to `__INVENTORY__`, label from `'CARD'` to `'Inventory'`, and `isCard` to `isInventory`
|
||||
- Include `cardItems` and `graniteItems` as separate properties on the inventory group object
|
||||
- _Requirements: 7.1, 7.5_
|
||||
|
||||
- [ ] 9.2 Update the QueuePanel rendering to handle the Inventory section
|
||||
- Update the `.map()` destructuring from `isCard` to `isInventory`
|
||||
- Update group header border and label color to use `isInventory` instead of `isCard`
|
||||
- For the Inventory group, render CARD items first, then a subtle sub-divider (only when both `cardItems.length > 0` and `graniteItems.length > 0`), then GRANITE items
|
||||
- _Requirements: 7.1, 7.2, 7.5_
|
||||
|
||||
- [ ] 9.3 Update the workflow type color mapping in QueuePanel item rendering
|
||||
- Add GRANITE to the `wfColor` ternary: `item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }` before the default CARD fallback
|
||||
- _Requirements: 7.3, 7.4_
|
||||
|
||||
- [ ] 9.4 Update `isCardItem` to `isInventoryItem` in QueuePanel item rendering
|
||||
- Change `const isCardItem = item.workflow_type === 'CARD'` to `const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE'`
|
||||
- Update the conditional rendering that uses `isCardItem` to use `isInventoryItem` (hostname/ip_address display vs CVE display)
|
||||
- _Requirements: 7.1_
|
||||
|
||||
- [ ] 10. Add GRANITE to AddToQueuePopover
|
||||
- [ ] 10.1 Update workflow type buttons in `AddToQueuePopover` (`frontend/src/components/pages/ReportingPage.js`)
|
||||
- Add `{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }` as the fourth button in the workflow type toggle array
|
||||
- _Requirements: 8.1, 8.3_
|
||||
|
||||
- [ ] 10.2 Update `isCard` condition in `AddToQueuePopover` to include GRANITE
|
||||
- Change `const isCard = queueForm.workflowType === 'CARD'` to `const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE'` (or rename to `isInventory`)
|
||||
- This controls the "No vendor required" message and hides the vendor input for GRANITE
|
||||
- _Requirements: 8.2_
|
||||
|
||||
- [ ] 10.3 Update `SelectionToolbar` component to include GRANITE
|
||||
- Add `{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' }` as the fourth button in the workflow type toggles array
|
||||
- Change `const isCard = workflowType === 'CARD'` to include GRANITE: `const isCard = workflowType === 'CARD' || workflowType === 'GRANITE'`
|
||||
- _Requirements: 8.1, 8.2, 8.3_
|
||||
|
||||
- [ ] 11. Final checkpoint — Verify all GRANITE changes
|
||||
- Ensure all changes compile and render correctly, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks 1–6 are the original redirect feature tasks, all completed
|
||||
- Tasks 7–11 are the new GRANITE workflow type additions
|
||||
- No test tasks included per user request — testing will be done manually on the dev server
|
||||
- Each task references specific requirements for traceability
|
||||
- The QueuePanel component is defined inside `ReportingPage.js`, not a separate file
|
||||
- The project uses plain JavaScript (no TypeScript)
|
||||
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.
|
||||
1
.kiro/specs/reporting-row-visibility/.config.kiro
Normal file
1
.kiro/specs/reporting-row-visibility/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||
400
.kiro/specs/reporting-row-visibility/design.md
Normal file
400
.kiro/specs/reporting-row-visibility/design.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Design Document: Reporting Row Visibility
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds row-level visibility controls to the Reporting page's findings table, allowing security analysts to temporarily hide specific rows from view. Hidden rows are excluded from the visible table, the Action Coverage donut chart, and CSV/XLSX exports. The feature is entirely frontend — no backend changes are needed. All state is persisted in browser localStorage.
|
||||
|
||||
The feature consists of five interconnected pieces:
|
||||
|
||||
1. **Per-row hide button** — An `EyeOff` icon button on each table row that hides that finding
|
||||
2. **Bulk select and hide** — Checkboxes on each row, a select-all checkbox, and a bulk action toolbar for hiding multiple rows at once
|
||||
3. **Row Visibility Manager** — A toolbar popover (modeled after the existing `ColumnManager`) for viewing and restoring hidden rows
|
||||
4. **Chart integration** — The Action Coverage donut chart recalculates using only visible (non-hidden) findings
|
||||
5. **Export integration** — CSV and XLSX exports include only visible rows
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **localStorage over backend storage**: Row visibility is a personal view preference, not shared team state. Storing it in localStorage keeps the feature zero-cost on the backend and avoids auth/permission complexity. This mirrors how column visibility is already handled via `STORAGE_KEY`.
|
||||
- **Hide-before-filter pipeline**: Hidden rows are removed from the dataset before column filters, action coverage filters, and EXC filters are applied. This ensures hidden rows never appear in filter dropdowns or affect filter counts.
|
||||
- **Stale IDs are retained silently**: If a hidden Finding_ID no longer exists after an Ivanti sync, the ID stays in localStorage. This avoids the need for cleanup logic and ensures the finding is re-hidden if it reappears in a future sync.
|
||||
- **Selection state is transient**: The checkbox selection state (`Row_Selection_State`) is not persisted. It resets on page reload and clears after a bulk hide operation.
|
||||
|
||||
## Architecture
|
||||
|
||||
The feature is contained entirely within `frontend/src/components/pages/ReportingPage.js`. No new files are created. The changes integrate into the existing component hierarchy and data flow.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph ReportingPage State
|
||||
A[findings - raw from API] --> B[hiddenRowIds - from localStorage]
|
||||
B --> C[visibleFindings = findings minus hiddenRowIds]
|
||||
C --> D[filtered - column/action/EXC filters applied]
|
||||
D --> E[sorted - sort order applied]
|
||||
E --> F[Table rows rendered]
|
||||
C --> G[ActionCoverageDonut receives visibleFindings]
|
||||
E --> H[Export functions use sorted visible rows]
|
||||
end
|
||||
|
||||
subgraph New Components
|
||||
I[RowVisibilityManager popover]
|
||||
J[BulkActionToolbar]
|
||||
K[Selection checkboxes]
|
||||
L[Per-row hide button]
|
||||
end
|
||||
|
||||
I -->|restore| B
|
||||
J -->|bulk hide| B
|
||||
L -->|single hide| B
|
||||
K -->|toggle selection| M[selectedRowIds - transient state]
|
||||
M --> J
|
||||
```
|
||||
|
||||
### Data Flow Pipeline
|
||||
|
||||
The existing filtering pipeline in `VulnerabilityTriagePage` is:
|
||||
|
||||
```
|
||||
findings → columnFilters → actionFilter → excFilter → sorted → rendered/exported
|
||||
```
|
||||
|
||||
The new pipeline inserts row hiding as the first step:
|
||||
|
||||
```
|
||||
findings → HIDE (remove hiddenRowIds) → columnFilters → actionFilter → excFilter → sorted → rendered/exported
|
||||
```
|
||||
|
||||
This ensures hidden rows are excluded before any other filtering logic runs.
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. Hidden Row State Management
|
||||
|
||||
New state and helpers added to `VulnerabilityTriagePage`:
|
||||
|
||||
```javascript
|
||||
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
|
||||
|
||||
// Load hidden row IDs from localStorage
|
||||
function loadHiddenRows() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
|
||||
if (saved && Array.isArray(saved)) return new Set(saved);
|
||||
} catch { /* corrupted — treat as empty */ }
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Persist hidden row IDs to localStorage
|
||||
function saveHiddenRows(hiddenSet) {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet]));
|
||||
} catch { /* localStorage unavailable — degrade silently */ }
|
||||
}
|
||||
```
|
||||
|
||||
**State declarations:**
|
||||
|
||||
```javascript
|
||||
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
|
||||
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
|
||||
```
|
||||
|
||||
**Hide/restore operations:**
|
||||
|
||||
```javascript
|
||||
// Hide a single row
|
||||
const hideRow = useCallback((findingId) => {
|
||||
setHiddenRowIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(String(findingId));
|
||||
saveHiddenRows(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore a single row
|
||||
const restoreRow = useCallback((findingId) => {
|
||||
setHiddenRowIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(String(findingId));
|
||||
saveHiddenRows(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore all hidden rows
|
||||
const restoreAllRows = useCallback(() => {
|
||||
setHiddenRowIds(new Set());
|
||||
saveHiddenRows(new Set());
|
||||
}, []);
|
||||
|
||||
// Bulk hide selected rows
|
||||
const hideSelectedRows = useCallback(() => {
|
||||
setHiddenRowIds(prev => {
|
||||
const next = new Set(prev);
|
||||
selectedRowIds.forEach(id => next.add(String(id)));
|
||||
saveHiddenRows(next);
|
||||
return next;
|
||||
});
|
||||
setSelectedRowIds(new Set());
|
||||
}, [selectedRowIds]);
|
||||
```
|
||||
|
||||
### 2. Modified Filtering Pipeline
|
||||
|
||||
The existing `filtered` useMemo is modified to exclude hidden rows first:
|
||||
|
||||
```javascript
|
||||
// New: visible findings (hidden rows removed) — fed to ActionCoverageDonut
|
||||
const visibleFindings = useMemo(() => {
|
||||
if (hiddenRowIds.size === 0) return findings;
|
||||
return findings.filter(f => !hiddenRowIds.has(String(f.id)));
|
||||
}, [findings, hiddenRowIds]);
|
||||
|
||||
// Modified: filtered now starts from visibleFindings instead of findings
|
||||
const filtered = useMemo(() => {
|
||||
let result = visibleFindings;
|
||||
// ... existing column filter, action filter, EXC filter logic unchanged
|
||||
}, [visibleFindings, columnFilters, actionFilter, excFilter]);
|
||||
```
|
||||
|
||||
The `ActionCoverageDonut` receives `visibleFindings` instead of `findings`:
|
||||
|
||||
```jsx
|
||||
<ActionCoverageDonut
|
||||
findings={visibleFindings} // was: findings
|
||||
activeSegment={actionFilter}
|
||||
onSegmentClick={...}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. Selection State Management
|
||||
|
||||
```javascript
|
||||
// Clear selection when visible rows change (filter changes)
|
||||
useEffect(() => {
|
||||
setSelectedRowIds(prev => {
|
||||
const visibleIds = new Set(sorted.map(f => String(f.id)));
|
||||
const next = new Set([...prev].filter(id => visibleIds.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [sorted]);
|
||||
|
||||
// Toggle a single row's selection
|
||||
const toggleRowSelection = useCallback((findingId) => {
|
||||
setSelectedRowIds(prev => {
|
||||
const next = new Set(prev);
|
||||
const id = String(findingId);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select/deselect all visible rows
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const allVisibleIds = sorted.map(f => String(f.id));
|
||||
setSelectedRowIds(prev => {
|
||||
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
|
||||
return new Set(allVisibleIds); // select all
|
||||
});
|
||||
}, [sorted]);
|
||||
```
|
||||
|
||||
### 4. RowVisibilityManager Component
|
||||
|
||||
A new component following the same pattern as `ColumnManager`:
|
||||
|
||||
```javascript
|
||||
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
|
||||
// Props:
|
||||
// hiddenRowIds: Set<string> — currently hidden finding IDs
|
||||
// findings: Array — full findings array (to look up titles for hidden IDs)
|
||||
// onRestore: (findingId: string) => void
|
||||
// onRestoreAll: () => void
|
||||
//
|
||||
// State:
|
||||
// open: boolean — popover visibility
|
||||
//
|
||||
// Behavior:
|
||||
// - Button shows EyeOff icon + "Hidden (N)" count
|
||||
// - Popover lists hidden findings by ID and title
|
||||
// - Each entry has an Eye icon restore button
|
||||
// - "Restore All" button at the bottom
|
||||
// - When hiddenRowIds.size === 0, popover shows "No rows hidden" message
|
||||
// - Closes on outside click (same pattern as ColumnManager)
|
||||
}
|
||||
```
|
||||
|
||||
**Placement:** Rendered in the toolbar `div` adjacent to the `ColumnManager` button, between the ColumnManager and the Sync button.
|
||||
|
||||
### 5. BulkActionToolbar Component
|
||||
|
||||
Rendered above the table when `selectedRowIds.size > 0`:
|
||||
|
||||
```javascript
|
||||
function BulkHideToolbar({ count, onHide, onClear }) {
|
||||
// Props:
|
||||
// count: number — selected row count
|
||||
// onHide: () => void — hide all selected rows
|
||||
// onClear: () => void — clear selection
|
||||
//
|
||||
// Renders:
|
||||
// "{count} rows selected" label
|
||||
// "Hide Selected" button with EyeOff icon
|
||||
// "Clear" button to deselect all
|
||||
}
|
||||
```
|
||||
|
||||
**Placement:** Rendered inside the table scroll container, above the `<table>` element, in the same position as the existing `SelectionToolbar` for batch FP submissions. The bulk hide toolbar appears alongside (or replaces) the existing selection toolbar depending on context.
|
||||
|
||||
### 6. Per-Row Hide Button and Selection Checkbox
|
||||
|
||||
Two new fixed columns are added to the table, before the existing checkbox column:
|
||||
|
||||
| Column | Width | Content | Position |
|
||||
|--------|-------|---------|----------|
|
||||
| Selection checkbox | 36px | `Square` / `CheckSquare` icon (lucide-react) | First column |
|
||||
| Hide button | 36px | `EyeOff` icon button | Second column |
|
||||
|
||||
Both columns are fixed (not managed by `ColumnManager`) and use sticky positioning in the header.
|
||||
|
||||
### 7. Select All Checkbox
|
||||
|
||||
Rendered in the table header for the selection column:
|
||||
|
||||
- **Unchecked** (`Square` icon): No rows selected
|
||||
- **Checked** (`CheckSquare` icon): All visible rows selected
|
||||
- **Indeterminate** (`MinusSquare` icon): Some but not all visible rows selected
|
||||
|
||||
## Data Models
|
||||
|
||||
### localStorage Schema
|
||||
|
||||
**Key:** `steam_findings_hidden_rows`
|
||||
|
||||
**Value:** JSON array of Finding ID strings
|
||||
|
||||
```json
|
||||
["12345", "67890", "11111"]
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Finding IDs are stored as strings for consistent comparison
|
||||
- The array may contain IDs that no longer exist in the current findings dataset (stale IDs are retained)
|
||||
- An empty array `[]` or missing key both mean "no rows hidden"
|
||||
- If the stored value fails JSON parsing, it is treated as empty (all rows visible)
|
||||
|
||||
### Component State
|
||||
|
||||
| State Variable | Type | Persisted | Description |
|
||||
|---------------|------|-----------|-------------|
|
||||
| `hiddenRowIds` | `Set<string>` | Yes (localStorage) | Finding IDs currently hidden |
|
||||
| `selectedRowIds` | `Set<string>` | No (transient) | Finding IDs currently selected via checkboxes |
|
||||
|
||||
### Derived Data
|
||||
|
||||
| Variable | Derivation | Used By |
|
||||
|----------|-----------|---------|
|
||||
| `visibleFindings` | `findings.filter(f => !hiddenRowIds.has(f.id))` | ActionCoverageDonut, filter pipeline |
|
||||
| `filtered` | `visibleFindings` → column filters → action filter → EXC filter | Sort, table render |
|
||||
| `sorted` | `filtered` → sort comparator | Table render, export |
|
||||
|
||||
|
||||
## 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: Hidden row filtering invariant
|
||||
|
||||
*For any* array of findings and *any* set of hidden Finding_IDs, the `visibleFindings` array SHALL never contain a finding whose ID is in the hidden set.
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
|
||||
|
||||
### Property 2: localStorage round-trip preserves hidden row state
|
||||
|
||||
*For any* set of valid Finding_ID strings, calling `saveHiddenRows(set)` followed by `loadHiddenRows()` SHALL return a set containing exactly the same elements.
|
||||
|
||||
**Validates: Requirements 2.1, 2.2**
|
||||
|
||||
### Property 3: Corrupted localStorage produces empty set
|
||||
|
||||
*For any* string that is not a valid JSON array of strings, `loadHiddenRows()` SHALL return an empty set, and no error SHALL be thrown.
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
|
||||
### Property 4: Restore removes exactly the specified ID
|
||||
|
||||
*For any* non-empty set of hidden Finding_IDs and *any* ID in that set, calling `restoreRow(id)` SHALL produce a new hidden set that is equal to the original set minus that single ID.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 5: Bulk hide produces the union of hidden and selected sets
|
||||
|
||||
*For any* set of currently hidden Finding_IDs and *any* set of selected Finding_IDs, calling `hideSelectedRows()` SHALL produce a hidden set equal to the union of both sets, and the selection set SHALL be empty afterward.
|
||||
|
||||
**Validates: Requirements 8.5, 8.6**
|
||||
|
||||
### Property 6: Selection is always a subset of visible rows
|
||||
|
||||
*For any* set of selected Finding_IDs and *any* change to the visible row set (via filter changes or row hiding), the resulting selection set SHALL be a subset of the current visible row IDs.
|
||||
|
||||
**Validates: Requirements 8.8**
|
||||
|
||||
### Property 7: Select all produces exactly the visible row ID set
|
||||
|
||||
*For any* array of currently visible (sorted) findings, calling `toggleSelectAll` when no rows are selected SHALL produce a selection set equal to the set of all visible Finding_IDs. Calling `toggleSelectAll` when all rows are selected SHALL produce an empty selection set.
|
||||
|
||||
**Validates: Requirements 8.2, 8.3**
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| localStorage unavailable (private browsing, quota exceeded) | `saveHiddenRows` fails silently via try/catch. `loadHiddenRows` returns empty set. All rows remain visible. Feature degrades to session-only (hidden state lost on reload). |
|
||||
| Corrupted localStorage value | `loadHiddenRows` catches JSON parse error and returns empty set. No error shown to user. |
|
||||
| Stale Finding_ID in hidden set (ID no longer in findings after sync) | ID is retained in localStorage. The `filter()` call simply doesn't match any finding, so no visible effect. If the finding reappears in a future sync, it will be hidden again automatically. |
|
||||
| Empty findings array | `visibleFindings` is empty. `selectedRowIds` is empty. Charts show "No data" state. Export produces headers only. All controls render but have no actionable items. |
|
||||
| Bulk hide with empty selection | The "Hide Selected" button is only shown when `selectedRowIds.size > 0`, so this state is unreachable via the UI. If called programmatically, `hideSelectedRows` is a no-op (union with empty set). |
|
||||
| Select all with no visible rows | `toggleSelectAll` produces an empty set (no rows to select). |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Property-Based Tests
|
||||
|
||||
The feature's core logic — set operations, filtering, localStorage serialization — is well-suited for property-based testing. The functions under test are pure or near-pure (localStorage can be mocked), and the input space (sets of string IDs, arrays of finding objects) is large.
|
||||
|
||||
**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/React projects.
|
||||
|
||||
**Configuration:** Each property test runs a minimum of 100 iterations.
|
||||
|
||||
**Tag format:** Each test is tagged with a comment: `// Feature: reporting-row-visibility, Property N: <property text>`
|
||||
|
||||
**Properties to implement:**
|
||||
|
||||
| Property | Test Description | Key Generators |
|
||||
|----------|-----------------|----------------|
|
||||
| 1 | Filter findings by hidden set, verify no hidden ID in output | `fc.array(findingArb)`, `fc.uniqueArray(fc.string())` |
|
||||
| 2 | Save then load hidden rows, verify round-trip equality | `fc.uniqueArray(fc.stringOf(fc.constantFrom(...digits), {minLength: 1}))` |
|
||||
| 3 | Set corrupted string in localStorage, verify loadHiddenRows returns empty set | `fc.string()` filtered to exclude valid JSON arrays |
|
||||
| 4 | Remove one ID from hidden set, verify set difference | `fc.uniqueArray(fc.string(), {minLength: 1})`, pick random element |
|
||||
| 5 | Union hidden + selected sets, verify result and empty selection | `fc.uniqueArray(fc.string())` × 2 |
|
||||
| 6 | Generate selection + filter change, verify selection ⊆ visible | `fc.uniqueArray(fc.string())` for selection and visible sets |
|
||||
| 7 | Select all from visible set, verify equality; toggle again, verify empty | `fc.array(findingArb)` |
|
||||
|
||||
### Unit Tests (Example-Based)
|
||||
|
||||
Unit tests cover specific scenarios, UI rendering, and edge cases that don't benefit from randomized input:
|
||||
|
||||
- **Hide button renders on each row** (Req 1.3) — verify EyeOff icon in fixed column
|
||||
- **Hide button visible for viewer role** (Req 1.4) — render with read-only auth context
|
||||
- **localStorage write on hide/restore** (Req 2.3) — mock localStorage, verify setItem called
|
||||
- **Row Visibility Manager button shows count** (Req 3.1) — verify "Hidden (N)" text
|
||||
- **Row Visibility Manager popover lists hidden findings** (Req 3.2) — click button, verify list
|
||||
- **Restore All clears all hidden rows** (Req 3.4) — verify empty set after restoreAll
|
||||
- **"Hidden (0)" when no rows hidden** (Req 3.5) — verify button text and empty message
|
||||
- **Chart re-renders after hide** (Req 4.2) — verify ActionCoverageDonut receives updated findings
|
||||
- **Hidden state preserved across sync** (Req 5.3) — simulate sync, verify hiddenRowIds unchanged
|
||||
- **Stale IDs retained silently** (Req 5.4) — hide ID, remove from findings, verify no error
|
||||
- **Bulk action toolbar appears with count** (Req 8.4) — select rows, verify toolbar renders
|
||||
- **Indeterminate checkbox state** (Req 8.9) — partial selection, verify MinusSquare icon
|
||||
- **Toolbar hidden when no selection** (Req 8.10) — empty selection, verify no toolbar
|
||||
- **Styling consistency** (Req 7.1, 7.2, 7.3, 8.11) — snapshot tests for visual consistency
|
||||
115
.kiro/specs/reporting-row-visibility/requirements.md
Normal file
115
.kiro/specs/reporting-row-visibility/requirements.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The Reporting page in the STEAM Security Dashboard displays a table of Ivanti host findings with columns for finding ID, severity, title, CVEs, hostname, IP address, DNS, due date, SLA status, BU ownership, workflow, last found date, and notes. Some findings have manually entered notes such as "NOT STEAM/ACCESS", "MongoDB Update", or other free-text annotations indicating that work is being done outside of the automated FP or Archer exception workflows. These manually-noted findings are classified as "pending" in the Action Coverage donut chart, inflating the pending count even though they represent active remediation efforts.
|
||||
|
||||
Users need the ability to temporarily hide specific rows from the table view — similar to how columns can already be hidden via the ColumnManager popover. Hidden rows should be excluded from the visible table and from the Action Coverage chart calculations, but the underlying data must remain intact. The feature should persist across page reloads and provide a clear mechanism to reveal hidden rows or restore them individually.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Reporting_Table**: The findings data table rendered on the Reporting page, displaying one row per Ivanti host finding with sortable, filterable columns.
|
||||
- **Row_Visibility_State**: A client-side record of which finding IDs have been hidden by the user. Stored in browser localStorage for persistence across sessions.
|
||||
- **Hidden_Row**: A finding whose ID is present in the Row_Visibility_State hidden set. Hidden rows are excluded from the visible table and from chart metric calculations.
|
||||
- **ColumnManager**: The existing popover component on the Reporting page that allows users to show/hide columns and reorder them via drag-and-drop. The row-hiding feature follows a similar UX pattern.
|
||||
- **Action_Coverage_Chart**: The donut chart on the Reporting page that classifies open findings into three categories — FP Request, Archer Exception, and Pending — based on workflow status and note content.
|
||||
- **Row_Visibility_Manager**: A new UI component that provides controls for viewing and restoring hidden rows, analogous to the ColumnManager for columns.
|
||||
- **Finding_ID**: The unique Ivanti-assigned identifier for each host finding, used as the key for tracking hidden rows.
|
||||
- **Row_Selection_State**: A transient client-side record of which Finding_IDs are currently selected via checkboxes. This state is not persisted and resets on page reload or after a bulk action completes.
|
||||
- **Selection_Checkbox**: A checkbox control rendered in a fixed column on each visible row, used to toggle that row's inclusion in the Row_Selection_State.
|
||||
- **Select_All_Checkbox**: A checkbox control rendered in the table header that toggles selection of all currently visible (non-hidden, post-filter) rows.
|
||||
- **Bulk_Action_Toolbar**: A contextual toolbar that appears above the Reporting_Table when one or more rows are selected, displaying the count of selected rows and bulk action controls.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Hide Individual Rows from the Reporting Table
|
||||
|
||||
**User Story:** As a security analyst, I want to hide specific rows in the Reporting table by clicking a hide control on each row, so that I can remove manually-handled findings from view without deleting them.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Reporting_Table SHALL display a hide button on each row that, when clicked, adds the row's Finding_ID to the Row_Visibility_State hidden set.
|
||||
2. WHEN a row's Finding_ID is added to the Row_Visibility_State hidden set, THE Reporting_Table SHALL immediately remove that row from the visible table without a page reload.
|
||||
3. THE hide button SHALL be rendered as an icon button (using the `EyeOff` icon from lucide-react) in a fixed column that is not managed by the ColumnManager.
|
||||
4. WHEN the user has no write permissions (viewer role), THE Reporting_Table SHALL still display the hide button, as row visibility is a personal view preference and not a data modification.
|
||||
|
||||
### Requirement 2: Persist Hidden Row State Across Sessions
|
||||
|
||||
**User Story:** As a security analyst, I want my hidden row selections to persist when I navigate away and return to the Reporting page, so that I do not have to re-hide the same rows every session.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Row_Visibility_State SHALL be stored in browser localStorage under a dedicated key (e.g., `steam_findings_hidden_rows`).
|
||||
2. WHEN the Reporting page loads, THE Reporting_Table SHALL read the Row_Visibility_State from localStorage and exclude hidden Finding_IDs from the visible table.
|
||||
3. WHEN the Row_Visibility_State changes (row hidden or restored), THE Reporting_Table SHALL write the updated state to localStorage immediately.
|
||||
4. IF localStorage is unavailable or the stored value is corrupted, THEN THE Reporting_Table SHALL treat all rows as visible and continue operating without error.
|
||||
|
||||
### Requirement 3: Row Visibility Manager Panel
|
||||
|
||||
**User Story:** As a security analyst, I want a panel that shows me which rows are currently hidden and lets me restore them, so that I can manage my hidden rows and bring back findings I no longer want to hide.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Row_Visibility_Manager SHALL be accessible via a toolbar button placed adjacent to the existing ColumnManager button, using the `EyeOff` icon and displaying a count of currently hidden rows (e.g., "Hidden (3)").
|
||||
2. WHEN the Row_Visibility_Manager button is clicked, THE Row_Visibility_Manager SHALL open a popover panel listing all currently hidden findings by Finding_ID and title.
|
||||
3. THE Row_Visibility_Manager panel SHALL provide a restore button (using the `Eye` icon) next to each hidden finding entry that, when clicked, removes that Finding_ID from the Row_Visibility_State and returns the row to the visible table.
|
||||
4. THE Row_Visibility_Manager panel SHALL provide a "Restore All" button that clears the entire Row_Visibility_State and returns all hidden rows to the visible table.
|
||||
5. WHEN no rows are hidden, THE Row_Visibility_Manager button SHALL display "Hidden (0)" and the popover panel SHALL display a message indicating no rows are hidden.
|
||||
|
||||
### Requirement 4: Exclude Hidden Rows from Action Coverage Metrics
|
||||
|
||||
**User Story:** As a security analyst, I want hidden rows to be excluded from the Action Coverage donut chart, so that manually-handled findings I have hidden do not inflate the "Pending" count.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Action_Coverage_Chart SHALL compute its FP Request, Archer Exception, and Pending counts using only visible (non-hidden) findings.
|
||||
2. WHEN a row is hidden or restored, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
|
||||
3. THE Action_Coverage_Chart segment click filtering SHALL operate only on visible findings, so clicking a segment filters within the non-hidden set.
|
||||
|
||||
### Requirement 5: Hidden Row Interaction with Existing Filters
|
||||
|
||||
**User Story:** As a security analyst, I want row hiding to work correctly alongside column filters, sort order, and the action coverage chart filter, so that hiding rows does not interfere with other table controls.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Reporting_Table SHALL apply row hiding before column filters, so that hidden rows are excluded from the dataset before any column filter, sort, or action coverage filter is applied.
|
||||
2. WHEN a finding is hidden and a column filter is active, THE Reporting_Table SHALL not include the hidden finding in filter value dropdowns or filter counts.
|
||||
3. WHEN findings are synced from Ivanti (Sync button), THE Row_Visibility_State SHALL be preserved — previously hidden Finding_IDs remain hidden if they still exist in the refreshed dataset.
|
||||
4. IF a hidden Finding_ID no longer exists in the synced findings data, THEN THE Row_Visibility_State SHALL retain the ID silently (no error) so that it is automatically re-hidden if the finding reappears in a future sync.
|
||||
|
||||
### Requirement 6: Export Behavior for Hidden Rows
|
||||
|
||||
**User Story:** As a security analyst, I want CSV and XLSX exports to include only visible rows by default, so that my exports reflect the same filtered view I see on screen.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the user exports data via CSV or XLSX, THE Reporting_Table SHALL export only the currently visible (non-hidden, post-filter) rows.
|
||||
2. THE export SHALL respect all active filters (column filters, action coverage filter, EXC filter) in addition to row hiding, exporting only the intersection of all active view constraints.
|
||||
|
||||
### Requirement 7: Visual Styling Consistency
|
||||
|
||||
**User Story:** As a security analyst, I want the row-hiding controls to match the existing dashboard aesthetic, so that the feature feels native to the application.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE hide button on each row SHALL use the same icon size (13px), color palette (muted slate for default, accent blue on hover), and monospace font styling as existing toolbar controls.
|
||||
2. THE Row_Visibility_Manager popover SHALL use the same panel styling (dark gradient background, accent border, box shadow) as the existing ColumnManager popover.
|
||||
3. THE Row_Visibility_Manager toolbar button SHALL use the same button styling (padding, border radius, font size, uppercase text) as the existing ColumnManager and Queue toolbar buttons.
|
||||
|
||||
### Requirement 8: Bulk Hide Rows via Multi-Select
|
||||
|
||||
**User Story:** As a security analyst, I want to select multiple rows and hide them all at once, so that I can quickly clear out batches of manually-handled findings without clicking hide on each row individually.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Reporting_Table SHALL display a Selection_Checkbox on each visible row in a fixed column that is not managed by the ColumnManager, positioned before the hide button column.
|
||||
2. THE Reporting_Table SHALL display a Select_All_Checkbox in the table header of the selection column that, when checked, adds all currently visible (non-hidden, post-filter) Finding_IDs to the Row_Selection_State.
|
||||
3. WHEN the Select_All_Checkbox is unchecked, THE Reporting_Table SHALL remove all Finding_IDs from the Row_Selection_State.
|
||||
4. WHEN one or more Finding_IDs are present in the Row_Selection_State, THE Bulk_Action_Toolbar SHALL appear above the Reporting_Table displaying the count of selected rows (e.g., "3 rows selected") and a "Hide Selected" button using the `EyeOff` icon.
|
||||
5. WHEN the "Hide Selected" button is clicked, THE Reporting_Table SHALL add all Finding_IDs in the Row_Selection_State to the Row_Visibility_State hidden set in a single operation.
|
||||
6. WHEN a bulk hide operation completes, THE Reporting_Table SHALL clear the Row_Selection_State so that no rows remain selected.
|
||||
7. WHEN a bulk hide operation completes, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
|
||||
8. WHEN column filters or the action coverage filter change the set of visible rows, THE Row_Selection_State SHALL remove any Finding_IDs that are no longer visible, so that the selection always reflects the current filtered view.
|
||||
9. THE Select_All_Checkbox SHALL display an indeterminate state when some but not all visible rows are selected.
|
||||
10. WHEN no rows are selected, THE Bulk_Action_Toolbar SHALL not be displayed.
|
||||
11. THE Selection_Checkbox, Select_All_Checkbox, and Bulk_Action_Toolbar SHALL use the same color palette (muted slate for default, accent blue for checked/active state), monospace font styling, and dark gradient background as existing toolbar controls defined in the design system.
|
||||
127
.kiro/specs/reporting-row-visibility/tasks.md
Normal file
127
.kiro/specs/reporting-row-visibility/tasks.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Implementation Plan: Reporting Row Visibility
|
||||
|
||||
## Overview
|
||||
|
||||
This plan implements row-level visibility controls for the Reporting page's findings table. All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files, no backend changes. The implementation adds hidden row state management (localStorage-persisted), a visibility filtering step in the data pipeline, selection checkboxes with bulk hide, a Row Visibility Manager popover, chart/export integration, and per-row hide buttons. Each task builds incrementally on the previous one, wiring everything together by the final step.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add hidden row state management and localStorage helpers
|
||||
- Add the `HIDDEN_ROWS_KEY` constant (`'steam_findings_hidden_rows'`)
|
||||
- Implement `loadHiddenRows()` function that reads from localStorage, parses JSON, returns a `Set<string>` (empty set on parse failure or missing key)
|
||||
- Implement `saveHiddenRows(hiddenSet)` function that serializes the set to a JSON array and writes to localStorage (silent catch on failure)
|
||||
- Add `hiddenRowIds` state initialized via `useState(loadHiddenRows)`
|
||||
- Implement `hideRow(findingId)` callback that adds a string ID to the set and persists
|
||||
- Implement `restoreRow(findingId)` callback that removes a string ID from the set and persists
|
||||
- Implement `restoreAllRows()` callback that clears the set and persists an empty set
|
||||
- _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
- [ ]* 1.1 Write property test: localStorage round-trip preserves hidden row state
|
||||
- **Property 2: localStorage round-trip preserves hidden row state**
|
||||
- Generate arbitrary sets of valid Finding_ID strings, call `saveHiddenRows` then `loadHiddenRows`, assert the returned set contains exactly the same elements
|
||||
- **Validates: Requirements 2.1, 2.2**
|
||||
|
||||
- [ ]* 1.2 Write property test: corrupted localStorage produces empty set
|
||||
- **Property 3: Corrupted localStorage produces empty set**
|
||||
- Generate arbitrary strings that are not valid JSON arrays of strings, set them in localStorage under the hidden rows key, call `loadHiddenRows`, assert the result is an empty set and no error is thrown
|
||||
- **Validates: Requirements 2.4**
|
||||
|
||||
- [x] 2. Insert visibility filtering into the data pipeline
|
||||
- Add `visibleFindings` useMemo that filters `findings` by excluding any finding whose `String(f.id)` is in `hiddenRowIds` (short-circuit when set is empty)
|
||||
- Modify the existing `filtered` useMemo to start from `visibleFindings` instead of `findings`
|
||||
- Ensure column filter dropdowns, action filter, and EXC filter all operate on the post-hide dataset
|
||||
- _Requirements: 1.2, 5.1, 5.2_
|
||||
|
||||
- [ ]* 2.1 Write property test: hidden row filtering invariant
|
||||
- **Property 1: Hidden row filtering invariant**
|
||||
- Generate arbitrary arrays of finding objects and arbitrary sets of hidden Finding_IDs, compute `visibleFindings`, assert no finding in the output has an ID present in the hidden set
|
||||
- **Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
|
||||
|
||||
- [x] 3. Integrate hidden rows with chart and export
|
||||
- Pass `visibleFindings` (instead of `findings`) to the `ActionCoverageDonut` component's `findings` prop
|
||||
- Modify the CSV export function to use the sorted/filtered visible rows (already derived from `visibleFindings` via the pipeline)
|
||||
- Modify the XLSX export function to use the sorted/filtered visible rows
|
||||
- Verify that chart segment click filtering operates on the visible set
|
||||
- _Requirements: 4.1, 4.2, 4.3, 6.1, 6.2_
|
||||
|
||||
- [x] 4. Checkpoint — Verify core hide/restore pipeline
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Add selection state and bulk hide logic
|
||||
- Add `selectedRowIds` state as `useState(new Set())`
|
||||
- Implement `toggleRowSelection(findingId)` callback that adds/removes a string ID from the selection set
|
||||
- Implement `toggleSelectAll()` callback that selects all visible sorted row IDs when not all are selected, or clears selection when all are selected
|
||||
- Implement `hideSelectedRows()` callback that unions `selectedRowIds` into `hiddenRowIds`, persists, and clears the selection set
|
||||
- Add a `useEffect` that prunes `selectedRowIds` to only include IDs present in the current `sorted` array whenever `sorted` changes
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.5, 8.6, 8.8_
|
||||
|
||||
- [ ]* 5.1 Write property test: bulk hide produces union of hidden and selected sets
|
||||
- **Property 5: Bulk hide produces the union of hidden and selected sets**
|
||||
- Generate two arbitrary sets of Finding_ID strings (hidden and selected), simulate `hideSelectedRows`, assert the resulting hidden set equals the union and the selection set is empty
|
||||
- **Validates: Requirements 8.5, 8.6**
|
||||
|
||||
- [ ]* 5.2 Write property test: selection is always a subset of visible rows
|
||||
- **Property 6: Selection is always a subset of visible rows**
|
||||
- Generate arbitrary selection and visible row sets, simulate the pruning effect, assert the resulting selection is a subset of visible row IDs
|
||||
- **Validates: Requirements 8.8**
|
||||
|
||||
- [ ]* 5.3 Write property test: select all produces exactly the visible row ID set
|
||||
- **Property 7: Select all produces exactly the visible row ID set**
|
||||
- Generate an arbitrary array of finding objects representing sorted visible rows, simulate `toggleSelectAll` from empty selection, assert the selection equals the full visible ID set; toggle again, assert empty
|
||||
- **Validates: Requirements 8.2, 8.3**
|
||||
|
||||
- [ ]* 5.4 Write property test: restore removes exactly the specified ID
|
||||
- **Property 4: Restore removes exactly the specified ID**
|
||||
- Generate a non-empty set of hidden Finding_IDs, pick a random element, simulate `restoreRow`, assert the result equals the original set minus that single ID
|
||||
- **Validates: Requirements 3.3**
|
||||
|
||||
- [x] 6. Add selection checkbox column and select-all checkbox to the table
|
||||
- Import `Square`, `CheckSquare`, and `MinusSquare` icons from lucide-react
|
||||
- Add a fixed 36px selection checkbox column as the first column in the table header and body
|
||||
- Render `Select_All_Checkbox` in the header: `CheckSquare` when all selected, `MinusSquare` when partially selected, `Square` when none selected; onClick calls `toggleSelectAll`
|
||||
- Render `Selection_Checkbox` on each row: `CheckSquare` when selected, `Square` when not; onClick calls `toggleRowSelection(finding.id)`
|
||||
- Style checkboxes with muted slate default color, accent blue when checked/active, matching existing icon sizing
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.9, 8.11_
|
||||
|
||||
- [x] 7. Add per-row hide button column
|
||||
- Add a fixed 36px hide button column as the second column (after selection checkbox) in the table header and body
|
||||
- Render an `EyeOff` icon button on each row; onClick calls `hideRow(finding.id)`
|
||||
- Style the button with 13px icon size, muted slate default color, accent blue on hover, matching existing toolbar icon patterns
|
||||
- The column header cell is empty (no label)
|
||||
- _Requirements: 1.1, 1.3, 1.4, 7.1_
|
||||
|
||||
- [x] 8. Implement BulkHideToolbar component
|
||||
- Create inline `BulkHideToolbar` component accepting `count`, `onHide`, and `onClear` props
|
||||
- Render "{count} rows selected" label, "Hide Selected" button with `EyeOff` icon, and "Clear" button
|
||||
- Style with dark gradient background, accent border, monospace font, matching existing toolbar patterns
|
||||
- Render the toolbar above the table inside the scroll container, only when `selectedRowIds.size > 0`
|
||||
- Wire `onHide` to `hideSelectedRows` and `onClear` to clearing the selection set
|
||||
- _Requirements: 8.4, 8.5, 8.6, 8.7, 8.10, 8.11_
|
||||
|
||||
- [x] 9. Checkpoint — Verify selection and bulk hide UI
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 10. Implement RowVisibilityManager popover component
|
||||
- Create inline `RowVisibilityManager` component accepting `hiddenRowIds`, `findings`, `onRestore`, and `onRestoreAll` props
|
||||
- Add `open` state for popover visibility, with outside-click-to-close behavior (same pattern as existing `ColumnManager`)
|
||||
- Render a toolbar button with `EyeOff` icon and "Hidden (N)" count text, styled to match the existing ColumnManager and Queue toolbar buttons (same padding, border radius, font size, uppercase text)
|
||||
- When open, render a popover panel listing hidden findings by Finding_ID and title (looked up from the full `findings` array)
|
||||
- Each entry has an `Eye` icon restore button that calls `onRestore(findingId)`
|
||||
- Include a "Restore All" button at the bottom that calls `onRestoreAll`
|
||||
- When `hiddenRowIds.size === 0`, show "No rows hidden" message in the popover
|
||||
- Use dark gradient background, accent border, and box shadow matching the ColumnManager popover
|
||||
- Place the button in the toolbar div adjacent to the ColumnManager button
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 7.2, 7.3_
|
||||
|
||||
- [x] 11. Final checkpoint — Verify complete feature integration
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files needed
|
||||
- The design uses JavaScript throughout; fast-check is the PBT library
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate the 7 correctness properties defined in the design document
|
||||
- Unit tests validate specific UI rendering scenarios and edge cases
|
||||
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 |
|
||||
290
DESIGN_SYSTEM.md
Normal file
290
DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# CVE Intelligence Dashboard - Design System Reference
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
```css
|
||||
--intel-darkest: #0F172A /* Slate 900 - Deepest background */
|
||||
--intel-dark: #1E293B /* Slate 800 - Card backgrounds */
|
||||
--intel-medium: #334155 /* Slate 700 - Elevated elements */
|
||||
```
|
||||
|
||||
### Accent & Status Colors
|
||||
```css
|
||||
--intel-accent: #0EA5E9 /* Sky Blue - Primary accent, links, interactive elements */
|
||||
--intel-warning: #F59E0B /* Amber - Warnings, high severity, open tickets */
|
||||
--intel-danger: #EF4444 /* Red - Critical severity, destructive actions */
|
||||
--intel-success: #10B981 /* Emerald - Success states, low severity, confirmations */
|
||||
```
|
||||
|
||||
### Text Colors
|
||||
```css
|
||||
--text-primary: #F8FAFC /* Slate 50 - Primary text */
|
||||
--text-secondary: #E2E8F0 /* Slate 200 - Secondary text */
|
||||
--text-tertiary: #CBD5E1 /* Slate 300 - Labels, metadata */
|
||||
--text-muted: #94A3B8 /* Slate 400 - Placeholders, disabled */
|
||||
```
|
||||
|
||||
### Severity Badge Colors
|
||||
| Severity | Border | Background | Text | Glow Dot |
|
||||
|----------|--------|------------|------|----------|
|
||||
| **Critical** | `#EF4444` | `rgba(239, 68, 68, 0.25)` | `#FCA5A5` | `#EF4444` |
|
||||
| **High** | `#F59E0B` | `rgba(245, 158, 11, 0.25)` | `#FCD34D` | `#F59E0B` |
|
||||
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
|
||||
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
### Three-Column Grid Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HEADER & STATS BAR │
|
||||
│ CVE INTEL | [Stats: Total, Entries, Tickets, Critical] │
|
||||
├──────────────┬─────────────────────────┬────────────────────┤
|
||||
│ │ │ │
|
||||
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
|
||||
│ (3 cols) │ (6 cols) │ (3 cols) │
|
||||
│ │ │ │
|
||||
│ Knowledge │ Quick CVE Lookup │ Calendar │
|
||||
│ Base │ Search & Filters │ Widget │
|
||||
│ - Wiki │ CVE Results List │ │
|
||||
│ - Docs │ - Expandable cards │ Open Tickets │
|
||||
│ - Policies │ - Vendor entries │ - Compact list │
|
||||
│ - Guides │ - Documents │ - Quick stats │
|
||||
│ │ - JIRA tickets │ │
|
||||
│ │ │ │
|
||||
└──────────────┴─────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### Responsive Breakpoints
|
||||
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
|
||||
- **Tablet/Mobile**: Stacked single column
|
||||
|
||||
## Component Specifications
|
||||
|
||||
### Stat Cards
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid [accent-color]
|
||||
Border Radius: 0.5rem
|
||||
Padding: 1rem
|
||||
Top Accent Line: 2px gradient, 0 0 8px glow
|
||||
Shadow: 0 4px 16px rgba(0, 0, 0, 0.5)
|
||||
Hover: translateY(-2px), enhanced shadow
|
||||
```
|
||||
|
||||
### Intel Cards (Main Content)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Border: 2px solid rgba(14, 165, 233, 0.4)
|
||||
Shadow: 0 8px 24px rgba(0, 0, 0, 0.6), subtle glow
|
||||
Hover: Enhanced border (0.5 opacity), lift effect
|
||||
```
|
||||
|
||||
### Buttons
|
||||
```css
|
||||
/* Primary */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))
|
||||
Border: 1px solid #0EA5E9
|
||||
Color: #38BDF8
|
||||
Text Shadow: 0 0 6px rgba(14, 165, 233, 0.2)
|
||||
|
||||
/* Hover State */
|
||||
Background: linear-gradient(135deg, rgba(14, 165, 233, 0.25), rgba(14, 165, 233, 0.2))
|
||||
Shadow: 0 0 20px rgba(14, 165, 233, 0.25)
|
||||
Transform: translateY(-1px)
|
||||
Ripple Effect: 300px radial on click
|
||||
```
|
||||
|
||||
### Input Fields
|
||||
```css
|
||||
Background: rgba(30, 41, 59, 0.6)
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
Font: 'JetBrains Mono', monospace
|
||||
Focus: border #0EA5E9, ring 2px rgba(14, 165, 233, 0.15)
|
||||
```
|
||||
|
||||
### Badges (Status/Severity)
|
||||
```css
|
||||
Display: inline-flex
|
||||
Align Items: center
|
||||
Gap: 0.5rem
|
||||
Border: 2px solid [severity-color]
|
||||
Border Radius: 0.375rem
|
||||
Padding: 0.375rem 0.875rem
|
||||
Font: 'JetBrains Mono', 0.75rem, 700, uppercase
|
||||
Letter Spacing: 0.5px
|
||||
Glow Dot: 8px circle with pulse animation
|
||||
```
|
||||
|
||||
## Interactions & Animations
|
||||
|
||||
### Hover Effects
|
||||
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
|
||||
- **Buttons**: Radial ripple expand (300px), slight lift
|
||||
- **List Items**: Border color shift, background lighten
|
||||
|
||||
### Animations
|
||||
```css
|
||||
/* Pulse Glow (for dots) */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
/* Scan Line */
|
||||
@keyframes scan {
|
||||
0%, 100% { transform: translateY(-100%); opacity: 0; }
|
||||
50% { transform: translateY(2000%); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Spin (loading) */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
```
|
||||
|
||||
### Transitions
|
||||
```css
|
||||
Standard: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
Fast: all 0.2s ease
|
||||
Ripple: width/height 0.5s
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Families
|
||||
```css
|
||||
Primary (UI): 'Outfit', system-ui, sans-serif
|
||||
Monospace (Data/Code): 'JetBrains Mono', monospace
|
||||
```
|
||||
|
||||
### Font Sizes & Weights
|
||||
```css
|
||||
/* Headings */
|
||||
h1: 2.5rem (40px), 700, monospace
|
||||
h2: 1.125rem (18px), 600, uppercase, tracking-wider
|
||||
h3: 1.125rem (18px), 600
|
||||
|
||||
/* Body */
|
||||
Body: 0.875rem (14px), 400
|
||||
Small: 0.75rem (12px), 400
|
||||
Labels: 0.75rem (12px), 500, uppercase, tracking-wider
|
||||
```
|
||||
|
||||
### Text Shadows (Headings)
|
||||
```css
|
||||
Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0.15)
|
||||
Badge Text: 0 0 8px rgba([color], 0.5)
|
||||
```
|
||||
|
||||
## Visual Effects
|
||||
|
||||
### Shadows
|
||||
```css
|
||||
/* Card Elevations */
|
||||
Level 1: 0 2px 6px rgba(0, 0, 0, 0.3)
|
||||
Level 2: 0 4px 12px rgba(0, 0, 0, 0.4)
|
||||
Level 3: 0 8px 24px rgba(0, 0, 0, 0.6)
|
||||
|
||||
/* Glows */
|
||||
Subtle: 0 0 12px rgba([color], 0.12)
|
||||
Medium: 0 0 20px rgba([color], 0.15)
|
||||
Strong: 0 0 28px rgba([color], 0.25)
|
||||
|
||||
/* Inset Highlights */
|
||||
Top: inset 0 1px 0 rgba(14, 165, 233, 0.15)
|
||||
Recessed: inset 0 2px 4px rgba(0, 0, 0, 0.3)
|
||||
```
|
||||
|
||||
### Border Styles
|
||||
```css
|
||||
/* Standard Cards */
|
||||
Border: 1.5-2px solid rgba(14, 165, 233, 0.3-0.4)
|
||||
|
||||
/* Accent Panels */
|
||||
Left Border: 3px solid [accent-color]
|
||||
|
||||
/* Vendor/Nested Cards */
|
||||
Border: 1px solid rgba(14, 165, 233, 0.25)
|
||||
```
|
||||
|
||||
### Gradients
|
||||
```css
|
||||
/* Backgrounds */
|
||||
Card: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))
|
||||
Nested: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9))
|
||||
|
||||
/* Accent Lines */
|
||||
Top Bar: linear-gradient(90deg, transparent, [color], transparent)
|
||||
|
||||
/* Grid Background */
|
||||
linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
|
||||
Size: 20px × 20px
|
||||
```
|
||||
|
||||
## Specific Component Patterns
|
||||
|
||||
### Wiki/Knowledge Base Entry
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(16, 185, 129, 0.25)
|
||||
Padding: 0.75rem
|
||||
Cursor: pointer
|
||||
Hover: border-color shift to rgba(16, 185, 129, 0.4)
|
||||
```
|
||||
|
||||
### Calendar Widget
|
||||
```css
|
||||
Day Cells:
|
||||
- Text: white, font-mono, 0.75rem
|
||||
- Hover: bg rgba(14, 165, 233, 0.2)
|
||||
- Current Day: bg rgba(14, 165, 233, 0.3), border 1px #0EA5E9
|
||||
- Other Month: text rgba(148, 163, 184, 0.5)
|
||||
```
|
||||
|
||||
### Ticket Cards (Compact)
|
||||
```css
|
||||
Background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))
|
||||
Border: 1px solid rgba(245, 158, 11, 0.25)
|
||||
Padding: 0.5rem
|
||||
Status Badge: Reduced size (0.65rem, 0.25rem padding)
|
||||
Glow Dot: 6px diameter
|
||||
```
|
||||
|
||||
### CVE Expandable Cards
|
||||
```css
|
||||
Header: Clickable, cursor pointer
|
||||
Collapsed: Show summary (severity, vendor count, doc count)
|
||||
Expanded: Full description, metadata, vendor entries
|
||||
Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
|
||||
Vendor Cards: Nested with reduced opacity borders
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Contrast Ratios
|
||||
- Primary text on dark: 18.5:1 (AAA)
|
||||
- Secondary text on dark: 12.3:1 (AAA)
|
||||
- Accent colors: All meet WCAG AA minimum
|
||||
|
||||
### Interactive States
|
||||
- Focus rings: 2px solid accent color
|
||||
- Hover: Visible border/background changes
|
||||
- Active: Transform feedback
|
||||
|
||||
### Typography
|
||||
- Minimum size: 12px (0.75rem)
|
||||
- Line height: 1.5 for body text
|
||||
- Letter spacing: Generous for uppercase labels
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Professional Sophistication**: Modern enterprise feel, not arcade
|
||||
2. **Tactical Intelligence**: Purpose-driven, information-dense
|
||||
3. **Refined Depth**: Layers and elevation without harsh neon
|
||||
4. **Purposeful Color**: Accent colors convey meaning (status, severity)
|
||||
5. **Smooth Interactions**: Polished micro-interactions and transitions
|
||||
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
|
||||
7. **Generous Spacing**: Breathing room prevents overwhelming density
|
||||
|
||||
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)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-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, contentType } of files) {
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${contentType || 'application/octet-stream'}\r\n\r\n`
|
||||
));
|
||||
parts.push(buffer);
|
||||
parts.push(Buffer.from('\r\n'));
|
||||
}
|
||||
|
||||
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
const bodyBuffer = Buffer.concat(parts);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: fullUrl.hostname,
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'x-api-key': apiKey,
|
||||
'x-http-client-type': 'browser',
|
||||
'content-length': bodyBuffer.length
|
||||
},
|
||||
rejectUnauthorized: !skipTls,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
});
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||
req.on('error', reject);
|
||||
req.write(bodyBuffer);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost, ivantiFormPost };
|
||||
@@ -12,7 +12,7 @@ function requireAuth(db) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
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
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -37,7 +37,8 @@ function requireAuth(db) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role
|
||||
role: session.role,
|
||||
group: session.user_group
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -48,18 +49,18 @@ function requireAuth(db) {
|
||||
};
|
||||
}
|
||||
|
||||
// Require specific role(s)
|
||||
function requireRole(...allowedRoles) {
|
||||
// Require specific group(s)
|
||||
function requireGroup(...allowedGroups) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
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({
|
||||
error: 'Insufficient permissions',
|
||||
required: allowedRoles,
|
||||
current: req.user.role
|
||||
required: allowedGroups,
|
||||
current: req.user.group
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,4 +68,4 @@ function requireRole(...allowedRoles) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireRole };
|
||||
module.exports = { requireAuth, requireGroup };
|
||||
|
||||
39
backend/migrate_jira_tickets.js
Normal file
39
backend/migrate_jira_tickets.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Migration: Add jira_tickets table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting JIRA tickets migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Create jira_tickets table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ jira_tickets table created');
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
||||
|
||||
console.log('✓ Indexes created');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
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!');
|
||||
});
|
||||
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Migration: Add group_id column to compliance_notes table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_compliance_notes_group_id migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
|
||||
if (err) console.error('Error adding group_id column:', err);
|
||||
else console.log('✓ group_id column added to compliance_notes');
|
||||
});
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
|
||||
if (err) console.error('Error creating group_id index:', err);
|
||||
else console.log('✓ idx_compliance_notes_group created');
|
||||
});
|
||||
|
||||
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
|
||||
if (err) console.error('Error backfilling group_id:', err);
|
||||
else console.log('✓ Existing rows backfilled with legacy group_id');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
108
backend/migrations/add_compliance_tables.js
Normal file
108
backend/migrations/add_compliance_tables.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Migration: Add compliance_uploads, compliance_items, compliance_notes tables
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_compliance_tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Each xlsx upload — one row per file ingested
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
report_date TEXT,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
new_count INTEGER DEFAULT 0,
|
||||
resolved_count INTEGER DEFAULT 0,
|
||||
recurring_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_uploads:', err);
|
||||
else console.log('✓ compliance_uploads created');
|
||||
});
|
||||
|
||||
// One row per non-compliant asset per metric per upload.
|
||||
// hostname + metric_id is the stable identity key used to link history and notes.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
device_type TEXT,
|
||||
team TEXT,
|
||||
metric_id TEXT NOT NULL,
|
||||
metric_desc TEXT,
|
||||
category TEXT,
|
||||
extra_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved')),
|
||||
first_seen_upload_id INTEGER,
|
||||
resolved_upload_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (upload_id) REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (first_seen_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (resolved_upload_id) REFERENCES compliance_uploads(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_items:', err);
|
||||
else console.log('✓ compliance_items created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_upload
|
||||
ON compliance_items(upload_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating upload index:', err);
|
||||
else console.log('✓ idx_compliance_items_upload created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_identity
|
||||
ON compliance_items(hostname, metric_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating identity index:', err);
|
||||
else console.log('✓ idx_compliance_items_identity created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_items_team_status
|
||||
ON compliance_items(team, status)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating team/status index:', err);
|
||||
else console.log('✓ idx_compliance_items_team_status created');
|
||||
});
|
||||
|
||||
// Notes keyed on (hostname, metric_id) — persists across uploads.
|
||||
// Each note is its own row so history is preserved.
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS compliance_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
metric_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating compliance_notes:', err);
|
||||
else console.log('✓ compliance_notes created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_notes_identity
|
||||
ON compliance_notes(hostname, metric_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes identity index:', err);
|
||||
else console.log('✓ idx_compliance_notes_identity created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
76
backend/migrations/add_created_by_columns.js
Normal file
76
backend/migrations/add_created_by_columns.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Migration: Add created_by column to cves, archer_tickets, and jira_tickets tables
|
||||
// Stores the user ID of the creator for ownership-based delete checks.
|
||||
// Idempotent — safe to run multiple times.
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Run the migration against the given database instance.
|
||||
* Exported for testing with in-memory databases.
|
||||
* @param {sqlite3.Database} db
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function runMigration(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tables = ['cves', 'archer_tickets', 'jira_tickets'];
|
||||
let completed = 0;
|
||||
|
||||
db.serialize(() => {
|
||||
tables.forEach((table) => {
|
||||
db.all(`PRAGMA table_info(${table})`, (err, columns) => {
|
||||
if (err) {
|
||||
// Table may not exist yet — skip gracefully
|
||||
console.log(`⚠ Could not inspect ${table}: ${err.message} — skipping`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCreatedBy = columns.some(col => col.name === 'created_by');
|
||||
|
||||
if (hasCreatedBy) {
|
||||
console.log(`✓ ${table}.created_by already exists — skipping`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
db.run(
|
||||
`ALTER TABLE ${table} ADD COLUMN created_by INTEGER REFERENCES users(id)`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log(`✓ Added created_by column to ${table}`);
|
||||
completed++;
|
||||
if (completed === tables.length) resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run directly if executed as a script
|
||||
if (require.main === module) {
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
console.log('Starting add_created_by_columns migration...');
|
||||
|
||||
runMigration(db)
|
||||
.then(() => {
|
||||
console.log('Migration complete!');
|
||||
db.close(() => {
|
||||
console.log('Database connection closed.');
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runMigration };
|
||||
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!');
|
||||
});
|
||||
94
backend/migrations/add_fp_submission_editing.js
Normal file
94
backend/migrations/add_fp_submission_editing.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Migration: Add FP submission editing support (lifecycle status, batch UUID, history table)
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting FP submission editing migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Add lifecycle_status column to ivanti_fp_submissions
|
||||
// Wrapped in try/catch style via callback — SQLite throws if column already exists
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'))`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ lifecycle_status column already exists');
|
||||
} else {
|
||||
console.error('Error adding lifecycle_status column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ lifecycle_status column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add ivanti_workflow_batch_uuid column
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ ivanti_workflow_batch_uuid column already exists');
|
||||
} else {
|
||||
console.error('Error adding ivanti_workflow_batch_uuid column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ ivanti_workflow_batch_uuid column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add updated_at column (SQLite requires constant defaults for ALTER TABLE, so default to NULL)
|
||||
db.run(
|
||||
`ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT NULL`,
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.message.includes('duplicate column')) {
|
||||
console.log('✓ updated_at column already exists');
|
||||
} else {
|
||||
console.error('Error adding updated_at column:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✓ updated_at column added');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create submission history table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||
'created', 'fields_updated', 'findings_added',
|
||||
'attachments_added', 'status_changed'
|
||||
)),
|
||||
change_details_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating history table:', err.message);
|
||||
else console.log('✓ ivanti_fp_submission_history table created');
|
||||
});
|
||||
|
||||
// Create index on submission_id for history lookups
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id)`,
|
||||
(err) => {
|
||||
if (err) console.error('Error creating history index:', err.message);
|
||||
else console.log('✓ idx_fp_history_submission index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
57
backend/migrations/add_fp_submissions_table.js
Normal file
57
backend/migrations/add_fp_submissions_table.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Migration: Add ivanti_fp_submissions table
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting ivanti_fp_submissions migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_fp_submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
ivanti_workflow_batch_id INTEGER,
|
||||
ivanti_generated_id TEXT,
|
||||
workflow_name TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
description TEXT,
|
||||
expiration_date TEXT NOT NULL,
|
||||
scope_override TEXT NOT NULL DEFAULT 'Authorized',
|
||||
finding_ids_json TEXT NOT NULL,
|
||||
queue_item_ids_json TEXT NOT NULL,
|
||||
attachment_count INTEGER DEFAULT 0,
|
||||
attachment_results_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success' CHECK(status IN ('success', 'partial', 'failed')),
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating table:', err);
|
||||
else console.log('✓ ivanti_fp_submissions table created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_user ON ivanti_fp_submissions(user_id)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ user_id index created');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_fp_submissions_ivanti_id ON ivanti_fp_submissions(ivanti_generated_id)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ ivanti_generated_id index created');
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✓ Migration statements queued');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
80
backend/migrations/add_granite_workflow_type.js
Normal file
80
backend/migrations/add_granite_workflow_type.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Migration: Add GRANITE to workflow_type CHECK constraint on ivanti_todo_queue
|
||||
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting add_granite_workflow_type migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||
if (err) console.error('PRAGMA error:', err);
|
||||
});
|
||||
|
||||
db.run('BEGIN TRANSACTION', (err) => {
|
||||
if (err) { console.error('BEGIN error:', err); return; }
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE ivanti_todo_queue_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
finding_title TEXT,
|
||||
cves_json TEXT,
|
||||
ip_address TEXT,
|
||||
hostname TEXT,
|
||||
vendor TEXT NOT NULL,
|
||||
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating new table:', err);
|
||||
else console.log('✓ ivanti_todo_queue_new created');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'INSERT INTO ivanti_todo_queue_new SELECT id, user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type, status, created_at, updated_at FROM ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error copying data:', err);
|
||||
else console.log('✓ Data copied');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||
if (err) console.error('Error dropping old table:', err);
|
||||
else console.log('✓ Old table dropped');
|
||||
});
|
||||
|
||||
db.run(
|
||||
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||
(err) => {
|
||||
if (err) console.error('Error renaming table:', err);
|
||||
else console.log('✓ Table renamed');
|
||||
}
|
||||
);
|
||||
|
||||
db.run(
|
||||
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||
(err) => {
|
||||
if (err) console.error('Error creating index:', err);
|
||||
else console.log('✓ Index recreated');
|
||||
}
|
||||
);
|
||||
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) console.error('COMMIT error:', err);
|
||||
else console.log('✓ Transaction committed');
|
||||
});
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON', () => {}); // FIXME: Callback does not handle the error parameter (should be `(err) => { if (err) ... }`)
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Migration: Add ivanti_counts_history table
|
||||
//
|
||||
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
|
||||
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
|
||||
// accumulates all snapshots so time-series charts can be built from it.
|
||||
//
|
||||
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
|
||||
// to the last snapshot per calendar day using a ROW_NUMBER window function.
|
||||
//
|
||||
// NOTE: This table is also created automatically at server startup via
|
||||
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
|
||||
// This script is provided for manual setup on fresh installs and for
|
||||
// documentation consistency with other migration files.
|
||||
//
|
||||
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting ivanti_counts_history migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating ivanti_counts_history table:', err);
|
||||
else console.log('✓ ivanti_counts_history table created (or already exists)');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete.');
|
||||
});
|
||||
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
58
backend/migrations/add_ivanti_findings_tables.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// Migration: Add ivanti_findings_cache and ivanti_finding_notes tables
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
console.log('Starting Ivanti findings tables migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Cache table — single row holding the latest sync result
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_findings_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total INTEGER DEFAULT 0,
|
||||
findings_json TEXT DEFAULT '[]',
|
||||
synced_at DATETIME,
|
||||
sync_status TEXT DEFAULT 'never',
|
||||
error_message TEXT
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating findings cache table:', err);
|
||||
else console.log('✓ ivanti_findings_cache table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
INSERT OR IGNORE INTO ivanti_findings_cache (id, total, findings_json, sync_status)
|
||||
VALUES (1, 0, '[]', 'never')
|
||||
`, (err) => {
|
||||
if (err) console.error('Error seeding findings cache row:', err);
|
||||
else console.log('✓ ivanti_findings_cache row seeded');
|
||||
});
|
||||
|
||||
// Notes table — one row per finding, persists across cache refreshes
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL UNIQUE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating finding notes table:', err);
|
||||
else console.log('✓ ivanti_finding_notes table created');
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => {
|
||||
if (err) console.error('Error creating notes index:', err);
|
||||
else console.log('✓ finding_id index created');
|
||||
});
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
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)
|
||||
const express = require('express');
|
||||
|
||||
function createAuditLogRouter(db, requireAuth, requireRole) {
|
||||
function createAuditLogRouter(db, requireAuth, requireGroup) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get paginated audit logs with filters
|
||||
router.get('/', async (req, res) => {
|
||||
|
||||
@@ -2,12 +2,35 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
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) {
|
||||
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;
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -110,7 +133,7 @@ function createAuthRouter(db, logAudit) {
|
||||
action: 'login',
|
||||
entityType: 'auth',
|
||||
entityId: null,
|
||||
details: { role: user.role },
|
||||
details: { group: user.user_group },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -120,7 +143,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
group: user.user_group
|
||||
}
|
||||
});
|
||||
} 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) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
@@ -172,7 +202,16 @@ function createAuthRouter(db, logAudit) {
|
||||
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) => {
|
||||
const sessionId = req.cookies?.session_id;
|
||||
|
||||
@@ -183,7 +222,7 @@ function createAuthRouter(db, logAudit) {
|
||||
try {
|
||||
const session = await new Promise((resolve, reject) => {
|
||||
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
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
|
||||
@@ -210,7 +249,7 @@ function createAuthRouter(db, logAudit) {
|
||||
id: session.user_id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
role: session.role
|
||||
group: session.user_group
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -219,13 +258,17 @@ function createAuthRouter(db, logAudit) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up expired sessions (admin only)
|
||||
router.post('/cleanup-sessions', async (req, res) => {
|
||||
// Basic auth check - require a valid session to call this
|
||||
const sessionId = req.cookies?.session_id;
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
/**
|
||||
* POST /api/auth/cleanup-sessions
|
||||
*
|
||||
* Deletes all expired sessions from the database. Requires Admin group.
|
||||
*
|
||||
* @returns {object} 200 - { message: 'Expired sessions cleaned up' }
|
||||
* @returns {object} 401 - { error: 'Authentication required' }
|
||||
* @returns {object} 403 - { error: 'Insufficient permissions', required: ['Admin'], current: '...' }
|
||||
* @returns {object} 500 - { error: 'Cleanup failed' }
|
||||
*/
|
||||
router.post('/cleanup-sessions', requireAuth(db), requireGroup('Admin'), async (req, res) => {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
|
||||
753
backend/routes/compliance.js
Normal file
753
backend/routes/compliance.js
Normal file
@@ -0,0 +1,753 @@
|
||||
// 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 one or more (hostname, metric_id) pairs
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
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.group_id, 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 one or more (hostname, metric_id) pairs.
|
||||
// Body: { hostname, metric_ids: [...], note } — or legacy { hostname, metric_id, note }
|
||||
// -----------------------------------------------------------------------
|
||||
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { hostname, metric_id, metric_ids, 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' });
|
||||
}
|
||||
|
||||
// --- Resolve metric IDs: metric_ids takes precedence over metric_id ---
|
||||
let resolvedIds;
|
||||
if (metric_ids !== undefined) {
|
||||
if (!Array.isArray(metric_ids)) {
|
||||
return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||
}
|
||||
resolvedIds = metric_ids;
|
||||
} else if (metric_id !== undefined && metric_id !== null && metric_id !== '') {
|
||||
if (typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||
}
|
||||
resolvedIds = [metric_id];
|
||||
} else {
|
||||
return res.status(400).json({ error: 'metric_id or metric_ids is required' });
|
||||
}
|
||||
|
||||
// --- Validate resolved metric IDs ---
|
||||
if (resolvedIds.length === 0) {
|
||||
return res.status(400).json({ error: 'At least one metric ID is required' });
|
||||
}
|
||||
for (let i = 0; i < resolvedIds.length; i++) {
|
||||
const mid = resolvedIds[i];
|
||||
if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) {
|
||||
return res.status(400).json({ error: `Invalid metric_id at index ${i}` });
|
||||
}
|
||||
}
|
||||
|
||||
const noteText = String(note || '').trim().slice(0, 1000);
|
||||
if (!noteText) {
|
||||
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||
}
|
||||
|
||||
const groupId = crypto.randomUUID();
|
||||
const userId = req.user?.id || null;
|
||||
|
||||
try {
|
||||
await dbRun(db, 'BEGIN TRANSACTION');
|
||||
|
||||
const insertedIds = [];
|
||||
for (const mid of resolvedIds) {
|
||||
const { lastID } = await dbRun(db,
|
||||
`INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[hostname, mid, noteText, groupId, userId]
|
||||
);
|
||||
insertedIds.push(lastID);
|
||||
}
|
||||
|
||||
await dbRun(db, 'COMMIT');
|
||||
|
||||
// Fetch all created rows with username
|
||||
const placeholders = insertedIds.map(() => '?').join(', ');
|
||||
const notes = await dbAll(db,
|
||||
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, 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 IN (${placeholders})
|
||||
ORDER BY cn.id ASC`,
|
||||
insertedIds
|
||||
);
|
||||
|
||||
res.status(201).json({ notes });
|
||||
|
||||
} catch (err) {
|
||||
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||
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;
|
||||
223
backend/routes/ivantiArchive.js
Normal file
223
backend/routes/ivantiArchive.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// Ivanti Archive Routes — list, stats, and transition history for archived findings
|
||||
const express = require('express');
|
||||
|
||||
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||
|
||||
/**
|
||||
* Find the most severe active finding related to an archived finding.
|
||||
*
|
||||
* A match requires:
|
||||
* - Exact hostname match (case-sensitive)
|
||||
* - The archive title is a case-insensitive substring of the active title, or vice versa
|
||||
* - The active finding ID differs from the archive's finding_id
|
||||
*
|
||||
* @param {Object} archive - Archive record from ivanti_finding_archives
|
||||
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
|
||||
* @returns {{ id: string, title: string, severity: number } | null}
|
||||
*/
|
||||
function findRelatedActive(archive, activeFindings) {
|
||||
const archiveTitle = (archive.finding_title || '').toLowerCase();
|
||||
|
||||
const matches = activeFindings.filter(f => {
|
||||
if (f.hostName !== archive.host_name) return false;
|
||||
if (f.id === archive.finding_id) return false;
|
||||
|
||||
const activeTitle = (f.title || '').toLowerCase();
|
||||
if (!archiveTitle.includes(activeTitle) && !activeTitle.includes(archiveTitle)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const best = matches.reduce((a, b) => (b.severity > a.severity ? b : a));
|
||||
return { id: best.id, title: best.title, severity: best.severity };
|
||||
}
|
||||
|
||||
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 || []);
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and parse active findings cache for related-finding enrichment
|
||||
let activeFindings = [];
|
||||
try {
|
||||
const cacheRow = await new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1',
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (cacheRow && cacheRow.findings_json) {
|
||||
activeFindings = JSON.parse(cacheRow.findings_json);
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.warn('Failed to load findings cache for related-active matching:', cacheErr);
|
||||
}
|
||||
|
||||
if (!Array.isArray(activeFindings)) {
|
||||
activeFindings = [];
|
||||
}
|
||||
|
||||
// Enrich each archive record with related active finding info
|
||||
const enrichedArchives = archives.map(archive => ({
|
||||
...archive,
|
||||
related_active: findRelatedActive(archive, activeFindings)
|
||||
}));
|
||||
|
||||
res.json({ archives: enrichedArchives, total: enrichedArchives.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;
|
||||
1425
backend/routes/ivantiFpWorkflow.js
Normal file
1425
backend/routes/ivantiFpWorkflow.js
Normal file
File diff suppressed because it is too large
Load Diff
569
backend/routes/ivantiTodoQueue.js
Normal file
569
backend/routes/ivantiTodoQueue.js
Normal file
@@ -0,0 +1,569 @@
|
||||
// routes/ivantiTodoQueue.js
|
||||
const express = require('express');
|
||||
const { requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
|
||||
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 q.*,
|
||||
o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.user_id = ?
|
||||
ORDER BY q.vendor ASC, q.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; prefer overridden hostname
|
||||
const parsed = rows.map((r) => ({
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
}));
|
||||
// Clean up the extra column from the response
|
||||
parsed.forEach((r) => delete r.override_hostname);
|
||||
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', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @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, CARD, or GRANITE.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : 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 q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.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) => {
|
||||
const item = {
|
||||
...r,
|
||||
hostname: r.override_hostname || r.hostname,
|
||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||
};
|
||||
delete item.override_hostname;
|
||||
return item;
|
||||
});
|
||||
|
||||
// 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} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||
*
|
||||
* @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, CARD, or GRANITE.' });
|
||||
}
|
||||
// Vendor is required for FP and Archer, optional for CARD/GRANITE
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type) && !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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : 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 q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[this.lastID],
|
||||
(err2, row) => {
|
||||
if (err2 || !row) {
|
||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||
}
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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', 'GRANITE'
|
||||
* @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, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
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 q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[id],
|
||||
(err3, row) => {
|
||||
if (err3 || !row) {
|
||||
return res.json({ message: 'Queue item updated.' });
|
||||
}
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirect a completed queue item to a different workflow type.
|
||||
* Creates a new pending item copying finding data from the original.
|
||||
*
|
||||
* @param {string} id - Original queue item ID (URL parameter)
|
||||
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE'
|
||||
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE
|
||||
*
|
||||
* @returns {Object} 201 - Newly created queue item with parsed cves array
|
||||
* @returns {Object} 400 - { error: string } on validation failure or item not complete
|
||||
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||
* @returns {Object} 500 - { error: string } on database error
|
||||
*/
|
||||
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { workflow_type, vendor } = req.body;
|
||||
|
||||
// --- Validation ---
|
||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||
}
|
||||
|
||||
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||
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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||
|
||||
// --- Fetch original item scoped to current user ---
|
||||
db.get(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||
[id, req.user.id],
|
||||
(err, original) => {
|
||||
if (err) {
|
||||
console.error('Error fetching queue item for redirect:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
}
|
||||
|
||||
// --- INSERT new row copying finding data from original ---
|
||||
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, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
|
||||
function (insertErr) {
|
||||
if (insertErr) {
|
||||
console.error('Error inserting redirected queue item:', insertErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
const newId = this.lastID;
|
||||
|
||||
// --- Fetch the inserted row ---
|
||||
db.get(
|
||||
`SELECT q.*, o.value AS override_hostname
|
||||
FROM ivanti_todo_queue q
|
||||
LEFT JOIN ivanti_finding_overrides o
|
||||
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||
WHERE q.id = ?`,
|
||||
[newId],
|
||||
(fetchErr, row) => {
|
||||
if (fetchErr || !row) {
|
||||
console.error('Error fetching redirected queue item:', fetchErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
// Audit log (fire-and-forget)
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
new_item_id: newId,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...row,
|
||||
hostname: row.override_hostname || row.hostname,
|
||||
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||
};
|
||||
delete result.override_hostname;
|
||||
return res.status(201).json(result);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 bcrypt = require('bcryptjs');
|
||||
|
||||
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
function createUsersRouter(db, requireAuth, requireGroup, logAudit) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require admin role
|
||||
router.use(requireAuth(db), requireRole('admin'));
|
||||
// All routes require Admin group
|
||||
router.use(requireAuth(db), requireGroup('Admin'));
|
||||
|
||||
// Get all users
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const users = await new Promise((resolve, reject) => {
|
||||
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`,
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
@@ -33,7 +33,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
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 = ?`,
|
||||
[req.params.id],
|
||||
(err, row) => {
|
||||
@@ -56,14 +56,17 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Create new user
|
||||
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) {
|
||||
return res.status(400).json({ error: 'Username, email, and password are required' });
|
||||
}
|
||||
|
||||
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
|
||||
const userGroup = group || 'Read_Only';
|
||||
|
||||
if (!VALID_GROUPS.includes(userGroup)) {
|
||||
return res.status(400).json({ error: 'Invalid group. Must be one of: Admin, Standard_User, Leadership, Read_Only' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -71,9 +74,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, role)
|
||||
`INSERT INTO users (username, email, password_hash, user_group)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[username, email, passwordHash, role || 'viewer'],
|
||||
[username, email, passwordHash, userGroup],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID });
|
||||
@@ -87,7 +90,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
action: 'user_create',
|
||||
entityType: 'user',
|
||||
entityId: String(result.id),
|
||||
details: { created_username: username, role: role || 'viewer' },
|
||||
details: { created_username: username, group: userGroup },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -97,7 +100,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
id: result.id,
|
||||
username,
|
||||
email,
|
||||
role: role || 'viewer'
|
||||
group: userGroup
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -111,20 +114,42 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
|
||||
// Update user
|
||||
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;
|
||||
|
||||
// Prevent self-demotion from admin
|
||||
if (userId == req.user.id && role && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin role' });
|
||||
// Validate group if provided
|
||||
if (group && !VALID_GROUPS.includes(group)) {
|
||||
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
|
||||
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' });
|
||||
}
|
||||
|
||||
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 values = [];
|
||||
|
||||
@@ -141,12 +166,9 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
updates.push('password_hash = ?');
|
||||
values.push(passwordHash);
|
||||
}
|
||||
if (role) {
|
||||
if (!['admin', 'editor', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
updates.push('role = ?');
|
||||
values.push(role);
|
||||
if (group) {
|
||||
updates.push('user_group = ?');
|
||||
values.push(group);
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push('is_active = ?');
|
||||
@@ -173,7 +195,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
const updatedFields = {};
|
||||
if (username) updatedFields.username = username;
|
||||
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 (password) updatedFields.password_changed = true;
|
||||
|
||||
@@ -187,6 +209,22 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
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 (is_active === false) {
|
||||
await new Promise((resolve) => {
|
||||
@@ -209,7 +247,7 @@ function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||
const userId = req.params.id;
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
|
||||
// Auth imports
|
||||
const { requireAuth, requireRole } = require('./middleware/auth');
|
||||
const { requireAuth, requireGroup } = require('./middleware/auth');
|
||||
const createAuthRouter = require('./routes/auth');
|
||||
const createUsersRouter = require('./routes/users');
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
const logAudit = require('./helpers/auditLog');
|
||||
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 PORT = process.env.PORT || 3001;
|
||||
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
|
||||
? process.env.CORS_ORIGINS.split(',')
|
||||
: ['http://localhost:3000'];
|
||||
@@ -32,7 +44,7 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||
// Allowed file extensions for document uploads (documents only, no executables)
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.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',
|
||||
'.odt', '.ods', '.odp',
|
||||
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||
@@ -78,6 +90,7 @@ function isValidCveId(cveId) {
|
||||
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
||||
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
||||
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
||||
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||
|
||||
// Validate vendor name - printable chars, reasonable length
|
||||
function isValidVendor(vendor) {
|
||||
@@ -93,7 +106,7 @@ app.use((req, res, next) => {
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
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('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
@@ -105,7 +118,11 @@ app.use(cors({
|
||||
origin: CORS_ORIGINS,
|
||||
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('/uploads', express.static('uploads', {
|
||||
dotfiles: 'deny',
|
||||
@@ -114,18 +131,45 @@ app.use('/uploads', express.static('uploads', {
|
||||
|
||||
// Database connection
|
||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||
if (err) console.error('Database connection error:', err);
|
||||
else console.log('Connected to CVE database');
|
||||
if (err) {
|
||||
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)
|
||||
app.use('/api/auth', createAuthRouter(db, logAudit));
|
||||
|
||||
// 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)
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
|
||||
|
||||
// NVD lookup routes (authenticated users)
|
||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||
@@ -166,6 +210,30 @@ const upload = multer({
|
||||
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 ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
@@ -280,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)
|
||||
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;
|
||||
|
||||
// Input validation
|
||||
@@ -303,11 +405,11 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
||||
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) {
|
||||
console.error('DATABASE ERROR:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
@@ -336,7 +438,7 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
||||
|
||||
|
||||
// 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 { status } = req.body;
|
||||
|
||||
@@ -364,7 +466,7 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No updates provided' });
|
||||
@@ -434,7 +536,7 @@ app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'),
|
||||
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||
|
||||
// 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 { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||
|
||||
@@ -578,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
|
||||
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;
|
||||
|
||||
// Get all rows for this CVE ID to know what we're deleting
|
||||
@@ -586,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 (!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
|
||||
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||
if (docErr) console.error('Error deleting documents:', docErr);
|
||||
@@ -618,13 +865,71 @@ app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor',
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
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 (!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
|
||||
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);
|
||||
@@ -671,7 +976,7 @@ app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (re
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== DOCUMENT ENDPOINTS ==========
|
||||
@@ -700,7 +1005,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
if (err) {
|
||||
console.error('Upload error:', err.message);
|
||||
@@ -808,7 +1113,7 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
||||
});
|
||||
});
|
||||
// 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;
|
||||
|
||||
// First get the file path to delete the actual file
|
||||
@@ -857,7 +1162,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
|
||||
// Get statistics (authenticated users)
|
||||
app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
const query = `
|
||||
SELECT
|
||||
SELECT
|
||||
COUNT(DISTINCT c.id) as total_cves,
|
||||
COUNT(DISTINCT CASE WHEN c.severity = 'Critical' THEN c.id END) as critical_count,
|
||||
COUNT(DISTINCT CASE WHEN c.status = 'Addressed' THEN c.id END) as addressed_count,
|
||||
@@ -867,7 +1172,7 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
||||
LEFT JOIN cve_document_status cd ON c.cve_id = cd.cve_id
|
||||
`;
|
||||
|
||||
|
||||
db.get(query, [], (err, row) => {
|
||||
if (err) {
|
||||
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||
@@ -876,6 +1181,234 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== JIRA TICKET ENDPOINTS ==========
|
||||
|
||||
// Get all JIRA tickets (with optional filters)
|
||||
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_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 JIRA tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Create JIRA ticket
|
||||
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
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 (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
}
|
||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
|
||||
const query = `
|
||||
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: this.lastID.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'JIRA ticket created successfully'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update JIRA ticket
|
||||
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||
}
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(id);
|
||||
|
||||
db.get('SELECT * FROM jira_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: 'JIRA ticket not found.' });
|
||||
}
|
||||
|
||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||
if (updateErr) {
|
||||
console.error('Error updating JIRA ticket:', updateErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_update',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete JIRA ticket
|
||||
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM jira_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: '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) {
|
||||
if (deleteErr) {
|
||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_delete',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -172,8 +173,9 @@ async function createDefaultAdmin(db) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create admin user with password 'admin123'
|
||||
const passwordHash = await bcrypt.hash('admin123', 10);
|
||||
// Generate a random admin password on first run
|
||||
const generatedPassword = crypto.randomBytes(12).toString('base64url');
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 10);
|
||||
|
||||
db.run(
|
||||
`INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
@@ -183,7 +185,12 @@ async function createDefaultAdmin(db) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -269,7 +276,7 @@ function displaySummary() {
|
||||
console.log(' ✓ Indexes for fast queries');
|
||||
console.log(' ✓ Document compliance view');
|
||||
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(' uploads/');
|
||||
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*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user