7 Commits

Author SHA1 Message Date
7a2c56a11f fix(reporting): visible queue checkbox + multi-select delete
Table: removed disabled={queued} from the row checkbox so accentColor
renders properly — checked rows now show a solid blue tick instead of
the greyed-out browser default.

Queue panel: each item now has a small red selection checkbox (opacity
0.35 when idle, full when selected). Selecting any items reveals a red
'Delete (N)' button in the footer alongside 'Clear Completed'. Bulk
deletes run in parallel; selection state is automatically pruned when
items are removed via the individual trash button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:43:43 -06:00
89b1f57ef4 feat(reporting): store and display IP address on CARD queue items
Adds ip_address column to ivanti_todo_queue so CARD entries carry the
host IP needed to locate the asset in CARD.

- Migration: ALTER TABLE ADD COLUMN ip_address TEXT (safe to re-run)
- Backend: accepts ip_address in POST body, stores up to 64 chars
- Frontend: captures finding.ipAddress when adding to queue; CARD items
  in the queue panel show the IP in green instead of the CVE list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:01:32 -06:00
6bf6371e51 feat(reporting): CARD workflow needs no vendor + own queue section
CARD workflow type no longer requires a vendor/platform entry since
asset disposition is handled entirely within CARD. In the popover the
vendor field is replaced with a note when CARD is selected, and the
Add button is enabled immediately.

In the queue panel, CARD items are separated into their own top section
(green header) rather than being mixed into vendor groups.

Backend validation updated to skip vendor requirement for CARD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:52:06 -06:00
4d472b0aef fix(reporting): smart-flip queue popover + add CARD workflow type
Popover now flips above the row when it would overflow the bottom of the
viewport, and clamps horizontally to stay within the window.

