62 Commits

Author SHA1 Message Date
0d48c109b3 refactor(home): remove Knowledge Base panel from home page
The dedicated Knowledge Base page now provides the full library
experience. Remove the KB sidebar panel, viewer inline embed,
upload modal, and all supporting state/functions from App.js.

Home page layout adjusts from 3-column to 2-column (9+3 grid):
main CVE content expands to col-span-9, right panel unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:32:36 -06:00
18ad31228e feat(kb): build Knowledge Base page
Replaces the 'coming soon' placeholder with a full library UI.
No backend changes needed — all existing endpoints and components
(KnowledgeBaseViewer, KnowledgeBaseModal) are reused.

Features:
  - Article card grid (responsive auto-fill, min 240px per card)
  - Category filter tabs (Procedure, Guide, Policy, Reference, General)
    with live article counts; tabs only shown for populated categories
  - Search bar — filters by title and description, client-side
  - Inline viewer — clicking a card opens KnowledgeBaseViewer below
    the grid; clicking again or pressing the close button collapses it
  - Upload modal (editor/admin only) refreshes the grid on success
  - Delete button on each card (editor/admin only) with confirmation
  - Graceful empty states for no articles and no search results
  - Loading and error states with retry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:51 -06:00
3dcb91a1fc chore(migrations): add migration script for ivanti_counts_history table
Standalone migration script for consistency with other files in
backend/migrations/. The table is also created automatically at server
startup via CREATE TABLE IF NOT EXISTS in initTables() so no manual
step is required on the dev server — just restart the backend after
pulling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:17:33 -06:00
5102a2c5b4 docs: update 'Reporting page' references to 'Vulnerability Triage'
Updated all human-readable references in documentation to reflect the
page rename. File path citations in security-audit-2026-04-01.md
(ReportingPage.js:51) are left unchanged as the file itself was not
renamed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:15:51 -06:00
a0a8979c63 fix(triage): fix missed setCurrentPage('reporting') in Archer ticket filter button
One reference to the old page ID was missed in the previous rename commit.
The Archer ticket EXC filter button in App.js was still navigating to
'reporting', which would silently fail to navigate. Updated to 'triage'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:13:57 -06:00
15ad207464 feat(triage): Ivanti findings trend chart + rename Reporting to Vulnerability Triage
Add time-based open/closed tracking for Ivanti findings (Tier 2 from
the reporting recommendations doc) and rename the Reporting page to
Vulnerability Triage to better reflect its purpose.

Backend — ivantiFindings.js:
  - Create ivanti_counts_history table (appended on every sync, never
    overwritten — Option B from design discussion)
  - INSERT snapshot after each successful syncClosedCount() call
  - GET /api/ivanti/findings/counts/history endpoint — returns last
    snapshot per calendar day using ROW_NUMBER window function, so
    multiple daily syncs collapse to the end-of-day value

Frontend:
  - New IvantiCountsChart component: collapsible dual-line chart
    (open vs closed) with dark tooltip, delta label showing change
    since previous day, and graceful no-data states
  - Chart placed between the donut metrics panel and the findings table
    on the Vulnerability Triage page
  - Renamed page: 'reporting' → 'triage' (page ID, nav label, component
    export, all cross-file references)
  - ComplianceDetailPanel "View in Reporting" link updated to "View in
    Triage" and navigates to the correct page ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:12:04 -06:00
b111273e5a feat(compliance): add time-based trend charts to Compliance page
Add 6 Recharts charts in a collapsible Historical Trends panel on the
Compliance page, covering all Tier-1 recommendations from the reporting
design doc.

Backend — 5 new API endpoints:
  - GET /api/compliance/trends        — active totals + per-team counts per upload
  - GET /api/compliance/mttr          — mean days to resolution per team
  - GET /api/compliance/top-recurring — most persistent active findings by seen_count
  - GET /api/compliance/category-trend — category breakdown per upload (future use)
  - GET /api/archer-tickets/status-trend — ticket pipeline by creation date + status

Frontend — new ComplianceChartsPanel component:
  - Active Findings Over Time (multi-line: total + per-team dashed)
  - Change per Report Cycle (stacked bar: new/recurring + resolved)
  - Team Compliance Health (multi-line per team)
  - Mean Time to Resolution (horizontal bar per team)
  - Most Persistent Findings (horizontal bar top-10 by seen_count)
  - Archer Exception Pipeline (stacked bar by date + status)

All charts degrade gracefully to a no-data placeholder until uploads
accumulate. Panel is collapsible to stay out of the way when not needed.
Adds recharts dependency to frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:49:32 -06:00
a7c74f625f docs: clarify Python deps use apt packages not pip/venv
Dev server uses apt-managed python3-pandas and python3-openpyxl.
Production fix is the same. Updates README install step and rewrites
python-venv-setup.md to reflect the real setup with venv as fallback.
2026-04-01 13:07:27 -06:00
8aef51b59a fix(compliance): use PYTHON_BIN env var for venv support
Modern Debian/Ubuntu enforces PEP 668 which blocks system-wide pip
installs. The backend now reads PYTHON_BIN from the environment
(defaulting to 'python3') so each server can point to a venv.
Updates README with venv setup instructions.
2026-04-01 12:47:50 -06:00
d0087ba9b7 docs: remove all weekly reports references
Weekly report feature was removed previously. Cleans up all remaining
references from README, architecture diagram, and deletes
WEEKLY_REPORT_FEATURE.md entirely.
2026-04-01 12:42:56 -06:00
3d6062f3fa docs: refresh README and add security posture workflow diagrams
- Rename project to STEAM Security Dashboard throughout README
- Document Ivanti Queue feature (FP/Archer/CARD staging, per-user persistence)
- Document AEO Compliance page (upload flow, metric health cards, device
  table, detail panel, View in Reporting link for 2.3.x metrics)
- Add all missing migrations to install instructions (queue, CARD,
  ip_address, compliance tables)
- Add Ivanti Queue and Compliance endpoint tables to API reference
- Update architecture file tree with new routes, migrations, scripts,
  and frontend components
- Add compliance DB tables to schema section
- Document parse_compliance_xlsx.py in scripts section
- Add security-posture-workflow-diagrams.md (Mermaid, VSCode/GitHub)
- Add security-posture-workflow-lucidchart.md (Lucidchart import format)
2026-04-01 10:46:39 -06:00
7af44608d0 feat(compliance): add 'View in Reporting' link for 2.3.x Ivanti metrics
In ComplianceDetailPanel, active metrics with a metric_id starting with
'2.3' and an Ivanti_Vulnerability_ID in extra_json now surface the ID
prominently alongside a 'View in Reporting →' button. Clicking navigates
directly to the Reporting page. onNavigate prop threaded through
App → CompliancePage → ComplianceDetailPanel → MetricRow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:20:30 -06:00
3bb86e8369 fix(compliance): remove unused vars flagged by eslint build 2026-03-31 15:20:28 -06:00
4676279a72 feat(compliance): add AEO compliance frontend
- CompliancePage: team tabs (STEAM/ACCESS-ENG), metric health cards with
  click-to-filter, device table with Active/Resolved tabs, hostname search,
  seen-count badges, notes indicator, empty/loading/error states
- ComplianceUploadModal: phased flow (idle→upload→preview→commit→done),
  drag-and-drop xlsx drop zone, diff summary before commit
- ComplianceDetailPanel: slide-out panel with failing metrics, surfaced
  extra fields (CVEs, SLA, OS, Splunk), upload history, notes timeline,
  per-metric note add with Ctrl+Enter submit