Adds CARD as a third workflow type (for out-of-team asset disposition in
CARD) alongside FP and Archer. CARD is styled in green (#10B981) across
the popover toggle and queue panel badge.

DB: new migration (add_card_workflow_type.js) recreates ivanti_todo_queue
with an updated CHECK constraint to allow 'CARD'; run manually on dev.
App-level validation in the route is updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:46:59 -06:00
887d11610e feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.

Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
  DELETE/completed — all scoped to req.user.id

Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
  popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
  by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
1520cc994b chore: remove AI tooling config from repo tracking
Untrack .claude/ directory and update .gitignore to keep AI-specific
config files out of the repository before handoff.
2026-03-26 11:26:52 -06:00
906066c7fa feat(exports): build Exports page with 5 export cards
Replaces the placeholder with a fully functional exports page.

Backend:
- Add GET /api/cves/compliance endpoint reading from cve_document_status view

Frontend (ExportsPage.js):
1. Ivanti Host Findings — 4 sub-exports:
   - Full dump (all findings, all columns)
   - Pending Action (no FP# and no EXC in notes)
   - Overdue SLA (past due date or OVERDUE SLA status)
   - By Business Unit (multi-sheet XLSX, one sheet per BU)

2. FP Workflow Summary — one row per unique FP# ticket ID with state,
   finding count, affected hosts, BUs, and CVEs

3. CVE Database — status filter dropdown + CSV and XLSX format options

4. Archer Tickets — full EXC ticket list with linked CVEs and URLs

5. Document Compliance Report — per CVE/vendor doc coverage with
   "missing only" toggle to generate a gap list

All exports are lazy (data fetched on click), per-button loading states,
global dismissable error banner, auto-fit column widths in XLSX outputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:39:26 -06:00
13 changed files with 1527 additions and 532 deletions

View File

@@ -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).

View File

@@ -1,107 +0,0 @@
# Frontend Agent — CVE Dashboard
## Role
You are the frontend specialist for the CVE Dashboard project. You build and maintain the React UI, handle client-side state, manage API communication, and implement user-facing features.
**IMPORTANT:** When creating new UI components or implementing frontend features, you should use the `frontend-design` skill to ensure production-grade, distinctive design quality. Invoke this skill using the Skill tool with `skill: "frontend-design"`.
## Project Context
### Tech Stack
- **Framework:** React 18.2.4 (Create React App)
- **Styling:** Tailwind CSS (loaded via CDN in `public/index.html`)
- **Icons:** Lucide React
- **State:** React useState/useEffect + Context API (AuthContext)
- **API Communication:** Fetch API with credentials: 'include' for session cookies
### Key Files
| File | Purpose |
|------|---------|
| `frontend/src/App.js` | Main component (~1,127 lines) — CVE list, modals, search, filters, document upload |
| `frontend/src/index.js` | React entry point |
| `frontend/src/App.css` | Global styles |
| `frontend/src/components/LoginForm.js` | Login page |
| `frontend/src/components/UserMenu.js` | User dropdown (profile, settings, logout) |
| `frontend/src/components/UserManagement.js` | Admin user management interface |
| `frontend/src/components/AuditLog.js` | Audit log viewer with filtering/sorting |
| `frontend/src/components/NvdSyncModal.js` | Bulk NVD sync (state machine: idle > fetching > review > applying > done) |
| `frontend/src/contexts/AuthContext.js` | Auth state + `useAuth()` hook |
| `frontend/public/index.html` | HTML shell (includes Tailwind CDN script) |
| `frontend/.env.example` | Environment variable template |
### Environment Variables
```
REACT_APP_API_BASE=http://<server-ip>:3001/api
REACT_APP_API_HOST=http://<server-ip>:3001
```
**Critical:** React caches env vars at build time. After `.env` changes, the dev server must be fully restarted (not just refreshed).
### API Base URL
All fetch calls use `process.env.REACT_APP_API_BASE` as the base URL. Requests include `credentials: 'include'` for session cookie auth.
### Authentication Flow
1. `LoginForm.js` posts credentials to `/api/auth/login`
2. Server returns session cookie (httpOnly, sameSite: lax)
3. `AuthContext.js` checks `/api/auth/me` on mount to restore sessions
4. `useAuth()` hook provides `user`, `login()`, `logout()`, `loading` throughout the app
5. Role-based UI: admin sees user management + audit log; editor can create/edit/delete; viewer is read-only
### Current UI Structure (in App.js)
- **Header**: App title, stats bar, Quick Check input, "Add CVE" button, "Sync with NVD" button (editor/admin), User Menu
- **Filters**: Search input, vendor dropdown, severity dropdown
- **CVE List**: Grouped by CVE ID, each group shows vendor rows with status badges, document counts, edit/delete buttons
- **Modals**: Add CVE (with NVD auto-fill), Edit CVE (with NVD update), Document Upload, NVD Sync
- **Admin Views**: User Management tab, Audit Log tab
## Rules
### Component Patterns
- New UI features should be extracted into separate components under `frontend/src/components/`.
- Use functional components with hooks. No class components.
- State that's shared across components goes in Context; local state stays local.
- Destructure props. Use meaningful variable names.
### Styling
- Use Tailwind CSS utility classes exclusively. No custom CSS unless absolutely necessary.
- Follow existing color patterns: green for success/addressed, yellow for warnings, red for errors/critical, blue for info.
- Responsive design: use Tailwind responsive prefixes (sm:, md:, lg:).
- Dark mode is not currently implemented — do not add it unless requested.
### API Communication
- Always use `fetch()` with `credentials: 'include'`.
- Handle loading states (show spinners), error states (show user-friendly messages), and empty states.
- On 401 responses, redirect to login (session expired).
- Pattern:
```js
const res = await fetch(`${process.env.REACT_APP_API_BASE}/endpoint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
if (!res.ok) { /* handle error */ }
const result = await res.json();
```
### Role-Based UI
- Check `user.role` before rendering admin/editor controls.
- Viewers see data but no create/edit/delete buttons.
- Editors see create/edit/delete for CVEs and documents.
- Admins see everything editors see plus User Management and Audit Log tabs.
### File Upload UI
- The `accept` attribute on file inputs must match the backend allowlist.
- Current allowed: `.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.svg,.zip,.tar,.gz,.7z,.rar,.eml,.msg`
- Max file size: 10MB (enforced backend, show friendly message on 413).
### Code Quality
- No inline styles — use Tailwind classes.
- Extract repeated logic into custom hooks or utility functions.
- Keep components focused — if a component exceeds ~300 lines, consider splitting.
- Use `key` props correctly on lists (use unique IDs, not array indexes).
- Clean up useEffect subscriptions and timers.
### Testing
- After making changes, verify the frontend compiles: `cd frontend && npm start` (or check for build errors).
- Test in browser: check console for errors, verify API calls succeed.
- Test role-based visibility with different user accounts.

View File

@@ -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.

View File

@@ -1,25 +0,0 @@
# Project Instructions
## Token Usage & Efficiency
Follow the guidelines in `.claude/optimization.md` for:
- When to use subagents vs main conversation
- Model selection (Haiku vs Sonnet)
- Token preservation strategies
- Rate limiting rules
## Project Context
This is a CVE (Common Vulnerabilities and Exposures) dashboard application for tracking security vulnerabilities, vendors, and JIRA tickets.
## Security Focus
All code changes should consider:
- Input validation
- SQL injection prevention
- XSS protection
- Authentication/authorization
## Frontend Development
When working on frontend features or UI components:
- Use the `frontend-design` skill for new component creation and UI implementation
- This skill provides production-grade design quality and avoids generic AI aesthetics
- Invoke it using: `Skill` tool with `skill: "frontend-design"`
- The skill will guide implementation with distinctive, polished code patterns

View File

@@ -1,143 +0,0 @@
OPTIMIZATION.md - Token Usage & Subagent Strategy
## SUBAGENT USAGE STRATEGY
Subagents run in separate contexts and preserve main conversation tokens.
### When to Use Subagents
**Use Subagents for:**
- Large-scale codebase exploration and analysis
- Complex multi-step investigations across many files
- Detailed code pattern searches and refactoring analysis
- Gathering comprehensive information before main conversation work
- When total tokens would exceed 30,000 in main conversation
**Keep in Main Conversation:**
- Direct file edits (1-3 files)
- Simple code changes and debugging
- Architecture decisions
- Security reviews and approvals
- User-facing responses and recommendations
- Questions requiring reasoning about codebase
- Frontend UI work (use `frontend-design` skill for new components)
### Subagent Types & When to Use
**Explore Agent** (Haiku 3.5)
- Codebase exploration and file discovery
- Pattern searching across large codebases
- Gathering information about file structure
- Finding references and relationships
**General-Purpose Agent** (Haiku 3.5)
- Multi-step code analysis tasks
- Summarizing findings from exploration
- Complex searches requiring multiple strategies
- Collecting data for main conversation decisions
---
## MODEL SELECTION STRATEGY
### Main Conversation (Sonnet 4.5)
- **Always use Sonnet 4.5 in main conversation**
- Direct file edits and modifications
- Architecture and design decisions
- Security analysis and approvals
- Complex reasoning and recommendations
- Final user responses
### Subagent Models
**Haiku 4.5** (Default for subagents)
- Code exploration and pattern searching
- File discovery and structure analysis
- Simple codebase investigations
- Gathering information and summarizing
- Task: Use Haiku first for subagent work
**Sonnet 4.5** (For subagents - when needed)
- Security-critical analysis within subagents
- Complex architectural decisions needed in exploration
- High-risk code analysis
- When exploration requires advanced reasoning
---
## RATE LIMITING GUIDANCE
### API Call Throttling
- 5 seconds minimum between API calls
- 10 seconds minimum between web searches
- Batch similar work whenever possible
- If you hit 429 error: STOP and wait 5 minutes
### Budget Management
- Track tokens used across all agents
- Main conversation should stay under 100,000 tokens
- Subagent work can extend to 50,000 tokens per agent
- Batch multiple subagent tasks together when possible
---
## TOKEN PRESERVATION RULES
### Best Practices for Long-Running Conversations
**In Main Conversation:**
1. Start with subagent for exploration (saves ~20,000 tokens)
2. Request subagent summarize findings
3. Use summary to inform main conversation edits/decisions
4. Keep main conversation focused on decisions and actions
**Information Gathering:**
- Use subagents to explore before asking for analysis in main conversation
- Have subagent provide condensed summaries (250-500 words max)
- Main conversation uses summary + provides feedback/decisions
**File Editing:**
- For <3 files: Keep in main conversation
- For 3+ files: Split between subagent (finding/analysis) and main (approval/execution)
- Simple edits (1-5 lines per file): Main conversation
- Complex refactoring (10+ lines per file): Subagent analysis + main approval
**Code Review Workflow:**
1. Subagent explores and analyzes code patterns
2. Subagent flags issues and suggests improvements
3. Main conversation reviews suggestions
4. Main conversation executes approved changes
### Token Budget Allocation Example
- Main conversation: 0-100,000 tokens (soft limit)
- Per subagent task: 0-50,000 tokens
- Critical work (security): Use Sonnet in main conversation
- Exploratory work: Use Explore agent (Haiku) in subagent
---
## DECISION TREE
```
Is this a direct file edit request?
├─ YES (1-3 files, <10 lines each) → Main conversation
├─ NO
└─ Is this exploratory analysis?
├─ YES (finding files, patterns) → Use Explore agent (Haiku)
├─ NO
└─ Is this complex multi-step work?
├─ YES (3+ steps, many files) → Use General agent (Haiku)
├─ NO
└─ Is this security-critical?
├─ YES → Main conversation (Sonnet)
└─ NO → Subagent (Haiku) or Main conversation
```
---
## SUMMARY
**Main Conversation (You):** Architecture, decisions, edits, reviews
**Subagents:** Exploration, analysis, information gathering
**Sonnet 4.5:** Security, complexity, final decisions
**Haiku 4.5:** Exploration, gathering, analysis support

7
.gitignore vendored
View File

@@ -37,9 +37,12 @@ frontend.pid
# Temporary files
backend/uploads/temp/
claude.md
claude_status.md
feature_request*.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

View File

@@ -0,0 +1,78 @@
// 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,
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!');
});

View 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!');
});

View 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!'));
}
);

View File

@@ -0,0 +1,214 @@
// routes/ivantiTodoQueue.js
const express = require('express');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete'];
function isValidVendor(vendor) {
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
}
function createIvantiTodoQueueRouter(db, requireAuth) {
const router = express.Router();
// GET /api/ivanti/todo-queue
// Fetch current user's queue items, ordered by vendor then created_at
router.get('/', requireAuth(db), (req, res) => {
db.all(
`SELECT * FROM ivanti_todo_queue
WHERE user_id = ?
ORDER BY vendor ASC, created_at ASC`,
[req.user.id],
(err, rows) => {
if (err) {
console.error('Error fetching todo queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Parse cves_json back to array for each row
const parsed = rows.map((r) => ({
...r,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
res.json(parsed);
}
);
});
// POST /api/ivanti/todo-queue
// Add a finding to the queue
router.post('/', requireAuth(db), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
return res.status(400).json({ error: 'finding_id is required.' });
}
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
}
// Vendor is required for FP and Archer, optional for CARD
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
const 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, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type],
function (err) {
if (err) {
console.error('Error adding to queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
[this.lastID],
(err2, row) => {
if (err2 || !row) {
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
}
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
}
);
}
);
});
// PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user
router.put('/:id', requireAuth(db), (req, res) => {
const { id } = req.params;
const { vendor, workflow_type, status } = req.body;
if (vendor !== undefined && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
}
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
}
if (status !== undefined && !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: 'status must be pending or complete.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, existing) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!existing) {
return res.status(404).json({ error: 'Queue item not found.' });
}
const updates = [];
const params = [];
if (vendor !== undefined) {
updates.push('vendor = ?');
params.push(vendor.trim());
}
if (workflow_type !== undefined) {
updates.push('workflow_type = ?');
params.push(workflow_type);
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id, req.user.id);
db.run(
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params,
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
[id],
(err3, row) => {
if (err3 || !row) {
return res.json({ message: 'Queue item updated.' });
}
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
}
);
}
);
}
);
});
// DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id
router.delete('/completed', requireAuth(db), (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 item — scoped to current user
router.delete('/:id', requireAuth(db), (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;

View File

@@ -22,6 +22,7 @@ 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 app = express();
const PORT = process.env.PORT || 3001;
@@ -123,8 +124,35 @@ 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)
@@ -187,6 +215,9 @@ 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));
// ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users)
@@ -302,6 +333,17 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
});
// 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) => {
const { cve_id, vendor, severity, description, published_date } = req.body;

View File

@@ -1,24 +1,459 @@
import React from 'react';
import { Download } from 'lucide-react';
import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
export default function ExportsPage() {
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const EXC_PATTERN = /EXC-\d+/i;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function classifyFinding(f) {
if (f.workflow != null) return 'fp';
if (EXC_PATTERN.test(f.note || '')) return 'archer';
return 'pending';
}
const dateStr = () => new Date().toISOString().slice(0, 10);
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function autoFit(ws, rows) {
if (!rows[0]) return;
ws['!cols'] = rows[0].map((_, ci) => ({
wch: Math.min(60, Math.max(10, ...rows.map(r => String(r[ci] ?? '').length)))
}));
}
function toXLSX(rows, sheetName, filename) {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, filename);
}
function toMultiXLSX(sheets, filename) {
const wb = XLSX.utils.book_new();
sheets.forEach(({ name, rows }) => {
const ws = XLSX.utils.aoa_to_sheet(rows);
autoFit(ws, rows);
XLSX.utils.book_append_sheet(wb, ws, String(name || 'Unknown').slice(0, 31));
});
XLSX.writeFile(wb, filename);
}
function toCSV(rows, filename) {
const csv = rows.map(row =>
row.map(cell => {
const s = String(cell ?? '');
return (s.includes(',') || s.includes('"') || s.includes('\n'))
? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')
).join('\r\n');
triggerDownload(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }), filename);
}
// ---------------------------------------------------------------------------
// Finding column definitions
// ---------------------------------------------------------------------------
const FINDING_HEADERS = [
'Finding ID', 'Title', 'Severity Score', 'Severity Group',
'Host', 'IP Address', 'DNS', 'Due Date', 'SLA Status',
'Business Unit', 'FP# ID', 'FP# State', 'Last Found', 'CVEs', 'Notes',
];
function findingRow(f) {
return [
f.id,
f.title,
f.severity != null ? Number(f.severity).toFixed(2) : '',
f.vrrGroup ?? '',
f.overrides?.hostName ?? f.hostName ?? '',
f.ipAddress ?? '',
f.overrides?.dns ?? f.dns ?? '',
f.dueDate ?? '',
f.slaStatus ?? '',
f.buOwnership ?? '',
f.workflow?.id ?? '',
f.workflow?.state ?? '',
f.lastFoundOn ?? '',
(f.cves || []).join(', '),
f.note ?? '',
];
}
// ---------------------------------------------------------------------------
// API fetchers
// ---------------------------------------------------------------------------
async function fetchFindings() {
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
const data = await res.json();
return data.findings || [];
}
async function fetchCVEs(status) {
const url = status ? `${API_BASE}/cves?status=${encodeURIComponent(status)}` : `${API_BASE}/cves`;
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`CVE list returned ${res.status}`);
return res.json();
}
async function fetchArcher() {
const res = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
if (!res.ok) throw new Error(`Archer tickets returned ${res.status}`);
return res.json();
}
async function fetchCompliance() {
const res = await fetch(`${API_BASE}/cves/compliance`, { credentials: 'include' });
if (!res.ok) throw new Error(`Compliance data returned ${res.status}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
background: 'rgba(139, 92, 246, 0.1)',
border: '1px solid rgba(139, 92, 246, 0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
border: `1px solid rgba(${colorRgb},0.2)`,
borderLeft: `3px solid ${color}`,
borderRadius: '0.5rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}>
<Download style={{ width: '36px', height: '36px', color: '#8B5CF6' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Icon style={{ width: '18px', height: '18px', color, flexShrink: 0 }} />
<h3 style={{
fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '600',
color, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 12px rgba(${colorRgb},0.4)`, margin: 0,
}}>
{title}
</h3>
</div>
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
Exports
</h2>
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
Under construction coming soon
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
{description}
</p>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
{children}
</div>
</div>
);
}
function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabled }) {
const isLoading = loading === exportKey;
return (
<button
onClick={onClick}
disabled={!!loading || disabled}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.45rem 0.875rem',
background: `rgba(${colorRgb},0.08)`,
border: `1px solid rgba(${colorRgb},0.25)`,
borderRadius: '0.375rem',
color: isLoading ? '#64748B' : color,
cursor: (!!loading || disabled) ? 'not-allowed' : 'pointer',
opacity: (!!loading && !isLoading) ? 0.45 : 1,
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
letterSpacing: '0.05em',
transition: 'opacity 0.15s, color 0.15s',
whiteSpace: 'nowrap',
}}
>
{isLoading
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
: <Download style={{ width: '12px', height: '12px', flexShrink: 0 }} />
}
{label}
</button>
);
}
function Toggle({ label, checked, onChange, color, colorRgb }) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
<div
onClick={() => onChange(!checked)}
style={{
width: '32px', height: '18px', borderRadius: '9px',
background: checked ? color : 'rgba(255,255,255,0.1)',
border: `1px solid rgba(${colorRgb},0.4)`,
position: 'relative', transition: 'background 0.2s',
cursor: 'pointer', flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: '2px',
left: checked ? '14px' : '2px',
width: '12px', height: '12px', borderRadius: '50%',
background: '#E2E8F0',
transition: 'left 0.2s',
}} />
</div>
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B' }}>{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function ExportsPage() {
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
const [cveStatus, setCveStatus] = useState('');
const [missingOnly, setMissingOnly] = useState(false);
const run = useCallback(async (key, fn) => {
setLoading(key);
setError(null);
try {
await fn();
} catch (e) {
console.error('[Export]', e);
setError(e.message || 'Export failed — check console for details');
} finally {
setLoading(null);
}
}, []);
// ---- Card 1: Ivanti Findings ----
const exportFullFindings = () => run('ivanti-full', async () => {
const findings = await fetchFindings();
toXLSX(
[FINDING_HEADERS, ...findings.map(findingRow)],
'All Findings',
`findings-full-${dateStr()}.xlsx`,
);
});
const exportPending = () => run('ivanti-pending', async () => {
const findings = await fetchFindings();
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
});
const exportOverdue = () => run('ivanti-overdue', async () => {
const findings = await fetchFindings();
const today = dateStr();
const rows = findings.filter(f => {
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
}).map(findingRow);
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
});
const exportByBU = () => run('ivanti-bu', async () => {
const findings = await fetchFindings();
const groups = {};
findings.forEach(f => {
const bu = f.buOwnership || 'Unknown';
if (!groups[bu]) groups[bu] = [];
groups[bu].push(f);
});
const sheets = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, rows]) => ({ name, rows: [FINDING_HEADERS, ...rows.map(findingRow)] }));
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [FINDING_HEADERS] });
toMultiXLSX(sheets, `findings-by-bu-${dateStr()}.xlsx`);
});
// ---- Card 2: FP Workflow Summary ----
const exportFPSummary = () => run('fp-summary', async () => {
const findings = await fetchFindings();
const fpMap = {};
findings.forEach(f => {
if (!f.workflow?.id) return;
const id = f.workflow.id;
if (!fpMap[id]) fpMap[id] = { id, state: f.workflow.state || '', count: 0, hosts: new Set(), bus: new Set(), cves: new Set() };
fpMap[id].count++;
const host = f.overrides?.hostName ?? f.hostName;
if (host) fpMap[id].hosts.add(host);
if (f.buOwnership) fpMap[id].bus.add(f.buOwnership);
(f.cves || []).forEach(c => fpMap[id].cves.add(c));
});
const headers = ['FP# ID', 'State', 'Finding Count', 'Hosts', 'Business Units', 'CVEs'];
const rows = Object.values(fpMap)
.sort((a, b) => a.id.localeCompare(b.id))
.map(e => [e.id, e.state, e.count, [...e.hosts].join(', '), [...e.bus].join(', '), [...e.cves].join(', ')]);
toXLSX([headers, ...rows], 'FP Workflows', `fp-workflow-summary-${dateStr()}.xlsx`);
});
// ---- Card 3: CVE Database ----
const exportCVEs = (fmt) => run(`cves-${fmt}`, async () => {
const data = await fetchCVEs(cveStatus);
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Published Date', 'Description', 'Documents'];
const rows = data.map(c => [c.cve_id, c.vendor, c.severity, c.status, c.published_date ?? '', c.description ?? '', c.document_count ?? 0]);
if (fmt === 'csv') {
toCSV([headers, ...rows], `cve-database-${dateStr()}.csv`);
} else {
toXLSX([headers, ...rows], 'CVEs', `cve-database-${dateStr()}.xlsx`);
}
});
// ---- Card 4: Archer Tickets ----
const exportArcher = () => run('archer', async () => {
const data = await fetchArcher();
const headers = ['EXC Number', 'Status', 'CVE ID', 'Vendor', 'Archer URL', 'Created'];
const rows = data.map(t => [t.exc_number, t.status, t.cve_id ?? '', t.vendor ?? '', t.archer_url ?? '', t.created_at ?? '']);
toXLSX([headers, ...rows], 'Archer Tickets', `archer-tickets-${dateStr()}.xlsx`);
});
// ---- Card 5: Compliance Report ----
const exportCompliance = () => run('compliance', async () => {
const data = await fetchCompliance();
const filtered = missingOnly ? data.filter(r => r.compliance_status !== 'Complete') : data;
const headers = ['CVE ID', 'Vendor', 'Severity', 'Status', 'Total Docs', 'Advisory Docs', 'Email Docs', 'Screenshot Docs', 'Compliance Status'];
const rows = filtered.map(r => [r.cve_id, r.vendor, r.severity, r.status, r.total_documents, r.advisory_count, r.email_count, r.screenshot_count, r.compliance_status]);
toXLSX([headers, ...rows], 'Compliance', `compliance-report-${dateStr()}.xlsx`);
});
// ---- Render ----
return (
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Download style={{ width: '20px', height: '20px', color: '#8B5CF6' }} />
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)', margin: 0 }}>
Exports
</h2>
</div>
{/* Error banner */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.75rem 1rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem',
}}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#EF4444', padding: 0 }}>
<X style={{ width: '14px', height: '14px' }} />
</button>
</div>
)}
{/* Card grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(420px, 1fr))', gap: '1.5rem' }}>
{/* ── Card 1: Ivanti Findings ── */}
<ExportCard
color="#F59E0B" colorRgb="245,158,11"
icon={BarChart2}
title="Ivanti Host Findings"
description="Export host findings from the local cache. Four report types: full dump, findings with no action taken, overdue SLA, and a per-business-unit multi-sheet workbook."
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<ExportBtn label="Full Dump" exportKey="ivanti-full" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportFullFindings} />
<ExportBtn label="Pending Action" exportKey="ivanti-pending" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportPending} />
<ExportBtn label="Overdue SLA" exportKey="ivanti-overdue" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportOverdue} />
<ExportBtn label="By Business Unit" exportKey="ivanti-bu" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportByBU} />
</div>
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
"By Business Unit" creates one sheet per BU in a single workbook.
</p>
</ExportCard>
{/* ── Card 2: FP Workflow Summary ── */}
<ExportCard
color="#0EA5E9" colorRgb="14,165,233"
icon={FileText}
title="FP Workflow Summary"
description="One row per unique FP# ticket ID. Shows state, how many findings belong to that ticket, which hosts are affected, and which CVEs are involved. Use this for status meetings."
>
<ExportBtn label="Export FP Summary (.xlsx)" exportKey="fp-summary" loading={loading} color="#0EA5E9" colorRgb="14,165,233" onClick={exportFPSummary} />
</ExportCard>
{/* ── Card 3: CVE Database ── */}
<ExportCard
color="#22C55E" colorRgb="34,197,94"
icon={Shield}
title="CVE Database"
description="Export the full CVE registry. Optionally filter by status to produce a focused remediation backlog. Includes document count per entry."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap' }}>Status</span>
<select
value={cveStatus}
onChange={e => setCveStatus(e.target.value)}
disabled={!!loading}
style={{
background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)',
borderRadius: '0.25rem', color: '#CBD5E1', padding: '0.25rem 0.5rem',
fontFamily: 'monospace', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
}}
>
<option value="">All Statuses</option>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Addressed">Addressed</option>
<option value="Resolved">Resolved</option>
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<ExportBtn label="Export CSV" exportKey="cves-csv" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('csv')} />
<ExportBtn label="Export .xlsx" exportKey="cves-xlsx" loading={loading} color="#22C55E" colorRgb="34,197,94" onClick={() => exportCVEs('xlsx')} />
</div>
</div>
</ExportCard>
{/* ── Card 4: Archer Tickets ── */}
<ExportCard
color="#F97316" colorRgb="249,115,22"
icon={Tag}
title="Archer Risk Acceptance Tickets"
description="Export all Archer EXC exception tickets with their linked CVE IDs, vendors, statuses, and Archer URLs. Useful for risk acceptance reporting and audits."
>
<ExportBtn label="Export Archer Tickets (.xlsx)" exportKey="archer" loading={loading} color="#F97316" colorRgb="249,115,22" onClick={exportArcher} />
</ExportCard>
{/* ── Card 5: Compliance Report ── */}
<ExportCard
color="#EF4444" colorRgb="239,68,68"
icon={CheckCircle}
title="Document Compliance Report"
description="Shows document coverage per CVE/vendor pair. A row is marked Complete when an advisory document has been uploaded; otherwise Missing Required Docs. Filter to missing-only to generate a gap list."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<Toggle
label="Missing required docs only"
checked={missingOnly}
onChange={setMissingOnly}
color="#EF4444"
colorRgb="239,68,68"
/>
<ExportBtn label="Export Compliance Report (.xlsx)" exportKey="compliance" loading={loading} color="#EF4444" colorRgb="239,68,68" onClick={exportCompliance} />
</div>
</ExportCard>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
@@ -1074,6 +1074,465 @@ function TableCell({ colKey, finding, canWrite }) {
}
}
// ---------------------------------------------------------------------------
// AddToQueuePopover — portal-based popover for adding a finding to the queue
// ---------------------------------------------------------------------------
function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) {
const panelRef = useRef(null);
const inputRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!anchorRect) return;
const PANEL_W = 260;
const PANEL_H = 360; // conservative estimate (3 workflow buttons)
const spaceBelow = window.innerHeight - anchorRect.bottom - 6;
const top = spaceBelow >= PANEL_H
? anchorRect.bottom + 6
: Math.max(8, anchorRect.top - PANEL_H - 6);
const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8);
setPos({ top, left });
setTimeout(() => inputRef.current?.focus(), 0);
}, [anchorRect]);
// Close on outside click
useEffect(() => {
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target)) onCancel();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onCancel]);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onCancel(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onCancel]);
const isCard = queueForm.workflowType === 'CARD';
const canSubmit = isCard || queueForm.vendor.trim().length > 0;
return ReactDOM.createPortal(
<div
ref={panelRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
width: '260px', zIndex: 9999,
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.35)',
borderRadius: '0.5rem',
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
padding: '0.875rem',
}}
>
{/* Header */}
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
Add to Ivanti Queue
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', marginBottom: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.id}>
{finding.id}
</div>
{/* Vendor input — hidden for CARD */}
{isCard ? (
<div style={{
marginBottom: '0.625rem', padding: '0.4rem 0.5rem',
background: 'rgba(16,185,129,0.06)',
border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
}}>
No vendor required disposition handled in CARD
</div>
) : (
<label style={{ display: 'block', marginBottom: '0.625rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Vendor / Platform
</span>
<input
ref={inputRef}
type="text"
value={queueForm.vendor}
onChange={(e) => setQueueForm((f) => ({ ...f, vendor: e.target.value }))}
placeholder="Juniper, Cisco, ADTRAN…"
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.35rem 0.5rem',
color: '#CBD5E1', fontSize: '0.78rem',
fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onAdd(); }}
/>
</label>
)}
{/* Workflow type toggle */}
<div style={{ marginBottom: '0.875rem' }}>
<span style={{ display: 'block', fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.3rem' }}>
Workflow Type
</span>
<div style={{ display: 'flex', gap: '0.375rem' }}>
{[
{ key: 'FP', col: '#F59E0B', rgb: '245,158,11' },
{ key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
].map(({ key, col, rgb }) => {
const active = queueForm.workflowType === key;
return (
<button
key={key}
onClick={() => setQueueForm((f) => ({ ...f, workflowType: key }))}
style={{
flex: 1, padding: '0.3rem',
background: active ? `rgba(${rgb},0.15)` : 'transparent',
border: `1px solid ${active ? col : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.25rem',
color: active ? col : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: 'pointer', transition: 'all 0.12s',
}}
>
{key}
</button>
);
})}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={onAdd}
disabled={!canSubmit}
style={{
flex: 1, padding: '0.4rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.05)',
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.4)' : 'rgba(14,165,233,0.1)'}`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
Add to Queue
</button>
<button
onClick={onCancel}
style={{
padding: '0.4rem 0.625rem',
background: 'none', border: 'none',
color: '#475569', fontFamily: 'monospace', fontSize: '0.72rem',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
const [selectedIds, setSelectedIds] = useState(new Set());
// Drop any selected IDs that no longer exist in items
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const valid = new Set(items.map((i) => i.id));
const next = new Set([...prev].filter((id) => valid.has(id)));
return next.size === prev.size ? prev : next;
});
}, [items]);
const toggleSelect = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleDeleteSelected = () => {
onDeleteMany([...selectedIds]);
setSelectedIds(new Set());
};
// CARD items are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
const otherItems = items.filter((i) => i.workflow_type !== 'CARD');
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], isCard: false,
}));
return cardItems.length > 0
? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups]
: vendorGroups;
}, [items]);
return (
<>
{/* Backdrop */}
{open && (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.45)',
zIndex: 9998,
}}
/>
)}
{/* Panel */}
<div
style={{
position: 'fixed', top: 0, right: 0,
height: '100vh', width: '420px',
zIndex: 9999,
display: 'flex', flexDirection: 'column',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
borderLeft: '1px solid rgba(14,165,233,0.2)',
boxShadow: '-8px 0 40px rgba(0,0,0,0.7)',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)',
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(14,165,233,0.15)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<ListTodo style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Ivanti Queue
</span>
{pendingCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '20px', height: '20px', padding: '0 5px',
background: 'rgba(14,165,233,0.2)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#0EA5E9',
}}>
{pendingCount}
</span>
)}
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem 1.25rem' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 0', fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155' }}>
No items in queue.<br />
<span style={{ fontSize: '0.68rem', color: '#1E293B', marginTop: '0.5rem', display: 'block' }}>
Check a row in the findings table to add it.
</span>
</div>
) : grouped.map(({ key, label, items: groupItems, isCard }) => (
<div key={key} style={{ marginBottom: '1.25rem' }}>
{/* Group header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: `1px solid ${isCard ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isCard ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{label}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{groupItems.length}
</span>
</div>
{/* Items */}
{groupItems.map((item) => {
const done = item.status === 'complete';
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
: item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' }
: { col: '#10B981', rgb: '16,185,129' };
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '')
: '—';
const isCardItem = item.workflow_type === 'CARD';
return (
<div
key={item.id}
style={{
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
padding: '0.5rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: done ? 'rgba(16,185,129,0.04)' : 'rgba(14,165,233,0.04)',
border: `1px solid ${done ? 'rgba(16,185,129,0.12)' : 'rgba(14,165,233,0.1)'}`,
opacity: done ? 0.55 : 1,
transition: 'opacity 0.15s',
}}
>
{/* Selection checkbox — for bulk delete */}
<input
type="checkbox"
checked={selectedIds.has(item.id)}
onChange={() => toggleSelect(item.id)}
style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }}
title="Select for deletion"
/>
{/* Complete checkbox */}
<input
type="checkbox"
checked={done}
onChange={() => onUpdate(item.id, { status: done ? 'pending' : 'complete' })}
style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }}
/>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
color: done ? '#475569' : '#CBD5E1',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={item.finding_id}>
{item.finding_id}
</div>
{isCardItem ? (
item.ip_address && (
<div style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: done ? '#334155' : '#10B981',
textDecoration: done ? 'line-through' : 'none',
marginTop: '2px',
}}>
{item.ip_address}
</div>
)
) : (
cves.length > 0 && (
<div style={{
fontFamily: 'monospace', fontSize: '0.62rem',
color: done ? '#334155' : '#64748B',
textDecoration: done ? 'line-through' : 'none',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
marginTop: '1px',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)
)}
</div>
{/* Workflow type badge */}
<span style={{
flexShrink: 0,
padding: '0.1rem 0.35rem',
borderRadius: '0.2rem',
background: `rgba(${wfColor.rgb},0.12)`,
border: `1px solid rgba(${wfColor.rgb},0.3)`,
color: wfColor.col,
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
}}>
{item.workflow_type}
</span>
{/* Delete button */}
<button
onClick={() => onDelete(item.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
title="Remove from queue"
>
<Trash2 style={{ width: '13px', height: '13px' }} />
</button>
</div>
);
})}
</div>
))}
</div>
{/* Footer */}
<div style={{
padding: '0.75rem 1.25rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
display: 'flex', gap: '0.5rem',
}}>
{/* Delete selected — only shown when items are selected */}
{selectedIds.size > 0 && (
<button
onClick={handleDeleteSelected}
style={{
flex: 1, padding: '0.45rem',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)',
borderRadius: '0.375rem',
color: '#EF4444',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: 'pointer',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Delete ({selectedIds.size})
</button>
)}
<button
onClick={onClearCompleted}
disabled={completedCount === 0}
style={{
flex: 1, padding: '0.45rem',
background: completedCount > 0 ? 'rgba(16,185,129,0.08)' : 'transparent',
border: `1px solid ${completedCount > 0 ? 'rgba(16,185,129,0.25)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: completedCount > 0 ? '#10B981' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: completedCount > 0 ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Clear Completed {completedCount > 0 ? `(${completedCount})` : ''}
</button>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
@@ -1174,6 +1633,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
fetchFindings();
fetchCounts();
fetchFPWorkflowCounts();
fetchQueue();
}, []); // eslint-disable-line
// Set/clear a single column filter
@@ -1250,6 +1710,116 @@ export default function ReportingPage({ filterDate, filterEXC }) {
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
// Queue state
const [queueItems, setQueueItems] = useState([]);
const [queueOpen, setQueueOpen] = useState(false);
const [queueLoading, setQueueLoading] = useState(false);
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
// Queue API helpers
const fetchQueue = useCallback(async () => {
setQueueLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setQueueItems(data);
} catch (e) {
console.error('Error fetching queue:', e);
} finally {
setQueueLoading(false);
}
}, []);
const addToQueue = useCallback(async () => {
if (!addPopover) return;
const { finding } = addPopover;
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
finding_id: finding.id,
finding_title: finding.title || null,
cves: finding.cves || [],
ip_address: finding.ipAddress || null,
vendor: queueForm.vendor.trim(),
workflow_type: queueForm.workflowType,
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, data].sort((a, b) =>
a.vendor.localeCompare(b.vendor) || a.id - b.id
));
}
} catch (e) {
console.error('Error adding to queue:', e);
}
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]);
const updateQueueItem = useCallback(async (id, changes) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => prev.map((item) => item.id === id ? data : item));
}
} catch (e) {
console.error('Error updating queue item:', e);
}
}, []);
const deleteQueueItem = useCallback(async (id) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.id !== id));
} catch (e) {
console.error('Error deleting queue item:', e);
}
}, []);
const deleteQueueItems = useCallback(async (ids) => {
try {
await Promise.all(ids.map((id) =>
fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { method: 'DELETE', credentials: 'include' })
));
const removed = new Set(ids);
setQueueItems((prev) => prev.filter((item) => !removed.has(item.id)));
} catch (e) {
console.error('Error bulk-deleting queue items:', e);
}
}, []);
const clearCompleted = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/completed`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) setQueueItems((prev) => prev.filter((item) => item.status !== 'complete'));
} catch (e) {
console.error('Error clearing completed queue items:', e);
}
}, []);
const isQueued = useCallback((findingId) =>
queueItems.some((item) => item.finding_id === findingId),
[queueItems]);
const pendingQueueCount = queueItems.filter((i) => i.status === 'pending').length;
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportBtnRef = useRef(null);
@@ -1540,6 +2110,35 @@ export default function ReportingPage({ filterDate, filterEXC }) {
</div>
)}
</div>
{/* Queue button */}
<button
onClick={() => setQueueOpen((o) => !o)}
style={{
position: 'relative',
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem',
background: queueOpen ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
border: `1px solid rgba(14,165,233,${queueOpen ? '0.5' : '0.25'})`,
borderRadius: '0.375rem',
color: '#0EA5E9', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
<ListTodo style={{ width: '13px', height: '13px' }} />
Queue
{pendingQueueCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '16px', height: '16px', padding: '0 4px',
background: '#0EA5E9', borderRadius: '999px',
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700', color: '#0A1628',
marginLeft: '1px',
}}>
{pendingQueueCount}
</span>
)}
</button>
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
<button
onClick={syncFindings}
@@ -1585,6 +2184,15 @@ export default function ReportingPage({ filterDate, filterEXC }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
{/* Fixed checkbox column — not part of column manager */}
<th
style={{
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
}}
/>
{visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
@@ -1637,6 +2245,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
<tbody>
{sorted.map((finding, idx) => {
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
const queued = isQueued(finding.id);
return (
<tr
key={finding.id}
@@ -1644,6 +2253,28 @@ export default function ReportingPage({ filterDate, filterEXC }) {
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
>
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => {
if (queued) return;
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
}}
>
<input
type="checkbox"
readOnly
checked={queued}
style={{
accentColor: '#0EA5E9',
width: '13px', height: '13px',
cursor: queued ? 'default' : 'pointer',
pointerEvents: 'none',
}}
/>
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
))}
@@ -1652,7 +2283,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
})}
{sorted.length === 0 && (
<tr>
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
<td colSpan={visibleCols.length + 1} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
</td>
</tr>
@@ -1674,6 +2305,32 @@ export default function ReportingPage({ filterDate, filterEXC }) {
onClose={() => setOpenFilter(null)}
/>
)}
{/* Add-to-queue popover — portal */}
{addPopover && (
<AddToQueuePopover
finding={addPopover.finding}
anchorRect={addPopover.anchorRect}
queueForm={queueForm}
setQueueForm={setQueueForm}
onAdd={addToQueue}
onCancel={() => {
setAddPopover(null);
setQueueForm({ vendor: '', workflowType: 'FP' });
}}
/>
)}
{/* Queue panel — fixed slide-out */}
<QueuePanel
open={queueOpen}
items={queueItems}
onClose={() => setQueueOpen(false)}
onUpdate={updateQueueItem}
onDelete={deleteQueueItem}
onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted}
/>
</div>
);
}