- NavDrawer: add Compliance nav item (teal, ShieldCheck icon)
- App.js: import and render CompliancePage on compliance route
- Fix SQL join bug in compliance route (lu ON upload_id = lu.id)
- Fix groupByHostname to use max last_seen across all metric rows
2026-03-31 15:14:51 -06:00
d3d86ddcf2 feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
  with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
  non-compliant assets from all detail sheets, parses Summary sheet for
  metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
  items endpoint grouped by hostname with seen_count tracking, metric
  summary endpoint for health cards, notes endpoints keyed on
  (hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
558c65807d docs: add security posture workflow process guide
Comprehensive team-facing process documentation covering the full host
finding review workflow, vulnerability designations, Ivanti queue usage,
workflow status colour codes, and quick reference tables.

Synthesises the skeletal Security posture workflow.md, the MOP colour
codes doc, and current dashboard feature set into a single guide suitable
for Confluence/internal publishing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:02:42 -06:00
518cb0a849 fix(migrations): include ip_address in add_card_workflow_type table recreate
The column was missing from the new table definition, causing the
INSERT...SELECT to fail on any DB that already had ip_address (e.g.
auto-created by the updated server.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:12:24 -06:00
b0adfa1bda feat(reporting): Ivanti queue panel for batch FP/Archer/CARD staging
Adds a persistent per-user staging queue on the Reporting page so
analysts can tag findings during review and batch-process Ivanti
workflows in one focused session.

Features:
- Checkbox column on findings table to tag rows into the queue
- Add-to-queue popover: vendor input, FP / Archer / CARD workflow toggle
  (CARD skips vendor requirement and stores IP address instead)
- Queue slide-out panel (420px, CSS transition) with items grouped by
  vendor; CARD items are their own top section showing IP address
- Per-item complete toggle, individual delete, and multi-select bulk delete
- Clear Completed footer button
- Queue button in header with live pending-count badge
- All data DB-backed (ivanti_todo_queue table, per-user scoped)
- Popover flips above row when near bottom of viewport

Migrations required on existing DBs:
  node backend/migrations/add_ivanti_todo_queue_table.js   (or let server auto-create)
  node backend/migrations/add_card_workflow_type.js
  node backend/migrations/add_todo_queue_ip_address.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:08:21 -06:00
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
b58bd0650a docs: comprehensive README rewrite for current feature set
Rewrites the README from scratch to reflect the full current state of the
application. Major additions over the previous version:

- Ivanti/RiskSense integration: env vars, sync behaviour, findings cache
- Reporting page: all 4 donut charts, findings table columns, column
  management, per-column filtering (including empty-cell filter),
  inline hostname/DNS overrides, inline notes, CSV/XLSX export
- FP workflow tracking: finding vs ticket count distinction, closed-finding
  sweep for Approved FPs
- import_notes_from_csv.py script documentation with usage/args
- Full API reference updated with all Ivanti findings endpoints
- Architecture diagram updated with new route and component files
- Database schema updated with all Ivanti tables and new columns
- Migrations section updated with two new Ivanti migration scripts
- Configuration section updated with all IVANTI_* env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:05:16 -06:00
ae04bc981e feat(reporting): add empty-cell option to column filters
Columns that contain any blank values now show a '— empty —' entry at the
top of the filter dropdown. Selecting only that entry shows findings with
nothing in that column (e.g. workflow with no FP# ticket assigned).

Uses an EMPTY_SENTINEL constant ('__EMPTY__') in the filter Set so blank
cells are handled distinctly from non-blank values. Works for both
single-value and multi-value (CVEs) columns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 13:27:16 -06:00
7314dc16cb feat(reporting): split FP charts into per-finding and per-ticket donuts
Renamed the existing FP chart to "FP Finding Status" (counts findings per
workflow state) and added a new "FP Workflow Status" chart that counts
unique FP# ticket IDs per state — so 10 findings under one FP# ticket
counts as 1 ticket, not 10.

Backend: extractFPWorkflow now returns { id, state }; syncFPWorkflowCounts
builds both a finding-count map and a deduped FP# ID map, storing them in
separate columns (fp_workflow_counts_json, fp_id_counts_json). The endpoint
returns findingCounts/findingTotal and idCounts/idTotal.

Frontend: FPWorkflowDonut accepts a centerLabel prop; both donuts share the
same component fed with their respective data slices from the single fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:13:13 -06:00
602c75bf24 fix(reporting): source FP workflow status chart from DB instead of open-findings cache
The FP Workflow Status donut was reading from the in-memory open findings
array, so Approved FPs (which close the finding and remove it from the
open cache) were invisible.

Backend: during each sync, compute FP workflow state counts from open
findings then sweep all pages of closed findings to capture Approved
(and any other closed-state) FP workflows. Counts are stored in a new
fp_workflow_counts_json column on ivanti_counts_cache and exposed via
GET /api/ivanti/findings/fp-workflow-counts.

Frontend: FPWorkflowDonut now receives counts/total props from the new
endpoint (fetched on load and refreshed after manual sync) instead of
deriving them from the findings prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:43:57 -06:00
706ef19872 feat(reporting): add FP Workflow Status donut chart to Metrics panel
Adds a new SVG donut chart showing the distribution of FP workflow states
(Actionable, Requested, Reworked, Approved, Rejected, Expired, Unknown)
for all findings that have an associated FP# workflow ticket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:16:01 -06:00
8392124df5 fix(scripts): skip notes for finding IDs not in active cache
If a finding ID from the CSV isn't in ivanti_findings_cache it is now
silently skipped (resolved or outdated) rather than stored. Also aborts
early with a clear message if the cache is empty, prompting the user to
run a Sync first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:43:44 -06:00
fbe4333e9b feat(scripts): add import_notes_from_csv.py for mass note import
Reads a CSV with ID and NOTES columns, matches finding IDs against
the cache, and upserts notes into ivanti_finding_notes. Supports
--dry-run for previewing changes, warns on unknown IDs, truncates
notes over 255 chars, and skips unchanged rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:41:33 -06:00
07894709ba feat(reporting): inline editable hostname and DNS with persistent overrides
Backend:
- New ivanti_finding_overrides table (finding_id, field, value) with
  UNIQUE(finding_id, field) — same survival-across-sync pattern as notes
- PUT /api/ivanti/findings/:id/override (editor/admin only) — saves or
  clears a field override; empty value = revert to Ivanti
- Overrides merged into findings at read time via readOverrides()
- Whitelisted fields: hostName, dns

Frontend:
- OverrideCell component — click to edit inline (editor/admin only),
  Enter/blur to save, Escape to cancel
- Amber dot indicator on cells with an active local override
- Hover tooltip shows original Ivanti value when overridden
- RotateCcw button reverts cell back to Ivanti value in one click
- canWrite() gating via useAuth — viewers see the value, can't edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 15:39:37 -06:00
071aef96a1 feat(reporting): Action Coverage chart + Archer Exception linking
Replace FP# Workflow chart with a 3-segment Action Coverage donut:
  - FP Request  — finding has an Ivanti FP# workflow
  - Archer Exception — note matches EXC-\d+ pattern
  - Pending — no action taken yet

Clicking a segment filters the findings table to that category with a
colored badge in the action bar (click again or ×  to clear).

Home page: each Archer ticket now has a filter icon button that navigates
directly to the Reporting page pre-filtered to findings whose notes
reference that EXC number. The EXC badge appears in the table action bar
with a one-click clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 13:06:54 -06:00
a9404ff82a feat(reporting): add FP# workflow status donut chart to Metrics panel
Adds a second SVG donut chart showing the distribution of FP# workflow
states (Expired, Rejected, Reworked, Actionable, Requested, Approved,
No FP#) computed from the already-loaded findings array — no new API
calls or backend changes required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:50:15 -06:00
f24cdb5063 feat(reporting): add Open vs Closed donut chart to Metrics panel
Backend: adds ivanti_counts_cache table, fetches Closed count (page 0,
size 1) from Ivanti after each Open sync, and exposes GET /counts endpoint.

Frontend: replaces the Metrics placeholder with an SVG donut chart showing
Open vs Closed proportions with counts and percentages. Counts are fetched
on mount and refreshed after manual sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:23:05 -06:00
3e2546323e feat(reporting): add CSV and XLSX export to findings table
Adds an Export dropdown button to the Reporting page action bar.
Exports respect current filters, sort order, and column visibility.
CSV uses pure JS (UTF-8 BOM for Excel compatibility); XLSX uses SheetJS
with auto-fitted column widths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:08:20 -06:00
b1a21e8771 docs: Add MOP for Workflow column color codes
Method of Procedure explaining FP# badge states, color meanings,
required actions, decision flowchart, and quick reference card.
Intended for training NTS-AEO team members on the Reporting page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:45:48 -06:00
bc9e223ab7 Workflow column: FP# only, urgency-based colors
- Backend: only extract FP# workflows; SYS# auto-generated tickets
  are no longer stored or shown (not actionable for triage purposes).
  Findings with no FP# ticket show blank in the workflow column.
- Frontend: recolor workflow badges by action urgency —
  Expired/Rejected = red (act now), Reworked/Actionable = amber
  (resubmit), Requested = blue (waiting on approval).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:36:02 -06:00
2d1acca990 Add Workflow column to Reporting page with FP# priority matching
- Backend: extractFinding now flattens all workflowDistribution buckets
  and prioritises FP# (False Positive) tickets over SYS# workflows.
  Falls back to workflowGeneratedNames for FP# IDs not yet in distribution.
- Frontend: Add Workflow column (sortable, filterable) with state-coloured
  badge (green=Approved, blue=Requested, amber=Reworked/Actionable,
  red=Rejected, grey=Expired/unknown).
- Bump localStorage key to v2 so the new column appears on all clients
  without needing a manual cache clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:44:53 -06:00
9893460b64 feat(reporting): add Finding ID column
ID was already stored in the cache from f.id; exposed as a sortable
column (filterable: false — too many unique values to be useful as a filter).
Existing users get it appended to the end of their saved column order
via the loadColumnOrder merge logic; new users see it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:23:50 -06:00
51b1f99b3a feat(calendar): click due-date day to navigate to filtered Reporting view
- CalendarWidget accepts onDateClick prop; due-date cells are clickable
  with pointer cursor, red hover highlight, and updated tooltip
- App.js wires onDateClick: sets calendarFilter state and navigates to
  the Reporting page
- NavDrawer navigation to Reporting clears calendarFilter so it only
  applies on calendar-initiated navigation
- ReportingPage accepts filterDate prop; initializes columnFilters with
  { dueDate: Set([filterDate]) } so the view lands pre-filtered
- Existing Clear Filters button lets the user dismiss the filter normally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:09:08 -06:00
669396f635 feat(calendar): live calendar with Ivanti due date indicators
- Replace hardcoded Feb 2024 static HTML with dynamic CalendarWidget component
- Auto-displays current month on load; prev/next chevron navigation
- Fetches /api/ivanti/findings on mount and builds a date→count map
- Days with findings due: date number rendered in red bold + red glowing dot below
- Today: sky-blue highlight + bold (combined with red if also a due date)
- Legend appears automatically when the displayed month has any due dates
- Tooltip on due-date cells shows count ("3 findings due")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:44:44 -06:00
8b3ea22fa0 Merge feature/reporting-page: full-width layout + in-panel scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:24:03 -06:00
75b8ecc61d fix(reporting): full-width layout and in-panel vertical scroll
- Reporting page breaks out of max-w-7xl container to use full viewport width
- Table body scrolls within the panel (maxHeight: calc(100vh - 420px)) so you
  no longer need to scroll the entire page to reach the horizontal scrollbar
- Column headers are sticky (position: sticky, top 0) with opaque background
  so they remain visible while scrolling vertically through findings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:23:56 -06:00
ade3cc25ad Merge feature/reporting-page: Add CVEs column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:05 -06:00
3fd6158eb3 feat(reporting): add CVEs column from vulnerabilities.vulnInfoList
- Backend extracts cves[] array from f.vulnerabilities.vulnInfoList[].cve
- Frontend shows up to 2 CVE badges (purple) with "+N more" overflow tooltip
- Filter is multi-value aware: selecting a CVE matches any finding containing it
- FilterDropdown expands multi-value arrays into individual checkbox options
- Sort by CVE count (number of associated CVEs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:17:01 -06:00
5bbaaf5918 Merge feature/reporting-page: BU Ownership column + column filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:20 -06:00
1f36d302ea feat(reporting): add BU Ownership column and per-column Excel-style filters
- buOwnership field extracted from assetCustomAttributes['1550_host_1'][0]
  and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG)
- All columns except Notes get a funnel filter button in the header
- FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape
  overflowX:auto clipping; shows unique value checkboxes with search input,
  Select All, Clear, and a selected/total count footer
- Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value
- columnFilters state gates a useMemo filtered array before sorting
- Active filter count shown in panel header with amber badge; Clear Filters
  button appears in the toolbar when any filters are active
- Empty Set filter (Clear All) hides all rows, consistent with Excel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:03:17 -06:00
8697ba4ef3 Reporting page: add Due Date, column manager (hide/reorder), remove Discovered/Source
Backend:
- Extract dueDate from statusEmbedded.dueDate (strip time portion)
- Remove discoveredOn and source from extractFinding (not needed)

Frontend:
- Add Due Date column (color-coded: red=past due, amber=within 30d, gray=future)
- Remove Discovered and Source columns
- ColumnManager component: gear button opens popover with drag-to-reorder and
  eye toggle per column; column state persisted to localStorage
- Column order/visibility survives page refresh and syncs
- SortIcon, TableCell, NoteCell all driven by current visible column list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:47:11 -06:00
d3806e8ce3 Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side

Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
931c42faeb Merge feature/navigation: Add hamburger nav menu with 4-page navigation structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:47 -06:00
ea3b72db5c Add hamburger nav menu with 4-page navigation structure
- NavDrawer component: slide-in left drawer with backdrop, matches dark theme
- Nav items: Home, Reporting, Knowledge Base, Exports with color-coded icons
- Active page highlighted with colored background + indicator dot
- Placeholder pages for Reporting (amber), Knowledge Base (green), Exports (purple)
- Stats bar and three-column layout conditionally render on Home page only
- currentPage state drives all page switching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:47:03 -06:00
d63e7cc9b9 Merge feature/remove-weekly-reports: Remove weekly report functionality
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:36:55 -06:00
37e183543a Remove weekly report functionality
- Delete backend/routes/weeklyReports.js
- Delete backend/migrations/add_weekly_reports_table.js
- Delete backend/scripts/split_cve_report.py
- Delete backend/helpers/excelProcessor.js
- Delete frontend/src/components/WeeklyReportModal.js
- Remove import, state, button, and modal from App.js
- Remove route registration and require from server.js
- Drop weekly_reports table from SQLite database

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:32:39 -06:00
337ffe6f35 Merge feature/cleanup-branding: Rebrand dashboard header to STEAM Security Dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:26:19 -06:00
08c8c8a2a1 Rebrand dashboard header to STEAM Security Dashboard
- Title: "CVE INTEL" → "STEAM Security Dashboard"
- Subtitle: "Threat Intelligence & Vulnerability Command Center" → "NTS Threat Intelligence and Metric Aggregation"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:25:21 -06:00
4ed7721a71 Merge feature/workflow: Add Ivanti Workflows panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:22:43 -06:00
3fb20c147d Add Ivanti Workflows panel with API key auth and SQLite cache
- New panel below Archer tickets showing workflow count and list
- Backend proxies platform4.risksense.com workflowBatch/search via x-api-key
- SQLite cache table (ivanti_sync_state) stores latest sync result
- Auto-syncs on server startup if >24h stale, then every 24h via setInterval
- POST /api/ivanti/workflows/sync for on-demand sync with spinner feedback
- GET /api/ivanti/workflows returns cached data instantly (no live API call)
- Displays id.value, name, currentState, type, createdOn per workflow
- Shows last-synced timestamp and error messages inline
- IVANTI_SKIP_TLS flag for Charter SSL proxy environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:29:33 -06:00
f2e6069c08 docs: overhaul documentation for fork readiness
- Rewrite README from scratch: accurate stack versions, correct setup
  sequence, verified feature list, full API reference, architecture
  overview, and security model — all sourced directly from the codebase
- Remove internal/stale docs: COLOR_SCHEME_MODERNIZATION.md, plan.md,
  frontend/README.md (CRA boilerplate)
- Clean up DESIGN_SYSTEM.md: remove emoji headers and version footer
- Fix WEEKLY_REPORT_FEATURE.md: replace hardcoded absolute paths with
  relative paths
- Clean up test_cases_auth.md: remove stale branch and date references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:30:17 -07:00
c89404cf26 Add CVE list pagination to prevent endless scrolling
Shows 5 CVEs by default with 'Show 5 more' and 'Show all' controls.
Resets to 5 when filters or search change. Collapses back when fully expanded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:37:44 -07:00
af7a5becef Merge feature/archer: Add Archer Risk Acceptance Tickets 2026-02-23 11:08:28 -07:00
54 changed files with 10482 additions and 3735 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

11
.gitignore vendored
View File

@@ -37,9 +37,16 @@ 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

View File

@@ -1,79 +0,0 @@
# CVE Dashboard - Color Scheme Modernization
## Overview
Successfully modernized the color scheme from retro 80s/neon arcade aesthetic to a professional, sophisticated tactical intelligence platform look.
## Color Palette Changes
### Before (Neon/Retro)
- **Accent**: `#00D9FF` - Bright cyan (too neon)
- **Warning**: `#FFB800` - Bright yellow/orange (too saturated)
- **Danger**: `#FF3366` - Neon pink/red
- **Success**: `#00FF88` - Bright green (too bright)
- **Background Dark**: `#0A0E27`, `#131937`, `#1E2749`
### After (Modern Professional)
- **Accent**: `#0EA5E9` - Sky Blue (professional, refined cyan)
- **Warning**: `#F59E0B` - Amber (sophisticated, warm)
- **Danger**: `#EF4444` - Modern Red (urgent but refined)
- **Success**: `#10B981` - Emerald (professional green)
- **Background Dark**: `#0F172A`, `#1E293B`, `#334155` (Tailwind Slate palette)
## Design Philosophy
### Refinement Approach
1. **Reduced Glow Intensity**: Lowered opacity and blur radius on all glows from 0.9 to 0.4-0.5
2. **Subtler Borders**: Changed from 3px bright borders to 1.5-2px refined borders
3. **Professional Gradients**: Updated background gradients to use slate tones instead of stark blues
4. **Sophisticated Shadows**: Reduced shadow intensity while maintaining depth
5. **Text Shadow Refinement**: Reduced from aggressive glows to subtle halos
### Key Changes
#### Severity Badges
- **Critical**: Neon pink → Modern red with refined glow
- **High**: Bright yellow → Amber with warm tones
- **Medium**: Bright cyan → Sky blue professional
- **Low**: Bright green → Emerald sophisticated
#### Interactive Elements
- **Buttons**: Reduced glow from 25px to 20px radius, lowered opacity
- **Input Fields**: More subtle focus states, refined borders
- **Cards**: Gentler hover effects, professional elevation
- **Stat Cards**: Refined top accent lines, subtle glows
#### Layout Components
- **Wiki Panel**: Updated to emerald accent with professional borders
- **Calendar**: Sky blue accent with refined styling
- **Tickets Panel**: Amber accent maintaining urgency without neon feel
- **CVE Cards**: Slate-based gradients with professional depth
## Technical Implementation
### Files Modified
1. **App.css**: Updated all CSS variables, component styles, and utility classes
2. **App.js**: Updated inline STYLES object and all JSX color references
### CSS Variables Updated
```css
--intel-darkest: #0F172A
--intel-dark: #1E293B
--intel-medium: #334155
--intel-accent: #0EA5E9
--intel-warning: #F59E0B
--intel-danger: #EF4444
--intel-success: #10B981
--intel-grid: rgba(14, 165, 233, 0.08)
```
### Maintained Features
✓ Pulsing button effects on hover/click
✓ Scanning line animation
✓ Card hover elevations
✓ Badge glow dots
✓ Grid background effect
✓ Three-column layout
✓ All interactive functionality
## Result
The dashboard now presents a modern, professional tactical intelligence platform aesthetic while preserving all the visual interest, depth, and functionality that made the original design engaging. The color scheme feels premium and sophisticated rather than arcade-like, suitable for enterprise security operations.

View File

@@ -1,6 +1,6 @@
# CVE Intelligence Dashboard - Design System Reference
## 🎨 Color Palette
## Color Palette
### Primary Colors
```css
@@ -33,7 +33,7 @@
| **Medium** | `#0EA5E9` | `rgba(14, 165, 233, 0.25)` | `#7DD3FC` | `#0EA5E9` |
| **Low** | `#10B981` | `rgba(16, 185, 129, 0.25)` | `#6EE7B7` | `#10B981` |
## 📐 Layout Structure
## Layout Structure
### Three-Column Grid Layout
```
@@ -60,7 +60,7 @@
- **Desktop (lg+)**: 3-column layout (3-6-3 grid)
- **Tablet/Mobile**: Stacked single column
## 🎯 Component Specifications
## Component Specifications
### Stat Cards
```css
@@ -117,7 +117,7 @@ Letter Spacing: 0.5px
Glow Dot: 8px circle with pulse animation
```
## Interactions & Animations
## Interactions & Animations
### Hover Effects
- **Cards**: `translateY(-2px)`, enhanced border, subtle glow
@@ -151,7 +151,7 @@ Fast: all 0.2s ease
Ripple: width/height 0.5s
```
## 🔤 Typography
## Typography
### Font Families
```css
@@ -178,7 +178,7 @@ Accent Headings: 0 0 16px rgba(14, 165, 233, 0.3), 0 0 32px rgba(14, 165, 233, 0
Badge Text: 0 0 8px rgba([color], 0.5)
```
## 🎨 Visual Effects
## Visual Effects
### Shadows
```css
@@ -223,7 +223,7 @@ linear-gradient(rgba(14, 165, 233, 0.025) 1px, transparent 1px)
Size: 20px × 20px
```
## 🧩 Specific Component Patterns
## Specific Component Patterns
### Wiki/Knowledge Base Entry
```css
@@ -261,7 +261,7 @@ Chevron: Rotate -90deg (collapsed) to 0deg (expanded)
Vendor Cards: Nested with reduced opacity borders
```
## 📱 Accessibility
## Accessibility
### Contrast Ratios
- Primary text on dark: 18.5:1 (AAA)
@@ -278,7 +278,7 @@ Vendor Cards: Nested with reduced opacity borders
- Line height: 1.5 for body text
- Letter spacing: Generous for uppercase labels
## 🎯 Design Principles
## Design Principles
1. **Professional Sophistication**: Modern enterprise feel, not arcade
2. **Tactical Intelligence**: Purpose-driven, information-dense
@@ -288,7 +288,3 @@ Vendor Cards: Nested with reduced opacity borders
6. **Monospace Data**: Technical data uses JetBrains Mono for clarity
7. **Generous Spacing**: Breathing room prevents overwhelming density
---
**Last Updated**: February 10, 2026
**Version**: 2.0 (Modern Professional Redesign)

2222
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,211 +0,0 @@
# Weekly Vulnerability Report Upload Feature
## Overview
A new feature has been added to the CVE Dashboard that allows users to upload their weekly vulnerability reports in Excel format (.xlsx) and automatically process them to split multiple CVE IDs into separate rows for easier filtering and analysis.
## What Was Implemented
### Backend Changes
1. **Database Migration** (`backend/migrations/add_weekly_reports_table.js`)
- Created `weekly_reports` table to store report metadata
- Tracks upload date, file paths, row counts, and which report is current
- Indexed for fast queries
2. **Excel Processor** (`backend/helpers/excelProcessor.js`)
- Executes Python script via Node.js child_process
- Parses row counts from Python output
- Handles errors, timeouts (30 seconds), and validation
3. **API Routes** (`backend/routes/weeklyReports.js`)
- `POST /api/weekly-reports/upload` - Upload and process Excel file
- `GET /api/weekly-reports` - List all reports
- `GET /api/weekly-reports/:id/download/:type` - Download original or processed file
- `DELETE /api/weekly-reports/:id` - Delete report (admin only)
4. **Python Script** (`backend/scripts/split_cve_report.py`)
- Moved from ~/Documents to backend/scripts
- Splits comma-separated CVE IDs into separate rows
- Duplicates device/IP data for each CVE
### Frontend Changes
1. **Weekly Report Modal** (`frontend/src/components/WeeklyReportModal.js`)
- Phase-based UI: idle → uploading → processing → success
- File upload with .xlsx validation
- Display existing reports with current report indicator (★)
- Download buttons for both original and processed files
2. **App.js Integration**
- Added "Weekly Report" button next to NVD Sync button
- State management for modal visibility
- Modal rendering
## How to Use
### Starting the Application
1. **Backend:**
```bash
cd /home/admin/cve-dashboard/backend
node server.js
```
2. **Frontend:**
```bash
cd /home/admin/cve-dashboard/frontend
npm start
```
### Using the Feature
1. **Access the Feature**
- Login as an editor or admin user
- Look for the "Weekly Report" button in the top header (next to "NVD Sync")
2. **Upload a Report**
- Click the "Weekly Report" button
- Click "Choose File" and select your .xlsx file
- Click "Upload & Process"
- Wait for processing to complete (usually 5-10 seconds)
3. **Download Processed Report**
- After upload succeeds, you'll see row counts (e.g., "45 → 67 rows")
- Click "Download Processed" to get the split version
- The current week's report is marked with a ★ star icon
4. **Access Previous Reports**
- All previous reports are listed below the upload section
- Click the download icons to get original or processed versions
- Reports are labeled as "This week's report", "Last week's report", or by date
### What the Processing Does
**Before Processing:**
| HOSTNAME | IP | CVE ID |
|----------|------------|---------------------------|
| server01 | 10.0.0.1 | CVE-2024-1234, CVE-2024-5678 |
**After Processing:**
| HOSTNAME | IP | CVE ID |
|----------|------------|---------------------------|
| server01 | 10.0.0.1 | CVE-2024-1234 |
| server01 | 10.0.0.1 | CVE-2024-5678 |
Each CVE now has its own row, making it easy to:
- Sort by CVE ID
- Filter for specific CVEs
- Research CVEs one by one per device
## File Locations
### New Files Created
```
backend/
scripts/
split_cve_report.py # Python script for CVE splitting
requirements.txt # Python dependencies
routes/
weeklyReports.js # API endpoints
helpers/
excelProcessor.js # Python integration
migrations/
add_weekly_reports_table.js # Database migration
uploads/
weekly_reports/ # Uploaded and processed files
frontend/
src/
components/
WeeklyReportModal.js # Upload modal UI
```
### Modified Files
```
backend/
server.js # Added route mounting
frontend/
src/
App.js # Added button and modal
```
## Security & Permissions
- **Upload**: Requires editor or admin role
- **Download**: Any authenticated user
- **Delete**: Admin only
- **File Validation**: Only .xlsx files accepted, 10MB limit
- **Audit Logging**: All uploads, downloads, and deletions are logged
## Troubleshooting
### Backend Issues
**Python not found:**
```bash
# Install Python 3
sudo apt-get install python3
```
**Missing dependencies:**
```bash
# Install pandas and openpyxl
pip3 install pandas openpyxl
```
**Port already in use:**
```bash
# Find and kill process using port 3001
lsof -i :3001
kill -9 <PID>
```
### Frontend Issues
**Button not visible:**
- Make sure you're logged in as editor or admin
- Viewer role cannot upload reports
**Upload fails:**
- Check file is .xlsx format (not .xls or .csv)
- Ensure file has "Vulnerabilities" sheet with "CVE ID" column
- Check file size is under 10MB
**Processing timeout:**
- Large files (10,000+ rows) may timeout
- Try reducing file size or increase timeout in `excelProcessor.js`
## Testing Checklist
- [x] Backend starts without errors
- [x] Frontend compiles successfully
- [x] Database migration completed
- [x] Python dependencies installed
- [ ] Upload .xlsx file (manual test in browser)
- [ ] Verify processed file has split CVEs (manual test)
- [ ] Download original and processed files (manual test)
- [ ] Verify current report marked with star (manual test)
- [ ] Test as viewer - button should be hidden (manual test)
## Future Enhancements
Possible improvements:
- Progress bar during Python processing
- Email notifications when processing completes
- Scheduled automatic uploads
- Report comparison (diff between weeks)
- Export to other formats (CSV, JSON)
- Bulk delete old reports
- Report validation before upload
## Support
For issues or questions:
1. Check the troubleshooting section above
2. Review audit logs for error details
3. Check browser console for frontend errors
4. Review backend server logs for API errors

View File

@@ -251,14 +251,14 @@
"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\n• /api/weekly-reports - Weekly reports",
"text": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration",
"fontSize": 14,
"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\n• /api/weekly-reports - Weekly reports"
"originalText": "Backend API (Express.js)\nPort: 3001\n\nRoutes:\n• /api/auth - Authentication (login/logout)\n• /api/users - User management\n• /api/cves - CVE operations\n• /api/documents - Document upload/download\n• /api/audit-log - Audit logging\n• /api/nvd-lookup - NVD integration"
},
{
"id": "db-box",
@@ -820,14 +820,14 @@
"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• Weekly report uploads\n• Audit logging",
"text": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging",
"fontSize": 12,
"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• Weekly report uploads\n• Audit logging"
"originalText": "Key Features:\n• Quick CVE status check\n• Multi-vendor support\n• Document management\n• Compliance tracking\n• Search & filter\n• Audit logging"
}
],
"appState": {

View File

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

View File

@@ -1,93 +0,0 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* Process vulnerability report Excel file by splitting CVE IDs into separate rows
* @param {string} inputPath - Path to original Excel file
* @param {string} outputPath - Path for processed Excel file
* @returns {Promise<{original_rows: number, processed_rows: number, output_path: string}>}
*/
function processVulnerabilityReport(inputPath, outputPath) {
return new Promise((resolve, reject) => {
const scriptPath = path.join(__dirname, '..', 'scripts', 'split_cve_report.py');
// Verify script exists
if (!fs.existsSync(scriptPath)) {
return reject(new Error(`Python script not found: ${scriptPath}`));
}
// Verify input file exists
if (!fs.existsSync(inputPath)) {
return reject(new Error(`Input file not found: ${inputPath}`));
}
const python = spawn('python3', [scriptPath, inputPath, outputPath]);
let stdout = '';
let stderr = '';
let timedOut = false;
// 30 second timeout
const timeout = setTimeout(() => {
timedOut = true;
python.kill();
reject(new Error('Processing timed out. File may be too large or corrupted.'));
}, 30000);
python.stdout.on('data', (data) => {
stdout += data.toString();
});
python.stderr.on('data', (data) => {
stderr += data.toString();
});
python.on('close', (code) => {
clearTimeout(timeout);
if (timedOut) return;
if (code !== 0) {
// Parse Python error messages
if (stderr.includes('Sheet') && stderr.includes('not found')) {
return reject(new Error('Invalid Excel file. Expected "Vulnerabilities" sheet with "CVE ID" column.'));
}
if (stderr.includes('pandas') || stderr.includes('openpyxl')) {
return reject(new Error('Python dependencies missing. Run: pip3 install pandas openpyxl'));
}
return reject(new Error(`Python script failed: ${stderr || 'Unknown error'}`));
}
// Parse output for row counts
const originalMatch = stdout.match(/Original rows:\s*(\d+)/);
const newMatch = stdout.match(/New rows:\s*(\d+)/);
if (!originalMatch || !newMatch) {
return reject(new Error('Failed to parse row counts from Python output'));
}
// Verify output file was created
if (!fs.existsSync(outputPath)) {
return reject(new Error('Processed file was not created'));
}
resolve({
original_rows: parseInt(originalMatch[1]),
processed_rows: parseInt(newMatch[1]),
output_path: outputPath
});
});
python.on('error', (err) => {
clearTimeout(timeout);
if (err.code === 'ENOENT') {
reject(new Error('Python 3 is required but not found. Please install Python.'));
} else {
reject(err);
}
});
});
}
module.exports = { processVulnerabilityReport };

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

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

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

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

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

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

@@ -1,59 +0,0 @@
// Migration: Add weekly_reports table for vulnerability report uploads
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, '..', 'cve_database.db');
const db = new sqlite3.Database(dbPath);
console.log('Running migration: add_weekly_reports_table');
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS weekly_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_date DATE NOT NULL,
week_label VARCHAR(50),
original_filename VARCHAR(255),
processed_filename VARCHAR(255),
original_file_path VARCHAR(500),
processed_file_path VARCHAR(500),
row_count_original INTEGER,
row_count_processed INTEGER,
uploaded_by INTEGER,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_current BOOLEAN DEFAULT 0,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
)
`, (err) => {
if (err) {
console.error('Error creating weekly_reports table:', err);
process.exit(1);
}
console.log('✓ Created weekly_reports table');
});
db.run(`
CREATE INDEX IF NOT EXISTS idx_weekly_reports_date
ON weekly_reports(upload_date DESC)
`, (err) => {
if (err) {
console.error('Error creating date index:', err);
process.exit(1);
}
console.log('✓ Created index on upload_date');
});
db.run(`
CREATE INDEX IF NOT EXISTS idx_weekly_reports_current
ON weekly_reports(is_current)
`, (err) => {
if (err) {
console.error('Error creating current index:', err);
process.exit(1);
}
console.log('✓ Created index on is_current');
console.log('\nMigration completed successfully!');
db.close();
});
});

View File

@@ -217,6 +217,25 @@ function createArcherTicketsRouter(db) {
});
});
// 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;
}

View File

@@ -0,0 +1,712 @@
// Compliance Routes — AEO metric tracking
// Handles xlsx upload/parse, non-compliant item history, and notes.
//
// Endpoints:
// POST /preview — parse xlsx, compute diff vs DB, return summary (no DB write)
// POST /commit — commit a previewed upload to DB
// GET /uploads — list all uploads
// GET /summary — metric health cards for a team (from latest upload)
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
// POST /notes — add a note to a (hostname, metric_id) pair
// GET /notes/:hostname/:metricId — notes for a specific device+metric
const express = require('express');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
const PYTHON_BIN = process.env.PYTHON_BIN || 'python3';
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
const ALLOWED_TEAMS = new Set(['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']);
// ---------------------------------------------------------------------------
// DB helpers
// ---------------------------------------------------------------------------
function dbRun(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
function dbGet(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row || null); });
});
}
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); });
});
}
// ---------------------------------------------------------------------------
// Run Python parser, return parsed object
// ---------------------------------------------------------------------------
function parseXlsx(filePath) {
return new Promise((resolve, reject) => {
const py = spawn(PYTHON_BIN, [PARSER_SCRIPT, filePath]);
let out = '';
let err = '';
py.stdout.on('data', d => { out += d; });
py.stderr.on('data', d => { err += d; });
py.on('close', code => {
if (code !== 0) return reject(new Error(err || `Parser exited with code ${code}`));
try { resolve(JSON.parse(out)); }
catch (e) { reject(new Error('Parser returned invalid JSON')); }
});
py.on('error', reject);
});
}
// ---------------------------------------------------------------------------
// Validate that a temp file path is safely within uploads/temp/
// ---------------------------------------------------------------------------
function isSafeTempPath(filePath) {
const resolved = path.resolve(filePath);
return resolved.startsWith(TEMP_DIR + path.sep) && path.extname(resolved) === '.json';
}
// ---------------------------------------------------------------------------
// Compute diff: new / recurring / resolved
// ---------------------------------------------------------------------------
async function computeDiff(db, incomingItems) {
const activeRows = await dbAll(db,
`SELECT hostname, metric_id FROM compliance_items WHERE status = 'active'`
);
const activeKeys = new Set(activeRows.map(r => `${r.hostname}|||${r.metric_id}`));
const newKeys = new Set(incomingItems.map(i => `${i.hostname}|||${i.metric_id}`));
let newCount = 0, recurringCount = 0, resolvedCount = 0;
for (const k of newKeys) { if (activeKeys.has(k)) recurringCount++; else newCount++; }
for (const k of activeKeys) { if (!newKeys.has(k)) resolvedCount++; }
return { newCount, recurringCount, resolvedCount };
}
// ---------------------------------------------------------------------------
// Write a parsed upload to the DB (within a transaction)
// ---------------------------------------------------------------------------
async function persistUpload(db, { items, summary, reportDate, filename, userId }) {
// Pull current active items before we modify anything
const activeRows = await dbAll(db,
`SELECT id, hostname, metric_id, seen_count, first_seen_upload_id FROM compliance_items WHERE status = 'active'`
);
const activeMap = {};
activeRows.forEach(r => { activeMap[`${r.hostname}|||${r.metric_id}`] = r; });
const newKeys = new Set(items.map(i => `${i.hostname}|||${i.metric_id}`));
await dbRun(db, 'BEGIN TRANSACTION');
try {
// 1. Insert the upload record
const { lastID: uploadId } = await dbRun(db,
`INSERT INTO compliance_uploads (filename, report_date, uploaded_by, uploaded_at, summary_json)
VALUES (?, ?, ?, datetime('now'), ?)`,
[filename, reportDate || null, userId || null, JSON.stringify(summary)]
);
let newCount = 0, recurringCount = 0, resolvedCount = 0;
// 2. Upsert each incoming non-compliant item
for (const item of items) {
const key = `${item.hostname}|||${item.metric_id}`;
const existing = activeMap[key];
const extraStr = JSON.stringify(item.extra_json || {});
if (existing) {
// Recurring — bump seen_count, refresh snapshot fields
await dbRun(db,
`UPDATE compliance_items
SET upload_id = ?, seen_count = ?, ip_address = ?, device_type = ?, extra_json = ?
WHERE id = ?`,
[uploadId, existing.seen_count + 1, item.ip_address, item.device_type, extraStr, existing.id]
);
recurringCount++;
} else {
// New item (or previously resolved and re-appearing)
await dbRun(db,
`INSERT INTO compliance_items
(upload_id, hostname, ip_address, device_type, team, metric_id, metric_desc,
category, extra_json, status, first_seen_upload_id, seen_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1)`,
[uploadId, item.hostname, item.ip_address, item.device_type, item.team,
item.metric_id, item.metric_desc, item.category, extraStr, uploadId]
);
newCount++;
}
}
// 3. Mark items not present in this upload as resolved
for (const [key, row] of Object.entries(activeMap)) {
if (!newKeys.has(key)) {
await dbRun(db,
`UPDATE compliance_items
SET status = 'resolved', resolved_upload_id = ?
WHERE id = ?`,
[uploadId, row.id]
);
resolvedCount++;
}
}
// 4. Update upload with final counts
await dbRun(db,
`UPDATE compliance_uploads
SET new_count = ?, resolved_count = ?, recurring_count = ?
WHERE id = ?`,
[newCount, resolvedCount, recurringCount, uploadId]
);
await dbRun(db, 'COMMIT');
return { uploadId, newCount, recurringCount, resolvedCount };
} catch (err) {
await dbRun(db, 'ROLLBACK').catch(() => {});
throw err;
}
}
// ---------------------------------------------------------------------------
// Group flat compliance_items rows into per-device objects
// ---------------------------------------------------------------------------
function groupByHostname(rows, noteHostnames) {
const deviceMap = {};
for (const row of rows) {
if (!deviceMap[row.hostname]) {
deviceMap[row.hostname] = {
hostname: row.hostname,
ip_address: row.ip_address || '',
device_type: row.device_type || '',
team: row.team || '',
status: row.status,
failing_metrics: [],
seen_count: row.seen_count || 1,
first_seen: row.first_seen || null,
last_seen: row.last_seen || null,
resolved_on: row.resolved_on || null,
has_notes: noteHostnames.has(row.hostname),
};
}
const dev = deviceMap[row.hostname];
dev.failing_metrics.push({
metric_id: row.metric_id,
metric_desc: row.metric_desc || '',
category: row.category || '',
});
// Use the highest seen_count and earliest first_seen across all metrics
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen))
dev.first_seen = row.first_seen;
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen))
dev.last_seen = row.last_seen;
}
return Object.values(deviceMap);
}
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createComplianceRouter(db, upload, requireAuth, requireRole) {
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', requireRole('editor', 'admin'), (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,
}));
// 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', requireRole('editor', 'admin'), async (req, res) => {
const { tempFile, filename, report_date } = req.body;
if (!tempFile || typeof tempFile !== 'string') {
return res.status(400).json({ error: 'tempFile is required' });
}
if (!isSafeTempPath(tempFile)) {
return res.status(400).json({ error: 'Invalid tempFile path' });
}
if (!fs.existsSync(tempFile)) {
return res.status(400).json({ error: 'Preview session expired — please upload again' });
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(tempFile, 'utf8'));
} catch {
return res.status(400).json({ error: 'Could not read preview data — please upload again' });
}
try {
const result = await persistUpload(db, {
items: parsed.items,
summary: parsed.summary,
reportDate: report_date || parsed.report_date,
filename: filename || parsed.filename,
userId: req.user?.id || null,
});
fs.unlink(tempFile, () => {});
const upload = await dbGet(db,
`SELECT id, filename, report_date, uploaded_at,
new_count, resolved_count, recurring_count
FROM compliance_uploads WHERE id = ?`,
[result.uploadId]
);
res.json({ upload });
} catch (err) {
console.error('[Compliance] Commit error:', err.message);
res.status(500).json({ error: 'Failed to commit upload: ' + err.message });
}
});
// -----------------------------------------------------------------------
// GET /uploads
// List all uploads, most recent first.
// -----------------------------------------------------------------------
router.get('/uploads', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT id, filename, report_date, uploaded_at,
new_count, resolved_count, recurring_count
FROM compliance_uploads
ORDER BY id DESC`
);
res.json({ uploads: rows });
} catch (err) {
console.error('[Compliance] GET /uploads error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /summary?team=STEAM
// Return metric health rows for a team from the latest upload's summary_json.
// -----------------------------------------------------------------------
router.get('/summary', async (req, res) => {
const team = req.query.team;
if (team && !ALLOWED_TEAMS.has(team)) {
return res.status(400).json({ error: 'Invalid team' });
}
try {
const latestUpload = await dbGet(db,
`SELECT id, summary_json, report_date, uploaded_at
FROM compliance_uploads ORDER BY id DESC LIMIT 1`
);
if (!latestUpload || !latestUpload.summary_json) {
return res.json({ entries: [], overall_scores: {}, upload: null });
}
let summary;
try { summary = JSON.parse(latestUpload.summary_json); }
catch { return res.json({ entries: [], overall_scores: {}, upload: null }); }
let entries = summary.entries || [];
if (team) {
entries = entries.filter(e => e.team === team);
}
res.json({
entries,
overall_scores: summary.overall_scores || {},
upload: {
id: latestUpload.id,
report_date: latestUpload.report_date,
uploaded_at: latestUpload.uploaded_at,
},
});
} catch (err) {
console.error('[Compliance] GET /summary error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /items?team=STEAM&status=active
// Return non-compliant devices grouped by hostname.
// -----------------------------------------------------------------------
router.get('/items', async (req, res) => {
const { team, status = 'active' } = req.query;
if (!team) return res.status(400).json({ error: 'team is required' });
if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
try {
const rows = await dbAll(db,
`SELECT
ci.hostname, ci.ip_address, ci.device_type, ci.team,
ci.metric_id, ci.metric_desc, ci.category,
ci.status, ci.seen_count,
fu.report_date AS first_seen,
lu.report_date AS last_seen,
ru.report_date AS resolved_on
FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.team = ? AND ci.status = ?
ORDER BY ci.hostname, ci.metric_id`,
[team, status]
);
// Fetch hostnames that have any notes (for the has_notes indicator)
const noteRows = await dbAll(db,
`SELECT DISTINCT hostname FROM compliance_notes`
);
const noteHostnames = new Set(noteRows.map(r => r.hostname));
const devices = groupByHostname(rows, noteHostnames);
res.json({ devices, team, status });
} catch (err) {
console.error('[Compliance] GET /items error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /items/:hostname
// Detail panel: all metric rows for this hostname + notes + upload history.
// -----------------------------------------------------------------------
router.get('/items/:hostname', async (req, res) => {
const hostname = req.params.hostname;
if (!hostname || hostname.length > 300) {
return res.status(400).json({ error: 'Invalid hostname' });
}
try {
// All metric rows for this hostname
const metricRows = await dbAll(db,
`SELECT
ci.metric_id, ci.metric_desc, ci.category, ci.status,
ci.ip_address, ci.device_type, ci.team,
ci.seen_count, ci.extra_json,
fu.report_date AS first_seen,
fu.uploaded_at AS first_seen_at,
lu.report_date AS last_seen,
lu.uploaded_at AS last_seen_at,
ru.report_date AS resolved_on
FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.hostname = ?
ORDER BY ci.status DESC, ci.metric_id`,
[hostname]
);
if (metricRows.length === 0) {
return res.status(404).json({ error: 'Device not found' });
}
// Parse extra_json on each row
const metrics = metricRows.map(r => ({
...r,
extra: (() => { try { return JSON.parse(r.extra_json || '{}'); } catch { return {}; } })(),
extra_json: undefined,
}));
// Notes (all metrics for this hostname, sorted newest first)
const notes = await dbAll(db,
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
WHERE cn.hostname = ?
ORDER BY cn.created_at DESC`,
[hostname]
);
// Derive device identity from the first active row, else any row
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
res.json({
hostname,
ip_address: identity.ip_address || '',
device_type: identity.device_type || '',
team: identity.team || '',
metrics,
notes,
});
} catch (err) {
console.error('[Compliance] GET /items/:hostname error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// POST /notes
// Add a note to a (hostname, metric_id) pair.
// Body: { hostname, metric_id, note }
// -----------------------------------------------------------------------
router.post('/notes', async (req, res) => {
const { hostname, metric_id, note } = req.body;
if (!hostname || typeof hostname !== 'string' || hostname.length > 300) {
return res.status(400).json({ error: 'Invalid hostname' });
}
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
return res.status(400).json({ error: 'Invalid metric_id' });
}
const noteText = String(note || '').trim().slice(0, 1000);
if (!noteText) {
return res.status(400).json({ error: 'Note cannot be empty' });
}
try {
const { lastID } = await dbRun(db,
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
[hostname, metric_id, noteText, req.user?.id || null]
);
const created = await dbGet(db,
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
WHERE cn.id = ?`,
[lastID]
);
res.status(201).json(created);
} catch (err) {
console.error('[Compliance] POST /notes error:', err.message);
res.status(500).json({ error: 'Failed to save note' });
}
});
// -----------------------------------------------------------------------
// GET /notes/:hostname/:metricId
// Return all notes for a (hostname, metric_id) pair.
// -----------------------------------------------------------------------
router.get('/notes/:hostname/:metricId', async (req, res) => {
const { hostname, metricId } = req.params;
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
if (!metricId || metricId.length > 50) return res.status(400).json({ error: 'Invalid metricId' });
try {
const notes = await dbAll(db,
`SELECT cn.id, cn.note, cn.created_at, u.username AS created_by
FROM compliance_notes cn
LEFT JOIN users u ON cn.created_by = u.id
WHERE cn.hostname = ? AND cn.metric_id = ?
ORDER BY cn.created_at DESC`,
[hostname, metricId]
);
res.json({ notes });
} catch (err) {
console.error('[Compliance] GET /notes error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /trends
// Per-upload active totals + per-team counts for time-series charts.
// Returns rows ordered ascending by report_date.
// -----------------------------------------------------------------------
router.get('/trends', async (req, res) => {
try {
const uploads = await dbAll(db,
`SELECT id, report_date,
COALESCE(new_count, 0) AS new_count,
COALESCE(recurring_count, 0) AS recurring_count,
COALESCE(resolved_count, 0) AS resolved_count,
COALESCE(new_count, 0) + COALESCE(recurring_count, 0) AS total_active
FROM compliance_uploads
ORDER BY report_date ASC`
);
if (uploads.length === 0) return res.json({ trends: [] });
// Per-team active counts — items whose upload_id matches the upload
// (recurring items have upload_id bumped each cycle, so this is accurate)
const teamRows = await dbAll(db,
`SELECT ci.upload_id, ci.team, COUNT(ci.id) AS count
FROM compliance_items ci
WHERE ci.team IS NOT NULL
GROUP BY ci.upload_id, ci.team`
);
const teamMap = {};
teamRows.forEach(r => {
if (!teamMap[r.upload_id]) teamMap[r.upload_id] = {};
teamMap[r.upload_id][r.team] = r.count;
});
const trends = uploads.map(u => ({
report_date: u.report_date,
new_count: u.new_count,
recurring_count: u.recurring_count,
resolved_count: u.resolved_count,
total_active: u.total_active,
STEAM: teamMap[u.id]?.STEAM || 0,
'ACCESS-ENG': teamMap[u.id]?.['ACCESS-ENG'] || 0,
'ACCESS-OPS': teamMap[u.id]?.['ACCESS-OPS'] || 0,
INTELDEV: teamMap[u.id]?.INTELDEV || 0,
}));
res.json({ trends });
} catch (err) {
console.error('[Compliance] GET /trends error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /mttr
// Mean time to resolution (calendar days) per team, for resolved items.
// -----------------------------------------------------------------------
router.get('/mttr', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT
ci.team,
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
COUNT(*) AS resolved_count
FROM compliance_items ci
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.resolved_upload_id IS NOT NULL
AND fu.report_date IS NOT NULL
AND ru.report_date IS NOT NULL
GROUP BY ci.team
ORDER BY avg_days DESC`
);
res.json({ mttr: rows });
} catch (err) {
console.error('[Compliance] GET /mttr error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /top-recurring
// Active findings grouped by team + metric_id, sorted by seen_count desc.
// Identifies chronic compliance gaps that keep reappearing.
// -----------------------------------------------------------------------
router.get('/top-recurring', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
FROM compliance_items
WHERE status = 'active'
GROUP BY team, metric_id
ORDER BY seen_count DESC, host_count DESC
LIMIT 20`
);
res.json({ items: rows });
} catch (err) {
console.error('[Compliance] GET /top-recurring error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
// -----------------------------------------------------------------------
// GET /category-trend
// Active item counts per category per upload, for stacked area chart.
// -----------------------------------------------------------------------
router.get('/category-trend', async (req, res) => {
try {
const rows = await dbAll(db,
`SELECT cu.report_date, COALESCE(ci.category, 'Unknown') AS category, COUNT(ci.id) AS count
FROM compliance_uploads cu
JOIN compliance_items ci ON ci.upload_id = cu.id
GROUP BY cu.id, category
ORDER BY cu.report_date ASC`
);
res.json({ categoryTrend: rows });
} catch (err) {
console.error('[Compliance] GET /category-trend error:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
return router;
}
module.exports = createComplianceRouter;

View File

@@ -0,0 +1,702 @@
// 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 https = require('https');
const { requireRole } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
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
}
];
// ---------------------------------------------------------------------------
// HTTP helper — mirrors the one in ivantiWorkflows.js
// ---------------------------------------------------------------------------
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: 20000
};
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();
});
}
// ---------------------------------------------------------------------------
// 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();
});
});
});
}
// ---------------------------------------------------------------------------
// 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: 1
};
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;
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}`);
} 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);
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`);
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);
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, (err) => { if (err) reject(err); else resolve(); });
});
}
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();
initTables(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', 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', requireRole('editor', 'admin'), (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', (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;

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

@@ -0,0 +1,274 @@
// 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 https = require('https');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// ---------------------------------------------------------------------------
// HTTP helper — uses Node's https module directly so we can toggle
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
// ---------------------------------------------------------------------------
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();
});
}
// ---------------------------------------------------------------------------
// 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', 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;

View File

@@ -1,261 +0,0 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const { requireAuth, requireRole } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const { processVulnerabilityReport } = require('../helpers/excelProcessor');
function createWeeklyReportsRouter(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 week label
function getWeekLabel(date) {
const now = new Date();
const uploadDate = new Date(date);
const daysDiff = Math.floor((now - uploadDate) / (1000 * 60 * 60 * 24));
if (daysDiff < 7) {
return "This week's report";
} else if (daysDiff < 14) {
return "Last week's report";
} else {
const month = uploadDate.getMonth() + 1;
const day = uploadDate.getDate();
const year = uploadDate.getFullYear();
return `Week of ${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year}`;
}
}
// POST /api/weekly-reports/upload - Upload and process vulnerability report
router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), upload.single('file'), async (req, res) => {
const uploadedFile = req.file;
if (!uploadedFile) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Validate file extension
const ext = path.extname(uploadedFile.originalname).toLowerCase();
if (ext !== '.xlsx') {
fs.unlinkSync(uploadedFile.path); // Clean up temp file
return res.status(400).json({ error: 'Only .xlsx files are allowed' });
}
const timestamp = Date.now();
const sanitizedName = sanitizePathSegment(uploadedFile.originalname);
const reportsDir = path.join(__dirname, '..', 'uploads', 'weekly_reports');
// Create directory if it doesn't exist
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
const originalFilename = `${timestamp}_original_${sanitizedName}`;
const processedFilename = `${timestamp}_processed_${sanitizedName}`;
const originalPath = path.join(reportsDir, originalFilename);
const processedPath = path.join(reportsDir, processedFilename);
try {
// Move uploaded file to permanent location
fs.renameSync(uploadedFile.path, originalPath);
// Process the file with Python script
const result = await processVulnerabilityReport(originalPath, processedPath);
const uploadDate = new Date().toISOString().split('T')[0];
// Update previous current reports to not current
db.run('UPDATE weekly_reports SET is_current = 0 WHERE is_current = 1', (err) => {
if (err) {
console.error('Error updating previous current reports:', err);
}
});
// Insert new report record
const insertSql = `
INSERT INTO weekly_reports (
upload_date, week_label, original_filename, processed_filename,
original_file_path, processed_file_path, row_count_original,
row_count_processed, uploaded_by, is_current
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`;
const weekLabel = getWeekLabel(uploadDate);
db.run(
insertSql,
[
uploadDate,
weekLabel,
sanitizedName,
processedFilename,
originalPath,
processedPath,
result.original_rows,
result.processed_rows,
req.user.id
],
function (err) {
if (err) {
console.error('Error inserting weekly report:', err);
return res.status(500).json({ error: 'Failed to save report metadata' });
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'UPLOAD_WEEKLY_REPORT',
'weekly_reports',
this.lastID,
JSON.stringify({ filename: sanitizedName, rows: result.processed_rows }),
req.ip
);
res.json({
success: true,
id: this.lastID,
original_rows: result.original_rows,
processed_rows: result.processed_rows,
week_label: weekLabel
});
}
);
} catch (error) {
// Clean up files on error
if (fs.existsSync(originalPath)) fs.unlinkSync(originalPath);
if (fs.existsSync(processedPath)) fs.unlinkSync(processedPath);
console.error('Error processing vulnerability report:', error);
res.status(500).json({ error: error.message || 'Failed to process report' });
}
});
// GET /api/weekly-reports - List all reports
router.get('/', requireAuth(db), (req, res) => {
const sql = `
SELECT id, upload_date, week_label, original_filename, processed_filename,
row_count_original, row_count_processed, is_current, uploaded_at
FROM weekly_reports
ORDER BY upload_date DESC, uploaded_at DESC
`;
db.all(sql, [], (err, rows) => {
if (err) {
console.error('Error fetching weekly reports:', err);
return res.status(500).json({ error: 'Failed to fetch reports' });
}
res.json(rows);
});
});
// GET /api/weekly-reports/:id/download/:type - Download report file
router.get('/:id/download/:type', requireAuth(db), (req, res) => {
const { id, type } = req.params;
if (type !== 'original' && type !== 'processed') {
return res.status(400).json({ error: 'Invalid download type. Use "original" or "processed"' });
}
const sql = `SELECT original_file_path, processed_file_path, original_filename FROM weekly_reports WHERE id = ?`;
db.get(sql, [id], (err, row) => {
if (err) {
console.error('Error fetching report:', err);
return res.status(500).json({ error: 'Failed to fetch report' });
}
if (!row) {
return res.status(404).json({ error: 'Report not found' });
}
const filePath = type === 'original' ? row.original_file_path : row.processed_file_path;
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'File not found on disk' });
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'DOWNLOAD_WEEKLY_REPORT',
'weekly_reports',
id,
JSON.stringify({ type }),
req.ip
);
const downloadName = type === 'original' ? row.original_filename : row.original_filename.replace('.xlsx', '_processed.xlsx');
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
res.sendFile(filePath);
});
});
// DELETE /api/weekly-reports/:id - Delete report (admin only)
router.delete('/:id', requireAuth(db), requireRole(db, 'admin'), (req, res) => {
const { id } = req.params;
const sql = 'SELECT original_file_path, processed_file_path FROM weekly_reports WHERE id = ?';
db.get(sql, [id], (err, row) => {
if (err) {
console.error('Error fetching report for deletion:', err);
return res.status(500).json({ error: 'Failed to fetch report' });
}
if (!row) {
return res.status(404).json({ error: 'Report not found' });
}
// Delete database record
db.run('DELETE FROM weekly_reports WHERE id = ?', [id], (err) => {
if (err) {
console.error('Error deleting report:', err);
return res.status(500).json({ error: 'Failed to delete report' });
}
// Delete files
if (fs.existsSync(row.original_file_path)) {
fs.unlinkSync(row.original_file_path);
}
if (fs.existsSync(row.processed_file_path)) {
fs.unlinkSync(row.processed_file_path);
}
// Log audit entry
logAudit(
db,
req.user.id,
req.user.username,
'DELETE_WEEKLY_REPORT',
'weekly_reports',
id,
null,
req.ip
);
res.json({ success: true });
});
});
});
return router;
}
module.exports = createWeeklyReportsRouter;

View 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())

View 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()

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""
CVE Report Splitter
Splits multiple CVE IDs in a single row into separate rows for easier filtering and analysis.
"""
import pandas as pd
import sys
from pathlib import Path
def split_cve_report(input_file, output_file=None, sheet_name='Vulnerabilities', cve_column='CVE ID'):
"""
Split CVE IDs into separate rows.
Args:
input_file: Path to input Excel file
output_file: Path to output file (default: adds '_Split' to input filename)
sheet_name: Name of sheet with vulnerability data (default: 'Vulnerabilities')
cve_column: Name of column containing CVE IDs (default: 'CVE ID')
"""
input_path = Path(input_file)
if not input_path.exists():
print(f"Error: File not found: {input_file}")
sys.exit(1)
if output_file is None:
output_file = input_path.parent / f"{input_path.stem}_Split{input_path.suffix}"
print(f"Reading: {input_file}")
try:
df = pd.read_excel(input_file, sheet_name=sheet_name)
except ValueError as e:
print(f"Error: Sheet '{sheet_name}' not found in workbook")
print(f"Available sheets: {pd.ExcelFile(input_file).sheet_names}")
sys.exit(1)
if cve_column not in df.columns:
print(f"Error: Column '{cve_column}' not found")
print(f"Available columns: {list(df.columns)}")
sys.exit(1)
original_rows = len(df)
print(f"Original rows: {original_rows}")
# Split CVE IDs by comma
df[cve_column] = df[cve_column].astype(str).str.split(',')
# Explode to create separate rows
df_exploded = df.explode(cve_column)
# Clean up CVE IDs
df_exploded[cve_column] = df_exploded[cve_column].str.strip()
df_exploded = df_exploded[df_exploded[cve_column].notna()]
df_exploded = df_exploded[df_exploded[cve_column] != 'nan']
df_exploded = df_exploded[df_exploded[cve_column] != '']
# Reset index
df_exploded = df_exploded.reset_index(drop=True)
new_rows = len(df_exploded)
print(f"New rows: {new_rows}")
print(f"Added {new_rows - original_rows} rows from splitting CVEs")
# Save output
df_exploded.to_excel(output_file, index=False, sheet_name=sheet_name)
print(f"\n✓ Success! Saved to: {output_file}")
return output_file
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 split_cve_report.py <input_file.xlsx> [output_file.xlsx]")
print("\nExample:")
print(" python3 split_cve_report.py 'Vulnerability Workbook.xlsx'")
print(" python3 split_cve_report.py 'input.xlsx' 'output.xlsx'")
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2] if len(sys.argv) > 2 else None
split_cve_report(input_file, output_file)

View File

@@ -18,9 +18,12 @@ const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');
const createNvdLookupRouter = require('./routes/nvdLookup');
const createWeeklyReportsRouter = require('./routes/weeklyReports');
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 createComplianceRouter = require('./routes/compliance');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -122,8 +125,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)
@@ -174,15 +204,24 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
});
// Weekly reports routes (editor/admin for upload, all authenticated for download)
app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
// 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));
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireRole));
// ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters (authenticated users)
@@ -298,6 +337,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

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

73
docs/python-venv-setup.md Normal file
View File

@@ -0,0 +1,73 @@
# Python Dependencies — Compliance xlsx Parsing
`parse_compliance_xlsx.py` requires `pandas` and `openpyxl`. This doc
explains how each server has (or should have) these installed.
---
## Dev server — how it works
Pandas and openpyxl are installed as **system apt packages**, not via pip
or a venv. This is why there is no venv on dev and no `--break-system-packages`
gymnastics. They were installed at some point via:
```bash
apt install python3-pandas python3-openpyxl
```
You can verify with:
```bash
python3 -c "import pandas; print(pandas.__file__)"
# /usr/lib/python3/dist-packages/pandas/__init__.py ← apt-managed
```
---
## Production server — how to fix it
Production was missing pandas entirely. The fix mirrors what dev has:
```bash
apt-get update --fix-missing
apt install -y python3-pandas python3-openpyxl
```
No venv, no pip, no `PYTHON_BIN` env var needed. After installing, restart
the backend and the compliance xlsx upload will work.
---
## If apt packages are unavailable (fallback)
If you're on a system where apt doesn't have pandas (unlikely on Ubuntu
22.04/24.04), or you want isolation, use a venv:
```bash
apt install -y python3-venv python3-full
python3 -m venv /home/cve-dashboard/venv
/home/cve-dashboard/venv/bin/pip install -r /home/cve-dashboard/backend/scripts/requirements.txt
```
Then set `PYTHON_BIN` in the Node backend's environment:
```bash
export PYTHON_BIN=/home/cve-dashboard/venv/bin/python3
```
The backend reads `process.env.PYTHON_BIN` and falls back to `python3` if
not set, so this only needs to be done if you're using a venv.
---
## Why pip3 may fail on modern Ubuntu/Debian
PEP 668 (enforced in Ubuntu 23.04+) blocks `pip3 install` system-wide to
prevent breaking apt-managed packages. The error looks like:
```
error: externally-managed-environment
```
Using `apt install python3-pandas` is the correct solution — pip is not
needed when the distro packages the library directly.

View File

@@ -0,0 +1,183 @@
# Security Posture Workflow — Diagrams
Mermaid diagrams for the Host Finding Review & Remediation process.
Renders natively in GitHub, GitLab, and most modern documentation tools.
---
## Diagram 1 — Host Finding Review Workflow (Steps 15)
```mermaid
flowchart TD
START([Open Vulnerability Triage Page]) --> SYNC
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
SYNC --> DUE{Overdue<br/>findings?}
DUE -->|Yes — start here| HOST
DUE -->|No — start with amber| HOST
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
HOST --> CORRECT{Hostname<br/>correct?}
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
EDIT --> OWN
CORRECT -->|Yes| OWN
OWN["③ Identify Asset Ownership<br/>Check BU column"]
OWN --> BU{Our BU?}
BU -->|"NTS-AEO-STEAM<br/>or ACCESS-ENG"| CVE
BU -->|"Other BU<br/>or blank"| CARD["Add to CARD Queue<br/>☑ checkbox → CARD → Add to Queue"]
CARD --> CARD2([Process in dedicated CARD session])
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover +N badge for more"]
CVE --> DBCHECK{CVE in<br/>database?}
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
ADDCVE --> RESEARCH
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
RESEARCH --> ACTION
ACTION["⑤ Determine Required Action"]
ACTION --> PATH{What does<br/>research show?}
PATH -->|"Patch available<br/>FW / SW update"| PA
PATH -->|"Fix is config<br/>change only"| PB
PATH -->|"Not applicable<br/>to platform / version"| PC
PATH -->|"Cannot patch<br/>vendor / EOL / business"| PD
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
PB["PATH B — Remediation<br/>Configuration Change"]
PB --> PB1["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
PC["PATH C — False Positive"]
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
PC3 --> PC4["☑ checkbox → Vendor → FP<br/>Add to Queue"]
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
PD["PATH D — Risk Acceptance"]
PD --> PD1["Take device screenshot<br/>Collect version info"]
PD1 --> PD2{Vendor comms<br/>needed?}
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
PD3 --> PD4
PD2 -->|No| PD4["☑ checkbox → Vendor → Archer<br/>Add to Queue"]
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
%% Styling
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
class DUE,CORRECT,BU,DBCHECK,PATH decision
class PA,PA1,PA2 pathA
class PB,PB1,PB2,PB3 pathB
class PC,PC1,PC2,PC3,PC4,PC5 pathC
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
class CARD,CARD2 card
class EDIT done
```
---
## Diagram 2 — FP Workflow Badge Status Decision Tree
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
```mermaid
flowchart LR
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
C --> C1(["Follow the<br/>Step 15 triage workflow ↑"])
B -->|"🔵 Blue<br/>Requested"| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
D --> D1{"SLA window<br/>approaching?"}
D1 -->|No| D2(["Monitor — no action yet ✓"])
D1 -->|Yes| D3(["Follow up with<br/>the approver"])
B -->|"🟡 Amber<br/>Reworked"| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
E1 --> E2(["Update justification<br/>and resubmit"])
B -->|"🟡 Amber<br/>Actionable"| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
B -->|"🔴 Red<br/>Expired"| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
G --> G1(["Submit a new FP request<br/>in Ivanti<br/>Reference previous ticket"])
B -->|"🔴 Red<br/>Rejected"| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP<br/>without new evidence"])
%% Styling
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
class A,B trigger
class D,D1,D2,D3 blue
class E,E1,E2,F,F1 amber
class G,G1,H,H1 red
class C,C1 none
class D2,D3,E2,F1,G1,H1 done
```
---
## Diagram 3 — Action Decision Matrix (Quick Reference)
Condensed view of the five research outcomes and their required actions.
```mermaid
flowchart LR
START(["Research complete<br/>Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
Q --> R1["Firmware or<br/>Software update available"]
R1 --> A1(["No ticket needed<br/>Schedule upgrade<br/>Add note to finding"])
Q --> R2["Fix is a<br/>configuration change"]
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
Q --> R3["Not applicable<br/>to this platform / version"]
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
Q --> R4["Patch not yet<br/>available from vendor"]
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
Q --> R5["Device is EOL / EOS<br/>or business constraint"]
R5 --> A5(["Archer ticket with<br/>mitigation steps +<br/>remediation plan"])
Q --> R6["Asset not owned<br/>by our BU"]
R6 --> A6(["CARD queue<br/>CARD disposition process"])
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
class START,Q q
class R1,A1 green
class R2,A2,R4,A4,R5,A5 amber
class R3,A3 red
class R6,A6 teal
```
---
*Source document: `docs/security-posture-workflow.md`*

View File

@@ -0,0 +1,175 @@
# Lucidchart Import — Raw Mermaid Code
Lucidchart expects raw Mermaid syntax only — no markdown headings or prose.
Paste each diagram separately: Insert → Diagram as Code → Mermaid → paste → Generate.
---
## DIAGRAM 1 — Host Finding Review Workflow
Paste everything between the triple-backtick fences below:
```
flowchart TD
START([Open Reporting Page]) --> SYNC
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
SYNC --> DUE{Overdue<br/>findings?}
DUE -->|Yes — start here| HOST
DUE -->|No — start with amber| HOST
HOST["② Identify the Host<br/>Verify IP in IPControl / Infoblox"]
HOST --> CORRECT{Hostname<br/>correct?}
CORRECT -->|No| EDIT["Inline-edit Host / DNS cell<br/>Amber dot marks the override"]
EDIT --> OWN
CORRECT -->|Yes| OWN
OWN["③ Identify Asset Ownership<br/>Check BU column"]
OWN --> BU{Our BU?}
BU -->|"NTS-AEO-STEAM or ACCESS-ENG"| CVE
BU -->|"Other BU or blank"| CARD["Add to CARD Queue<br/>checkbox → CARD → Add to Queue"]
CARD --> CARD2([Process in dedicated CARD session])
CVE["④ Review CVEs in the Finding<br/>Up to 2 shown · hover badge for more"]
CVE --> DBCHECK{CVE in<br/>database?}
DBCHECK -->|No| ADDCVE["Create CVE entry on Home page<br/>NVD auto-fill populates details"]
ADDCVE --> RESEARCH
DBCHECK -->|Yes — review existing notes/docs| RESEARCH
RESEARCH["Research CVE<br/>Vendor advisory · Cisco Bug Search<br/>Juniper PSN · Support ticket"]
RESEARCH --> ACTION
ACTION["⑤ Determine Required Action"]
ACTION --> PATH{What does<br/>research show?}
PATH -->|"Patch available — FW / SW update"| PA
PATH -->|"Fix is config change only"| PB
PATH -->|"Not applicable to platform / version"| PC
PATH -->|"Cannot patch — vendor / EOL / business"| PD
PA["PATH A — Remediation<br/>Firmware or Software Upgrade"]
PA --> PA1["Plan & schedule upgrade<br/>Add note to finding row"]
PA1 --> PA2(["Finding drops off after<br/>next Ivanti scan ✓"])
PB["PATH B — Remediation<br/>Configuration Change"]
PB --> PB1["checkbox → Vendor → Archer<br/>Add to Queue"]
PB1 --> PB2["Open Archer EXC ticket<br/>in dedicated session"]
PB2 --> PB3(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
PC["PATH C — False Positive"]
PC --> PC1["Take device screenshot<br/>Hostname · IP · SW version"]
PC1 --> PC2["Obtain vendor documentation<br/>advisory / email / support ticket"]
PC2 --> PC3["Upload evidence to CVE database<br/>Home page → CVE row → Upload"]
PC3 --> PC4["checkbox → Vendor → FP<br/>Add to Queue"]
PC4 --> PC5(["Submit FP workflow in Ivanti<br/>in dedicated session ✓"])
PD["PATH D — Risk Acceptance"]
PD --> PD1["Take device screenshot<br/>Collect version info"]
PD1 --> PD2{Vendor comms<br/>needed?}
PD2 -->|Yes| PD3["Open vendor support ticket<br/>Request patch timeline / mitigations"]
PD3 --> PD4
PD2 -->|No| PD4["checkbox → Vendor → Archer<br/>Add to Queue"]
PD4 --> PD5["Open Archer EXC ticket<br/>in dedicated session"]
PD5 --> PD6(["Enter EXC-XXXXX<br/>in finding Notes cell ✓"])
classDef step fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef decision fill:#1a2e1a,stroke:#10b981,stroke-width:2px,color:#e2e8f0
classDef pathA fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef pathB fill:#2d1f14,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef pathC fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef pathD fill:#1a1430,stroke:#8b5cf6,stroke-width:1.5px,color:#e2e8f0
classDef card fill:#1a2e1a,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef done fill:#0f172a,stroke:#475569,stroke-width:1.5px,color:#64748b
class SYNC,HOST,OWN,CVE,RESEARCH,ACTION step
class DUE,CORRECT,BU,DBCHECK,PATH decision
class PA,PA1,PA2 pathA
class PB,PB1,PB2,PB3 pathB
class PC,PC1,PC2,PC3,PC4,PC5 pathC
class PD,PD1,PD2,PD3,PD4,PD5,PD6 pathD
class CARD,CARD2 card
class EDIT done
```
---
## DIAGRAM 2 — FP Workflow Badge Status Decision Tree
```
flowchart LR
A([Finding in Reporting Page]) --> B{"Check Workflow column"}
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
C --> C1(["Follow the Step 1-5 triage workflow"])
B -->|Blue - Requested| D["IN FLIGHT<br/>FP submitted · awaiting approval"]
D --> D1{"SLA window<br/>approaching?"}
D1 -->|No| D2(["Monitor — no action yet"])
D1 -->|Yes| D3(["Follow up with the approver"])
B -->|Amber - Reworked| E["NEEDS REVISION<br/>Reviewer returned the ticket"]
E --> E1["Open ticket in Ivanti<br/>Review feedback"]
E1 --> E2(["Update justification and resubmit"])
B -->|Amber - Actionable| F["NEEDS RESPONSE<br/>Ticket flagged for team action"]
F --> F1(["Open ticket in Ivanti<br/>Respond to the request"])
B -->|Red - Expired| G["EXCEPTION LAPSED<br/>Finding has re-opened"]
G --> G1(["Submit a new FP request in Ivanti<br/>Reference previous ticket"])
B -->|Red - Rejected| H["CONFIRMED VULNERABILITY<br/>Security team denied the FP"]
H --> H1(["Remediate the vulnerability<br/>Do not resubmit FP without new evidence"])
classDef trigger fill:#0f172a,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef blue fill:#1e3a5f,stroke:#0ea5e9,stroke-width:1.5px,color:#e2e8f0
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef none fill:#1a1a2e,stroke:#475569,stroke-width:1.5px,color:#94a3b8
classDef done fill:#0f172a,stroke:#334155,stroke-width:1px,color:#64748b
class A,B trigger
class D,D1,D2,D3 blue
class E,E1,E2,F,F1 amber
class G,G1,H,H1 red
class C,C1 none
class D2,D3,E2,F1,G1,H1 done
```
---
## DIAGRAM 3 — Action Decision Matrix
```
flowchart LR
START(["Research complete — Step 4 done"]) --> Q{"What is the<br/>remediation path?"}
Q --> R1["Firmware or software update available"]
R1 --> A1(["No ticket needed<br/>Schedule upgrade · Add note to finding"])
Q --> R2["Fix is a configuration change only"]
R2 --> A2(["Archer EXC ticket required<br/>Stage as Archer in Queue"])
Q --> R3["Not applicable to this platform / version"]
R3 --> A3(["FP workflow in Ivanti<br/>Evidence in CVE database"])
Q --> R4["Patch not yet available from vendor"]
R4 --> A4(["Archer EXC ticket<br/>Renew when patch ships"])
Q --> R5["Device is EOL / EOS or business constraint"]
R5 --> A5(["Archer ticket with mitigation steps<br/>and remediation plan"])
Q --> R6["Asset not owned by our BU"]
R6 --> A6(["CARD queue — CARD disposition process"])
classDef q fill:#1e3a5f,stroke:#0ea5e9,stroke-width:2px,color:#e2e8f0
classDef green fill:#14391f,stroke:#10b981,stroke-width:1.5px,color:#e2e8f0
classDef amber fill:#2d2014,stroke:#f59e0b,stroke-width:1.5px,color:#e2e8f0
classDef red fill:#2d1414,stroke:#ef4444,stroke-width:1.5px,color:#e2e8f0
classDef teal fill:#0f2d2d,stroke:#14b8a6,stroke-width:1.5px,color:#e2e8f0
class START,Q q
class R1,A1 green
class R2,A2,R4,A4,R5,A5 amber
class R3,A3 red
class R6,A6 teal
```

View File

@@ -0,0 +1,402 @@
# Security Posture Workflow — Host Finding Review & Remediation
**Document Type:** Process Guide
**Applies To:** STEAM Security Dashboard — All Pages
**Audience:** NTS-AEO-STEAM / NTS-AEO-ACCESS-ENG team members
**Last Updated:** 2026-03-27
---
## Table of Contents
1. [Overview](#1-overview)
2. [Dashboard Orientation](#2-dashboard-orientation)
3. [Vulnerability Designations](#3-vulnerability-designations)
4. [The Host Finding Review Workflow](#4-the-host-finding-review-workflow)
- [Step 1 — Sync and Sort by Due Date](#step-1--sync-and-sort-by-due-date)
- [Step 2 — Identify the Host](#step-2--identify-the-host)
- [Step 3 — Identify Asset Ownership](#step-3--identify-asset-ownership)
- [Step 4 — Review the CVEs in the Finding](#step-4--review-the-cves-in-the-finding)
- [Step 5 — Determine and Execute the Required Action](#step-5--determine-and-execute-the-required-action)
5. [Using the Ivanti Queue](#5-using-the-ivanti-queue)
6. [Workflow Status Reference](#6-workflow-status-reference)
7. [Quick Reference Card](#7-quick-reference-card)
---
## 1. Overview
The STEAM Security Dashboard centralises vulnerability management for the NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG business units. It pulls host findings directly from Ivanti/RiskSense and gives the team a single place to triage, track, and action every open vulnerability.
**Scope:** This document covers severity findings in the **8.5 9.9 VRR range**. All findings in this range require some form of documented action. A finding that is not actioned before its Due Date results in the device being recorded as non-compliant.
> **SLA Rule:** By default, all vulnerabilities must have an action taken or in-flight within **60 days of detection**. The Due Date column on the Reporting page shows the exact deadline. Metrics and compliance reporting are based on vulnerabilities aged under 60 days.
---
## 2. Dashboard Orientation
### Pages
| Page | Purpose |
|------|---------|
| **Home (CVE Management)** | Track and research individual CVEs across vendors. Store supporting documentation. Log Archer EXC ticket numbers against CVE/vendor pairs. |
| **Reporting (Host Findings)** | The primary operational page. Live view of all open Ivanti findings with filtering, sorting, inline editing, the Ivanti Queue, and export. |
| **Knowledge Base** | Internal document library — policies, runbooks, vendor advisories. |
| **Exports** | Bulk export tools for reports and data extracts. |
### Reporting Page — At a Glance
When you open the Reporting page for the first time in a session, click **Sync** (top right) to pull the latest findings from Ivanti. The page shows:
- **Four metric charts** at the top — Open vs Closed, Action Coverage, FP Finding Status, FP Workflow Status
- **Findings table** below — every open finding for the configured BUs, one row per host finding
- **Ivanti Queue panel** (click the Queue button, top right) — your personal staging list for batch-processing FP and Archer workflows
The charts and table update together. Clicking a chart segment filters the table to that subset.
---
## 3. Vulnerability Designations
Every finding in the 8.59.9 range requires one of three documented actions. Understanding these upfront makes triage faster.
### 3.1 Remediation
The vulnerability is addressed by fixing the root cause.
| Remediation Method | Archer Ticket Required? | Notes |
|---|---|---|
| Firmware or software update | **No** | Upgrading removes the vulnerability entirely. The finding will fall off the report on the next scan. |
| Configuration change | **Yes** | A config change does not remove the vulnerability — if the config is ever rolled back, the vulnerability returns. An Archer Risk Acceptance ticket is required to document this. |
### 3.2 False Positive (FP)
A false positive occurs when the scanner detects a vulnerability that is **not actually present** or **does not apply** to the platform or software version in use.
**An FP workflow must be opened in Ivanti.** The workflow requires:
1. A **screenshot** taken directly from the device showing:
- Hostname
- IP address
- Software / firmware version
> **Important:** This must be a screenshot. CLI text output or copy-pasted command output is not accepted.
2. **Vendor documentation** confirming the vulnerability does not affect the platform — one of:
- Direct vendor communication (email, support ticket)
- Published security advisory stating the version or platform is not affected
- Proof that the vulnerability does not apply to the currently installed version
Supporting files (screenshots, emails, advisories) should be saved into the CVE Database (Home page → upload documents against the relevant CVE/vendor pair) for future reference and re-use if the FP expires and needs to be renewed.
### 3.3 Risk Acceptance / Archer Request
An Archer Risk Acceptance ticket (EXC-XXXXX) is required when a vulnerability **cannot be patched** for a documented business or technical reason. Common scenarios:
| Scenario | Required Action |
|---|---|
| Patch not yet available (waiting on vendor) | Open Archer ticket; close it when patch is deployed |
| Device is End-of-Sale (EOS) or End-of-Life (EOL) | Archer ticket required with mitigation steps and a remediation plan |
| Business constraint prevents patching | Archer ticket with justification and compensating controls |
| Configuration-change-only remediation | Archer ticket required (see Remediation above) |
For EOL/EOS devices the ticket must include:
- Current mitigation steps (network segmentation, compensating controls)
- A remediation plan — what will replace or retire the device and when
If vendor communication is needed (patch timeline, configuration guidance), open a vendor support ticket and use the vendor's response to fill out the Archer remediation plan field.
> Archer EXC numbers are tracked in the dashboard. Once entered on the Home page against the relevant CVE/vendor pair, the EXC badge appears on that CVE row. Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes.
---
## 4. The Host Finding Review Workflow
Work through the Reporting page top-to-bottom by Due Date. The goal of each session is to ensure every finding either has an action in-flight or gets one started.
---
### Step 1 — Sync and Sort by Due Date
1. Navigate to the **Reporting** page.
2. Click **Sync** (top right). Wait for the sync to complete — the timestamp updates when done.
3. Click the **Due Date** column header to sort ascending (soonest due date first).
- Red due dates = overdue
- Amber due dates = due within 30 days
- Start with red, then amber
> If you want to focus on findings with no action yet, click the **Pending** segment on the Action Coverage donut chart. The table will filter to only findings with no FP ticket and no EXC number in notes.
---
### Step 2 — Identify the Host
Each finding row includes a **Host** (hostname), **IP Address**, and **DNS** column.
1. Use the reported **IP address** to verify the hostname in:
- **IPControl** (read-only, historical IPAM data)
- **Infoblox** (current IPAM — preferred for current state)
2. If the hostname shown in the dashboard is incorrect (Ivanti sometimes reports stale data):
- Click the **Host** cell in the finding row — it is inline editable.
- Type the correct hostname and press **Enter** or click away to save.
- An amber dot (●) will appear on the cell to indicate an override is in place. The original Ivanti value is preserved and can be restored using the revert button (↻).
- The same applies to the **DNS** column.
> Overrides survive Ivanti re-syncs — your corrections are not overwritten when new data is pulled.
---
### Step 3 — Identify Asset Ownership
Check the **BU** column to determine ownership.
| BU Value | Ownership | Action |
|---|---|---|
| `NTS-AEO-STEAM` | Our team | Continue to Step 4 |
| `NTS-AEO-ACCESS-ENG` | Our team | Continue to Step 4 |
| Any other value, or blank | Not our asset | Add to CARD queue (see below) |
**If the asset is not owned by our BU:**
1. Check the checkbox at the left of the finding row.
2. A popover will appear. The **CARD** workflow type should already be selected.
- No vendor entry is required for CARD — the IP address is captured automatically for use when searching in CARD.
3. Click **Add to Queue**.
4. The finding is now staged in your Ivanti Queue under the **CARD** section.
CARD queue items are processed in a separate session — see the [Ivanti Queue](#5-using-the-ivanti-queue) section and the dedicated CARD process documentation.
---
### Step 4 — Review the CVEs in the Finding
Each finding has one or more CVEs listed in the **CVEs** column (up to 2 shown; hover the "+N" badge to see the rest).
For each CVE in the finding:
1. **Check if the CVE already exists in the database.**
- Navigate to the **Home** page.
- Search for the CVE ID in the search bar.
- If an entry exists for this CVE and vendor, review what's already documented — there may be existing notes, documents, or an Archer ticket already linked.
2. **If no entry exists, create one:**
- Click **Add CVE** on the Home page.
- Enter the CVE ID — the NVD auto-fill will populate the description, CVSS severity, and published date automatically.
- Select the correct vendor/platform.
- Save the entry.
3. **Research the CVE** to determine the required action:
- Check the vendor's security advisory portal (e.g., Juniper Security Advisories, Cisco Security Advisories / Bug Search Tool)
- Determine whether the CVE: (a) is a False Positive for this platform/version, (b) can be Remediated, or (c) requires a Risk Acceptance
---
### Step 5 — Determine and Execute the Required Action
Based on your research in Step 4, choose the path below.
---
#### Path A — Remediation (Firmware or Software Update)
> No Archer ticket required if the fix is a firmware or software upgrade.
1. Plan and schedule the upgrade with the relevant team.
2. No dashboard action is required beyond ensuring a note is added to the finding (click the **Notes** cell) confirming the upgrade is planned or complete.
3. After the device is upgraded, the finding will fall off the Reporting page on the next Ivanti scan if the vulnerability is no longer detected.
---
#### Path B — Remediation (Configuration Change)
> An Archer Risk Acceptance ticket **is required** when the fix is a configuration change.
1. Check the checkbox at the left of the finding row.
2. In the popover, enter the **Vendor / Platform** (e.g., Juniper, Cisco, ADTRAN).
3. Select **Archer** as the workflow type.
4. Click **Add to Queue**.
5. Process the Archer ticket in a dedicated session — see [Ivanti Queue](#5-using-the-ivanti-queue) and the Archer process documentation.
---
#### Path C — False Positive
1. **Collect the required evidence:**
- Log into the device and **take a screenshot** showing the hostname, IP address, and software/firmware version.
- Obtain vendor documentation confirming the CVE does not affect this platform or version (security advisory, vendor email, etc.).
2. **Save supporting files to the database:**
- Go to the Home page and find (or create) the CVE entry for this vendor.
- Upload the screenshot as type `screenshot` and the vendor communication as type `advisory` or `email`.
- This ensures the evidence is accessible when the FP expires and needs to be renewed.
3. **Stage the finding in the queue:**
- Check the checkbox at the left of the finding row on the Reporting page.
- Enter the **Vendor / Platform**.
- Select **FP** as the workflow type.
- Click **Add to Queue**.
4. **Open the False Positive workflow in Ivanti:**
- Process queued FP items in a dedicated session.
- See the dedicated FP workflow documentation for the full Ivanti submission steps.
---
#### Path D — Risk Acceptance (Archer Ticket)
1. **Collect information** as you would for a False Positive (device screenshot, version info).
2. If vendor communication is required (patch timeline, EOL statement, recommended mitigations):
- Open a vendor support ticket requesting remediation steps, configuration guidance, or a patch commitment date.
- Use the vendor's response to fill out the Archer remediation plan.
3. **Stage the finding in the queue:**
- Check the checkbox on the finding row.
- Enter the **Vendor / Platform**.
- Select **Archer** as the workflow type.
- Click **Add to Queue**.
4. **Open the Archer Risk Acceptance ticket:**
- Process queued Archer items in a dedicated session.
- See the dedicated Archer process documentation for the full submission steps.
5. Once the EXC number is assigned, enter it in the finding's **Notes** cell on the Reporting page (format: `EXC-XXXXX`). The dashboard will recognise the pattern and include it in the Action Coverage chart under "Archer Exception".
---
## 5. Using the Ivanti Queue
The Ivanti Queue is a personal staging list built into the Reporting page. Rather than interrupting your review to context-switch into Ivanti, you tag findings as you go and then batch-process all the Ivanti work in one focused session.
### Adding Items to the Queue
1. On the Reporting page, check the **checkbox at the far left** of any finding row.
2. A popover appears anchored to the row.
3. For **FP** and **Archer** items: enter the **Vendor / Platform** (free text — e.g., "Juniper MX", "Cisco IOS-XE").
4. Select the **workflow type**:
- **FP** — False Positive request to be submitted in Ivanti
- **Archer** — Archer Risk Acceptance ticket to be opened
- **CARD** — Asset not owned by our BU; IP address is captured automatically
5. Click **Add to Queue**. The row checkbox turns solid blue to indicate it is queued.
### Opening the Queue Panel
Click the **Queue** button in the top-right of the Reporting page. A slide-out panel opens from the right showing all your queued items.
- **CARD** items appear at the top of the panel in their own green section, with the IP address displayed for easy CARD search.
- **FP and Archer** items are grouped alphabetically by vendor/platform below.
- Each item shows: Finding ID, CVEs (or IP for CARD), and the workflow type badge (amber = FP, sky = Archer, green = CARD).
### Working the Queue
**Marking items complete:**
Once you have submitted the FP or Archer ticket in Ivanti (or actioned the CARD item), check the item's green checkbox to mark it complete. Completed items are shown with a strikethrough at reduced opacity.
**Deleting items:**
- Click the trash icon on an individual item to remove it.
- To remove multiple items at once: check the small red selection checkbox on the left of each item you want to remove, then click **Delete (N)** in the footer.
**Clearing completed items:**
Click **Clear Completed** in the footer to remove all marked-complete items at once.
> Queue items are stored in the database and are **personal to your login** — they persist across sessions and page refreshes. Other team members see only their own queue.
---
## 6. Workflow Status Reference
The **Workflow** column on the Reporting page tracks FP# tickets — False Positive requests submitted in Ivanti. The badge shows the ticket ID and its current state, colour-coded by urgency.
> SYS# workflows are auto-generated system tracking records. They are not displayed and do not require team action.
### Status Colour Codes
#### 🔴 Red — Act Immediately
| State | Meaning | Required Action |
|---|---|---|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding has re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
| **Rejected** | The security team reviewed the FP and denied it. The finding is a confirmed, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
#### 🟡 Amber — Action Required Soon
| State | Meaning | Required Action |
|---|---|---|
| **Reworked** | The FP request was challenged by the reviewer and returned for revision. | Open the ticket in Ivanti, review the feedback, update the justification, and **resubmit**. |
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti and respond to what is required. |
#### 🔵 Blue — In Flight, Monitor
| State | Meaning | Required Action |
|---|---|---|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If the SLA window is approaching with no response, follow up with the approver. |
#### — (No Badge) — Untriaged
| State | Meaning | Required Action |
|---|---|---|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding using the workflow in Section 4. Determine whether to remediate, submit an FP, or open an Archer ticket. |
### Decision Flowchart
```
Finding appears in Reporting page
├── Check the Workflow column
├── No badge (—)
│ └── Triage → follow Section 4 workflow
└── Has a badge → check the colour:
├── 🔵 BLUE (Requested)
│ └── Monitor. Follow up if SLA window is approaching.
├── 🟡 AMBER (Reworked / Actionable)
│ └── Open Ivanti ticket → review feedback → update → resubmit
└── 🔴 RED
├── Expired → Submit a new FP request in Ivanti
└── Rejected → Remediate the vulnerability
```
---
## 7. Quick Reference Card
### Action Decision Matrix
| Research Outcome | Config Change? | Action Required |
|---|---|---|
| Can be patched (firmware/software) | N/A | Upgrade device — no ticket needed |
| Can be patched (configuration change only) | Yes | Archer Risk Acceptance ticket (EXC-XXXXX) |
| False Positive — not applicable to platform/version | N/A | FP workflow in Ivanti + evidence in CVE database |
| Cannot be patched — patch pending from vendor | N/A | Archer Risk Acceptance ticket (renew when patched) |
| Cannot be patched — EOL/EOS device | N/A | Archer ticket with mitigation steps + remediation plan |
| Asset not owned by our BU | N/A | CARD queue → CARD asset disposition process |
### Workflow Badge Quick Reference
| Badge | State | One-Line Action |
|---|---|---|
| 🔴 Red | Expired | Renew FP request in Ivanti |
| 🔴 Red | Rejected | Remediate the vulnerability |
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
| 🟡 Amber | Actionable | Review ticket in Ivanti and respond |
| 🔵 Blue | Requested | Monitor — no action yet |
| — | No badge | Triage: follow Section 4 |
### Dashboard Shortcut Reference
| Task | How |
|---|---|
| See only untriaged findings | Click **Pending** segment on Action Coverage chart |
| See findings due this week | Click a date on the Home page calendar widget |
| See all findings for a specific Archer ticket | Click the EXC badge on the Home page CVE row |
| Correct a wrong hostname | Click the Host cell inline on the Reporting page |
| Save a screenshot or advisory to a CVE | Home page → CVE row → Upload document |
| Stage findings for a batch FP/Archer session | Use the Ivanti Queue (checkbox column on Reporting page) |
| Filter to a specific vendor or SLA status | Click the filter icon (⊙) on the relevant column header |
---
*Related documentation: FP Workflow Submission (Ivanti) · Archer Risk Acceptance Process · CARD Asset Disposition Process · MOP: Workflow Status Colour Codes*

View File

@@ -0,0 +1,158 @@
# STEAM Security Dashboard — Team Training Agenda
**Session length:** 3040 minutes
**Format:** Live walkthrough (share your screen on the dashboard)
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
---
## Pre-meeting prep
- Have the dashboard open and logged in before the meeting starts
- Sync Vulnerability Triage page so data is fresh when you get there
- Print or share `security-posture-workflow.md` as a take-home reference
---
## Segment 1 — Why this tool exists (3 min)
**Talking points:**
- We have open Ivanti findings in the 8.59.9 VRR range — these are the ones we own and are accountable for
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
---
## Segment 2 — Dashboard orientation (4 min)
**Show on screen:** Navigate through each page in the nav drawer
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
- **Knowledge Base** — internal docs, runbooks, advisories
- **Exports** — bulk data extracts when needed
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
---
## Segment 3 — The three things you can do with a finding (5 min)
**Talking points — before showing the table, set context:**
Every finding in our range gets one of three designations:
1. **Remediation** — you fix the root cause
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
- Requires an FP workflow opened in Ivanti
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
- Vendor hasn't released a patch yet
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
- Business constraint — needs justification and compensating controls
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
---
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
### Step 1 — Sync and sort (1 min)
- Click **Sync** top-right, wait for timestamp to update
- Click **Due Date** column to sort ascending — reds first, then ambers
- Red = overdue, Amber = due within 30 days — work these first
### Step 2 — Identify the host (3 min)
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
- An amber dot appears on overridden cells; original value is preserved and can be restored
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
### Step 3 — Check who owns the asset (2 min)
- Look at the **BU column**
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
- Anything else (or blank) → not ours → **CARD queue**
- Check the row checkbox, select CARD, click Add to Queue
- IP address is captured automatically for the CARD search
- Process CARD items in a separate session
### Step 4 — Look up the CVEs (4 min)
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
- Go to Home page, search for the CVE ID
- If it exists → review existing notes, docs, and any EXC numbers already linked
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
### Step 5 — Take action (5 min)
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
---
## Segment 5 — The Ivanti Queue (5 min)
**Show on screen:** Click the Queue button, show the panel
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
- CARD items show the IP address so you can search directly in CARD
- Check the green checkbox on an item when the Ivanti/Archer action is done
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
- Queue is **personal to your login** — each person has their own; it persists across sessions
---
## Segment 6 — Workflow badge colours (3 min)
**Show on screen:** Workflow column on the Vulnerability Triage table
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
| Badge | What it means | What to do |
|---|---|---|
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
| No badge | Never been triaged | Run it through the 5-step workflow |
---
## Segment 7 — Quick tips (2 min)
Quick features worth pointing out before Q&A:
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
---
## Segment 8 — Q&A (remaining time)
Suggested prompts to open discussion if no questions come up:
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
---
## Takeaway for the team
Point them to:
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners

View File

@@ -0,0 +1,333 @@
# Time-Based Reporting Recommendations
**Date:** 2026-04-02
**Author:** Engineering (Claude Code)
**Status:** Draft — for director review
---
## Executive Summary
This document analyzes the current CVE Dashboard data model and recommends a set of time-based visualizations that can be added to the Reporting page. Recommendations are grouped by feasibility: **Tier 1** can be built with data already in the database, **Tier 2** requires a lightweight new tracking table, and **Tier 3** requires structural additions.
---
## Current Data Inventory
### What Already Has Time-Series History
| Source | Table | Date Fields | History? |
|--------|-------|-------------|----------|
| Compliance uploads | `compliance_uploads` | `report_date`, `uploaded_at` | **Yes** — one row per report cycle |
| Compliance items | `compliance_items` | `created_at`, `first_seen_upload_id`, `resolved_upload_id` | **Yes** — tracks lifecycle |
| Archer tickets | `archer_tickets` | `created_at`, `updated_at` | **Yes** — full history |
| Todo queue | `ivanti_todo_queue` | `created_at`, `updated_at` | **Yes** — by action |
| Finding notes | `ivanti_finding_notes` | `updated_at` | **Yes** — note activity |
### What Is Point-in-Time Only (no history yet)
| Source | Table | Problem |
|--------|-------|---------|
| Ivanti findings | `ivanti_findings_cache` | Single-row cache — overwritten on every sync |
| Ivanti counts | `ivanti_counts_cache` | Single-row cache — no snapshots stored |
| FP workflow states | Computed from `findings_json` | Ephemeral — not persisted historically |
---
## Tier 1 Recommendations — Build Now (No Schema Changes)
All of these use data that is already in the database.
---
### 1.1 Compliance Trend Line — Total Active Findings Over Time
**Description:** A line chart showing the total number of active (non-compliant) items per compliance upload date. This directly answers "are we improving over time?"
**Data Source:**
```sql
SELECT
cu.report_date,
COUNT(ci.id) AS active_count
FROM compliance_uploads cu
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
GROUP BY cu.id
ORDER BY cu.report_date ASC;
```
**Chart Type:** Line chart with data points per upload
**Axes:** X = Report Date, Y = Number of Active Findings
**Value-add:** Overlay a trend line (linear regression) to show trajectory
---
### 1.2 New / Recurring / Resolved Bar Chart — Per Report Cycle
**Description:** A grouped or stacked bar chart showing the delta breakdown for each compliance upload: how many findings were newly introduced, how many recurred from a prior cycle, and how many were resolved.
**Data Source:** Already computed and stored in `compliance_uploads`:
```sql
SELECT report_date, new_count, recurring_count, resolved_count
FROM compliance_uploads
ORDER BY report_date ASC;
```
**Chart Type:** Stacked bar chart (one bar per upload date)
**Legend:** New (red/amber), Recurring (yellow), Resolved (green)
**Value-add:** Shows whether each reporting cycle is improving (more resolved than new) or degrading
---
### 1.3 Team Compliance Health Over Time — Multi-Line Chart
**Description:** A multi-line chart showing the active finding count per team per upload date. Answers "which team is trending better or worse?"
**Data Source:**
```sql
SELECT
cu.report_date,
ci.team,
COUNT(ci.id) AS active_count
FROM compliance_uploads cu
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
GROUP BY cu.id, ci.team
ORDER BY cu.report_date ASC;
```
**Chart Type:** Multi-line chart (one line per team)
**Teams:** STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV
**Value-add:** Immediately visible which team is outlier or improving fastest
---
### 1.4 Mean Time to Resolution (MTTR) — Per Team
**Description:** A bar chart showing average number of upload cycles between when a finding first appeared and when it was resolved, broken out by team.
**Data Source:**
```sql
SELECT
ci.team,
AVG(ci.resolved_upload_id - ci.first_seen_upload_id) AS avg_cycles_to_resolve,
COUNT(*) AS resolved_count
FROM compliance_items ci
WHERE ci.resolved_upload_id IS NOT NULL
GROUP BY ci.team;
```
**Chart Type:** Horizontal bar chart
**Axes:** Y = Team, X = Average Cycles to Resolution
**Value-add:** Normalize to calendar days by joining with upload dates for true MTTR in days
---
### 1.5 Recurring Findings Heatmap — Seen Count Distribution
**Description:** A heatmap or bubble chart showing findings grouped by how many times they have recurred (`seen_count`). Identifies chronic, long-standing compliance gaps.
**Data Source:**
```sql
SELECT
team,
metric_id,
metric_desc,
seen_count,
COUNT(*) AS host_count
FROM compliance_items
WHERE status = 'active'
GROUP BY team, metric_id
ORDER BY seen_count DESC;
```
**Chart Type:** Horizontal bar chart sorted by `seen_count`, grouped by team
**Value-add:** Highlights the "chronic" findings that repeatedly appear — high priority for remediation
---
### 1.6 Archer Exception Ticket Status Over Time
**Description:** A line chart or cumulative area chart showing Archer ticket status transitions over time using `created_at` and `updated_at`.
**Data Source:**
```sql
SELECT
DATE(created_at) AS date,
status,
COUNT(*) AS count
FROM archer_tickets
GROUP BY DATE(created_at), status
ORDER BY date ASC;
```
**Chart Type:** Stacked area chart
**Statuses:** Draft, Open, Under Review, Accepted
**Value-add:** Tracks exception request pipeline velocity — are exceptions getting processed or stacking up?
---
### 1.7 Compliance Category Breakdown Over Time
**Description:** A stacked area chart showing what categories of compliance failures are driving the total over time (if the `category` field in `compliance_items` is populated).
**Data Source:**
```sql
SELECT
cu.report_date,
ci.category,
COUNT(ci.id) AS count
FROM compliance_uploads cu
JOIN compliance_items ci ON ci.upload_id = cu.id AND ci.status = 'active'
WHERE ci.category IS NOT NULL
GROUP BY cu.id, ci.category
ORDER BY cu.report_date ASC;
```
**Chart Type:** Stacked area chart
**Value-add:** Shows whether one category dominates or if failures are spread across areas
---
## Tier 2 Recommendations — Lightweight Schema Addition Required
These require adding one new table to persist snapshots of data that is currently overwritten on each sync.
---
### 2.1 Ivanti Findings Count Over Time — Open vs Closed Trend
**Description:** The single most-requested metric: "are we making progress on vulnerabilities?" A line chart showing open and closed Ivanti finding counts over time.
**Problem:** The current `ivanti_counts_cache` is a single-row table overwritten on each sync. No history is kept.
**Solution:** Add a `ivanti_counts_history` table and append a row on every successful sync:
```sql
CREATE TABLE ivanti_counts_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_count INTEGER NOT NULL,
closed_count INTEGER NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Backend change:** In the sync route (`POST /api/ivanti/findings/sync`), after updating the cache, also `INSERT INTO ivanti_counts_history`.
**New API endpoint:** `GET /api/ivanti/findings/counts/history`
```sql
SELECT open_count, closed_count, recorded_at
FROM ivanti_counts_history
ORDER BY recorded_at ASC;
```
**Chart Type:** Dual-line chart
**Lines:** Open findings (red), Closed findings (green)
**Value-add:** Most direct measure of vulnerability remediation velocity
---
### 2.2 FP Workflow State Snapshots Over Time
**Description:** A stacked area or line chart showing how FP workflow states (Actionable, Requested, Approved, Rejected, Expired) trend over sync cycles.
**Solution:** Add a `ivanti_fp_workflow_history` table:
```sql
CREATE TABLE ivanti_fp_workflow_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state TEXT NOT NULL,
finding_count INTEGER NOT NULL DEFAULT 0,
id_count INTEGER NOT NULL DEFAULT 0,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Chart Type:** Stacked area chart
**Value-add:** Shows whether FP requests are being worked through or stacking up in "Requested" state
---
### 2.3 Todo Queue Velocity — Items Added vs Completed Per Week
**Description:** A bar chart showing weekly queue throughput (items added vs items marked complete).
**Data Source:** Already available in `ivanti_todo_queue.created_at` and `updated_at` + `status = 'complete'`:
```sql
SELECT
STRFTIME('%Y-W%W', created_at) AS week,
COUNT(*) AS items_added,
SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END) AS items_completed
FROM ivanti_todo_queue
GROUP BY week
ORDER BY week ASC;
```
**Chart Type:** Grouped bar chart (weekly)
**Value-add:** Measures operational pace of the team's workflow action throughput
---
## Tier 3 Recommendations — Structural Additions (Future Consideration)
These require more significant changes but would provide powerful long-term reporting.
---
### 3.1 Finding Age / Dwell Time Distribution
**Description:** A histogram showing how long open findings have been open (age in days). The `lastFoundOn` field exists in the Ivanti findings JSON but is not persisted to a structured table.
**Requirement:** Parse and store `lastFoundOn` from findings JSON into a structured column during sync.
**Value-add:** Highlights findings that have been open for 90+ days — high-priority remediation targets.
---
### 3.2 SLA Breach Trends
**Description:** Track how many findings breach SLA (Due Date exceeded) over time. Currently SLA status is computed in the frontend on-the-fly.
**Requirement:** Add SLA breach tracking during sync — stamp findings that cross SLA date.
**Value-add:** Compliance and audit reporting for SLA adherence metrics.
---
## Recommended Implementation Order
| Priority | Chart | Effort | Impact |
|----------|-------|--------|--------|
| 1 | 1.2 — New/Recurring/Resolved bar chart | Low (data ready) | High |
| 2 | 1.1 — Compliance trend line | Low (data ready) | High |
| 3 | 1.3 — Team health multi-line | Low (data ready) | High |
| 4 | 2.1 — Ivanti open/closed history | Medium (new table) | Very High |
| 5 | 1.4 — MTTR per team | Low (data ready) | Medium |
| 6 | 1.6 — Archer ticket pipeline | Low (data ready) | Medium |
| 7 | 2.3 — Queue velocity | Low (data ready) | Medium |
| 8 | 1.5 — Recurring findings heatmap | Low (data ready) | Medium |
| 9 | 2.2 — FP workflow snapshots | Medium (new table) | Medium |
| 10 | 1.7 — Category breakdown | Low (data ready) | LowMedium |
---
## Charting Library Consideration
The current implementation uses **hand-rolled SVG donut charts** (no external library). For time-series line/bar/area charts, the team should decide:
| Option | Pros | Cons |
|--------|------|------|
| **Continue hand-rolled SVG** | Zero dependencies, full style control | Significant effort for axes, labels, tooltips |
| **Recharts** (React-native) | Well-matched to React 19, composable, responsive | ~500KB dependency |
| **Chart.js via react-chartjs-2** | Mature, widely documented | Less React-idiomatic |
| **Lightweight: uPlot or Chart.xkcd** | Very small bundle | Less community support |
**Recommendation:** Recharts aligns best with the React 19 stack and allows declaring charts as JSX components consistent with the existing code style. It supports all chart types listed above.
---
## Notes for Director Review
- All **Tier 1** recommendations can be implemented with zero database migrations — the data is already there.
- The **single highest-value addition** is `2.1 — Ivanti open/closed count history`, as it captures the most direct remediation progress metric. It only requires one new table and one line added to the sync handler.
- **Compliance charts (1.11.5)** will only be meaningful once multiple compliance uploads have been committed. If only 12 uploads exist currently, the trend will not show much until more data accumulates — but building the charts now means data will automatically populate them.
- All queries listed above have been validated against the actual database schema.
---
*Next step: Review with director, confirm priority order, then schedule sprint for implementation.*

View File

@@ -1,70 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@@ -12,7 +12,9 @@
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"recharts": "^3.8.1",
"web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal';
import WeeklyReportModal from './components/WeeklyReportModal';
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
import NavDrawer from './components/NavDrawer';
import CalendarWidget from './components/CalendarWidget';
import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
import CompliancePage from './components/pages/CompliancePage';
import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -172,14 +175,14 @@ export default function App() {
const [cveDocuments, setCveDocuments] = useState({});
const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null);
const [currentPage, setCurrentPage] = useState('home');
const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null);
const [reportingExcFilter, setReportingExcFilter] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false);
const [showWeeklyReport, setShowWeeklyReport] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
const [newCVE, setNewCVE] = useState({
cve_id: '',
vendor: '',
@@ -200,6 +203,7 @@ export default function App() {
const [editNvdError, setEditNvdError] = useState(null);
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
const [expandedCVEs, setExpandedCVEs] = useState({});
const [visibleCount, setVisibleCount] = useState(5);
const [jiraTickets, setJiraTickets] = useState([]);
const [showAddTicket, setShowAddTicket] = useState(false);
const [showEditTicket, setShowEditTicket] = useState(false);
@@ -220,6 +224,15 @@ export default function App() {
});
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
// Ivanti workflows state
const [ivantiTotal, setIvantiTotal] = useState(null);
const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
const [ivantiSyncError, setIvantiSyncError] = useState(null);
const [ivantiLoading, setIvantiLoading] = useState(false);
const [ivantiSyncing, setIvantiSyncing] = useState(false);
const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
};
@@ -293,19 +306,6 @@ export default function App() {
}
};
const fetchKnowledgeBaseArticles = async () => {
try {
const response = await fetch(`${API_BASE}/knowledge-base`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
const data = await response.json();
setKnowledgeBaseArticles(data);
} catch (err) {
console.error('Error fetching knowledge base articles:', err);
}
};
const fetchJiraTickets = async () => {
try {
const response = await fetch(`${API_BASE}/jira-tickets`, {
@@ -332,6 +332,43 @@ export default function App() {
}
};
const applyIvantiState = (data) => {
setIvantiTotal(data.total ?? 0);
setIvantiWorkflows(data.workflows || []);
setIvantiSyncedAt(data.synced_at || null);
setIvantiSyncStatus(data.sync_status || null);
setIvantiSyncError(data.error_message || null);
};
const fetchIvantiWorkflows = async () => {
setIvantiLoading(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
const data = await response.json();
if (response.ok) applyIvantiState(data);
} catch (err) {
console.error('Error loading Ivanti workflows:', err);
} finally {
setIvantiLoading(false);
}
};
const syncIvantiWorkflows = async () => {
setIvantiSyncing(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
if (response.ok) applyIvantiState(data);
} catch (err) {
console.error('Error syncing Ivanti workflows:', err);
} finally {
setIvantiSyncing(false);
}
};
const fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return;
@@ -387,45 +424,6 @@ export default function App() {
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
};
const handleViewKBArticle = async (articleId) => {
try {
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch article');
const article = await response.json();
setSelectedKBArticle(article);
} catch (err) {
console.error('Error fetching knowledge base article:', err);
setError('Failed to load article');
}
};
const handleDownloadKBArticle = async (id, filename) => {
try {
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Error downloading knowledge base article:', err);
setError('Failed to download document');
}
};
const handleAddCVE = async (e) => {
e.preventDefault();
try {
@@ -860,7 +858,7 @@ export default function App() {
fetchVendors();
fetchJiraTickets();
fetchArcherTickets();
fetchKnowledgeBaseArticles();
fetchIvantiWorkflows();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
@@ -869,6 +867,7 @@ export default function App() {
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
setVisibleCount(5);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
@@ -903,18 +902,39 @@ export default function App() {
return (
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
<NavDrawer
isOpen={navOpen}
onClose={() => setNavOpen(false)}
currentPage={currentPage}
onNavigate={(page) => {
// Clear contextual filters when navigating directly via the nav drawer
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
setCurrentPage(page);
}}
/>
{/* Scanning line effect */}
<div className="scan-line"></div>
<div className="max-w-7xl mx-auto relative z-10">
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
{/* Header */}
<div className="mb-8">
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<div className="flex items-center gap-4 flex-1">
<button
onClick={() => setNavOpen(true)}
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
title="Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div>
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
CVE INTEL
STEAM Security Dashboard
</h1>
<p className="text-gray-400 text-sm font-sans">Threat Intelligence & Vulnerability Command Center</p>
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
</div>
</div>
<div className="flex items-center gap-3">
{canWrite() && (
@@ -926,15 +946,6 @@ export default function App() {
NVD Sync
</button>
)}
{canWrite() && (
<button
onClick={() => setShowWeeklyReport(true)}
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Weekly Report
</button>
)}
{canWrite() && (
<button
onClick={() => setShowAddCVE(true)}
@@ -948,8 +959,8 @@ export default function App() {
</div>
</div>
{/* Stats Bar - Modern refined styling */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Stats Bar - only shown on Home page */}
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div style={STYLES.statCard}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
@@ -970,8 +981,14 @@ export default function App() {
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
</div>
</div>}
</div>
</div>
{/* Page content */}
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
{currentPage === 'exports' && <ExportsPage />}
{/* User Management Modal */}
{showUserManagement && (
@@ -988,19 +1005,6 @@ export default function App() {
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}
{/* Weekly Report Modal */}
{showWeeklyReport && (
<WeeklyReportModal onClose={() => setShowWeeklyReport(false)} />
)}
{/* Knowledge Base Modal */}
{showKnowledgeBase && (
<KnowledgeBaseModal
onClose={() => setShowKnowledgeBase(false)}
onUpdate={fetchKnowledgeBaseArticles}
/>
)}
{/* Add CVE Modal */}
{showAddCVE && (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
@@ -1591,89 +1595,10 @@ export default function App() {
</div>
)}
{/* Three Column Layout */}
<div className="grid grid-cols-12 gap-6">
{/* LEFT PANEL - Wiki/Knowledge Base */}
<div className="col-span-12 lg:col-span-3 space-y-4">
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
<div className="flex items-center justify-between mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
Knowledge Base
</h2>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small"
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
title="Manage Knowledge Base"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
{/* Knowledge Base Entries */}
<div className="space-y-3">
{knowledgeBaseArticles.length === 0 ? (
<div className="text-center py-8" style={{ color: '#64748B' }}>
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No documents yet</p>
{(user?.role === 'admin' || user?.role === 'editor') && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="intel-button intel-button-small mt-3"
>
Add First Document
</button>
)}
</div>
) : (
knowledgeBaseArticles.slice(0, 5).map((article) => (
<div
key={article.id}
onClick={() => handleViewKBArticle(article.id)}
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
className="hover:border-intel-success"
>
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
{article.description && (
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-intel-success font-mono">
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
{article.category && article.category !== 'General' && (
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
{article.category}
</span>
)}
</div>
</div>
))
)}
{knowledgeBaseArticles.length > 5 && (
<button
onClick={() => setShowKnowledgeBase(true)}
className="text-xs text-center w-full py-2"
style={{ color: '#10B981' }}
>
View all {knowledgeBaseArticles.length} documents
</button>
)}
</div>
</div>
</div>
{/* Two Column Layout - Home page only */}
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
{/* CENTER PANEL - Main Content */}
<div className="col-span-12 lg:col-span-6 space-y-4">
{/* Knowledge Base Viewer */}
{selectedKBArticle ? (
<KnowledgeBaseViewer
article={selectedKBArticle}
onClose={() => setSelectedKBArticle(null)}
/>
) : (
<div className="col-span-12 lg:col-span-9 space-y-4">
<>
{/* Quick Check */}
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
@@ -1829,7 +1754,7 @@ export default function App() {
</div>
) : (
<div className="space-y-4">
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
{Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
const isCVEExpanded = expandedCVEs[cveId];
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
const highestSeverity = vendorEntries.reduce((highest, entry) => {
@@ -2101,6 +2026,40 @@ export default function App() {
</div>
);
})}
{/* Show more / pagination footer */}
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
<div className="flex items-center justify-between pt-2">
<span className="text-gray-500 font-mono text-xs">
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
</span>
<div className="flex gap-2">
<button
onClick={() => setVisibleCount(v => v + 5)}
className="intel-button intel-button-primary text-xs px-3 py-1"
>
Show 5 more
</button>
<button
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Show all
</button>
</div>
</div>
)}
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
<div className="flex justify-end pt-2">
<button
onClick={() => setVisibleCount(5)}
className="intel-button text-xs px-3 py-1"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
>
Collapse
</button>
</div>
)}
</div>
)}
@@ -2112,7 +2071,6 @@ export default function App() {
</div>
)}
</>
)}
</div>
{/* End Center Panel */}
@@ -2124,63 +2082,12 @@ export default function App() {
Calendar
</h2>
{/* Simple Calendar Grid */}
<div className="mb-2">
<div className="text-center mb-3">
<span className="text-white font-semibold font-mono">February 2024</span>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs mb-2">
<div className="text-gray-400 font-mono">Su</div>
<div className="text-gray-400 font-mono">Mo</div>
<div className="text-gray-400 font-mono">Tu</div>
<div className="text-gray-400 font-mono">We</div>
<div className="text-gray-400 font-mono">Th</div>
<div className="text-gray-400 font-mono">Fr</div>
<div className="text-gray-400 font-mono">Sa</div>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{/* Week 1 */}
<div className="text-gray-600 font-mono text-xs p-1">28</div>
<div className="text-gray-600 font-mono text-xs p-1">29</div>
<div className="text-gray-600 font-mono text-xs p-1">30</div>
<div className="text-gray-600 font-mono text-xs p-1">31</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">1</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">2</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">3</div>
{/* Week 2 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">4</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">5</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">6</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">7</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">8</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">9</div>
<div className="bg-intel-accent/30 text-white font-mono text-xs p-1 rounded font-bold border border-intel-accent">10</div>
{/* Week 3 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">11</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">12</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">13</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">14</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">15</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">16</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">17</div>
{/* Week 4 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">18</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">19</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">20</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">21</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">22</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">23</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">24</div>
{/* Week 5 */}
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">25</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">26</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">27</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">28</div>
<div className="text-white font-mono text-xs p-1 hover:bg-intel-accent/20 rounded cursor-pointer">29</div>
<div className="text-gray-600 font-mono text-xs p-1">1</div>
<div className="text-gray-600 font-mono text-xs p-1">2</div>
</div>
</div>
<CalendarWidget
onDateClick={(dateStr) => {
setCalendarFilter(dateStr);
setCurrentPage('triage');
}}
/>
</div>
{/* Open Vendor Tickets */}
@@ -2282,16 +2189,23 @@ export default function App() {
>
{ticket.exc_number}
</a>
{canWrite() && (
<div className="flex gap-1">
<button
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
title="View findings referencing this ticket"
className="text-gray-400 hover:text-sky-400 transition-colors"
>
<Filter className="w-3 h-3" />
</button>
{canWrite() && (<>
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</>)}
</div>
)}
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div>
@@ -2311,10 +2225,101 @@ export default function App() {
)}
</div>
</div>
{/* Ivanti Workflows */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
<div className="flex justify-between items-center mb-1">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
<Activity className="w-5 h-5" />
Ivanti Workflows
</h2>
<button
onClick={syncIvantiWorkflows}
disabled={ivantiSyncing || ivantiLoading}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
title="Sync now"
>
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
{ivantiSyncing ? 'Syncing…' : 'Sync'}
</button>
</div>
{/* Last synced line */}
<div className="text-xs text-gray-500 font-mono mb-4">
{ivantiSyncedAt
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
: 'Never synced'}
</div>
{ivantiLoading ? (
<div className="text-center py-8">
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
<p className="text-xs text-gray-400 font-mono">Loading...</p>
</div>
) : ivantiSyncStatus === 'error' ? (
<>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
{ivantiTotal ?? '—'}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
</div>
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
<p className="text-xs text-red-400 font-mono">{ivantiSyncError}</p>
</div>
</>
) : (
<>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="font-mono text-xs font-semibold text-teal-300">
{wf.id?.value || wf.uuid?.slice(0, 8)}
</span>
{wf.currentState && (
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
{wf.currentState}
</span>
)}
</div>
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
<div className="flex items-center justify-between gap-2">
{wf.type && (
<span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>
)}
{wf.createdOn && (
<span className="text-xs text-gray-500">{wf.createdOn}</span>
)}
</div>
</div>
))}
{ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
</div>
)}
{ivantiSyncStatus === 'never' && (
<div className="text-center py-6">
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
</div>
)}
</div>
</>
)}
</div>
</div>
{/* End Right Panel */}
</div>
</div>}
{/* End Three Column Layout */}
</div>
</div>

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
function toLocalDateStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export default function CalendarWidget({ onDateClick }) {
const today = new Date();
const todayStr = toLocalDateStr(today);
const [calYear, setCalYear] = useState(today.getFullYear());
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
// Map of "YYYY-MM-DD" → count of findings due that day
const [dueDates, setDueDates] = useState({});
useEffect(() => {
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!data?.findings) return;
const counts = {};
data.findings.forEach((f) => {
if (f.dueDate) {
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
}
});
setDueDates(counts);
})
.catch(() => {});
}, []);
const prevMonth = () => {
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
else { setCalMonth((m) => m - 1); }
};
const nextMonth = () => {
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
else { setCalMonth((m) => m + 1); }
};
// Build cell array: null = padding, number = day of month
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const cells = [
...Array(firstDow).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
while (cells.length % 7 !== 0) cells.push(null); // complete last row
const hasDueDatesThisMonth = cells.some((day) => {
if (!day) return false;
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return !!dueDates[ds];
});
return (
<div>
{/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<button
onClick={prevMonth}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
>
<ChevronLeft style={{ width: '14px', height: '14px' }} />
</button>
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
{MONTH_NAMES[calMonth]} {calYear}
</span>
<button
onClick={nextMonth}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
>
<ChevronRight style={{ width: '14px', height: '14px' }} />
</button>
</div>
{/* Day-of-week headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
{DAY_NAMES.map((d) => (
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
{cells.map((day, idx) => {
if (!day) return <div key={idx} />;
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isToday = dateStr === todayStr;
const dueCount = dueDates[dateStr] || 0;
const hasDue = dueCount > 0;
return (
<div
key={idx}
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: '2px', padding: '3px 1px',
borderRadius: '4px',
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
cursor: hasDue ? 'pointer' : 'default',
transition: hasDue ? 'background 0.15s' : undefined,
}}
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
>
<span style={{
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
fontWeight: (isToday || hasDue) ? '700' : '400',
}}>
{day}
</span>
{/* Red dot indicator for due dates */}
{hasDue ? (
<div style={{
width: '4px', height: '4px', borderRadius: '50%',
background: '#EF4444',
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
flexShrink: 0,
}} />
) : (
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
)}
</div>
);
})}
</div>
{/* Legend — only shown when there are due dates this month */}
{hasDueDatesThisMonth && (
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Ivanti finding due
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
];
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(0, 0, 0, 0.65)',
backdropFilter: 'blur(3px)',
zIndex: 50
}}
/>
{/* Drawer */}
<div style={{
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
zIndex: 51,
display: 'flex', flexDirection: 'column',
padding: '1.5rem'
}}>
{/* Drawer header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
<div>
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
STEAM
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
Security Dashboard
</div>
</div>
<button
onClick={onClose}
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* Nav items */}
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
const active = currentPage === id;
return (
<button
key={id}
onClick={() => { onNavigate(id); onClose(); }}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.75rem 0.875rem',
borderRadius: '0.5rem',
border: active ? `1px solid ${color}50` : '1px solid transparent',
background: active ? `${color}18` : 'transparent',
cursor: 'pointer', textAlign: 'left', width: '100%',
transition: 'background 0.15s, border-color 0.15s'
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
{/* Icon box */}
<div style={{
width: '36px', height: '36px', flexShrink: 0,
borderRadius: '0.375rem',
background: `${color}18`,
border: `1px solid ${color}40`,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<Icon style={{ width: '17px', height: '17px', color }} />
</div>
{/* Label + description */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
color: active ? color : '#CBD5E1',
textTransform: 'uppercase', letterSpacing: '0.06em'
}}>
{label}
</div>
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
{description}
</div>
</div>
{/* Active indicator dot */}
{active && (
<div style={{
width: '6px', height: '6px', borderRadius: '50%',
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
}} />
)}
</button>
);
})}
</nav>
{/* Footer */}
<div style={{
marginTop: 'auto', paddingTop: '1rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
textAlign: 'center'
}}>
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
NTS Threat Intelligence
</div>
</div>
</div>
</>
);
}

View File

@@ -1,291 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, Loader, AlertCircle, CheckCircle, Upload as UploadIcon, Download, Star } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function WeeklyReportModal({ onClose }) {
const [phase, setPhase] = useState('idle'); // idle, uploading, processing, success, error
const [selectedFile, setSelectedFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [result, setResult] = useState(null);
const [existingReports, setExistingReports] = useState([]);
const [error, setError] = useState('');
// Fetch existing reports on mount
useEffect(() => {
fetchExistingReports();
}, []);
const fetchExistingReports = async () => {
try {
const response = await fetch(`${API_BASE}/weekly-reports`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch reports');
const data = await response.json();
setExistingReports(data);
} catch (err) {
console.error('Error fetching reports:', err);
}
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
if (!file.name.endsWith('.xlsx')) {
setError('Please select an Excel file (.xlsx)');
return;
}
setSelectedFile(file);
setError('');
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setPhase('uploading');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', selectedFile);
try {
setUploadProgress(50); // Simulated progress
setPhase('processing');
const response = await fetch(`${API_BASE}/weekly-reports/upload`, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Upload failed');
}
const data = await response.json();
setResult(data);
setPhase('success');
// Refresh the list of existing reports
await fetchExistingReports();
} catch (err) {
setError(err.message);
setPhase('error');
}
};
const handleDownload = async (id, type) => {
try {
const response = await fetch(`${API_BASE}/weekly-reports/${id}/download/${type}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `vulnerability_report_${type}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Error downloading file:', err);
setError(`Failed to download ${type} file`);
}
};
const resetForm = () => {
setPhase('idle');
setSelectedFile(null);
setUploadProgress(0);
setResult(null);
setError('');
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<h2 className="modal-title">Weekly Vulnerability Report</h2>
<button onClick={onClose} className="modal-close">
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="modal-body">
{/* Idle Phase - File Selection */}
{phase === 'idle' && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
Upload Excel File (.xlsx)
</label>
<input
type="file"
accept=".xlsx"
onChange={handleFileSelect}
className="intel-input w-full"
/>
{selectedFile && (
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
Selected: {selectedFile.name}
</p>
)}
</div>
<button
onClick={handleUpload}
disabled={!selectedFile}
className={`intel-button w-full ${selectedFile ? 'intel-button-success' : 'opacity-50 cursor-not-allowed'}`}
>
<UploadIcon className="w-4 h-4 mr-2" />
Upload & Process
</button>
{error && (
<div className="flex items-start gap-2 p-3 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
<AlertCircle className="w-5 h-5 flex-shrink-0" style={{ color: '#EF4444' }} />
<p style={{ color: '#FCA5A5' }}>{error}</p>
</div>
)}
</div>
)}
{/* Uploading Phase */}
{phase === 'uploading' && (
<div className="text-center py-8">
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
<p style={{ color: '#94A3B8' }}>Uploading file...</p>
<div className="w-full bg-gray-700 rounded-full h-2 mt-4">
<div
className="h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%`, background: '#0EA5E9' }}
/>
</div>
</div>
)}
{/* Processing Phase */}
{phase === 'processing' && (
<div className="text-center py-8">
<Loader className="w-12 h-12 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
<p style={{ color: '#94A3B8' }}>Processing vulnerability report...</p>
<p className="text-sm mt-2" style={{ color: '#64748B' }}>Splitting CVE IDs into separate rows</p>
</div>
)}
{/* Success Phase */}
{phase === 'success' && result && (
<div className="space-y-4">
<div className="flex items-center gap-2 p-4 rounded" style={{ background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10B981' }}>
<CheckCircle className="w-6 h-6" style={{ color: '#10B981' }} />
<div>
<p className="font-medium" style={{ color: '#34D399' }}>Upload Successful!</p>
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>
Original: {result.original_rows} rows Processed: {result.processed_rows} rows
<span className="ml-2" style={{ color: '#10B981' }}>
(+{result.processed_rows - result.original_rows} rows from splitting CVEs)
</span>
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => handleDownload(result.id, 'original')}
className="intel-button flex-1"
>
<Download className="w-4 h-4 mr-2" />
Download Original
</button>
<button
onClick={() => handleDownload(result.id, 'processed')}
className="intel-button intel-button-success flex-1"
>
<Download className="w-4 h-4 mr-2" />
Download Processed
</button>
</div>
<button onClick={resetForm} className="intel-button w-full">
Upload Another Report
</button>
</div>
)}
{/* Error Phase */}
{phase === 'error' && (
<div className="space-y-4">
<div className="flex items-start gap-2 p-4 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid #EF4444' }}>
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
<div>
<p className="font-medium" style={{ color: '#FCA5A5' }}>Upload Failed</p>
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
</div>
</div>
<button onClick={resetForm} className="intel-button w-full">
Try Again
</button>
</div>
)}
{/* Existing Reports Section */}
{(phase === 'idle' || phase === 'success') && existingReports.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium mb-4" style={{ color: '#94A3B8' }}>
Previous Reports
</h3>
<div className="space-y-3">
{existingReports.map((report) => (
<div
key={report.id}
className="intel-card p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{report.is_current && (
<Star className="w-4 h-4 fill-current" style={{ color: '#F59E0B' }} />
)}
<p className="font-medium" style={{ color: report.is_current ? '#F59E0B' : '#94A3B8' }}>
{report.week_label}
</p>
</div>
<p className="text-sm" style={{ color: '#64748B' }}>
{new Date(report.upload_date).toLocaleDateString()}
{report.row_count_original} {report.row_count_processed} rows
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleDownload(report.id, 'original')}
className="intel-button intel-button-small"
title="Download Original"
>
<Download className="w-3 h-3" />
</button>
<button
onClick={() => handleDownload(report.id, 'processed')}
className="intel-button intel-button-success intel-button-small"
title="Download Processed"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,424 @@
// ComplianceChartsPanel.js
// Tier-1 time-based compliance charts using Recharts.
// Charts rendered: Active Findings Over Time, Change per Cycle,
// Team Health, MTTR by Team, Persistent Findings, Archer Pipeline.
import React, { useState, useEffect, useMemo } from 'react';
import {
LineChart, Line,
BarChart, Bar,
XAxis, YAxis, CartesianGrid,
Tooltip, Legend,
ResponsiveContainer,
} from 'recharts';
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const TEAM_COLORS = {
'STEAM': '#0EA5E9',
'ACCESS-ENG': '#F59E0B',
'ACCESS-OPS': '#8B5CF6',
'INTELDEV': '#10B981',
};
const ARCHER_STATUS_COLORS = {
'Draft': '#475569',
'Open': '#0EA5E9',
'Under Review': '#F59E0B',
'Accepted': '#10B981',
};
// ---------------------------------------------------------------------------
// Shared style tokens
// ---------------------------------------------------------------------------
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
// ---------------------------------------------------------------------------
// Custom dark tooltip
// ---------------------------------------------------------------------------
function DarkTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
return (
<div style={{
background: 'rgba(10,17,32,0.97)',
border: '1px solid rgba(20,184,166,0.3)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.7rem',
minWidth: '130px',
}}>
<div style={{ color: TEAL, marginBottom: '0.3rem', fontWeight: '700', fontSize: '0.65rem' }}>
{label}
</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color || '#94A3B8', marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ opacity: 0.8 }}>{p.name}</span>
<span style={{ fontWeight: '700' }}>
{typeof p.value === 'number'
? Number.isInteger(p.value) ? p.value : p.value.toFixed(1)
: p.value}
</span>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Chart card wrapper
// ---------------------------------------------------------------------------
function ChartCard({ title, subtitle, children }) {
return (
<div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)',
borderRadius: '0.5rem',
padding: '1rem 1.125rem 0.875rem',
}}>
<div style={{ marginBottom: '0.75rem' }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
{title}
</div>
{subtitle && (
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
{subtitle}
</div>
)}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty / no-data state
// ---------------------------------------------------------------------------
function NoData({ msg }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '160px', color: '#334155',
fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(20,184,166,0.1)',
borderRadius: '0.375rem',
}}>
{msg || 'No data yet — upload compliance reports to populate this chart'}
</div>
);
}
// ---------------------------------------------------------------------------
// Shorten a YYYY-MM-DD string to MM/DD/YY
// ---------------------------------------------------------------------------
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
// ---------------------------------------------------------------------------
// Chart 1 — Active Findings Over Time (line, total + per team)
// ---------------------------------------------------------------------------
function ActiveTrendChart({ data }) {
if (data.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Line
type="monotone" dataKey="total_active" name="Total"
stroke={TEAL} strokeWidth={2}
dot={{ r: 3, fill: TEAL, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={1.5}
dot={false} strokeDasharray="5 3"
activeDot={{ r: 4, fill: color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 2 — New / Recurring / Resolved per cycle (stacked + grouped bar)
// ---------------------------------------------------------------------------
function DeltaChart({ data }) {
if (data.length === 0) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Bar dataKey="new_count" name="New" stackId="in" fill="#EF4444" fillOpacity={0.85} radius={[0,0,0,0]} />
<Bar dataKey="recurring_count" name="Recurring" stackId="in" fill="#F59E0B" fillOpacity={0.85} radius={[2,2,0,0]} />
<Bar dataKey="resolved_count" name="Resolved" fill="#10B981" fillOpacity={0.8} radius={[2,2,2,2]} />
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 3 — Team Health Multi-Line
// ---------------------------------------------------------------------------
function TeamTrendChart({ data }) {
if (data.length < 2) return <NoData />;
return (
<ResponsiveContainer width="100%" height={210}>
<LineChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(TEAM_COLORS).map(([team, color]) => (
<Line
key={team}
type="monotone" dataKey={team} name={team}
stroke={color} strokeWidth={2}
dot={{ r: 3, fill: color, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 4 — MTTR by Team (horizontal bar)
// ---------------------------------------------------------------------------
function MttrChart({ data }) {
if (data.length === 0) return <NoData msg="No resolved findings yet — MTTR will appear after items are remediated" />;
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 44 + 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" d" />
<YAxis type="category" dataKey="team" tick={AXIS_STYLE} width={86} />
<Tooltip content={<DarkTooltip />} />
<Bar dataKey="avg_days" name="Avg Days" fill={TEAL} fillOpacity={0.8} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}d` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 5 — Most Persistent Findings (horizontal bar by seen_count)
// ---------------------------------------------------------------------------
function RecurringChart({ data }) {
if (data.length === 0) return <NoData />;
const top10 = data.slice(0, 10).map(r => ({
...r,
label: r.metric_id.length > 18 ? r.metric_id.slice(0, 18) + '…' : r.metric_id,
}));
return (
<ResponsiveContainer width="100%" height={Math.max(160, top10.length * 28 + 40)}>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 48, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis type="number" tick={AXIS_STYLE} unit=" cycles" allowDecimals={false} />
<YAxis type="category" dataKey="label" tick={AXIS_STYLE} width={110} />
<Tooltip content={<DarkTooltip />} formatter={(val, name, props) => [
`${val} cycles (${props.payload.host_count} host${props.payload.host_count !== 1 ? 's' : ''})`, props.payload.team
]} />
<Bar dataKey="seen_count" name="Cycles Seen" fill="#F59E0B" fillOpacity={0.85} radius={[0, 3, 3, 0]}
label={{ position: 'right', style: { ...AXIS_STYLE, fill: '#64748B' }, formatter: v => `${v}×` }}
/>
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Chart 6 — Archer Exception Ticket Pipeline (stacked bar by creation date)
// ---------------------------------------------------------------------------
function ArcherPipelineChart({ data }) {
if (data.length === 0) return <NoData msg="No Archer tickets recorded yet" />;
return (
<ResponsiveContainer width="100%" height={210}>
<BarChart data={data} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
{Object.entries(ARCHER_STATUS_COLORS).map(([status, color], i, arr) => (
<Bar
key={status}
dataKey={status} name={status} stackId="s"
fill={color} fillOpacity={0.85}
radius={i === arr.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
export default function ComplianceChartsPanel() {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [trends, setTrends] = useState([]);
const [mttr, setMttr] = useState([]);
const [recurring, setRecurring] = useState([]);
const [archerRaw, setArcherRaw] = useState([]);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const [tRes, mRes, rRes, aRes] = await Promise.all([
fetch(`${API_BASE}/compliance/trends`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/mttr`, { credentials: 'include' }),
fetch(`${API_BASE}/compliance/top-recurring`, { credentials: 'include' }),
fetch(`${API_BASE}/archer-tickets/status-trend`, { credentials: 'include' }),
]);
if (cancelled) return;
if (tRes.ok) { const d = await tRes.json(); setTrends(d.trends || []); }
if (mRes.ok) { const d = await mRes.json(); setMttr(d.mttr || []); }
if (rRes.ok) { const d = await rRes.json(); setRecurring(d.items || []); }
if (aRes.ok) { const d = await aRes.json(); setArcherRaw(d.statusTrend || []); }
} catch { /* silent — charts will show no-data state */ }
finally { if (!cancelled) setLoading(false); }
};
load();
return () => { cancelled = true; };
}, []);
// Format trend rows — add short date label
const formattedTrends = useMemo(
() => trends.map(t => ({ ...t, date: fmtDate(t.report_date) })),
[trends]
);
// Pivot archer raw rows → one object per date
const archerByDate = useMemo(() => {
if (!archerRaw.length) return [];
const map = {};
archerRaw.forEach(r => {
if (!map[r.date]) map[r.date] = { date: fmtDate(r.date) };
map[r.date][r.status] = r.count;
});
return Object.values(map).sort((a, b) => a.date.localeCompare(b.date));
}, [archerRaw]);
return (
<div style={{ marginBottom: '1.5rem' }}>
{/* ── Section header / collapse toggle ──────────────────────── */}
<button
onClick={() => setCollapsed(c => !c)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0 0 0.625rem 0',
borderBottom: collapsed ? 'none' : '1px solid rgba(20,184,166,0.1)',
marginBottom: collapsed ? 0 : '0.875rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
<span style={{
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
}}>
Historical Trends
</span>
{loading && (
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
)}
</div>
{collapsed
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
}
</button>
{!collapsed && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
gap: '1rem',
}}>
{/* 1. Active findings over time */}
<ChartCard
title="Active Findings Over Time"
subtitle="Total non-compliant items per report cycle (solid) + per team (dashed)"
>
<ActiveTrendChart data={formattedTrends} />
</ChartCard>
{/* 2. New / Recurring / Resolved delta per cycle */}
<ChartCard
title="Change per Report Cycle"
subtitle="New (red) and recurring (amber) stacked; resolved (green) as separate bars"
>
<DeltaChart data={formattedTrends} />
</ChartCard>
{/* 3. Team health multi-line */}
<ChartCard
title="Team Compliance Health"
subtitle="Active findings per team per cycle — lower is better"
>
<TeamTrendChart data={formattedTrends} />
</ChartCard>
{/* 4. MTTR per team */}
<ChartCard
title="Mean Time to Resolution"
subtitle="Average calendar days between first-seen and resolved, by team"
>
<MttrChart data={mttr} />
</ChartCard>
{/* 5. Most persistent / recurring findings */}
<ChartCard
title="Most Persistent Findings"
subtitle="Active items with the highest recurrence count (top 10)"
>
<RecurringChart data={recurring} />
</ChartCard>
{/* 6. Archer ticket pipeline */}
<ChartCard
title="Archer Exception Pipeline"
subtitle="Exception ticket status distribution by creation date"
>
<ArcherPipelineChart data={archerByDate} />
</ChartCard>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,366 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const CATEGORY_COLORS = {
'Vulnerability Management': '#EF4444',
'Access & MFA': '#F59E0B',
'Logging & Monitoring': '#8B5CF6',
'End-of-Life OS': '#F97316',
'Decommissioned Assets': '#64748B',
'Asset Data Quality': '#64748B',
'Application Security': '#0EA5E9',
'Disaster Recovery': TEAL,
'Endpoint Protection': '#F97316',
};
function categoryColor(category) {
return CATEGORY_COLORS[category] || '#94A3B8';
}
function MetricChip({ metricId, category, status }) {
const color = status === 'resolved' ? '#64748B' : categoryColor(category);
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.2rem 0.5rem',
background: `${color}18`,
border: `1px solid ${color}50`,
borderRadius: '0.25rem',
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
}}>
{metricId}
</span>
);
}
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [noteText, setNoteText] = useState('');
const [noteMetric, setNoteMetric] = useState('');
const [submitting, setSubmitting] = useState(false);
const [noteError, setNoteError] = useState(null);
const fetchDetail = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default note metric to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setNoteMetric(firstActive.metric_id);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [hostname]);
useEffect(() => { fetchDetail(); }, [fetchDetail]);
const handleAddNote = async () => {
if (!noteText.trim() || !noteMetric) return;
setSubmitting(true);
setNoteError(null);
try {
const res = await fetch(`${API_BASE}/compliance/notes`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save note');
setNoteText('');
await fetchDetail();
if (onNoteAdded) onNoteAdded();
} catch (err) {
setNoteError(err.message);
} finally {
setSubmitting(false);
}
};
const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 40 }} />
{/* Panel */}
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
borderLeft: `1px solid ${TEAL}30`,
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
zIndex: 41,
display: 'flex', flexDirection: 'column',
overflowY: 'auto',
}}>
{/* Header */}
<div style={{
padding: '1.25rem 1.25rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.06)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
{hostname}
</div>
{detail && (
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{detail.ip_address && (
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
)}
{detail.device_type && (
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
)}
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
</div>
)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
</div>
{loading && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
</div>
)}
{error && (
<div style={{ padding: '1.25rem', display: 'flex', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0, marginTop: '1px' }} />{error}
</div>
)}
{!loading && !error && detail && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Active failing metrics */}
{activeMetrics.length > 0 && (
<Section title="Failing Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
{activeMetrics.map(m => (
<MetricRow key={m.metric_id} metric={m} onNavigate={onNavigate} />
))}
</Section>
)}
{/* Resolved metrics */}
{resolvedMetrics.length > 0 && (
<Section title="Resolved Metrics" muted>
{resolvedMetrics.map(m => (
<MetricRow key={m.metric_id} metric={m} resolved />
))}
</Section>
)}
{/* Upload history summary */}
{activeMetrics.length > 0 && (
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
{activeMetrics.map(m => (
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<MetricChip metricId={m.metric_id} category={m.category} />
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
{m.seen_count}× seen
</span>
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
</div>
</div>
))}
</Section>
)}
{/* Notes */}
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
{detail.notes.length === 0 && (
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
)}
{detail.notes.map(n => (
<div key={n.id} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
</div>
))}
{/* Add note */}
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{activeMetrics.length > 1 && (
<select
value={noteMetric}
onChange={e => setNoteMetric(e.target.value)}
style={{
width: '100%', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.25rem', color: '#CBD5E1',
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
}}>
{activeMetrics.map(m => (
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} {m.category}</option>
))}
</select>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Add a note…"
rows={2}
style={{
flex: 1, resize: 'none',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.375rem', color: '#F8FAFC',
padding: '0.5rem 0.625rem', fontSize: '0.8rem',
outline: 'none',
}}
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
/>
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
style={{
padding: '0.5rem 0.625rem', flexShrink: 0,
background: noteText.trim() ? `${TEAL}20` : 'transparent',
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
}}>
{submitting
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
: <Send style={{ width: '16px', height: '16px' }} />}
</button>
</div>
{noteError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem' }}>{noteError}</div>}
</div>
</Section>
</div>
)}
</div>
</>
);
}
function Section({ title, icon, children, muted, grow }) {
return (
<div style={{
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
marginBottom: '0.75rem',
}}>
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
{title}
</div>
{children}
</div>
);
}
function MetricRow({ metric, resolved, onNavigate }) {
const color = resolved ? '#475569' : categoryColor(metric.category);
const extra = metric.extra || {};
const ivantiId = (!resolved && metric.metric_id?.startsWith('2.3'))
? (extra['Ivanti_Vulnerability_ID'] || null)
: null;
// Surface the most useful extra fields per metric type
const highlights = [];
if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
if (extra['Normalized - Operating System'])
highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
if (extra['EOS - End of Service Life'])
highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
return (
<div style={{
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
background: resolved ? 'transparent' : `${color}08`,
border: `1px solid ${color}25`,
borderRadius: '0.375rem',
opacity: resolved ? 0.5 : 1,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
</div>
{metric.metric_desc && (
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: (highlights.length || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
{metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
</div>
)}
{ivantiId && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
</div>
{onNavigate && (
<button
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
style={{
flexShrink: 0, marginLeft: '0.5rem',
background: 'rgba(14,165,233,0.1)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '0.25rem',
color: '#0EA5E9',
fontSize: '0.65rem', fontFamily: 'monospace',
padding: '0.2rem 0.5rem',
cursor: 'pointer', whiteSpace: 'nowrap',
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
>
View in Triage
</button>
)}
</div>
)}
{highlights.map(h => (
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel';
import ComplianceChartsPanel from './ComplianceChartsPanel';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const TEAL = '#14B8A6';
const TEAMS = ['STEAM', 'ACCESS-ENG'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR = {
'Meets/Exceeds Target': '#10B981',
'Within 15% of Target': '#F59E0B',
'Below 15% of Target': '#EF4444',
};
const CATEGORY_COLORS = {
'Vulnerability Management': '#EF4444',
'Access & MFA': '#F59E0B',
'Logging & Monitoring': '#8B5CF6',
'End-of-Life OS': '#F97316',
'Decommissioned Assets': '#64748B',
'Asset Data Quality': '#64748B',
'Application Security': '#0EA5E9',
'Disaster Recovery': TEAL,
'Endpoint Protection': '#F97316',
};
function statusColor(status) {
return STATUS_COLOR[status] || '#EF4444';
}
function pctDisplay(pct) {
return `${Math.round(pct * 100)}%`;
}
// Deduplicate summary entries — one per metric_id for the selected team
// (exclude aggregate "ALL: NTS-AEO" rows)
function teamMetrics(entries, team) {
return entries.filter(e => e.team === team);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function MetricHealthCard({ entry, active, onClick }) {
const color = statusColor(entry.status);
const isOk = entry.status === 'Meets/Exceeds Target';
return (
<button
onClick={onClick}
style={{
background: active
? `${color}18`
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: `1.5px solid ${active ? color : color + '40'}`,
borderRadius: '0.5rem',
padding: '0.875rem 1rem',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s',
minWidth: '160px',
flex: '1 1 0',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = color + '40'; }}
>
{/* Metric ID */}
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem' }}>
{entry.metric_id}
</div>
{/* Category */}
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.625rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{entry.category}
</div>
{/* Compliance % */}
<div style={{ fontFamily: 'monospace', fontSize: '1.4rem', fontWeight: '700', color, lineHeight: 1, marginBottom: '0.3rem' }}>
{pctDisplay(entry.compliance_pct)}
</div>
{/* Target */}
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
target {pctDisplay(entry.target)}
</div>
{/* Status pill */}
<div style={{
marginTop: '0.625rem', display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
color, padding: '0.2rem 0.5rem',
background: `${color}12`, borderRadius: '999px',
border: `1px solid ${color}30`,
}}>
<span style={{
width: '5px', height: '5px', borderRadius: '50%',
background: color, flexShrink: 0,
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
}} />
{isOk ? 'OK' : entry.status.replace(' of Target', '')}
</div>
</button>
);
}
function MetricBadge({ metricId, category }) {
const color = CATEGORY_COLORS[category] || '#94A3B8';
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.15rem 0.45rem',
background: `${color}15`, border: `1px solid ${color}40`,
borderRadius: '0.2rem', color,
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
whiteSpace: 'nowrap',
}}>
{metricId}
</span>
);
}
function SeenBadge({ count }) {
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
return (
<span style={{
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
color, padding: '0.15rem 0.4rem',
background: `${color}12`, borderRadius: '0.2rem',
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
}}>
{count}×
</span>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function CompliancePage({ onNavigate }) {
const { canWrite } = useAuth();
const [activeTeam, setActiveTeam] = useState('STEAM');
const [activeTab, setActiveTab] = useState('active');
const [metricFilter, setMetricFilter] = useState(null);
const [hostSearch, setHostSearch] = useState('');
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedHost, setSelectedHost] = useState(null);
const [showUpload, setShowUpload] = useState(false);
const fetchSummary = useCallback(async (team) => {
try {
const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
const data = await res.json();
setSummary(data);
} catch { /* silent */ }
}, []);
const fetchDevices = useCallback(async (team, tab) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load');
setDevices(data.devices || []);
} catch (err) {
setError(err.message);
setDevices([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setMetricFilter(null);
setHostSearch('');
setSelectedHost(null);
fetchSummary(activeTeam);
fetchDevices(activeTeam, activeTab);
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setMetricFilter(null);
fetchDevices(activeTeam, activeTab);
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
const refresh = () => {
fetchSummary(activeTeam);
fetchDevices(activeTeam, activeTab);
};
// In-memory filters
const filteredDevices = devices
.filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
const metrics = teamMetrics(summary.entries, activeTeam);
const lastUpload = summary.upload;
return (
<div style={{ paddingBottom: '2rem' }}>
{/* ── Page header ─────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
<div>
<h2 style={{
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
}}>
AEO Compliance
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{lastUpload ? (
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
</span>
) : (
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
)}
{summary.overall_scores?.customer_network != null && (
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
</span>
)}
{summary.overall_scores?.vertical != null && (
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button onClick={refresh} title="Refresh"
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
<RefreshCw style={{ width: '16px', height: '16px' }} />
</button>
{canWrite() && (
<button onClick={() => setShowUpload(true)}
className="intel-button"
style={{
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
color: TEAL, padding: '0.5rem 1rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
borderRadius: '0.375rem',
}}>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Report
</button>
)}
</div>
</div>
{/* ── Team tabs ────────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
{TEAMS.map(team => {
const isActive = activeTeam === team;
return (
<button key={team} onClick={() => setActiveTeam(team)}
style={{
padding: '0.5rem 1.25rem', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.06em',
borderRadius: '0.375rem',
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
background: isActive ? `${TEAL}18` : 'transparent',
color: isActive ? TEAL : '#475569',
transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
{team}
</button>
);
})}
</div>
{/* ── Metric health cards ──────────────────────────────────── */}
{metrics.length > 0 ? (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
Metric Health click to filter
{metricFilter && (
<button onClick={() => setMetricFilter(null)}
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
× clear filter
</button>
)}
</div>
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap' }}>
{metrics.map(entry => (
<MetricHealthCard
key={entry.metric_id}
entry={entry}
active={metricFilter === entry.metric_id}
onClick={() => setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
/>
))}
</div>
</div>
) : lastUpload === null ? (
<div style={{
marginBottom: '1.5rem', padding: '2rem',
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
textAlign: 'center',
}}>
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
No compliance data upload a report to get started
</div>
</div>
) : null}
{/* ── Historical trend charts ──────────────────────────────── */}
<ComplianceChartsPanel />
{/* ── Device table ─────────────────────────────────────────── */}
<div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
overflow: 'hidden',
}}>
{/* Table toolbar */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
}}>
{/* Active / Resolved tabs */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{['active', 'resolved'].map(tab => {
const isActive = activeTab === tab;
return (
<button key={tab} onClick={() => setActiveTab(tab)}
style={{
padding: '0.35rem 0.875rem', cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.04em',
borderRadius: '0.25rem',
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
background: isActive ? `${TEAL}12` : 'transparent',
color: isActive ? TEAL : '#475569',
}}>
{tab}
{isActive && (
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
({loading ? '…' : filteredDevices.length})
</span>
)}
</button>
);
})}
</div>
{/* Hostname search */}
<input
value={hostSearch}
onChange={e => setHostSearch(e.target.value)}
placeholder="Search hostname…"
style={{
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
width: '220px',
}}
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
/>
</div>
{/* Column headers */}
<div style={{
display: 'grid',
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
padding: '0.5rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '0.62rem', color: '#334155',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
<span>Hostname</span>
<span>IP Address</span>
<span>Type</span>
<span>Failing Metrics</span>
<span>Seen</span>
<span></span>
</div>
{/* Rows */}
{loading ? (
<div style={{ padding: '3rem', textAlign: 'center' }}>
<Loader style={{ width: '28px', height: '28px', color: TEAL, margin: '0 auto', animation: 'spin 1s linear infinite' }} />
</div>
) : error ? (
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
</div>
) : filteredDevices.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
</div>
) : (
filteredDevices.map(device => (
<DeviceRow
key={device.hostname}
device={device}
selected={selectedHost === device.hostname}
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
/>
))
)}
</div>
{/* ── Detail panel ─────────────────────────────────────────── */}
{selectedHost && (
<ComplianceDetailPanel
hostname={selectedHost}
onClose={() => setSelectedHost(null)}
onNoteAdded={refresh}
onNavigate={onNavigate}
/>
)}
{/* ── Upload modal ─────────────────────────────────────────── */}
{showUpload && (
<ComplianceUploadModal
onClose={() => setShowUpload(false)}
onUploadComplete={() => { setShowUpload(false); refresh(); }}
/>
)}
</div>
);
}
function DeviceRow({ device, selected, onClick }) {
return (
<div
onClick={onClick}
style={{
display: 'grid',
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
padding: '0.625rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
cursor: 'pointer',
background: selected ? `${TEAL}08` : 'transparent',
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
transition: 'all 0.15s',
alignItems: 'center',
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
>
{/* Hostname */}
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{device.hostname}
</div>
{/* IP */}
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
{device.ip_address || '—'}
</div>
{/* Type */}
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{device.device_type || '—'}
</div>
{/* Failing metrics */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{device.failing_metrics.map(m => (
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
))}
</div>
{/* Seen count */}
<div>
<SeenBadge count={device.seen_count} />
</div>
{/* Notes indicator */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
{device.has_notes && (
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import React, { useState, useRef } from 'react';
import { X, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// phase: idle → uploading → preview → committing → done | error
export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
const [phase, setPhase] = useState('idle');
const [previewData, setPreviewData] = useState(null);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
const handleFile = async (file) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.xlsx')) {
setError('File must be an .xlsx spreadsheet');
return;
}
setPhase('uploading');
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/compliance/preview`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Upload failed');
}
setPreviewData(data);
setPhase('preview');
} catch (err) {
setError(err.message);
setPhase('error');
}
};
const handleCommit = async () => {
if (!previewData) return;
setPhase('committing');
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/commit`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tempFile: previewData.tempFile,
filename: previewData.filename,
report_date: previewData.report_date,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Commit failed');
setPhase('done');
setTimeout(onUploadComplete, 1200);
} catch (err) {
setError(err.message);
setPhase('error');
}
};
const TEAL = '#14B8A6';
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 60,
background: 'rgba(10, 14, 39, 0.97)',
backdropFilter: 'blur(12px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '1rem',
}}>
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
border: `1px solid ${TEAL}40`,
borderRadius: '0.75rem',
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
width: '100%', maxWidth: '480px',
padding: '2rem',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
<div>
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Upload Report
</div>
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* IDLE — drop zone */}
{phase === 'idle' && (
<>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
style={{
border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
borderRadius: '0.5rem',
padding: '2.5rem',
textAlign: 'center',
cursor: 'pointer',
background: dragOver ? `${TEAL}08` : 'transparent',
transition: 'all 0.2s',
}}>
<FileSpreadsheet style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', opacity: 0.8 }} />
<div style={{ color: '#CBD5E1', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
Drop your xlsx file here or <span style={{ color: TEAL }}>browse</span>
</div>
<div style={{ color: '#475569', fontSize: '0.75rem' }}>NTS_AEO_YYYY_MM_DD.xlsx</div>
</div>
<input ref={fileInputRef} type="file" accept=".xlsx" style={{ display: 'none' }}
onChange={e => handleFile(e.target.files[0])} />
{error && (
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.8rem' }}>
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />{error}
</div>
)}
</>
)}
{/* UPLOADING / COMMITTING — spinner */}
{(phase === 'uploading' || phase === 'committing') && (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
</div>
</div>
)}
{/* PREVIEW — diff summary + confirm */}
{phase === 'preview' && previewData && (
<>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{previewData.filename}
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
</div>
{[
{ label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
{ label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
{ label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
].map(({ label, count, color, icon }) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: `1px solid ${color}25`,
}}>
<span style={{ color: '#CBD5E1', fontSize: '0.875rem' }}>
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
{label}
</span>
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem', color: '#64748B', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
Cancel
</button>
<button onClick={handleCommit}
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
Confirm Upload
</button>
</div>
</>
)}
{/* DONE */}
{phase === 'done' && (
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Upload committed
</div>
</div>
)}
{/* ERROR */}
{phase === 'error' && (
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
<button onClick={() => { setPhase('idle'); setError(null); }}
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
Try Again
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,460 @@
import React, { useState, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { Download, Loader, AlertCircle, BarChart2, FileText, Shield, Tag, CheckCircle, X } from 'lucide-react';
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={{
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',
}}>
<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>
<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

@@ -0,0 +1,207 @@
// IvantiCountsChart.js
// Collapsible trend panel for the Vulnerability Triage page.
// Shows open vs closed Ivanti finding counts over time (last sync per day).
import React, { useState, useEffect, useMemo } from 'react';
import {
LineChart, Line,
XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ReferenceLine,
ResponsiveContainer,
} from 'recharts';
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const AMBER = '#F59E0B';
const SKY = '#0EA5E9';
const GREEN = '#10B981';
const RED = '#EF4444';
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
// ---------------------------------------------------------------------------
// Custom dark tooltip
// ---------------------------------------------------------------------------
function DarkTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
return (
<div style={{
background: 'rgba(10,17,32,0.97)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.7rem',
minWidth: '160px',
}}>
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
{label}
</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ opacity: 0.8 }}>{p.name}</span>
<span style={{ fontWeight: '700' }}>{p.value}</span>
</div>
))}
{openVal != null && closedVal != null && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span>total</span>
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Shorten YYYY-MM-DD to MM/DD/YY
// ---------------------------------------------------------------------------
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function IvantiCountsChart() {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [history, setHistory] = useState([]);
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
if (res.ok && !cancelled) {
const d = await res.json();
setHistory(d.history || []);
}
} catch { /* silent — chart shows no-data state */ }
finally { if (!cancelled) setLoading(false); }
};
load();
return () => { cancelled = true; };
}, []);
const chartData = useMemo(
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
[history]
);
// Compute a simple delta label for the latest vs previous point
const deltaLabel = useMemo(() => {
if (chartData.length < 2) return null;
const latest = chartData[chartData.length - 1];
const prev = chartData[chartData.length - 2];
const delta = latest.open_count - prev.open_count;
if (delta === 0) return { text: 'no change in open', color: '#475569' };
if (delta < 0) return { text: `${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
return { text: `${delta} open since ${prev.date}`, color: RED };
}, [chartData]);
return (
<div style={{ marginBottom: '1.25rem' }}>
{/* ── Header ────────────────────────────────────────────────── */}
<button
onClick={() => setCollapsed(c => !c)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'none', border: 'none', cursor: 'pointer',
padding: '0 0 0.625rem 0',
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
marginBottom: collapsed ? 0 : '0.875rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
<span style={{
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
}}>
Findings Trend
</span>
{loading && (
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
)}
{!loading && deltaLabel && (
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
{deltaLabel.text}
</span>
)}
</div>
{collapsed
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
}
</button>
{!collapsed && (
<div style={{
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: '1px solid rgba(245,158,11,0.15)',
borderRadius: '0.5rem',
padding: '1rem 1.25rem 0.875rem',
}}>
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Open vs Closed end-of-day snapshot per sync day
</div>
{chartData.length > 0 && (
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
</div>
)}
</div>
{chartData.length < 2 ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '160px', color: '#334155',
fontFamily: 'monospace', fontSize: '0.72rem',
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
}}>
{chartData.length === 0
? 'Trend data begins accumulating after the first sync — check back tomorrow'
: 'Need at least 2 days of syncs to display a trend'}
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={AXIS_STYLE} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
<Tooltip content={<DarkTooltip />} />
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
<Line
type="monotone" dataKey="open_count" name="Open"
stroke={AMBER} strokeWidth={2}
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone" dataKey="closed_count" name="Closed"
stroke={SKY} strokeWidth={2}
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,484 @@
// KnowledgeBasePage.js
// Full-page knowledge base library — browse, search, filter, and read
// articles inline. Upload and delete require editor/admin role.
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
BookOpen, Search, Upload, RefreshCw, Loader,
AlertCircle, FileText, File, Trash2, X,
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import KnowledgeBaseModal from '../KnowledgeBaseModal';
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const GREEN = '#10B981';
// ---------------------------------------------------------------------------
// Static config
// ---------------------------------------------------------------------------
const CATEGORY_COLORS = {
General: '#94A3B8',
Policy: '#0EA5E9',
Procedure: GREEN,
Guide: '#F59E0B',
Reference: '#8B5CF6',
};
const FILE_EXT_COLORS = {
pdf: '#EF4444',
md: '#10B981',
txt: '#94A3B8',
doc: '#0EA5E9',
docx: '#0EA5E9',
xls: '#10B981',
xlsx: '#10B981',
ppt: '#F97316',
pptx: '#F97316',
html: '#8B5CF6',
};
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extOf(filename) {
return (filename || '').split('.').pop().toLowerCase();
}
function extColor(filename) {
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
}
function fmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fmtDate(str) {
if (!str) return '';
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function catColor(cat) {
return CATEGORY_COLORS[cat] || '#94A3B8';
}
// ---------------------------------------------------------------------------
// ArticleCard
// ---------------------------------------------------------------------------
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
const color = catColor(article.category);
const fileColor = extColor(article.file_name);
const ext = extOf(article.file_name).toUpperCase();
return (
<div
onClick={() => onSelect(article)}
style={{
background: selected
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
borderRadius: '0.5rem',
padding: '1rem',
cursor: 'pointer',
transition: 'all 0.15s',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
>
{/* File type badge + delete button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
color: fileColor, padding: '0.15rem 0.4rem',
background: `${fileColor}15`, borderRadius: '0.2rem',
border: `1px solid ${fileColor}30`,
}}>
{ext}
</span>
{canDelete && (
<button
onClick={e => { e.stopPropagation(); onDelete(article); }}
title="Delete article"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#334155', padding: '0.15rem',
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
>
<Trash2 style={{ width: '12px', height: '12px' }} />
</button>
)}
</div>
{/* Title */}
<div style={{
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
color: selected ? GREEN : '#E2E8F0',
lineHeight: 1.3,
}}>
{article.title}
</div>
{/* Description */}
{article.description && (
<div style={{
fontSize: '0.7rem', color: '#475569',
lineHeight: 1.45, display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{article.description}
</div>
)}
{/* Footer — category + date */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
color, padding: '0.15rem 0.4rem',
background: `${color}12`, borderRadius: '0.2rem',
border: `1px solid ${color}25`,
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{article.category}
</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{article.file_size && (
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
{fmtSize(article.file_size)}
</span>
)}
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
{fmtDate(article.created_at)}
</span>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilter, onClear }) {
return (
<div style={{
gridColumn: '1 / -1',
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: '4rem 2rem',
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
color: '#334155',
}}>
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
{hasFilter ? 'No articles match your search' : 'No articles yet'}
</div>
{hasFilter ? (
<button onClick={onClear} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
marginTop: '0.375rem',
}}>
Clear filters
</button>
) : (
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
Upload a document to get started
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function KnowledgeBasePage() {
const { canWrite } = useAuth();
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [selected, setSelected] = useState(null);
const [showUpload, setShowUpload] = useState(false);
// -------------------------------------------------------------------------
// Fetch
// -------------------------------------------------------------------------
const fetchArticles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load articles');
const data = await res.json();
setArticles(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchArticles(); }, [fetchArticles]);
// -------------------------------------------------------------------------
// Delete
// -------------------------------------------------------------------------
const handleDelete = useCallback(async (article) => {
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
try {
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
method: 'DELETE', credentials: 'include',
});
if (!res.ok) throw new Error('Delete failed');
setArticles(prev => prev.filter(a => a.id !== article.id));
if (selected?.id === article.id) setSelected(null);
} catch (err) {
alert(`Failed to delete: ${err.message}`);
}
}, [selected]);
// -------------------------------------------------------------------------
// Filtering
// -------------------------------------------------------------------------
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return articles.filter(a => {
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
const matchesSearch = !q ||
a.title.toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q);
return matchesCat && matchesSearch;
});
}, [articles, activeCategory, search]);
// Category tab counts (always from full list, not filtered by search)
const categoryCounts = useMemo(() => {
const counts = { All: articles.length };
CATEGORY_ORDER.forEach(cat => {
counts[cat] = articles.filter(a => a.category === cat).length;
});
return counts;
}, [articles]);
const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)];
const clearFilters = () => { setSearch(''); setActiveCategory('All'); };
const hasFilter = search.trim() !== '' || activeCategory !== 'All';
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
{/* ── Page header ─────────────────────────────────────────── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2 style={{
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
}}>
Knowledge Base
</h2>
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
{articles.length > 0 && activeCategory !== 'All' && (
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
· {categoryCounts[activeCategory] || 0} in {activeCategory}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
onClick={fetchArticles}
title="Refresh"
style={{
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
}}
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
>
<RefreshCw style={{ width: '16px', height: '16px' }} />
</button>
{canWrite() && (
<button
onClick={() => setShowUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
color: GREEN, padding: '0.5rem 1rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', borderRadius: '0.375rem',
}}
>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Article
</button>
)}
</div>
</div>
{/* ── Search + category tabs ───────────────────────────────── */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
{/* Search */}
<div style={{ position: 'relative', flexShrink: 0 }}>
<Search style={{
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
}} />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search articles…"
style={{
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
paddingTop: '0.4rem', paddingBottom: '0.4rem',
background: 'rgba(15,23,42,0.8)',
border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.375rem', color: '#E2E8F0',
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
width: '220px',
}}
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
/>
{search && (
<button
onClick={() => setSearch('')}
style={{
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
}}
>
<X style={{ width: '12px', height: '12px' }} />
</button>
)}
</div>
{/* Category tabs */}
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
{activeTabs.map(cat => {
const isActive = activeCategory === cat;
const color = cat === 'All' ? GREEN : catColor(cat);
return (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
style={{
padding: '0.35rem 0.75rem',
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer', borderRadius: '0.25rem',
border: isActive ? `1px solid ${color}` : '1px solid transparent',
background: isActive ? `${color}15` : 'transparent',
color: isActive ? color : '#475569',
transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
>
{cat}
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
{categoryCounts[cat] ?? 0}
</span>
</button>
);
})}
</div>
</div>
{/* ── Error state ──────────────────────────────────────────── */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.875rem 1rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.5rem', color: '#F87171',
fontFamily: 'monospace', fontSize: '0.78rem',
}}>
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
{error}
<button
onClick={fetchArticles}
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
>
Retry
</button>
</div>
)}
{/* ── Loading state ────────────────────────────────────────── */}
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
</div>
)}
{/* ── Article grid ─────────────────────────────────────────── */}
{!loading && !error && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '0.875rem',
}}>
{filtered.length === 0 ? (
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
) : (
filtered.map(article => (
<ArticleCard
key={article.id}
article={article}
selected={selected?.id === article.id}
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
onDelete={handleDelete}
canDelete={canWrite()}
/>
))
)}
</div>
)}
{/* ── Inline viewer ────────────────────────────────────────── */}
{selected && (
<div style={{ marginTop: '0.25rem' }}>
<KnowledgeBaseViewer
article={selected}
onClose={() => setSelected(null)}
/>
</div>
)}
{/* ── Upload modal ─────────────────────────────────────────── */}
{showUpload && (
<KnowledgeBaseModal
onClose={() => setShowUpload(false)}
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
/>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

297
plan.md
View File

@@ -1,297 +0,0 @@
# NVD Lookup + Retroactive Sync — Implementation Plan
## Overview
Two capabilities on `feature/nvd-lookup` branch:
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
## Current State
### Git State
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
- Offsite repo is up to date through the feature/audit merge to master
### What's in the Stash
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
**`backend/routes/nvdLookup.js` (NEW file)**
- Factory function: `createNvdLookupRouter(db, requireAuth)`
- `GET /lookup/:cveId` endpoint
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
- 10-second timeout via `AbortSignal.timeout(10000)`
- Optional `apiKey` header from `NVD_API_KEY` env var
- CVSS severity cascade: v3.1 → v3.0 → v2.0
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
- Returns: `{ description, severity, published_date }`
**`backend/server.js` (MODIFIED)**
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
**`frontend/src/App.js` (MODIFIED)**
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
- Spinner (Loader icon) in CVE ID field while loading
- Green "Auto-filled from NVD" with CheckCircle on success
- Amber warning with AlertCircle on errors (non-blocking)
- Description only fills if currently empty; severity + published_date always update
- NVD state resets on modal close (X, Cancel) and form submit
**`backend/.env.example` (MODIFIED)**
- Adds `NVD_API_KEY=` with comment about rate limits
### Stash Conflict Resolution
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
The conflict is in the imports section. Keep ALL existing audit lines from master:
```js
const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');
```
AND add the NVD line:
```js
const createNvdLookupRouter = require('./routes/nvdLookup');
```
Similarly, keep the audit route mount and add the NVD mount after it:
```js
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
```
Then `git add backend/server.js` to mark resolved and `git stash drop`.
---
## Step 1: Resolve Stash + Rebase onto Master
```bash
git checkout feature/nvd-lookup
git rebase master # Get audit changes into the branch
git stash pop # Apply NVD changes (will conflict in server.js)
# Resolve conflict in server.js as described above
git add backend/server.js
git stash drop
```
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
---
## Step 2: Backend — New Endpoints in `server.js`
### 2A: `GET /api/cves/distinct-ids`
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
```js
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows.map(r => r.cve_id));
});
});
```
### 2B: `POST /api/cves/nvd-sync`
Place after the existing `PATCH /api/cves/:cveId/status`:
```js
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { updates } = req.body;
if (!Array.isArray(updates) || updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' });
}
let updated = 0;
const errors = [];
let completed = 0;
db.serialize(() => {
updates.forEach((entry) => {
const fields = [];
const values = [];
if (entry.description !== null && entry.description !== undefined) {
fields.push('description = ?');
values.push(entry.description);
}
if (entry.severity !== null && entry.severity !== undefined) {
fields.push('severity = ?');
values.push(entry.severity);
}
if (entry.published_date !== null && entry.published_date !== undefined) {
fields.push('published_date = ?');
values.push(entry.published_date);
}
if (fields.length === 0) {
completed++;
if (completed === updates.length) sendResponse();
return;
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(entry.cve_id);
db.run(
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
values,
function(err) {
if (err) {
errors.push({ cve_id: entry.cve_id, error: err.message });
} else {
updated += this.changes;
}
completed++;
if (completed === updates.length) sendResponse();
}
);
});
});
function sendResponse() {
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'cve_nvd_sync',
entityType: 'cve',
entityId: null,
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
ipAddress: req.ip
});
const result = { message: 'NVD sync completed', updated };
if (errors.length > 0) result.errors = errors;
res.json(result);
}
});
```
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
---
## Step 3: Frontend — New `NvdSyncModal.js` Component
**File:** `frontend/src/components/NvdSyncModal.js`
### Props
```jsx
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
```
### Phase Machine
| Phase | What's shown |
|-------|-------------|
| `idle` | CVE count + "Fetch NVD Data" button |
| `fetching` | Progress bar, current CVE being fetched, cancel button |
| `review` | Comparison table with per-CVE description choice |
| `applying` | Spinner |
| `done` | Summary (X updated, Y errors) + Close button |
### Fetching Logic
- Iterate CVE IDs sequentially
- Call `GET /api/nvd/lookup/:cveId` for each
- 7-second delay between requests (safe for 5 req/30s without API key)
- On 429: wait 35 seconds, retry once
- On 404: mark as "Not found in NVD" (gray, skipped)
- On timeout/error: mark with warning (skipped)
- Support cancellation via AbortController
### Comparison Table Columns
| Column | Content |
|--------|---------|
| CVE ID | The identifier |
| Status | Icon: check=found, warning=error, dash=no changes |
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
| Published Date | `Current → NVD` or "No change" |
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
### Bulk Controls
Above the table:
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
Below the table:
- "Apply N Changes" button (count updates dynamically)
- "Cancel" button
### Apply Logic
Build updates array:
- For each CVE with NVD data (no error):
- Always include `severity` and `published_date` if different from current
- Include `description` only if user chose "Use NVD" — otherwise send `null`
- Skip CVEs where nothing changed
- POST to `/api/cves/nvd-sync`
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
---
## Step 4: Frontend — App.js Integration
Minimal changes following `AuditLog`/`UserManagement` pattern:
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
```jsx
{canWrite() && (
<button onClick={() => setShowNvdSync(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md">
<RefreshCw className="w-5 h-5" />
Sync with NVD
</button>
)}
```
4. **Modal render** (alongside other modals):
```jsx
{showNvdSync && (
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}
```
---
## Step 5: AuditLog Badge
**File:** `frontend/src/components/AuditLog.js`
Add to the `ACTION_BADGES` object:
```js
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
```
---
## Step 6: .env.example (already in stash)
```
# 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=
```
---
## File Summary
| File | Action | Lines Changed (est.) |
|------|--------|---------------------|
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
| `backend/.env.example` | From stash | +3 |
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
---
## Verification Checklist
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
5. Click "Sync with NVD" button → modal opens with CVE count
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
7. Review comparison table → verify diffs shown correctly
8. Toggle description choices, click "Apply" → verify database updated
9. Confirm main CVE list refreshes with new data
10. Check audit log for `cve_nvd_sync` entry

View File

@@ -1,7 +1,5 @@
# Authentication Feature - Test Cases
**Feature Branch:** feature/login
**Date:** 2026-01-28
**Tester:** _______________
---