26 Commits

Author SHA1 Message Date
Jordan Ramos
c288d235e0 Release v2.4.0 — team enforcement, page visibility, View As 2026-06-24 14:42:56 -06:00
Jordan Ramos
c000bf3f8b Merge feature/team-and-page-access-enforcement
Add backend team enforcement, page visibility by group, and Admin
impersonation (View As) feature.

- requireTeam() middleware enforces team-scoped data access on Ivanti
  findings, compliance, CARD, Atlas, and archive routes
- Centralized page visibility matrix controls nav and API access per group
- Admin can View As any non-Admin user to verify permissions
- Impersonation events hidden from non-Admin activity feed
- db-schema.sql and steering files updated to reflect new patterns
2026-06-24 14:41:53 -06:00
Jordan Ramos
e34f9e567c Extend team enforcement to Atlas and Archive routes, update schema reference
- Atlas: add requireTeam() at router level; replace client ?teams= param
  parsing with req.teamScope in /metrics, /status, and /sync endpoints
- Archive: add requireTeam() at router level; replace client ?teams= param
  parsing with req.teamScope in GET / and GET /stats endpoints
- db-schema.sql: add impersonate_user_id column to sessions table reference

The frontend still sends ?teams= as a query param to these endpoints
(harmless no-op since backend ignores it). Frontend cleanup deferred
to avoid churn in the 7000-line ReportingPage component.
2026-06-24 13:41:16 -06:00
Jordan Ramos
0e17318cba Hide impersonation events from non-Admin activity feed
Non-Admin users should not see impersonate_start/impersonate_stop
entries in the recent activity feed. The feed now filters these
actions for non-Admin groups alongside the existing login/logout
exclusions.
2026-06-24 13:01:15 -06:00
Jordan Ramos
8c789ce765 Add View As (impersonation) feature for Admin users
Allow Admin users to temporarily view the app as another user to verify
permissions and team scoping without switching accounts.

Backend:
- Migration: add impersonate_user_id column to sessions table
- requireAuth(): when impersonation is active, override req.user with
  target user's identity; store real admin identity in req.realUser
- POST /api/auth/impersonate: start impersonation (Admin only, cannot
  impersonate self or other Admins)
- POST /api/auth/stop-impersonate: end impersonation, revert to real user
- GET /api/auth/me: returns impersonating flag and realUser when active
- Audit logging on impersonate start/stop

Frontend:
- AuthContext: add impersonating, realUser state; startImpersonation()
  and stopImpersonation() helpers
- ImpersonationBanner: fixed amber banner showing target user identity
  with Exit button
- UserManagement: Eye icon button on each non-Admin user row to start
  View As (visible only to Admin, hidden for self and other Admins)
- App.js: mount ImpersonationBanner at top of authenticated view
2026-06-24 12:57:57 -06:00
Jordan Ramos
11d9fec3ec Add page visibility by group with centralized matrix
Introduce a Page Visibility Matrix that controls which pages each user
group can access, enforced in both frontend and backend:

Frontend:
- Create frontend/src/config/pageVisibility.js with PAGE_VISIBILITY
  matrix and canAccessPage() / getAccessiblePages() helpers
- NavDrawer: replace inline requiredGroups with canAccessPage() filter
- App.js: replace per-page isInGroup()/isAdmin() checks with generic
  route guard in setCurrentPage; remove VALID_PAGES constant
- localStorage validation: verify persisted page is accessible on load

Backend (page-level access enforcement):
- jiraTickets.js: add router-level requireGroup('Admin','Standard_User')
- archerTemplates.js: add router-level requireGroup('Admin','Standard_User')
- VCL multi-vertical already had requireGroup('Admin','Leadership')

Visibility matrix:
- Home, Knowledge Base: all groups
- Triage, Compliance, Exports: Admin, Standard_User, Leadership
- CCP Metrics: Admin, Leadership
- Jira, Archer Templates: Admin, Standard_User
- Admin Panel: Admin only
- Read_Only sees only Home and Knowledge Base
2026-06-24 11:41:50 -06:00
Jordan Ramos
a003091b6a Add backend team enforcement via requireTeam() middleware
Introduce server-side team-scoped data access enforcement:

- Add TEAM_TO_IVANTI/IVANTI_TO_TEAM mapping to helpers/teams.js
- Add requireTeam() middleware to middleware/auth.js
  - Admin bypass (req.teamScope = null)
  - 403 for users with no team assignment
  - Populates req.teamScope with short and ivanti name arrays
- Ivanti findings: replace client ?teams= param with req.teamScope filtering
  on GET /, /counts, /counts/history, /fp-workflow-counts, POST /sync
  - Override and note endpoints verify finding is in team scope
- Compliance: add requireTeam() router-level, validate ?team= param against scope
  on GET /items and GET /summary
- CARD: validate teamName param on GET /teams/:teamName/assets
- Todo queue: verify findings belong to user's teams on POST /batch
- Clarify IVANTI_BU_FILTER comment (sync-level vs query-time filtering)
- Update 14 test files to include requireTeam in auth middleware mocks
2026-06-24 11:36:25 -06:00
Jordan Ramos
ab66d7d813 Add drag-and-drop document upload
Replace the old prompt()-based file upload flow with an inline drop zone.

Before: Click 'Upload Doc' → native file picker → prompt() for type →
prompt() for notes → upload. Three modal interruptions.

After: Drop a file (or click to browse) → inline form shows with type
dropdown and notes field → click Upload. Zero browser dialogs.

New component: DocumentDropZone
- Drag-and-drop with visual feedback (border color change on dragover)
- Click-to-browse fallback
- Inline type selector (advisory, email, screenshot, patch, other)
- Inline notes field
- Cancel button to dismiss without uploading
- Shows filename and size before upload
- Uses toast notifications for success/error

Removed from CVECard:
- handleFileUpload function (createElement('input') + prompt() pattern)
- uploadingFile state variable
- Upload (lucide) icon import
2026-06-23 12:19:56 -06:00
Jordan Ramos
f119cca1d7 Add recent activity feed and tabbed sidebar layout
New features:
- Recent Activity feed widget shows last 8 actions from audit log
  with relative timestamps, auto-refreshes every 60s
- Right sidebar reorganized: Calendar + Activity always visible,
  Tickets/Archer/Ivanti behind tab switcher to eliminate dead space

Backend:
- New GET /api/recent-activity endpoint (any authenticated user)
  Returns last N audit entries excluding login/logout noise
  Lighter than the full admin audit-logs endpoint

Frontend:
- RecentActivityFeed component with action labels, colored dots,
  timeAgo formatting, and manual refresh button
- SidebarTabs component with Tickets/Archer/Ivanti tabs
- OpenTicketsPanel and IvantiWorkflowPanel support embedded prop
  to render without their own panel wrapper when inside tabs

Layout change:
Before: Calendar | Tickets | Archer | Ivanti (4 stacked panels)
After:  Calendar | Activity | [Tickets | Archer | Ivanti] (tabs)

This keeps the sidebar height proportional to the CVE list area
instead of extending far below the main content.
2026-06-23 12:16:40 -06:00
Jordan Ramos
306950e360 Extract inline styles to CSS classes
Move JavaScript style objects from home page components into reusable
CSS classes in App.css. This follows the existing pattern (intel-button,
intel-card, intel-input) and consolidates all visual styling in one place.

New CSS classes added:
- .panel-card (--accent, --warning, --teal) — sidebar panels
- .section-heading (--accent, --warning, --teal) — monospace headings
- .stat-card modifiers (--clickable, --active, --warning, --danger)
- .stat-card__label / .stat-card__value (--accent, --neutral, etc.)
- .severity-badge (--critical, --high, --medium, --low)
- .glow-dot (--critical, --high, --medium, --low)
- .sidebar-ticket — compact ticket cards
- .workflow-item — Ivanti workflow entries
- .workflow-state-badge — teal state pill
- .ticket-status-badge — small status indicator
- .archive-item (--active, --resolved) — finding archive entries
- .big-counter (--warning, --teal) — large centered stat numbers

Benefits:
- 578 fewer lines of JavaScript across components
- Styles are browser-cached separately from JS bundle
- Single source of truth for the design system
- Easier to update colors/spacing project-wide
2026-06-23 11:58:44 -06:00
Jordan Ramos
4a0adfb574 Refactor home page: extract components, add toast system, debounce search
Major restructuring of the monolithic App.js (2484 lines) into focused,
testable components:

Architecture:
- App.js is now a 189-line routing shell (header, nav, page switching)
- HomePage.js orchestrates all home page state and layout
- Each visual section is its own component with clear props API

Extracted components:
- StatsBar: clickable stat cards that filter by severity
- QuickCVELookup: CVE existence check with inline results
- CVEFilters: search + vendor/severity dropdowns
- CVECard: expandable CVE with vendor entries, docs, tickets
- OpenTicketsPanel: right sidebar open JIRA tickets
- IvantiWorkflowPanel: right sidebar Ivanti workflow status + archive

Extracted modals:
- AddCVEModal: self-contained add form with NVD auto-fill
- EditCVEModal: self-contained edit form with NVD update
- JiraTicketModal: unified add/edit JIRA ticket modal
- ArcherTicketModal: unified add/edit Archer ticket modal

Performance optimizations:
- Debounced search (300ms) via useDebounce hook — eliminates
  redundant API calls on every keystroke
- Memoized groupedCVEs, openTicketCount, criticalCount via useMemo
- Proper state updates (no direct mutation of cveDocuments)
- useCallback on fetch functions to stabilize effect dependencies

UX improvements:
- Toast notification system replaces all alert() calls
- Stat cards are now clickable to filter CVE list by severity
- onKeyDown replaces deprecated onKeyPress
- aria-labels added to interactive elements

Infrastructure:
- ToastContext with auto-dismiss, typed toasts (success/error/warning/info)
- useDebounce custom hook for reuse across the app
- Toast slide-in animation in App.css
2026-06-23 11:46:39 -06:00
Jordan Ramos
223b6f22b8 Fix BulkHideToolbar not sticking when scrolling on Reporting page
Add position: sticky, top: 0, zIndex: 20 to BulkHideToolbar so the
Atlas Action Plan button remains visible while scrolling through findings.
Matches the existing sticky behavior of SelectionToolbar.
2026-06-22 15:57:45 -06:00
Jordan Ramos
55795710d9 Add TLS/HTTPS support with auto-detection
- Server auto-detects cert/key in backend/certs/ and starts HTTPS
- Falls back to plain HTTP if no certs found or TLS_ENABLED=false
- Self-signed cert generated for dev (365-day, gitignored)
- Added TLS env vars to .env.example
- Frontend rebuilt with https:// API URLs for dev server
2026-06-19 14:44:04 -06:00
Jordan Ramos
e9d6038636 Add Granite Loader to AEO Compliance page with CARD enrichment and pagination
- Add checkbox selection + Granite Loader button to compliance device table
- Integrate LoaderModal for generating loader sheets from compliance devices
- Add direct IP resolve path (resolveAssetId + searchByAssetId) for CARD
  enrichment on compliance devices without Ivanti host IDs
- Add searchByAssetId helper for full enriched record via asset-search endpoint
- Include NTS-AEO-ACCESS-OPS in default enrich-batch team search
- Increase CARD quick-mode timeout from 15s to 30s
- Add timeout vs not-found distinction in enrichment error reporting
- Fix LoaderModal enriching state not resetting on modal reopen
- Add pagination to compliance device table (25/50/100/200 per page)
- Page resets on team, tab, filter, or search change
2026-06-19 13:49:26 -06:00
Jordan Ramos
c7274be66d Bump compliance upload limit to 100MB
NTS_AVVOC vertical xlsx is 72MB — 50MB was still too low.
2026-06-18 08:45:01 -06:00
Jordan Ramos
ba6e67c639 Increase compliance upload limit to 50MB
SDIT_CSD xlsx files exceed the 10MB general upload limit. Add a
separate multer instance (complianceUpload, 50MB) for the compliance
and VCL multi-vertical routes while keeping the 10MB cap for general
document/KB uploads.
2026-06-18 08:38:57 -06:00
Jordan Ramos
f257cfad88 Skip BU history entries when previous_bu is unknown
Only record BU reassignment in ivanti_finding_bu_history when the
previous_bu is a known managed BU (from EXPECTED_BUS). Findings that
were never in our sync cache show as UNKNOWN which provides no
actionable insight for asset movement tracking.

Closes #28
2026-06-17 14:58:01 -06:00
Jordan Ramos
a95fd03f5e Rebrand STEAM → AEGIS, fix BU drift checker previous_bu bug
- Replace all STEAM branding with AEGIS (Advanced Engineering Group
  Intelligence System) across login, header, nav drawer, manifest, and
  browser title
- Add shield logo to login page, main header, and nav drawer
- Fix BU drift checker recording incorrect previous_bu values by
  building a previousBuMap snapshot BEFORE the upsert/delete cycle
  instead of querying the DB after rows are already gone
- Clean 526 bogus BU history entries generated by the broken logic
- Add docs and scripts from prior session
2026-06-17 14:40:38 -06:00
Jordan Ramos
479c61b88f Restrict VCL/CCP Metrics page to Admin and Leadership groups
Add requireGroup('Admin', 'Leadership') as router-level middleware on all
VCL multi-vertical routes. Hide the CCP Metrics nav item from users not in
those groups and guard the page render in App.js with a redirect fallback.
2026-06-17 09:27:01 -06:00
Jordan Ramos
2fed9221f1 Fix test-backend — remove redundant CLI path arg conflicting with roots
The jest.roots config in package.json already restricts to backend/__tests__
and testPathIgnorePatterns excludes integration tests. The CLI path arg
was being interpreted as an additional ignore pattern, causing 0 matches.
2026-06-16 16:19:23 -06:00
Jordan Ramos
8b985a21f8 Restrict root Jest to backend/__tests__ only — stop scanning frontend
Jest 30 default test discovery was finding frontend/src/**/*.test.js
and __tests__/ files when running from the project root. These need
react-scripts (CRA's Babel config) to parse ESM imports and JSX.
Added jest.roots to confine root-level Jest to backend tests only.
Frontend tests run separately via react-scripts test in test-frontend.
2026-06-16 16:17:57 -06:00
Jordan Ramos
55a4d299ef Force npm ci in test-frontend to fix stale cache missing transforms
The cached node_modules was missing react-scripts babel config after
package-lock.json changed (remark-gfm addition). Tests failed with
'Cannot use import statement outside a module' and JSX parse errors.
Always run npm ci to ensure fresh dependencies match the lockfile.
2026-06-16 16:15:48 -06:00
Jordan Ramos
28714eed47 Cache plan IDs from Atlas create responses
Single-host PUT and bulk POST now extract and store the action_plan_id
from the Atlas API response in the local cache. Previously only a stub
with plan_type/commit_date was  now the actual plan ID iscached
included so it can be referenced for updates/display without re-fetching
from Atlas.
2026-06-16 16:10:54 -06:00
Jordan Ramos
c0e3139503 Fix atlas_known — parse response body to detect 'not found' hosts
Instead of blanket-marking managed BU hosts, now parses the Atlas API
response: if it returns a valid {active, inactive} structure, the host
is known. If it returns an error or 'not found' message (even with a
2xx status), the host is not known and won't show a badge.

This prevents the shield showing on hosts Atlas doesn't actually track,
while correctly showing it on hosts Atlas recognizes (with or without
plans).
2026-06-16 15:45:43 -06:00
Jordan Ramos
09db1c2ae9 Fix atlas_known — managed BU hosts always show badge regardless of plans
A STEAM/ACCESS-ENG host with zero Atlas plans but tracked in Atlas
(like olt01k7) wasn't showing the amber shield because atlas_known
was only true when plans existed. Now managed BU hosts always get
atlas_known=true so the '0 plans' warning badge renders. Non-managed
BU hosts only show badge if Atlas actually has plan data for them.
2026-06-16 15:40:51 -06:00
Jordan Ramos
c1a266f4f7 Skip integration tests in CI — no Postgres service available
The migrations-idempotency.integration.test.js requires a reachable
Postgres instance. The CI Docker container can't resolve the DATABASE_URL
hostname. Skip files matching 'integration' in the test-backend job.
2026-06-16 15:09:10 -06:00
73 changed files with 5075 additions and 2598 deletions

View File

@@ -100,7 +100,7 @@ test-backend:
policy: pull policy: pull
script: script:
- test -d node_modules || npm ci - test -d node_modules || npm ci
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/ - ./node_modules/.bin/jest --ci --forceExit
timeout: 5 minutes timeout: 5 minutes
needs: needs:
- install-backend - install-backend
@@ -118,7 +118,7 @@ test-frontend:
- node_modules/ - node_modules/
policy: pull policy: pull
script: script:
- cd frontend && (test -d node_modules || npm ci) && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci - cd frontend && npm ci && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
timeout: 5 minutes timeout: 5 minutes
needs: needs:
- install-frontend - install-frontend

View File

@@ -75,11 +75,36 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
## Environment Configuration ## Environment Configuration
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials - `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials, CARD API credentials
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST - `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
- Both `.env` files are gitignored; see `.env.example` files for templates. - Both `.env` files are gitignored; see `.env.example` files for templates.
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them. - React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
### Key Backend Env Vars
| Variable | Purpose |
|---|---|
| `IVANTI_API_KEY` | RiskSense platform API key |
| `IVANTI_CLIENT_ID` | RiskSense client ID (default: 1550) |
| `IVANTI_BU_FILTER` | Comma-separated BU teams to sync findings for (default: `NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM`) |
| `IVANTI_FIRST_NAME` / `IVANTI_LAST_NAME` | Fallback Ivanti identity for workflow sync (used only if no per-user identities configured) |
| `CARD_API_URL` | CARD API base URL (e.g., `https://card.charter.com`) |
| `CARD_API_USER` / `CARD_API_PASS` | CARD OAuth credentials for Bearer token acquisition |
| `CARD_SKIP_TLS` | Set to `true` to skip TLS verification (for SSL inspection proxies) |
| `DATABASE_URL` | PostgreSQL connection string |
### CARD API and Ivanti Integration Details
See `.kiro/steering/integrations.md` for full API contracts, response shapes, and quirks for CARD, Ivanti, Atlas, and Jira.
### Ivanti Findings IPv6 Handling
Some Ivanti findings have no IPv4 address. The sync captures fallback addresses:
- `qualys_ipv6` — from `hostAdditionalDetails[].["IPv6 Address"]` (resolves in CARD)
- `primary_ipv6` — from `assetCustomAttributes['1550_host_6'][0]` (may not resolve in CARD)
Display priority in the UI: IPv4 > Qualys IPv6 (amber "Q" badge) > Primary IPv6 (indigo "v6" badge)
## Code Style & Lint Rules ## Code Style & Lint Rules
### Unused Variables ### Unused Variables

View File

@@ -6,6 +6,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
--- ---
## [v2.4.0] — 2026-06-24
### Added
- **Backend team enforcement** — `requireTeam()` middleware enforces team-scoped data access at the API level. Non-admin users can only access findings, compliance data, CARD assets, and Atlas metrics for their assigned BU teams. Users with no team assignment receive 403.
- **Page visibility by group** — centralized matrix in `config/pageVisibility.js` controls which pages each user group can access. Read_Only sees Home + Knowledge Base only. Leadership adds Triage, Compliance, CCP Metrics, and Exports. Standard_User adds Jira and Archer Templates. Admin sees all.
- **View As (impersonation)** — Admin users can temporarily view the app as another user via the eye icon in User Management. An amber banner shows the impersonated identity with an Exit button. Exercises real enforcement code paths for permission verification.
- **Homepage UX improvements** — drag-and-drop document upload, recent activity feed with tabbed sidebar layout, extracted components for cleaner architecture.
- **TLS/HTTPS support** — auto-detection of cert/key files enables HTTPS serving without configuration changes.
- **Granite Loader on Compliance page** — CARD enrichment with pagination for compliance-driven Granite sheet generation.
### Changed
- **Team name mapping centralized** — `helpers/teams.js` now exports `TEAM_TO_IVANTI`, `IVANTI_TO_TEAM`, `teamToIvanti()`, and `ivantiToTeam()` for consistent mapping between short names and Ivanti BU identifiers.
- **Atlas and Archive routes** — now use `requireTeam()` instead of client-provided `?teams=` query parameter for team scoping.
- **Jira and Archer Template routes** — now enforce `requireGroup('Admin', 'Standard_User')` at router level for page-level access control.
- **Activity feed** — impersonation events (`impersonate_start`, `impersonate_stop`) are hidden from non-Admin users.
- **NavDrawer** — uses centralized `canAccessPage()` instead of inline `requiredGroups` properties.
- **App.js** — generic route guard via `setCurrentPage` replaces per-page `isInGroup()` checks; localStorage validation ensures persisted page is accessible.
### Fixed
- **BulkHideToolbar** — no longer detaches from viewport when scrolling on Reporting page.
- **BU drift checker** — skips history entries when `previous_bu` is unknown.
- **Compliance upload limits** — raised to 100MB to accommodate larger xlsx files.
---
## [2.3.0] — 2026-06-16 ## [2.3.0] — 2026-06-16
### Added ### Added

View File

@@ -80,3 +80,11 @@ GITLAB_PAT=
# Generate with: openssl rand -hex 20 # Generate with: openssl rand -hex 20
GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret
# TLS / HTTPS Configuration
# If cert and key files exist at the paths below, the server starts with HTTPS.
# Set TLS_ENABLED=false to force plain HTTP even when certs are present.
# Generate a self-signed cert: openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=cve-dashboard.local"
TLS_ENABLED=true
TLS_CERT=certs/cert.pem
TLS_KEY=certs/key.pem

3
backend/.gitignore vendored
View File

@@ -3,3 +3,6 @@
backend/fix_multivendor_constraint.js backend/fix_multivendor_constraint.js
backend/migrate_multivendor.js backend/migrate_multivendor.js
backend/add_vendor_to_documents.js backend/add_vendor_to_documents.js
# TLS certificates (self-signed or CA-issued)
certs/

View File

@@ -36,6 +36,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -40,6 +40,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -26,6 +26,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -16,6 +16,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -20,6 +20,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => next(), requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(),
})); }));

View File

@@ -22,6 +22,7 @@ const fc = require('fast-check');
// Mock dependencies required by the compliance module // Mock dependencies required by the compliance module
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => next(), requireAuth: () => (req, res, next) => next(),
requireGroup: () => (req, res, next) => next(), requireGroup: () => (req, res, next) => next(),
})); }));

View File

@@ -14,6 +14,7 @@ const express = require('express');
// Mock auth middleware to bypass real session checks // Mock auth middleware to bypass real session checks
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -35,6 +35,7 @@ const fc = require('fast-check');
// --- Mocks (must be installed BEFORE requiring the route module) --- // --- Mocks (must be installed BEFORE requiring the route module) ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 42, username: 'testuser', group: 'Admin' }; req.user = { id: 42, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -18,6 +18,7 @@ const express = require('express');
// --- Mocks --- // --- Mocks ---
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 7, username: 'testuser', group: 'Admin' }; req.user = { id: 7, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -7,6 +7,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, _res, next) => { requireAuth: () => (req, _res, next) => {
req.user = { id: 7, username: 'testuser' }; req.user = { id: 7, username: 'testuser' };
next(); next();

View File

@@ -18,6 +18,7 @@ const express = require('express');
// Mock the auth middleware so routes don't require real sessions/cookies. // Mock the auth middleware so routes don't require real sessions/cookies.
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'test', group: 'Admin' }; req.user = { id: 1, username: 'test', group: 'Admin' };
next(); next();

View File

@@ -12,6 +12,7 @@ const express = require('express');
// Mock the auth middleware so routes don't require real sessions/cookies. // Mock the auth middleware so routes don't require real sessions/cookies.
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'test', group: 'Admin' }; req.user = { id: 1, username: 'test', group: 'Admin' };
next(); next();

View File

@@ -17,6 +17,7 @@ const express = require('express');
// Mock auth middleware // Mock auth middleware
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -17,6 +17,7 @@ const express = require('express');
// Mock auth middleware to bypass real session checks // Mock auth middleware to bypass real session checks
jest.mock('../middleware/auth', () => ({ jest.mock('../middleware/auth', () => ({
requireTeam: () => (req, res, next) => { req.teamScope = null; next(); },
requireAuth: () => (req, res, next) => { requireAuth: () => (req, res, next) => {
req.user = { id: 1, username: 'testuser', group: 'Admin' }; req.user = { id: 1, username: 'testuser', group: 'Admin' };
next(); next();

View File

@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(255) UNIQUE NOT NULL, session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW(),
impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
); );
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id); CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);

View File

@@ -312,6 +312,23 @@ async function searchByIvantiHostId(ivantiHostId, options) {
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
} }
/**
* GET /api/v2/asset-search/{assetId}?search_param=deep_search
* Search CARD by asset ID (e.g., "24.24.100.20-CTEC"). Returns the full
* enriched asset record including ncim_discovery, netops_granite_allips, etc.
*
* @param {string} assetId - CARD asset identifier (IP-SUFFIX format)
* @param {object} [options] - { timeout }
*/
async function searchByAssetId(assetId, options) {
const id = (assetId || '').trim();
if (!id) {
return { status: 400, body: '{"error":"Asset ID is required."}', ok: false };
}
const res = await cardGet(`/api/v2/asset-search/${encodeURIComponent(id)}?search_param=deep_search`, options);
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
}
/** /**
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes. * Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
* Returns the first asset ID that returns a valid owner record, or null if none found. * Returns the first asset ID that returns a valid owner record, or null if none found.
@@ -322,7 +339,7 @@ async function searchByIvantiHostId(ivantiHostId, options) {
async function resolveAssetId(ip, options) { async function resolveAssetId(ip, options) {
const quick = options && options.quick; const quick = options && options.quick;
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode const timeout = quick ? 30000 : undefined; // 30s timeout for quick mode
const trimmedIp = (ip || '').trim(); const trimmedIp = (ip || '').trim();
if (!trimmedIp) return null; if (!trimmedIp) return null;
@@ -384,4 +401,5 @@ module.exports = {
invalidateToken, invalidateToken,
resolveAssetId, resolveAssetId,
searchByIvantiHostId, searchByIvantiHostId,
searchByAssetId,
}; };

View File

@@ -1,8 +1,41 @@
// Shared BU team constants and validation // Shared BU team constants, validation, and name mapping.
// Used by user management routes, auth middleware, and frontend-facing endpoints. // Used by user management routes, auth middleware, and frontend-facing endpoints.
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
// Mapping between short team names (stored on users) and full Ivanti BU identifiers
// (used in ivanti_findings.bu_ownership column).
const TEAM_TO_IVANTI = {
'STEAM': 'NTS-AEO-STEAM',
'ACCESS-ENG': 'NTS-AEO-ACCESS-ENG',
'ACCESS-OPS': 'NTS-AEO-ACCESS-OPS',
'INTELDEV': 'NTS-AEO-INTELDEV'
};
const IVANTI_TO_TEAM = Object.fromEntries(
Object.entries(TEAM_TO_IVANTI).map(([k, v]) => [v, k])
);
/**
* Convert a short team name to the full Ivanti BU identifier.
* Returns the input unchanged if no mapping exists.
* @param {string} shortName - e.g. 'STEAM'
* @returns {string} e.g. 'NTS-AEO-STEAM'
*/
function teamToIvanti(shortName) {
return TEAM_TO_IVANTI[shortName] || shortName;
}
/**
* Convert a full Ivanti BU identifier to the short team name.
* Returns the input unchanged if no mapping exists.
* @param {string} ivantiName - e.g. 'NTS-AEO-STEAM'
* @returns {string} e.g. 'STEAM'
*/
function ivantiToTeam(ivantiName) {
return IVANTI_TO_TEAM[ivantiName] || ivantiName;
}
/** /**
* Parse and validate a comma-separated teams string. * Parse and validate a comma-separated teams string.
* @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG') * @param {string} teamsString - Comma-separated team identifiers (e.g. 'STEAM,ACCESS-ENG')
@@ -23,4 +56,4 @@ function validateTeams(teamsString) {
}; };
} }
module.exports = { KNOWN_TEAMS, validateTeams }; module.exports = { KNOWN_TEAMS, TEAM_TO_IVANTI, IVANTI_TO_TEAM, teamToIvanti, ivantiToTeam, validateTeams };

View File

@@ -1,5 +1,6 @@
// Authentication Middleware // Authentication Middleware
const pool = require('../db'); const pool = require('../db');
const { teamToIvanti } = require('../helpers/teams');
// Require authenticated user — no parameters needed, pool is imported directly // Require authenticated user — no parameters needed, pool is imported directly
function requireAuth() { function requireAuth() {
@@ -12,7 +13,8 @@ function requireAuth() {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active `SELECT s.*, s.impersonate_user_id,
u.id as user_id, u.username, u.email, u.role, u.user_group, u.bu_teams, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1 AND s.expires_at > NOW()`, WHERE s.session_id = $1 AND s.expires_at > NOW()`,
@@ -29,8 +31,8 @@ function requireAuth() {
return res.status(401).json({ error: 'Account is disabled' }); return res.status(401).json({ error: 'Account is disabled' });
} }
// Attach user to request // Store the real admin identity (always the session owner)
req.user = { req.realUser = {
id: session.user_id, id: session.user_id,
username: session.username, username: session.username,
email: session.email, email: session.email,
@@ -39,6 +41,35 @@ function requireAuth() {
teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : [] teams: session.bu_teams ? session.bu_teams.split(',').filter(Boolean) : []
}; };
// If impersonating, load the target user's identity
if (session.impersonate_user_id) {
const { rows: targetRows } = await pool.query(
`SELECT id, username, email, role, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[session.impersonate_user_id]
);
const target = targetRows[0];
if (target && target.is_active) {
req.user = {
id: target.id,
username: target.username,
email: target.email,
role: target.role,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
};
req.impersonating = true;
} else {
// Target user no longer valid — clear impersonation and use real user
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
req.user = req.realUser;
req.impersonating = false;
}
} else {
req.user = req.realUser;
req.impersonating = false;
}
next(); next();
} catch (err) { } catch (err) {
console.error('Auth middleware error:', err); console.error('Auth middleware error:', err);
@@ -66,4 +97,38 @@ function requireGroup(...allowedGroups) {
}; };
} }
module.exports = { requireAuth, requireGroup }; // Require team assignment — enforces team-scoped data access.
// Admin group bypasses (req.teamScope = null means "no filter").
// Non-admin users without teams get 403.
// Non-admin users with teams get req.teamScope = { short: [...], ivanti: [...] }.
function requireTeam() {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Admin bypass — full access to all teams
if (req.user.group === 'Admin') {
req.teamScope = null;
return next();
}
// No teams assigned — block access
if (!req.user.teams || req.user.teams.length === 0) {
return res.status(403).json({
error: 'No team assignment. Contact an administrator to assign BU teams to your account.',
code: 'NO_TEAM_ASSIGNMENT'
});
}
// Build scope with both naming conventions
req.teamScope = {
short: req.user.teams,
ivanti: req.user.teams.map(t => teamToIvanti(t))
};
next();
};
}
module.exports = { requireAuth, requireGroup, requireTeam };

View File

@@ -0,0 +1,26 @@
// Migration: Add impersonate_user_id column to sessions table
// Allows Admin users to temporarily view the app as another user.
// When set, requireAuth() overrides req.user with the target user's identity.
const pool = require('../db');
async function run() {
console.log('[Migration] add_session_impersonation: starting...');
// Add impersonate_user_id column (nullable FK to users)
await pool.query(`
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS impersonate_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
`);
console.log('[Migration] add_session_impersonation: column added.');
console.log('[Migration] add_session_impersonation: done.');
await pool.end();
}
// Run directly if invoked as a script
if (require.main === module) {
run().catch(err => { console.error(err); process.exit(1); });
}
module.exports = run;

View File

@@ -33,6 +33,7 @@ const POSTGRES_MIGRATIONS = [
'add_ivanti_findings_ipv6_columns.js', 'add_ivanti_findings_ipv6_columns.js',
'add_user_ivanti_identity.js', 'add_user_ivanti_identity.js',
'add_atlas_known_column.js', 'add_atlas_known_column.js',
'add_session_impersonation.js',
]; ];
async function runAll() { async function runAll() {

View File

@@ -20,6 +20,10 @@ const SECTION_MAX_LENGTH = 10000;
function createArcherTemplatesRouter() { function createArcherTemplatesRouter() {
const router = express.Router(); const router = express.Router();
// All Archer template routes require authentication and Admin or Standard_User group (page-level access)
router.use(requireAuth());
router.use(requireGroup('Admin', 'Standard_User'));
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) --- // --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
/** /**

View File

@@ -4,7 +4,7 @@
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi'); const { isConfigured, atlasGet, atlasPut, atlasPatch, atlasPost } = require('../helpers/atlasApi');
@@ -70,49 +70,44 @@ function aggregateAtlasMetrics(rows) {
function createAtlasRouter() { function createAtlasRouter() {
const router = express.Router(); const router = express.Router();
// All atlas routes require authentication and team scoping
router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET /metrics * GET /metrics
* *
* Returns aggregated Atlas action plan metrics from the local cache. * Returns aggregated Atlas action plan metrics from the local cache.
* Accepts optional `teams` query parameter to scope metrics to hosts * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
* belonging to specific BUs (via JOIN on ivanti_findings).
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans } * @returns {Object} 200 - { totalHosts, hostsWithPlans, hostsWithoutPlans, plansByType, plansByStatus, totalPlans }
* @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on database failure * @returns {Object} 500 - { error } on database failure
*/ */
router.get('/metrics', requireAuth(), async (req, res) => { router.get('/metrics', async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
const teamsParam = req.query.teams;
let rows; let rows;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); // Non-admin: scope to user's team findings
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); const result = await pool.query(
const result = await pool.query( `SELECT a.has_action_plan, a.plans_json
`SELECT a.has_action_plan, a.plans_json FROM atlas_action_plans_cache a
FROM atlas_action_plans_cache a INNER JOIN (
INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings
SELECT DISTINCT host_id FROM ivanti_findings WHERE bu_ownership ILIKE ANY($1::text[])
WHERE bu_ownership ILIKE ANY($1::text[]) ) f ON a.host_id = f.host_id
) f ON a.host_id = f.host_id WHERE a.atlas_known = true`,
WHERE a.atlas_known = true`, [patterns]
[patterns] );
); rows = result.rows;
rows = result.rows;
} else {
const result = await pool.query(
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
);
rows = result.rows;
}
} else { } else {
// Admin bypass — all cached plans
const result = await pool.query( const result = await pool.query(
`SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true`
); );
@@ -131,44 +126,35 @@ function createAtlasRouter() {
* GET /status * GET /status
* *
* Returns atlas_action_plans_cache contents for status display. * Returns atlas_action_plans_cache contents for status display.
* Accepts optional `teams` query parameter to scope results to hosts * Team scoping enforced by requireTeam() — scopes to hosts belonging to user's BUs.
* belonging to specific BUs (via JOIN on ivanti_findings).
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at } * @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at }
* @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on database failure * @returns {Object} 500 - { error } on database failure
*/ */
router.get('/status', requireAuth(), async (req, res) => { router.get('/status', async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
const teamsParam = req.query.teams;
let rows; let rows;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); // Non-admin: scope to user's team findings
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); const result = await pool.query(
const result = await pool.query( `SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at
`SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at FROM atlas_action_plans_cache a
FROM atlas_action_plans_cache a INNER JOIN (
INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings
SELECT DISTINCT host_id FROM ivanti_findings WHERE bu_ownership ILIKE ANY($1::text[])
WHERE bu_ownership ILIKE ANY($1::text[]) ) f ON a.host_id = f.host_id`,
) f ON a.host_id = f.host_id`, [patterns]
[patterns] );
); rows = result.rows;
rows = result.rows;
} else {
const result = await pool.query(
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
);
rows = result.rows;
}
} else { } else {
// Admin bypass — all cached entries
const result = await pool.query( const result = await pool.query(
`SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache` `SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache`
); );
@@ -187,70 +173,59 @@ function createAtlasRouter() {
* *
* Syncs action plan data from Atlas for all hosts found in ivanti_findings. * Syncs action plan data from Atlas for all hosts found in ivanti_findings.
* Fetches plans per host in batches of 5 and upserts into the local cache. * Fetches plans per host in batches of 5 and upserts into the local cache.
* Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS. * Team scoping enforced by requireTeam() — syncs only hosts in user's BUs.
* Falls back to IVANTI_MANAGED_BUS for admin when no team scope is set.
* Requires Admin or Standard_User group. * Requires Admin or Standard_User group.
* *
* @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG')
* @param {Object} [req.body]
* @param {string} [req.body.teams] - Comma-separated team names (alternative to query param)
* @returns {Object} 200 - { synced, withPlans, failed } * @returns {Object} 200 - { synced, withPlans, failed }
* @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 503 - { error } when Atlas API is not configured
* @returns {Object} 500 - { error } on unexpected failure * @returns {Object} 500 - { error } on unexpected failure
*/ */
router.post('/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' });
} }
try { try {
// Scope sync to the user's active teams if provided, otherwise sync only // Use team scope from middleware, fall back to managed BUs for admin
// findings from managed BUs (IVANTI_MANAGED_BUS) to avoid polluting cache
// with "no plan" entries for BUs not covered by Atlas.
const teamsParam = req.query.teams || req.body.teams || '';
const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM') const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM')
.split(',').map(b => b.trim()).filter(Boolean); .split(',').map(b => b.trim()).filter(Boolean);
let findingsRows; let patterns;
if (teamsParam) { if (req.teamScope) {
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); patterns = req.teamScope.ivanti.map(t => `%${t}%`);
if (teams.length > 0) {
const patterns = teams.map(t => `%${t}%`);
const result = await pool.query(
`SELECT DISTINCT host_id FROM ivanti_findings
WHERE host_id IS NOT NULL AND host_id > 0
AND bu_ownership ILIKE ANY($1::text[])`,
[patterns]
);
findingsRows = result.rows;
} else {
// No valid teams — fall back to managed BUs
const patterns = managedBUs.map(b => `%${b}%`);
const result = await pool.query(
`SELECT DISTINCT host_id FROM ivanti_findings
WHERE host_id IS NOT NULL AND host_id > 0
AND bu_ownership ILIKE ANY($1::text[])`,
[patterns]
);
findingsRows = result.rows;
}
} else { } else {
// No teams specified — default to managed BUs only // Admin with no specific scope — sync managed BUs
const patterns = managedBUs.map(b => `%${b}%`); patterns = managedBUs.map(b => `%${b}%`);
const result = await pool.query(
`SELECT DISTINCT host_id FROM ivanti_findings
WHERE host_id IS NOT NULL AND host_id > 0
AND bu_ownership ILIKE ANY($1::text[])`,
[patterns]
);
findingsRows = result.rows;
} }
const result = await pool.query(
`SELECT DISTINCT host_id FROM ivanti_findings
WHERE host_id IS NOT NULL AND host_id > 0
AND bu_ownership ILIKE ANY($1::text[])`,
[patterns]
);
const findingsRows = result.rows;
const hostIds = findingsRows.map(r => r.host_id); const hostIds = findingsRows.map(r => r.host_id);
if (hostIds.length === 0) { if (hostIds.length === 0) {
return res.json({ synced: 0, withPlans: 0, failed: 0 }); return res.json({ synced: 0, withPlans: 0, failed: 0 });
} }
// Build a set of host IDs belonging to managed BUs — these always show the badge
const managedPatterns = managedBUs.map(b => `%${b}%`);
let managedHostIds = new Set();
try {
const { rows: managedRows } = await pool.query(
`SELECT DISTINCT host_id FROM ivanti_findings
WHERE host_id IS NOT NULL AND host_id > 0
AND bu_ownership ILIKE ANY($1::text[])`,
[managedPatterns]
);
managedHostIds = new Set(managedRows.map(r => r.host_id));
} catch (_) { /* non-fatal — fall back to plans-only logic */ }
let synced = 0; let synced = 0;
let withPlans = 0; let withPlans = 0;
let failed = 0; let failed = 0;
@@ -273,30 +248,40 @@ function createAtlasRouter() {
} }
const { hostId, result } = settled.value; const { hostId, result } = settled.value;
const isManagedHost = managedHostIds.has(hostId);
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let allPlans = []; let allPlans = [];
let activePlans = []; let activePlans = [];
let atlasRecognizesHost = false;
try { try {
const parsed = JSON.parse(result.body); const parsed = JSON.parse(result.body);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
activePlans = Array.isArray(parsed.active) ? parsed.active : []; // Check for "not found" error responses that come back as 200
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : []; if (parsed.error || parsed.message?.includes('not found')) {
allPlans = [...activePlans, ...inactive]; atlasRecognizesHost = false;
} else {
atlasRecognizesHost = true;
activePlans = Array.isArray(parsed.active) ? parsed.active : [];
const inactive = Array.isArray(parsed.inactive) ? parsed.inactive : [];
allPlans = [...activePlans, ...inactive];
}
} else if (Array.isArray(parsed)) { } else if (Array.isArray(parsed)) {
atlasRecognizesHost = true;
allPlans = parsed; allPlans = parsed;
activePlans = parsed; activePlans = parsed;
} }
} catch (e) { } catch (e) {
allPlans = []; allPlans = [];
activePlans = []; activePlans = [];
atlasRecognizesHost = false;
} }
const planCount = activePlans.length; const planCount = activePlans.length;
const hasActionPlan = planCount > 0; const hasActionPlan = planCount > 0;
// Atlas "knows" this host if it returned any plans (active or inactive). // Atlas knows this host if it returned a valid structured response
// Hosts with completely empty responses are not tracked by Atlas. // (not "not found" or error). This determines whether the badge renders.
const atlasKnown = allPlans.length > 0; const atlasKnown = atlasRecognizesHost;
try { try {
if (!hasActionPlan) { if (!hasActionPlan) {
@@ -449,6 +434,45 @@ function createAtlasRouter() {
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
let body; let body;
try { body = JSON.parse(result.body); } catch (e) { body = result.body; } try { body = JSON.parse(result.body); } catch (e) { body = result.body; }
// Update local cache with the created plan
try {
const { rows: existingRows } = await pool.query(
`SELECT plan_count, plans_json FROM atlas_action_plans_cache WHERE host_id = $1`,
[hostId]
);
const existing = existingRows[0];
let existingPlans = [];
if (existing && existing.plans_json) {
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
}
// Include the plan ID from Atlas response if available
const newPlan = {
plan_type,
commit_date,
source: 'create',
created_at: new Date().toISOString(),
...(body?.action_plan_id ? { action_plan_id: body.action_plan_id } : {}),
...(body?.id ? { action_plan_id: body.id } : {}),
};
const updatedPlans = [...existingPlans, newPlan];
await pool.query(
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
VALUES ($1, true, $2, $3, true, NOW())
ON CONFLICT(host_id) DO UPDATE SET
has_action_plan = true,
plan_count = EXCLUDED.plan_count,
plans_json = EXCLUDED.plans_json,
atlas_known = true,
synced_at = EXCLUDED.synced_at`,
[hostId, updatedPlans.length, JSON.stringify(updatedPlans)]
);
} catch (cacheErr) {
console.error('[Atlas] Cache update failed after plan create for host', hostId, ':', cacheErr.message);
}
res.status(result.status).json(body); res.status(result.status).json(body);
} else { } else {
let errorBody; let errorBody;
@@ -580,7 +604,27 @@ function createAtlasRouter() {
try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {} try { existingPlans = JSON.parse(existing.plans_json); } catch (_) {}
} }
const stubPlan = { plan_type, commit_date, source: 'bulk-create', created_at: new Date().toISOString() }; // Extract plan ID from bulk response if available (keyed by host_id)
let planId = null;
if (body && typeof body === 'object') {
// Response may be { results: [{host_id, action_plan_id}] } or { [hostId]: {id} }
if (Array.isArray(body.results)) {
const match = body.results.find(r => r.host_id === hid || r.host_id === String(hid));
if (match) planId = match.action_plan_id || match.id;
} else if (body[hid]) {
planId = body[hid].action_plan_id || body[hid].id;
} else if (body[String(hid)]) {
planId = body[String(hid)].action_plan_id || body[String(hid)].id;
}
}
const stubPlan = {
plan_type,
commit_date,
source: 'bulk-create',
created_at: new Date().toISOString(),
...(planId ? { action_plan_id: planId } : {}),
};
const updatedPlans = [...existingPlans, stubPlan]; const updatedPlans = [...existingPlans, stubPlan];
const newCount = updatedPlans.length; const newCount = updatedPlans.length;

View File

@@ -195,9 +195,10 @@ function createAuthRouter(logAudit) {
* GET /api/auth/me * GET /api/auth/me
* *
* Returns the currently authenticated user based on the session cookie. * Returns the currently authenticated user based on the session cookie.
* Clears the cookie and returns 401 if the session is expired or the account is disabled. * If impersonating, returns the impersonated user's identity with an
* `impersonating` flag and the real admin user's info.
* *
* @returns {object} 200 - { user: { id, username, email, group } } * @returns {object} 200 - { user: { id, username, email, group, teams }, impersonating?: boolean, realUser?: { id, username, group } }
* @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' } * @returns {object} 401 - { error: 'Not authenticated' } | { error: 'Session expired' } | { error: 'Account is disabled' }
* @returns {object} 500 - { error: 'Failed to get user' } * @returns {object} 500 - { error: 'Failed to get user' }
*/ */
@@ -210,7 +211,8 @@ function createAuthRouter(logAudit) {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT s.*, u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active `SELECT s.*, s.impersonate_user_id,
u.id as user_id, u.username, u.email, u.user_group, u.bu_teams, u.is_active
FROM sessions s FROM sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_id = $1 AND s.expires_at > NOW()`, WHERE s.session_id = $1 AND s.expires_at > NOW()`,
@@ -229,6 +231,36 @@ function createAuthRouter(logAudit) {
return res.status(401).json({ error: 'Account is disabled' }); return res.status(401).json({ error: 'Account is disabled' });
} }
// If impersonating, return target user's identity
if (session.impersonate_user_id) {
const { rows: targetRows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[session.impersonate_user_id]
);
const target = targetRows[0];
if (target && target.is_active) {
return res.json({
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
},
impersonating: true,
realUser: {
id: session.user_id,
username: session.username,
group: session.user_group
}
});
} else {
// Target invalid — clear impersonation
await pool.query(`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`, [sessionId]);
}
}
res.json({ res.json({
user: { user: {
id: session.user_id, id: session.user_id,
@@ -244,6 +276,133 @@ function createAuthRouter(logAudit) {
} }
}); });
/**
* POST /api/auth/impersonate
*
* Start impersonating another user. Only Admin group can impersonate.
* Cannot impersonate another Admin user.
*
* @body {number} userId - The ID of the user to impersonate
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 400 - { error } — cannot impersonate Admin or self
* @returns {object} 403 - { error } — not Admin
* @returns {object} 404 - { error } — target user not found
* @returns {object} 500 - { error }
*/
router.post('/impersonate', requireAuth(), async (req, res) => {
// Only the real user (not an impersonated identity) can start impersonation
const realUser = req.realUser || req.user;
if (realUser.group !== 'Admin') {
return res.status(403).json({ error: 'Only Admin users can impersonate.' });
}
const { userId } = req.body;
if (!userId || typeof userId !== 'number') {
return res.status(400).json({ error: 'userId is required and must be a number.' });
}
if (userId === realUser.id) {
return res.status(400).json({ error: 'Cannot impersonate yourself.' });
}
try {
const { rows } = await pool.query(
`SELECT id, username, email, user_group, bu_teams, is_active FROM users WHERE id = $1`,
[userId]
);
const target = rows[0];
if (!target) {
return res.status(404).json({ error: 'User not found.' });
}
if (!target.is_active) {
return res.status(400).json({ error: 'Cannot impersonate a disabled account.' });
}
if (target.user_group === 'Admin') {
return res.status(400).json({ error: 'Cannot impersonate another Admin user.' });
}
// Set impersonation on the session
const sessionId = req.cookies?.session_id;
await pool.query(
`UPDATE sessions SET impersonate_user_id = $1 WHERE session_id = $2`,
[userId, sessionId]
);
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_start',
entityType: 'user',
entityId: String(userId),
details: { target_username: target.username, target_group: target.user_group },
ipAddress: req.ip
});
res.json({
message: `Now viewing as ${target.username}`,
user: {
id: target.id,
username: target.username,
email: target.email,
group: target.user_group,
teams: target.bu_teams ? target.bu_teams.split(',').filter(Boolean) : []
}
});
} catch (err) {
console.error('Impersonate error:', err);
res.status(500).json({ error: 'Failed to start impersonation.' });
}
});
/**
* POST /api/auth/stop-impersonate
*
* Stop impersonating and revert to the real Admin identity.
*
* @returns {object} 200 - { message, user: { id, username, group, teams } }
* @returns {object} 500 - { error }
*/
router.post('/stop-impersonate', requireAuth(), async (req, res) => {
const sessionId = req.cookies?.session_id;
try {
await pool.query(
`UPDATE sessions SET impersonate_user_id = NULL WHERE session_id = $1`,
[sessionId]
);
const realUser = req.realUser || req.user;
logAudit({
userId: realUser.id,
username: realUser.username,
action: 'impersonate_stop',
entityType: 'user',
entityId: null,
details: null,
ipAddress: req.ip
});
res.json({
message: 'Impersonation ended',
user: {
id: realUser.id,
username: realUser.username,
email: realUser.email,
group: realUser.group,
teams: realUser.teams
}
});
} catch (err) {
console.error('Stop impersonate error:', err);
res.status(500).json({ error: 'Failed to stop impersonation.' });
}
});
/** /**
* GET /api/auth/profile * GET /api/auth/profile
* *

View File

@@ -4,7 +4,7 @@
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const { const {
isConfigured, isConfigured,
@@ -17,6 +17,7 @@ const {
redirectAsset, redirectAsset,
resolveAssetId, resolveAssetId,
searchByIvantiHostId, searchByIvantiHostId,
searchByAssetId,
} = require('../helpers/cardApi'); } = require('../helpers/cardApi');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -111,7 +112,7 @@ function createCardApiRouter() {
* @response 400 - { error: string } — missing disposition * @response 400 - { error: string } — missing disposition
* @response 503 - { error: string, missingVars: string[] } — CARD not configured * @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/ */
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), requireTeam(), async (req, res) => {
if (!isConfigured) { if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
} }
@@ -119,6 +120,16 @@ function createCardApiRouter() {
const { teamName } = req.params; const { teamName } = req.params;
const { disposition, page, page_size } = req.query; const { disposition, page, page_size } = req.query;
// Validate requested team is in user's scope
if (req.teamScope && !req.teamScope.short.includes(teamName)) {
return res.status(403).json({
error: 'Access denied. You do not have access to the requested team.',
code: 'TEAM_ACCESS_DENIED',
requested: teamName,
allowed: req.teamScope.short
});
}
if (!disposition) { if (!disposition) {
return res.status(400).json({ error: 'disposition query parameter is required.' }); return res.status(400).json({ error: 'disposition query parameter is required.' });
} }
@@ -1025,16 +1036,66 @@ function createCardApiRouter() {
} }
} }
let foundCount = Object.keys(resultMap).length; // Direct resolve path: for IPs not found via ivanti_findings, resolve
// the asset ID via suffix guessing (CTEC first) and fetch the full asset
// record via asset-search. This returns ncim_discovery, netops_granite, etc.
const unresolvedIps = ipsArray.filter(ip => !resultMap[ip]);
if (unresolvedIps.length > 0) {
const CONCURRENCY = 5;
for (let i = 0; i < unresolvedIps.length; i += CONCURRENCY) {
const batch = unresolvedIps.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(async (ip) => {
if (resultMap[ip]) return;
try {
const assetId = await resolveAssetId(ip, { quick: true });
if (assetId) {
// Use asset-search to get the full enriched record (30s timeout)
const searchResult = await searchByAssetId(assetId, { timeout: 30000 });
if (searchResult.ok) {
const searchData = JSON.parse(searchResult.body);
const assets = searchData.assets || [];
if (assets.length > 0) {
resultMap[ip] = extractGraniteFields(assets[0], ip);
} else {
// Fallback: asset-search returned empty, try owner record
const ownerResult = await getOwner(assetId);
if (ownerResult.ok) {
const ownerData = JSON.parse(ownerResult.body);
resultMap[ip] = extractGraniteFields(ownerData, ip);
}
}
} else {
// asset-search failed, fall back to owner endpoint
const ownerResult = await getOwner(assetId);
if (ownerResult.ok) {
const ownerData = JSON.parse(ownerResult.body);
resultMap[ip] = extractGraniteFields(ownerData, ip);
}
}
}
} catch (err) {
const isTimeout = err.message && (err.message.includes('CARD_TIMEOUT') || err.message.includes('timed out'));
if (isTimeout) {
console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} timed out`);
resultMap[ip] = { _timeout: true };
} else {
console.warn(`[card-api] enrich-batch: Direct resolve for ${ip} failed:`, err.message);
}
}
}));
}
}
let foundCount = Object.keys(resultMap).filter(k => !resultMap[k]._timeout).length;
// Fallback: paginated team-assets loop for any IPs not resolved by fast path // Fallback: paginated team-assets loop for any IPs not resolved by fast path
// The team assets endpoint returns the full enriched record with ncim_discovery, // Skip if all unresolved IPs already timed out (heavier calls will also timeout)
// card_flags, netops_granite_allips, etc. const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS'];
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
const dispositions = ['confirmed', 'unconfirmed', 'candidate']; const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
const stillUnresolved = [...targetIps].filter(ip => !resultMap[ip]);
for (const teamName of teams) { for (const teamName of teams) {
if (foundCount >= targetIps.size) break; if (stillUnresolved.length === 0 || foundCount >= targetIps.size) break;
for (const disposition of dispositions) { for (const disposition of dispositions) {
if (foundCount >= targetIps.size) break; if (foundCount >= targetIps.size) break;
@@ -1097,8 +1158,13 @@ function createCardApiRouter() {
} }
if (resultMap[trimmedIp]) { if (resultMap[trimmedIp]) {
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] }); if (resultMap[trimmedIp]._timeout) {
enrichedCount++; results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'CARD lookup timed out — try again' });
notFoundCount++;
} else {
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
enrichedCount++;
}
} else { } else {
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' }); results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
notFoundCount++; notFoundCount++;

View File

@@ -7,7 +7,7 @@ const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker'); const { loadConfig, compareSchemaToDrift, reconcileConfig } = require('../helpers/driftChecker');
const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers'); const { isValidDateString, validateRemediationPlan, computeVCLStats, categorizeNonCompliant, rankHeavyHitters, computeForecastBurndown, matchByHostname, computeBulkDiff, mapColumnHeaders } = require('../helpers/vclHelpers');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
@@ -288,8 +288,9 @@ function computeWaterfall(uploads) {
function createComplianceRouter(upload) { function createComplianceRouter(upload) {
const router = express.Router(); const router = express.Router();
// All compliance routes require authentication // All compliance routes require authentication and team assignment
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* POST /preview * POST /preview
@@ -537,6 +538,16 @@ function createComplianceRouter(upload) {
const team = req.query.team; const team = req.query.team;
if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); if (team && !ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' });
// Validate requested team is in user's scope
if (team && req.teamScope && !req.teamScope.short.includes(team)) {
return res.status(403).json({
error: 'Access denied. You do not have access to the requested team.',
code: 'TEAM_ACCESS_DENIED',
requested: team,
allowed: req.teamScope.short
});
}
try { try {
// Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload // Try AEO uploads first (vertical IS NULL), fall back to NTS_AEO multi-vertical upload
let { rows: latestRows } = await pool.query( let { rows: latestRows } = await pool.query(
@@ -600,6 +611,16 @@ function createComplianceRouter(upload) {
if (!ALLOWED_TEAMS.has(team)) return res.status(400).json({ error: 'Invalid team' }); 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' }); if (!['active', 'resolved'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
// Validate requested team is in user's scope
if (req.teamScope && !req.teamScope.short.includes(team)) {
return res.status(403).json({
error: 'Access denied. You do not have access to the requested team.',
code: 'TEAM_ACCESS_DENIED',
requested: team,
allowed: req.teamScope.short
});
}
try { try {
// Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads // Include items from both AEO uploads (vertical IS NULL) and NTS_AEO multi-vertical uploads
// DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs, keeping the representative row // DISTINCT ON deduplicates cross-vertical (hostname, metric_id) pairs, keeping the representative row

View File

@@ -1,7 +1,7 @@
// Ivanti Archive Routes — list, stats, and transition history for archived findings // Ivanti Archive Routes — list, stats, and transition history for archived findings
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth } = require('../middleware/auth'); const { requireAuth, requireTeam } = require('../middleware/auth');
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED']; const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
@@ -30,18 +30,18 @@ function findRelatedActive(archive, activeFindings) {
function createIvantiArchiveRouter() { function createIvantiArchiveRouter() {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication and team scoping
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET / * GET /
* List archive records with optional state and teams filtering. * List archive records with optional state filtering.
* Team scoping enforced by requireTeam() middleware via req.teamScope.
* *
* @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED. * @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED.
* When state=ACTIVE, returns live open findings from ivanti_findings instead of archives. * When state=ACTIVE, returns live open findings from ivanti_findings instead of archives.
* When state=CLOSED, includes both CLOSED and CLOSED_GONE records. * When state=CLOSED, includes both CLOSED and CLOSED_GONE records.
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
* Filters results to findings whose bu_ownership contains one of the specified teams.
* *
* @response {object} 200 * @response {object} 200
* { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number } * { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number }
@@ -51,7 +51,7 @@ function createIvantiArchiveRouter() {
* { error: string } * { error: string }
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { state, teams } = req.query; const { state } = req.query;
if (state && !VALID_STATES.includes(state)) { if (state && !VALID_STATES.includes(state)) {
return res.status(400).json({ return res.status(400).json({
@@ -59,9 +59,9 @@ function createIvantiArchiveRouter() {
}); });
} }
// Parse teams filter into ILIKE patterns // Build team patterns from middleware (null = admin, no filter)
const teamPatterns = teams const teamPatterns = req.teamScope
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') ? req.teamScope.ivanti.map(t => `%${t}%`)
: []; : [];
try { try {
@@ -148,9 +148,7 @@ function createIvantiArchiveRouter() {
/** /**
* GET /stats * GET /stats
* Summary counts of archive records grouped by lifecycle state. * Summary counts of archive records grouped by lifecycle state.
* * Team scoping enforced by requireTeam() middleware via req.teamScope.
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
* Filters counts to findings whose bu_ownership contains one of the specified teams.
* *
* @response {object} 200 * @response {object} 200
* { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number } * { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
@@ -159,9 +157,9 @@ function createIvantiArchiveRouter() {
*/ */
router.get('/stats', async (req, res) => { router.get('/stats', async (req, res) => {
try { try {
const { teams } = req.query; // Build team patterns from middleware (null = admin, no filter)
const teamPatterns = teams const teamPatterns = req.teamScope
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%') ? req.teamScope.ivanti.map(t => `%${t}%`)
: []; : [];
let archiveQuery, archiveParams = []; let archiveQuery, archiveParams = [];
@@ -190,7 +188,7 @@ function createIvantiArchiveRouter() {
} }
} }
// ACTIVE = total live findings count (scoped by teams if provided) // ACTIVE = total live findings count (scoped by teams)
let activeQuery, activeParams = []; let activeQuery, activeParams = [];
if (teamPatterns.length > 0) { if (teamPatterns.length > 0) {
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`; activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;

View File

@@ -4,7 +4,7 @@
// Daily auto-sync fetches from Ivanti API and upserts rows. // Daily auto-sync fetches from Ivanti API and upserts rows.
const express = require('express'); const express = require('express');
const { requireGroup } = require('../middleware/auth'); const { requireGroup, requireTeam } = require('../middleware/auth');
const { ivantiPost } = require('../helpers/ivantiApi'); const { ivantiPost } = require('../helpers/ivantiApi');
const pool = require('../db'); const pool = require('../db');
@@ -23,8 +23,10 @@ function formatDate(val) {
return String(val).slice(0, 10); return String(val).slice(0, 10);
} }
// Configurable BU filter — broadened via env var to support multi-tenancy. // Configurable BU filter — controls sync-level filtering (what gets pulled from Ivanti API).
// Users see only their assigned teams' findings (filtered at query time). // Per-user query-time filtering is handled by requireTeam() middleware, which scopes
// API responses to the user's assigned teams. This env var determines the superset of
// data that exists in the local database.
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
const FINDINGS_FILTERS = [ const FINDINGS_FILTERS = [
@@ -681,7 +683,7 @@ async function syncFPWorkflowCounts(openFindings, apiKey, clientId, skipTls) {
const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM'; const MANAGED_BUS_VALUE = process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean)); const EXPECTED_BUS = new Set(MANAGED_BUS_VALUE.split(',').map(b => b.trim()).filter(Boolean));
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) { async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls, previousBuMap) {
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 }; const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary; if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
@@ -797,17 +799,18 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
// Record BU reassignment in ivanti_finding_bu_history for detail view // Record BU reassignment in ivanti_finding_bu_history for detail view
if (classification === 'bu_reassignment' && found) { if (classification === 'bu_reassignment' && found) {
try { try {
// Determine previous BU — look up from the cached finding record // Determine previous BU from the pre-sync snapshot (passed in from syncFindings)
const { rows: prevRows } = await pool.query( const previousBu = (previousBuMap && previousBuMap.get(id)) || '';
`SELECT bu_ownership FROM ivanti_findings WHERE id = $1`,
[id] // Only record if we have a known previous BU — "UNKNOWN → X" entries
); // provide no actionable insight for asset movement tracking.
const previousBu = prevRows[0]?.bu_ownership || 'UNKNOWN'; if (previousBu && EXPECTED_BUS.has(previousBu)) {
await pool.query( await pool.query(
`INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at) `INSERT INTO ivanti_finding_bu_history (finding_id, finding_title, host_name, previous_bu, new_bu, detected_at)
VALUES ($1, $2, $3, $4, $5, NOW())`, VALUES ($1, $2, $3, $4, $5, NOW())`,
[id, found.title || '', found.hostName || '', previousBu, found.bu] [id, found.title || '', found.hostName || '', previousBu, found.bu]
); );
}
} catch (err) { } catch (err) {
console.error(`[BU Drift Checker] Error recording BU change for finding ${id}:`, err.message); console.error(`[BU Drift Checker] Error recording BU change for finding ${id}:`, err.message);
} }
@@ -897,12 +900,14 @@ async function syncFindings() {
// Read previous open findings from DB for archive detection // Read previous open findings from DB for archive detection
let previousFindings = []; let previousFindings = [];
let previousBuMap = new Map(); // id → bu_ownership snapshot BEFORE upsert
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership" `SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership"
FROM ivanti_findings WHERE state = 'open'` FROM ivanti_findings WHERE state = 'open'`
); );
previousFindings = rows; previousFindings = rows;
previousBuMap = new Map(rows.map(f => [String(f.id), f.buOwnership || '']));
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message); console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
} }
@@ -1004,7 +1009,7 @@ async function syncFindings() {
console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`); console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`);
idsToCheck = newlyArchivedOnly; idsToCheck = newlyArchivedOnly;
} }
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls); classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls, previousBuMap);
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message); console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
} }
@@ -1076,32 +1081,29 @@ function createIvantiFindingsRouter(db, requireAuth) {
scheduleSync(); scheduleSync();
router.use(requireAuth()); router.use(requireAuth());
router.use(requireTeam());
/** /**
* GET /api/ivanti/findings * GET /api/ivanti/findings
* *
* Return findings from ivanti_findings table (state='open') with notes and overrides. * Return findings from ivanti_findings table (state='open') with notes and overrides.
* Accepts optional `teams` query parameter (comma-separated) to filter * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* findings by buOwnership. If omitted, returns all open findings. * Admin users see all findings; non-admin users see only their assigned teams.
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message } * @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`; let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`;
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
query += ' ORDER BY severity DESC'; query += ' ORDER BY severity DESC';
@@ -1163,10 +1165,19 @@ function createIvantiFindingsRouter(db, requireAuth) {
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
await syncFindings(); await syncFindings();
try { try {
// Return fresh state after sync // Return fresh state after sync, scoped to user's teams
const { rows } = await pool.query( let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`;
`SELECT * FROM ivanti_findings WHERE state = 'open' ORDER BY severity DESC` const params = [];
); let paramIndex = 1;
if (req.teamScope) {
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
params.push(patterns);
}
query += ' ORDER BY severity DESC';
const { rows } = await pool.query(query, params);
const findings = rows.map(row => ({ const findings = rows.map(row => ({
id: row.id, id: row.id,
hostId: row.host_id, hostId: row.host_id,
@@ -1212,27 +1223,22 @@ function createIvantiFindingsRouter(db, requireAuth) {
* GET /api/ivanti/findings/counts * GET /api/ivanti/findings/counts
* *
* Return open vs closed finding totals. * Return open vs closed finding totals.
* Accepts optional `teams` query parameter to scope counts to specific BUs. * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* With Postgres, both open AND closed counts are per-BU when filtered.
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { open: number, closed: number, filtered: boolean } * @returns {Object} 200 - { open: number, closed: number, filtered: boolean }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/counts', async (req, res) => { router.get('/counts', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let whereExtra = ''; let whereExtra = '';
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -1243,7 +1249,7 @@ function createIvantiFindingsRouter(db, requireAuth) {
const counts = { open: 0, closed: 0 }; const counts = { open: 0, closed: 0 };
rows.forEach(r => { counts[r.state] = parseInt(r.count); }); rows.forEach(r => { counts[r.state] = parseInt(r.count); });
res.json({ ...counts, filtered: !!teamsParam }); res.json({ ...counts, filtered: !!req.teamScope });
} catch (err) { } catch (err) {
console.error('[Ivanti Findings] GET /counts error:', err.message); console.error('[Ivanti Findings] GET /counts error:', err.message);
res.status(500).json({ error: 'Database error reading counts' }); res.status(500).json({ error: 'Database error reading counts' });
@@ -1254,45 +1260,38 @@ function createIvantiFindingsRouter(db, requireAuth) {
* GET /api/ivanti/findings/counts/history * GET /api/ivanti/findings/counts/history
* *
* Return the last snapshot per day (ascending) for the trend chart. * Return the last snapshot per day (ascending) for the trend chart.
* Accepts optional `teams` query parameter to scope the trend to specific BUs. * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* When teams is provided, uses the per-BU history table. * When scoped, uses the per-BU history table. When admin (no scope), returns global aggregate.
* When no teams, returns the global aggregate history.
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> } * @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/counts/history', async (req, res) => { router.get('/counts/history', async (req, res) => {
try { try {
const teamsParam = req.query.teams; if (req.teamScope) {
// Per-BU history — filter and aggregate by user's assigned teams
if (teamsParam) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
// Per-BU history — filter and aggregate by selected teams const { rows } = await pool.query(
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); `SELECT date,
if (teams.length > 0) { SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count,
const patterns = teams.map(t => `%${t}%`); SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count
const { rows } = await pool.query( FROM (
`SELECT date, SELECT recorded_at::date AS date, bu_ownership, state, count,
SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count, ROW_NUMBER() OVER (
SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count PARTITION BY recorded_at::date, bu_ownership, state
FROM ( ORDER BY recorded_at DESC
SELECT recorded_at::date AS date, bu_ownership, state, count, ) AS rn
ROW_NUMBER() OVER ( FROM ivanti_counts_history_by_bu
PARTITION BY recorded_at::date, bu_ownership, state WHERE bu_ownership ILIKE ANY($1::text[])
ORDER BY recorded_at DESC ) sub WHERE rn = 1
) AS rn GROUP BY date
FROM ivanti_counts_history_by_bu ORDER BY date ASC`,
WHERE bu_ownership ILIKE ANY($1::text[]) [patterns]
) sub WHERE rn = 1 );
GROUP BY date return res.json({ history: rows });
ORDER BY date ASC`,
[patterns]
);
return res.json({ history: rows });
}
} }
// Global history (no filter) // Global history (admin — no filter)
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT date, open_count, closed_count FROM ( `SELECT date, open_count, closed_count FROM (
SELECT recorded_at::date AS date, SELECT recorded_at::date AS date,
@@ -1317,26 +1316,22 @@ function createIvantiFindingsRouter(db, requireAuth) {
* *
* Return FP finding counts and unique workflow ID counts (open + closed), * Return FP finding counts and unique workflow ID counts (open + closed),
* broken down by workflow status. * broken down by workflow status.
* Accepts optional `teams` query parameter to scope to specific BUs. * Team scoping is enforced by requireTeam() middleware via req.teamScope.
* *
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
* @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number } * @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.get('/fp-workflow-counts', async (req, res) => { router.get('/fp-workflow-counts', async (req, res) => {
try { try {
const teamsParam = req.query.teams;
let whereExtra = ''; let whereExtra = '';
const params = []; const params = [];
let paramIndex = 1; let paramIndex = 1;
if (teamsParam) { // Team scoping (null = admin bypass, no filter)
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean); if (req.teamScope) {
if (teams.length > 0) { const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const patterns = teams.map(t => `%${t}%`); whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`; params.push(patterns);
params.push(patterns);
}
} }
// Finding counts: number of findings per workflow state // Finding counts: number of findings per workflow state
@@ -1554,19 +1549,35 @@ function createIvantiFindingsRouter(db, requireAuth) {
* PUT /api/ivanti/findings/:findingId/override * PUT /api/ivanti/findings/:findingId/override
* *
* Save or clear field overrides for a finding. Requires Admin or Standard_User group. * Save or clear field overrides for a finding. Requires Admin or Standard_User group.
* Accepts hostName and/or dns in the body. Empty/null values clear the override. * Team scoping enforced — user can only override findings in their team scope.
* *
* @param {string} findingId - The finding identifier (URL param) * @param {string} findingId - The finding identifier (URL param)
* @body {string} [hostName] - Override for host name; empty/null to clear * @body {string} [hostName] - Override for host name; empty/null to clear
* @body {string} [dns] - Override for DNS; empty/null to clear * @body {string} [dns] - Override for DNS; empty/null to clear
* *
* @returns {Object} 200 - { finding_id, overrides: { hostName, dns } } * @returns {Object} 200 - { finding_id, overrides: { hostName, dns } }
* @returns {Object} 403 - { error: string } when finding is outside team scope
* @returns {Object} 404 - { error: string } when finding not found * @returns {Object} 404 - { error: string } when finding not found
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => {
try { try {
const { findingId } = req.params; const { findingId } = req.params;
// Verify finding is in user's team scope
if (req.teamScope) {
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const check = await pool.query(
`SELECT id FROM ivanti_findings WHERE id = $1 AND bu_ownership ILIKE ANY($2::text[])`,
[findingId, patterns]
);
if (check.rows.length === 0) {
return res.status(403).json({
error: 'Access denied. This finding is outside your team scope.',
code: 'TEAM_ACCESS_DENIED'
});
}
}
const { hostName, dns, field, value } = req.body; const { hostName, dns, field, value } = req.body;
// Support legacy single-field format: { field: 'hostName', value: 'x' } // Support legacy single-field format: { field: 'hostName', value: 'x' }
@@ -1636,16 +1647,34 @@ function createIvantiFindingsRouter(db, requireAuth) {
* *
* Save or update a note for a finding (max 255 characters). * Save or update a note for a finding (max 255 characters).
* Requires Admin or Standard_User group. * Requires Admin or Standard_User group.
* Team scoping enforced — user can only note findings in their team scope.
* *
* @param {string} findingId - The finding identifier (URL param) * @param {string} findingId - The finding identifier (URL param)
* @body {string} [note] - The note text (truncated to 255 chars) * @body {string} [note] - The note text (truncated to 255 chars)
* *
* @returns {Object} 200 - { finding_id: string, note: string } * @returns {Object} 200 - { finding_id: string, note: string }
* @returns {Object} 403 - { error: string } when finding is outside team scope
* @returns {Object} 500 - { error: string } on database error * @returns {Object} 500 - { error: string } on database error
*/ */
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => { router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => {
try { try {
const { findingId } = req.params; const { findingId } = req.params;
// Verify finding is in user's team scope
if (req.teamScope) {
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const check = await pool.query(
`SELECT id FROM ivanti_findings WHERE id = $1 AND bu_ownership ILIKE ANY($2::text[])`,
[findingId, patterns]
);
if (check.rows.length === 0) {
return res.status(403).json({
error: 'Access denied. This finding is outside your team scope.',
code: 'TEAM_ACCESS_DENIED'
});
}
}
const note = String(req.body.note || '').slice(0, 255); const note = String(req.body.note || '').slice(0, 255);
await pool.query( await pool.query(

View File

@@ -1,7 +1,7 @@
// routes/ivantiTodoQueue.js // routes/ivantiTodoQueue.js
const express = require('express'); const express = require('express');
const pool = require('../db'); const pool = require('../db');
const { requireAuth, requireGroup } = require('../middleware/auth'); const { requireAuth, requireGroup, requireTeam } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog'); const logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate']; const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM', 'Remediate'];
@@ -88,7 +88,7 @@ function createIvantiTodoQueueRouter() {
* @error 400 Invalid input * @error 400 Invalid input
* @error 500 Internal server error * @error 500 Internal server error
*/ */
router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { router.post('/batch', requireAuth(), requireGroup('Admin', 'Standard_User'), requireTeam(), async (req, res) => {
const { findings, workflow_type, vendor } = req.body; const { findings, workflow_type, vendor } = req.body;
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) { if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
@@ -102,6 +102,25 @@ function createIvantiTodoQueueRouter() {
} }
} }
// Verify findings belong to user's team scope (skip for admin)
if (req.teamScope) {
const findingIds = findings.map(f => f.finding_id.trim());
const patterns = req.teamScope.ivanti.map(t => `%${t}%`);
const { rows: validFindings } = await pool.query(
`SELECT id FROM ivanti_findings WHERE id = ANY($1) AND bu_ownership ILIKE ANY($2::text[])`,
[findingIds, patterns]
);
const validIds = new Set(validFindings.map(r => String(r.id)));
const blocked = findingIds.filter(id => !validIds.has(id));
if (blocked.length > 0) {
return res.status(403).json({
error: 'Some findings are outside your team scope and cannot be added to your queue.',
code: 'TEAM_ACCESS_DENIED',
blocked_findings: blocked
});
}
}
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' }); return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, DECOM, or Remediate.' });
} }

View File

@@ -30,6 +30,10 @@ function isValidVendor(vendor) {
function createJiraTicketsRouter() { function createJiraTicketsRouter() {
const router = express.Router(); const router = express.Router();
// All Jira routes require authentication and Admin or Standard_User group (page-level access)
router.use(requireAuth());
router.use(requireGroup('Admin', 'Standard_User'));
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Jira API integration endpoints // Jira API integration endpoints
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -186,8 +186,9 @@ function isSafeTempPath(filePath) {
function createVCLMultiVerticalRouter(upload) { function createVCLMultiVerticalRouter(upload) {
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication + Leadership or Admin group
router.use(requireAuth()); router.use(requireAuth());
router.use(requireGroup('Admin', 'Leadership'));
/** /**
* POST /preview * POST /preview

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
// Temporary diagnostic script — fetch a specific finding and dump host fields
require('dotenv').config();
const { ivantiPost } = require('../helpers/ivantiApi');
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
const findingId = process.argv[2] || '2814870699';
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
const body = {
filters: [
{ field: 'id', exclusive: false, operator: 'EXACT', orWithPrevious: false, implicitFilters: [], value: findingId, caseSensitive: false }
],
projection: 'internal',
sort: [{ field: 'severity', direction: 'ASC' }],
page: 0,
size: 1
};
ivantiPost(urlPath, body, apiKey, skipTls).then(r => {
const data = JSON.parse(r.body);
const finding = (data._embedded && data._embedded.hostFindings || [])[0];
if (!finding) { console.log('Finding not found'); process.exit(0); }
console.log('=== host object ===');
console.log(JSON.stringify(finding.host, null, 2));
console.log('');
console.log('=== hostAdditionalDetails ===');
console.log(JSON.stringify(finding.hostAdditionalDetails, null, 2));
process.exit(0);
}).catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -157,6 +157,30 @@ app.use('/api/users', createUsersRouter(requireAuth, requireGroup, logAudit));
// Audit log routes (admin only) // Audit log routes (admin only)
app.use('/api/audit-logs', createAuditLogRouter()); app.use('/api/audit-logs', createAuditLogRouter());
// Recent activity feed (any authenticated user, limited data)
app.get('/api/recent-activity', requireAuth(), async (req, res) => {
try {
const limit = Math.min(15, Math.max(1, parseInt(req.query.limit) || 10));
// Hide impersonation events from non-Admin users
const excludedActions = ['login', 'logout', 'login_failed'];
if (req.user.group !== 'Admin') {
excludedActions.push('impersonate_start', 'impersonate_stop');
}
const { rows } = await pool.query(
`SELECT username, action, entity_type, entity_id, details, created_at
FROM audit_logs
WHERE action NOT IN (${excludedActions.map((_, i) => `$${i + 1}`).join(', ')})
ORDER BY created_at DESC
LIMIT $${excludedActions.length + 1}`,
[...excludedActions, limit]
);
res.json({ activities: rows });
} catch (err) {
console.error('Recent activity error:', err);
res.status(500).json({ error: 'Failed to fetch recent activity' });
}
});
// NVD lookup routes (authenticated users) // NVD lookup routes (authenticated users)
app.use('/api/nvd', createNvdLookupRouter()); app.use('/api/nvd', createNvdLookupRouter());
@@ -196,6 +220,13 @@ const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
}); });
// Separate multer instance for compliance xlsx uploads — these can be 75MB+ for large verticals
const complianceUpload = multer({
storage: storage,
fileFilter: fileFilter,
limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit for compliance spreadsheets
});
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view) // Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload)); app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
@@ -223,10 +254,10 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
// VCL multi-vertical routes — cross-organizational compliance reporting // VCL multi-vertical routes — cross-organizational compliance reporting
// Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix // Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload)); app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(complianceUpload));
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes // AEO compliance routes — xlsx upload, non-compliant item tracking, notes
app.use('/api/compliance', createComplianceRouter(upload)); app.use('/api/compliance', createComplianceRouter(complianceUpload));
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges // Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
app.use('/api/atlas', createAtlasRouter()); app.use('/api/atlas', createAtlasRouter());
@@ -1196,8 +1227,30 @@ if (fs.existsSync(frontendBuild)) {
}); });
} }
// Start server // Start server — use HTTPS if TLS cert/key are available, otherwise plain HTTP
app.listen(PORT, () => { const TLS_CERT = process.env.TLS_CERT || path.join(__dirname, 'certs', 'cert.pem');
console.log(`CVE API server running on http://${API_HOST}:${PORT}`); const TLS_KEY = process.env.TLS_KEY || path.join(__dirname, 'certs', 'key.pem');
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`); const TLS_ENABLED = process.env.TLS_ENABLED !== 'false' && fs.existsSync(TLS_CERT) && fs.existsSync(TLS_KEY);
});
if (TLS_ENABLED) {
const https = require('https');
const httpsOptions = {
cert: fs.readFileSync(TLS_CERT),
key: fs.readFileSync(TLS_KEY),
};
https.createServer(httpsOptions, app).listen(PORT, () => {
console.log(`CVE API server running on https://${API_HOST}:${PORT}`);
console.log(`TLS: enabled (cert: ${TLS_CERT})`);
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
});
} else {
app.listen(PORT, () => {
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
if (!fs.existsSync(TLS_CERT) || !fs.existsSync(TLS_KEY)) {
console.log('TLS: disabled (no certs found in backend/certs/)');
} else {
console.log('TLS: disabled (TLS_ENABLED=false)');
}
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
});
}

View File

@@ -0,0 +1,354 @@
# AD/SAML Integration Architecture
## Overview
This document describes the architecture for integrating Active Directory (AD) authentication via SAML 2.0 into the STEAM Security Dashboard. The integration adds Single Sign-On (SSO) as the primary authentication method while retaining local password login as a break-glass fallback for administrators. AD group memberships drive automatic permission assignment and BU team scoping through a configurable mapping layer.
---
## Authentication Model
The dashboard supports two authentication paths simultaneously:
| Path | Users | Mechanism | Session |
|---|---|---|---|
| Local | Break-glass admins, service accounts | Username + bcrypt password | Cookie-based, PostgreSQL sessions table |
| SAML SSO | All AD users | SP-initiated SAML 2.0 via AD FS | Same cookie-based session (identical to local) |
Both paths produce the same session artifact — an httpOnly cookie containing a `session_id` that maps to a row in the `sessions` table. Downstream middleware (`requireAuth`, `requireGroup`) is unaware of how the session was created.
---
## SAML 2.0 Authentication Flow
### SP-Initiated Login (Success Path)
```mermaid
sequenceDiagram
participant B as Browser
participant SP as Dashboard (SP)
participant IdP as AD FS (IdP)
participant DB as PostgreSQL
B->>SP: GET /api/auth/saml/login
SP->>SP: Generate AuthnRequest XML
SP->>B: HTTP 302 Redirect to IdP SSO URL (with AuthnRequest)
B->>IdP: Follow redirect (user sees AD FS login page)
IdP->>IdP: Authenticate user against AD
IdP->>IdP: Build assertion (NameID, email, groups)
IdP->>IdP: Sign assertion with IdP private key
IdP->>B: HTTP 200 with auto-submit form (POST to SP callback)
B->>SP: POST /api/auth/saml/callback (SAMLResponse in body)
SP->>SP: Base64-decode SAMLResponse
SP->>SP: Validate XML signature against IdP certificate
SP->>SP: Check NotBefore/NotOnOrAfter (120s clock skew tolerance)
SP->>SP: Extract NameID, email, displayName, group claims
SP->>DB: Look up user by external_id (NameID)
alt New user (no matching external_id)
SP->>DB: INSERT into users (JIT provisioning)
SP->>DB: INSERT audit_log (saml_user_provisioned)
else Existing user
SP->>DB: UPDATE user group, teams, email
SP->>DB: INSERT audit_log (saml_user_updated) if changed
end
SP->>DB: INSERT into sessions (session_id, user_id, expires_at)
SP->>DB: INSERT audit_log (saml_login)
SP->>B: Set-Cookie: session_id=xxx; HttpOnly; SameSite=Lax
SP->>B: HTTP 302 Redirect to /?saml_success=true
B->>SP: GET /api/auth/me (with cookie)
SP->>B: 200 { user: { id, username, group, teams, authSource } }
```
### Assertion Rejection Path
```mermaid
sequenceDiagram
participant B as Browser
participant SP as Dashboard (SP)
participant IdP as AD FS (IdP)
B->>SP: GET /api/auth/saml/login
SP->>B: HTTP 302 Redirect to IdP
B->>IdP: Authenticate
IdP->>B: POST assertion to SP callback
B->>SP: POST /api/auth/saml/callback
SP->>SP: Validate assertion
alt Invalid signature
SP->>SP: Log audit (saml_auth_failed, reason: invalid_signature)
SP->>B: Redirect /?saml_error=Invalid+assertion+signature
else Expired assertion
SP->>SP: Log audit (saml_auth_failed, reason: assertion_expired)
SP->>B: Redirect /?saml_error=Assertion+expired
else Account disabled
SP->>SP: Log audit (saml_auth_failed, reason: account_disabled)
SP->>B: Redirect /?saml_error=Account+is+disabled
end
```
---
## Component Architecture
```
┌───────────────────────────────────────────────────────────────────┐
│ Express Backend (port 3001) │
│ │
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ routes/saml.js │ │ routes/auth.js │ │ middleware/auth.js│ │
│ │ │ │ │ │ │ │
│ │ GET /status │ │ POST /login │ │ requireAuth() │ │
│ │ GET /login │ │ POST /logout │ │ requireGroup() │ │
│ │ POST /callback │ │ GET /me │ │ │ │
│ │ GET /metadata │ │ POST /change-pw │ │ (unchanged — │ │
│ └───────┬────────┘ └────────┬────────┘ │ reads session │ │
│ │ │ │ cookie only) │ │
│ │ │ └──────────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ helpers/samlProvisioning.js │ │
│ │ │ │
│ │ resolveGroup(adGroups, config) → dashboardGroup │ │
│ │ resolveTeams(adGroups, config) → "STEAM,..." │ │
│ │ deriveUsername(nameId) → username │ │
│ │ provisionOrUpdateUser(assertion, config, ip) │ │
│ └───────────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ helpers/samlConfig.js │ │
│ │ │ │
│ │ loadGroupMappingConfig() → validated config obj │ │
│ │ (reads config/adGroupMapping.json or env var) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
```
### Key Design Decisions
1. **Session reuse**: SAML login creates the exact same session format as local login. No changes to `requireAuth()` middleware.
2. **Feature flag isolation**: When `SAML_ENABLED=false`, SAML routes return 404 and no SAML library is loaded. Zero runtime cost when disabled.
3. **Config-driven mapping**: AD group names are externalized in `config/adGroupMapping.json`. Changing the mapping requires only a file edit and backend restart — no code changes.
4. **JIT provisioning**: Users are created on first login, updated on each subsequent login. AD is the source of truth for SSO users.
5. **Separation of concerns**: The provisioning logic (`samlProvisioning.js`) is a pure module with no HTTP dependencies — fully unit-testable without a web server.
---
## AD Group-to-Permission Mapping
### Configuration Structure
```json
{
"groups": {
"<AD-GROUP-CN>": "<Dashboard-Group>",
"CVE-Dashboard-Admins": "Admin",
"CVE-Dashboard-Users": "Standard_User",
"CVE-Dashboard-Leadership": "Leadership",
"CVE-Dashboard-ReadOnly": "Read_Only"
},
"teams": {
"<AD-GROUP-CN>": "<BU-Team-ID>",
"NTS-AEO-STEAM": "STEAM",
"NTS-AEO-ACCESS-ENG": "ACCESS-ENG",
"NTS-AEO-ACCESS-OPS": "ACCESS-OPS",
"NTS-AEO-INTELDEV": "INTELDEV"
},
"groupPriority": ["Admin", "Standard_User", "Leadership", "Read_Only"],
"defaultGroup": "Read_Only",
"attributes": {
"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"displayName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"groups": "http://schemas.xmlsoap.org/claims/Group"
}
}
```
### Privilege Hierarchy
When a user belongs to multiple AD groups that map to different dashboard groups, the highest-privilege group wins:
```
Admin > Standard_User > Leadership > Read_Only
```
### Multi-Team Assignment
When a user belongs to multiple AD groups that map to BU teams, all matching teams are assigned:
```
AD Groups: ["NTS-AEO-STEAM", "NTS-AEO-ACCESS-ENG", "CVE-Dashboard-Users"]
→ user_group: "Standard_User"
→ bu_teams: "ACCESS-ENG,STEAM" (sorted alphabetically, deduplicated)
```
### Placeholder Group Names
The AD group names in the `groups` and `teams` sections (e.g., "CVE-Dashboard-Admins") are placeholders. When real group CNs are obtained from the AD administrators, update only this configuration file. No code changes required.
---
## Database Schema Changes
### Migration: `add_saml_auth_columns.js`
| Column | Type | Nullable | Default | Purpose |
|---|---|---|---|---|
| `auth_source` | VARCHAR(10) | NOT NULL | `'local'` | Discriminates local vs SSO users |
| `external_id` | VARCHAR(256) | NULL | NULL | SAML NameID for IdP correlation |
Additional changes:
- `password_hash` becomes nullable (SAML users have no local password)
- Partial unique index on `external_id WHERE external_id IS NOT NULL`
### Impact on Existing Data
- All existing users receive `auth_source = 'local'` and `external_id = NULL`
- No existing functionality is affected
- Migration is idempotent (safe to re-run)
---
## Environment Variables
| Variable | Required | Description | Example |
|---|---|---|---|
| `SAML_ENABLED` | Always | Master feature flag for SAML authentication | `false` |
| `SAML_IDP_METADATA_URL` | When SAML_ENABLED=true | AD FS federation metadata endpoint | `https://adfs.corp.local/FederationMetadata/2007-06/FederationMetadata.xml` |
| `SAML_SP_ENTITY_ID` | When SAML_ENABLED=true | Unique identifier for this Service Provider | `http://71.85.90.6:3001` |
| `SAML_SP_CALLBACK_URL` | When SAML_ENABLED=true | Assertion consumer service URL | `http://71.85.90.6:3001/api/auth/saml/callback` |
| `SAML_IDP_CERT_PATH` | When SAML_ENABLED=true | File path to IdP signing certificate (PEM format) | `/etc/cve-dashboard/idp-cert.pem` |
| `SESSION_LIFETIME_HOURS` | Optional | Session duration (1-720 hours, default: 24) | `8` |
| `AD_GROUP_MAPPING_JSON` | Optional | JSON string override for adGroupMapping.json | `{"groups":{...},"teams":{...}}` |
### Startup Validation
When `SAML_ENABLED=true`, the server validates at startup:
1. All required SAML env vars are set (fails with descriptive error if missing)
2. Certificate file exists and is readable (fails if not)
3. Group mapping config parses as valid JSON (fails if not)
4. All mapped team names exist in KNOWN_TEAMS (fails if not)
5. All mapped dashboard groups are valid (fails if not)
This fail-fast approach prevents silent misconfiguration in production.
---
## Build vs Wait: Phase Breakdown
### Phase 1 — Build Now (No AD Access Required)
| Component | File | Test Strategy |
|---|---|---|
| Database migration | `backend/migrations/add_saml_auth_columns.js` | Run against test DB, verify columns |
| Group mapping config | `backend/config/adGroupMapping.json` | Startup validation tests |
| Config loader | `backend/helpers/samlConfig.js` | Unit test with mock JSON files |
| JIT provisioner | `backend/helpers/samlProvisioning.js` | Unit test all paths with mock pool |
| SAML routes (skeleton) | `backend/routes/saml.js` | Integration test: feature flag, status, metadata |
| Session lifetime | `server.js` (startup block) | Unit test env var parsing |
| Auth route changes | `backend/routes/auth.js` | Integration test: SAML user login rejection |
| User route changes | `backend/routes/users.js` | Integration test: auth_source in responses, password block |
| Frontend SSO button | `frontend/src/components/LoginForm.js` | Render test: button hidden when flag=false |
| Admin auth_source badges | `frontend/src/components/UserManagement.js` | Render test: badge displays |
| Architecture doc | `docs/architecture/ad-saml-integration.md` | N/A (documentation) |
### Phase 2 — Requires Live AD FS Connection
| Component | Dependency | Who Provides It |
|---|---|---|
| SAML library installation | Package selection (`@node-saml/passport-saml` or `saml2-js`) | Development team |
| IdP metadata URL | AD FS federation metadata endpoint | AD administrators |
| IdP signing certificate | Token-signing cert exported from AD FS | AD administrators |
| SP registration | Relying party trust created in AD FS console | AD administrators |
| Real AD group names | Actual CNs of permission/team groups | AD administrators |
| Assertion parsing implementation | Fill in `routes/saml.js` callback | Development team |
| End-to-end flow testing | Working AD user accounts | AD administrators |
| Session lifetime tuning | AD FS token lifetime policy value | AD administrators |
---
## Security Considerations
### Certificate Management
- The IdP signing certificate is stored on disk at `SAML_IDP_CERT_PATH`
- When the IdP rotates its certificate, replace the file and restart the backend
- No database migration required for certificate rotation
- Consider monitoring certificate expiry dates (AD FS certs typically rotate annually)
### Assertion Replay Prevention
- Each SAML assertion is consumed exactly once by the callback handler
- The JIT provisioner's idempotent update pattern means replayed assertions would simply re-update the same user record (no escalation possible)
- For additional protection in Phase 2, implement InResponseTo validation and a short-lived assertion ID cache
### Trust Boundary
```
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Trust Zone A │ │ Trust Zone B │
│ │ │ │
│ Dashboard (SP) │ │ AD FS (IdP) │
│ - Validates assertions │ │ - Authenticates users │
│ - Trusts ONLY signed assertions │ │ - Signs assertions with │
│ - Creates local sessions │ │ private key │
│ - Enforces local authorization │ │ - Asserts group memberships │
│ │ │ │
└────────────────┬─────────────────┘ └────────────────┬────────────────┘
│ │
└─── SAML 2.0 over HTTPS (HTTP-POST) ────┘
```
- The SP trusts assertions only when cryptographically signed by the IdP
- Group memberships in the assertion drive permission assignment — the SP does not query AD directly
- If the IdP is compromised, an attacker could forge assertions. Mitigate with certificate pinning and monitoring assertion patterns in audit logs.
- The SP never sends credentials to the IdP — authentication happens entirely on the IdP side
### Break-Glass Protection
- The last local Admin account cannot be deleted or deactivated
- If the IdP is unavailable, local Admin users can still log in with username/password
- SAML users cannot authenticate via password (and vice versa) — the two paths are isolated per user record
### Transport Security
- Production deployments should serve the SP callback over HTTPS
- The SAMLResponse is transmitted via HTTP-POST binding (browser-mediated, not direct server-to-server)
- The assertion is signed — even if transmitted over HTTP, it cannot be tampered with without detection
- For defense in depth, HTTPS prevents assertion interception by network observers
---
## Hybrid Login User Experience
```
┌─────────────────────────────────────────────────────────────┐
│ Login Page │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Sign in with SSO │ │ │
│ │ │ (redirects to AD FS) │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ ─── or sign in with local account ─── │ │
│ │ │ │
│ │ Username: [________________] │ │
│ │ Password: [________________] │ │
│ │ │ │
│ │ [Sign In] │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Note: SSO button only visible when SAML_ENABLED=true │
│ Local login always available (break-glass for admins) │
└─────────────────────────────────────────────────────────────┘
```
---
## Related Specifications
- `.kiro/specs/ad-saml-integration/requirements.md` — detailed acceptance criteria
- `.kiro/specs/ad-saml-integration/design.md` — implementation design with code examples
- `.kiro/specs/group-based-access-control/requirements.md` — existing RBAC system
- `.kiro/specs/multi-bu-tenancy/design.md` — BU team scoping (leveraged by AD integration)

View File

@@ -0,0 +1,547 @@
# Split Architecture Proposal: Collector + Indexer
**Author:** Infrastructure Team
**Date:** 2026-06-08
**Status:** Draft — Pending Review
**Scope:** Scale CVE Dashboard from 2 teams / ~15 users to company-wide deployment (100+ users, 15+ teams)
---
## Executive Summary
The STEAM Security Dashboard currently runs as a monolithic single-process Express application on CT107 (dashboard-dev, 71.85.90.9). This single process simultaneously serves the frontend, handles all API requests, and performs background data collection from Ivanti, Jira, CARD, Atlas, and NVD APIs.
At current scale (2 teams, <15 users, daily sync), this architecture works. At company-wide scale (15+ teams, hundreds of users, sub-hourly sync), it will not. This document proposes a phased transition to a **Collector + API Server** architecture that separates data ingestion from request serving.
**Critical constraint:** CT107 (71.85.90.9) has the firewall rules granting access to the production Ivanti, Jira, and CARD APIs. The collector component must remain on this machine or firewall rules must be extended.
---
## Table of Contents
- [Current Architecture](#current-architecture)
- [Problem Statement](#problem-statement)
- [Proposed Architecture](#proposed-architecture)
- [Phase Plan](#phase-plan)
- [Infrastructure Requirements](#infrastructure-requirements)
- [Risk Assessment](#risk-assessment)
- [Decision Points](#decision-points)
- [Appendix: Current Data Flow Analysis](#appendix-current-data-flow-analysis)
---
## Current Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ CT107 (dashboard-dev) │
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Express Process (port 3001/3100) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
│ │ │ React SPA │ │ API Routes │ │ Sync Workers │ │ │
│ │ │ (static) │ │ (50+ endpts)│ │ (setInterval) │ │ │
│ │ └─────────────┘ └──────────────┘ └────────────────┘ │ │
│ │ │ │ │ │
│ │ │ Shared PG Pool (10 conn) │ │
│ │ │ │ │ │
│ └──────────────────────────┼──────────┼─────────────────────┘ │
│ │ │ │
│ ┌──────────────────────────▼──────────▼─────────────────────┐ │
│ │ PostgreSQL 16 (Docker, port 5433) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Firewall Access: Ivanti API, Jira DC, CARD API, Atlas API │
└─────────────────────────────────────────────────────────────────┘
```
### Key Metrics (Current)
| Metric | Current Value | Company-Wide Projection |
|--------|--------------|------------------------|
| Concurrent users | 515 | 100300 |
| Teams tracked | 2 | 15+ |
| Ivanti findings (open) | ~200500 | 2,00010,000+ |
| Ivanti sync frequency | 24h | 14h desired |
| PG connection pool | 10 | Insufficient |
| Jira API rate limit | 1,440/day | Shared across all users |
| Data sources | 5 (Ivanti, NVD, Jira, Atlas, CARD) | 8+ (add CrowdStrike, Qualys, Tanium) |
---
## Problem Statement
### 1. Sync Blocks the API Server
`syncFindings()` runs sequentially through:
1. Fetch all open findings pages (100/page)
2. Upsert findings batch into PostgreSQL
3. Detect archive changes (compare all previous vs current)
4. Fetch all closed findings pages
5. Upsert closed findings
6. Run BU drift checker (makes additional API calls per disappeared finding)
7. Sync FP workflow counts (sweeps all closed pages again)
8. Compute and store anomaly summary
9. Record counts history
At 500 findings, this takes 25 minutes. At 10,000 findings across 15 teams, this could take 1530 minutes. During sync, the Express process is saturated — API responses slow, the connection pool contends.
### 2. Single Point of Failure
One process handles everything. A memory leak during sync, an unhandled promise rejection in the BU drift checker, or a runaway loop in archive detection crashes the entire dashboard for all users.
### 3. Connection Pool Exhaustion
10 connections shared between:
- User-facing read queries (findings list, compliance items, charts)
- Sync bulk upserts (batches of 100 rows × 18 columns)
- User writes (notes, overrides, queue operations)
The pool already logs warnings at 8/10 active. At 100+ concurrent users issuing reads while a sync writes thousands of rows, this will deadlock or time out.
### 4. Rate Limits Shared Across Functions
Jira's 1,440/day limit is consumed by both background sync and user-initiated operations (lookups, ticket creation). A bulk sync could exhaust the daily budget, blocking users from creating tickets the rest of the day.
### 5. No Horizontal Scaling Path
Cannot add a second API server without also duplicating the sync scheduler, which would cause duplicate syncs, double-writes, and race conditions.
### 6. Firewall Constraint
CT107 has the only firewall access to production Ivanti, Jira, and CARD APIs. The collector (data fetcher) must run on this machine. The API server could potentially move elsewhere, but the collector cannot without firewall changes.
---
## Proposed Architecture
### Target State
```
┌─────────────────────────────────────────────────────────────────┐
│ CT107 (dashboard-dev) │
│ 71.85.90.9 — 48 GB RAM, 250 GB Disk │
│ ★ Firewall access to prod APIs ★ │
│ │
│ ┌───────────────────────────────────┐ ┌─────────────────────┐│
│ │ API Server (Express, port 3001) │ │ Collector Service ││
│ │ │ │ (Node.js worker) ││
│ │ • React SPA serving │ │ ││
│ │ • All /api/* read endpoints │ │ • Ivanti sync ││
│ │ • User writes (notes, queue) │ │ • Jira bulk sync ││
│ │ • On-demand lookups (proxied) │ │ • CARD cache sync ││
│ │ • Triggers collector via │ │ • Atlas cache sync ││
│ │ pg NOTIFY │ │ • NVD bulk sync ││
│ │ │ │ • Archive detect ││
│ │ Pool: 15 conn (reads + writes) │ │ • BU drift checker ││
│ │ │ │ • Anomaly compute ││
│ └───────────────┬───────────────────┘ │ • Compliance parse ││
│ │ │ ││
│ │ │ Pool: 10 conn ││
│ │ │ (bulk upserts) ││
│ │ │ ││
│ │ │ Listens: ││
│ │ │ pg LISTEN ││
│ │ │ 'sync_trigger' ││
│ │ └──────────┬──────────┘│
│ │ │ │
│ ┌───────────────▼──────────────────────────────────▼─────────┐│
│ │ PostgreSQL 16 (Docker, port 5433) ││
│ │ Pool: 25 total connections allocated ││
│ └────────────────────────────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Component Responsibilities
#### API Server (`cve-api.service`)
| Responsibility | Details |
|---|---|
| Frontend serving | Static React build via `express.static` |
| Read endpoints | All GET routes — findings, compliance, charts, exports |
| User writes | Notes, overrides, queue items, ticket CRUD, KB uploads |
| On-demand lookups | Single NVD lookup, single Jira issue lookup, CARD real-time queries |
| Sync trigger | `SELECT pg_notify('sync_trigger', '{"type":"findings","user":"admin"}')` |
| Health/status | Expose collector status via sync_state table reads |
#### Collector (`cve-collector.service`)
| Responsibility | Details |
|---|---|
| Scheduled syncs | Ivanti findings (configurable interval), workflows (24h) |
| Bulk API operations | Jira JQL sync-all, Atlas cache refresh, NVD bulk sync |
| Post-sync processing | Archive detection, BU drift classification, closed-gone detection |
| Anomaly computation | Open/closed deltas, classification breakdown, significance flagging |
| Compliance parsing | Spawns Python subprocess for xlsx parsing on upload commit |
| Event-driven triggers | Listens on `pg LISTEN sync_trigger` for on-demand requests |
| Rate budget management | Owns the Jira daily/burst counters; API server gets a reserved allocation |
### Communication Pattern
```
User clicks "Sync" in UI
API Server receives POST /api/ivanti/findings/sync
API Server: SELECT pg_notify('sync_trigger', '{"type":"findings"}')
API Server responds: { status: 'sync_started', message: 'Check /sync-status' }
Collector receives NOTIFY, starts syncFindings()
Collector updates ivanti_sync_state (status='syncing')
Collector completes, updates ivanti_sync_state (status='success')
Frontend polls GET /api/ivanti/findings/sync-status → sees 'success' → refreshes
```
No Redis. No message broker. Just PostgreSQL LISTEN/NOTIFY — zero new infrastructure.
---
## Phase Plan
### Phase 0: Immediate Improvements (Week 12)
**Goal:** Reduce risk within the current monolith. No architectural changes.
| Task | Effort | Impact |
|------|--------|--------|
| Make `POST /sync` non-blocking — return immediately, let sync run in background | 2h | Unblocks users during sync |
| Add `GET /api/ivanti/findings/sync-status` endpoint | 1h | Frontend can poll for completion |
| Increase PG pool from 10 → 20 connections | 10min | Headroom for concurrent operations |
| Add `pg_stat_activity` monitoring query to health endpoint | 30min | Visibility into pool pressure |
| Update frontend to poll sync-status instead of waiting | 2h | UX improvement |
**Deliverables:**
- Updated `ivantiFindings.js` with async sync dispatch
- New sync-status polling endpoint
- Frontend ReportingPage sync UX updated
- Pool configuration change in `db.js`
---
### Phase 1: Extract Collector (Weeks 34)
**Goal:** Separate data collection into its own process on CT107.
| Task | Effort | Impact |
|------|--------|--------|
| Create `backend/collector.js` — standalone Node process | 4h | Fault isolation |
| Move sync functions from route files into shared `lib/sync/` modules | 3h | Code reuse between collector and API |
| Implement pg LISTEN/NOTIFY trigger mechanism | 2h | API → Collector communication |
| Create `cve-collector.service` systemd unit | 30min | Process management |
| Add collector health check and status reporting | 1h | Observability |
| Update `POST /sync` routes to use pg_notify instead of inline sync | 1h | Complete decoupling |
| Add `sync_jobs` table for job tracking (queued, running, complete, failed) | 1h | Multi-user sync coordination |
| Update CI/CD pipeline to deploy collector service | 2h | Automated deployment |
**Deliverables:**
- `backend/collector.js` — entry point for collector process
- `backend/lib/sync/` — shared sync logic (extracted from routes)
- `systemd/cve-collector.service` — systemd unit
- Updated `.gitlab-ci.yml` with collector deploy stage
- `sync_jobs` table for job state tracking
**File structure after Phase 1:**
```
backend/
├── server.js # API server (unchanged entry point)
├── collector.js # NEW — collector entry point
├── db.js # Shared pool config
├── lib/
│ └── sync/
│ ├── ivantiFindings.js # Extracted from routes/ivantiFindings.js
│ ├── ivantiWorkflows.js # Extracted from routes/ivantiWorkflows.js
│ ├── jiraBulkSync.js # Extracted from routes/jiraTickets.js
│ ├── atlasCache.js # Extracted from routes/atlas.js
│ ├── nvdBulkSync.js # New — bulk NVD operations
│ ├── archiveDetection.js # Extracted from routes/ivantiFindings.js
│ └── anomalyCompute.js # Extracted from routes/ivantiFindings.js
├── routes/ # API routes — now thin, read-heavy
└── helpers/ # Shared API client helpers (unchanged)
```
---
### Phase 2: Multi-Tenancy & Scale Hardening (Weeks 58)
**Goal:** Prepare for 15 teams and hundreds of users.
| Task | Effort | Impact |
|------|--------|--------|
| Per-team sync scheduling — stagger syncs to avoid API burst | 3h | Spreads load |
| Jira rate budget partitioning (collector gets 80%, API gets 20%) | 2h | Prevents sync from starving users |
| Per-BU finding isolation — team users only see their findings | 4h | Data scoping |
| Add connection pooling metrics endpoint (`/api/admin/pool-stats`) | 1h | Operational visibility |
| Implement sync queue with priority (user-triggered > scheduled) | 3h | Better UX |
| Add retry logic with exponential backoff to collector | 2h | Resilience |
| Partial-progress persistence — don't lose work on mid-sync failure | 4h | Data integrity |
| PG connection pool separation — API pool (15) + Collector pool (10) | 1h | Isolation |
| Add `pg_bouncer` or similar for connection multiplexing (optional) | 4h | Scale past 50 concurrent |
**Deliverables:**
- Team-scoped sync scheduler in collector
- Rate budget allocation system
- Retry/backoff logic
- Partial progress tracking
- Pool separation
---
### Phase 3: Additional Data Sources (Weeks 912)
**Goal:** Integrate CrowdStrike, Qualys, and Tanium feeds.
| Task | Effort | Impact |
|------|--------|--------|
| CrowdStrike Falcon API integration in collector | 8h | New vulnerability source |
| Qualys VMDR API integration in collector | 8h | New vulnerability source |
| Tanium asset inventory sync | 6h | Asset correlation |
| Cross-source finding deduplication logic | 6h | Data quality |
| Unified findings view (merged from all sources) | 4h | Single pane of glass |
| Source-specific sync schedules (configurable per source) | 2h | Flexibility |
**Note:** All new API integrations go into the collector. The API server never makes outbound calls to external vulnerability platforms except for single-item on-demand lookups.
**Firewall implications:** CrowdStrike, Qualys, and Tanium API access will need firewall rules added to CT107 (71.85.90.9). Submit firewall requests in advance.
---
### Phase 4: Horizontal Scaling (Weeks 13+)
**Goal:** Support 300+ concurrent users if company-wide adoption materializes.
| Task | Effort | Impact |
|------|--------|--------|
| Move API server to a separate LXC container (with more resources) | 4h | Dedicated API resources |
| Run multiple API server instances behind a load balancer | 8h | Horizontal scale |
| Keep collector on CT107 (firewall access) | 0h | No change needed |
| Add Redis for session store (replace PG sessions) | 4h | Multi-instance sessions |
| Add read replicas if PG becomes the bottleneck | 8h | Read scale |
| Evaluate moving PG to CT109 (zbl-indexer, 32GB/500GB) | 2h | Larger DB host |
**Architecture at Phase 4:**
```
┌─────────────────┐
│ Load Balancer │
│ (nginx/HAProxy)│
└────┬───────┬────┘
│ │
┌─────────────▼─┐ ┌─▼─────────────┐
│ API Server 1 │ │ API Server 2 │ (New LXC or CT103)
│ (Express) │ │ (Express) │
└───────┬───────┘ └───────┬───────┘
│ │
└─────────┬─────────┘
┌────────────────────────────▼──────────────────────────────────────┐
│ CT107 (71.85.90.9) │
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Collector Service │ │ PostgreSQL 16 │ │
│ │ (sole process with │ │ (or moved to CT109) │ │
│ │ firewall API access) │ │ │ │
│ └─────────────────────────┘ └──────────────────────────────┘ │
│ │
│ ★ Firewall: Ivanti, Jira, CARD, Atlas, CrowdStrike, Qualys ★ │
└───────────────────────────────────────────────────────────────────┘
```
---
## Infrastructure Requirements
### CT107 Resource Allocation (Current → Phase 2)
| Resource | Current | Phase 2 Target | Notes |
|----------|---------|---------------|-------|
| RAM | 48 GB | 48 GB (sufficient) | Node processes use <2GB each |
| CPU | Shared | May need 4+ dedicated cores | Sync is CPU-intensive during transform |
| Disk | 250 GB | 250 GB (sufficient) | PG data + uploads + logs |
| PG connections | 10 | 25 (15 API + 10 collector) | Configure in `postgresql.conf` |
| Systemd services | 2 (backend + frontend) | 3 (api + collector + postgres) | Frontend served by API |
### PostgreSQL Tuning (for 15 teams / hundreds of users)
```
# postgresql.conf changes
max_connections = 50 # Up from default 100 is fine, need headroom
shared_buffers = 4GB # 25% of available RAM for PG
effective_cache_size = 12GB # 75% of RAM PG can expect from OS
work_mem = 64MB # Per-sort/hash operation
maintenance_work_mem = 512MB # For VACUUM, CREATE INDEX
wal_level = replica # If read replicas needed later
```
### Firewall Dependencies
| Service | Endpoint | Required By | Current Access |
|---------|----------|-------------|----------------|
| Ivanti/RiskSense | platform4.risksense.com:443 | Collector | ✅ CT107 only |
| Jira Data Center | jira.charter.com:443 | Collector + API (lookups) | ✅ CT107 only |
| CARD API | card.charter.com:443 | API (real-time) | ✅ CT107 only |
| Atlas InfoSec | (internal) | Collector | ✅ CT107 only |
| NVD API | services.nvd.nist.gov:443 | Collector + API | ✅ Public |
| CrowdStrike | api.crowdstrike.com:443 | Collector | ❌ Firewall request needed |
| Qualys | qualysapi.qualys.com:443 | Collector | ❌ Firewall request needed |
| Tanium | (internal) | Collector | ❌ Firewall request needed |
**Key constraint:** If the API server moves off CT107 in Phase 4, you'll need firewall rules for the new host to reach Jira (for user lookups) and CARD (for real-time queries). Alternatively, the collector could proxy those on-demand requests — adds latency but avoids firewall changes.
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Collector crash doesn't affect API users | — | — | This is the primary benefit of splitting |
| Collector and API race on DB writes | Medium | Low | Collector does bulk upserts; API does single-row writes. Different tables mostly. Use advisory locks for sync_state. |
| Sync trigger lost (pg NOTIFY missed) | Low | Medium | Collector also runs on a schedule. Missed trigger just delays to next interval. |
| Phase 1 introduces bugs in extraction | Medium | Medium | Comprehensive test suite exists. Run parallel (old monolith + new split) in staging for 1 week. |
| Firewall change delays block Phase 4 | High | Medium | Start firewall requests early. Phase 4 is optional — single-machine split (Phases 13) works fine at 15 teams. |
| PG becomes bottleneck at 300+ users | Low | High | Phase 4 addresses with read replicas. CT109 (500GB, 32GB) available as larger DB host. |
---
## Decision Points
These require team/leadership input before proceeding:
1. **Sync frequency target:** Is 1-hour sync acceptable, or do teams need near-real-time (15 min)? This affects collector design complexity and API rate budget math.
2. **API server location:** Keep everything on CT107, or move the API server to a separate container? Keeping it on CT107 is simpler (no firewall changes for CARD/Jira lookups) but limits scaling options.
3. **Database location:** Keep PG on CT107, or move to CT109 (zbl-indexer, 500GB disk, 32GB RAM)? Moving adds network latency but gives more room for growth.
4. **CrowdStrike/Qualys/Tanium priority:** Which new data sources are most urgent? This affects Phase 3 ordering and firewall request timing.
5. **Session management:** At 300+ users, PG-backed sessions will be high-churn. Acceptable, or invest in Redis? Redis adds infrastructure but is the industry standard for session stores at scale.
6. **Multi-instance API:** Is the goal to survive a single API server restart without downtime? If yes, Phase 4 (load balancer + multiple instances) is needed. If brief restarts during deploys are acceptable, single-instance on CT107 works through Phase 3.
---
## Appendix: Current Data Flow Analysis
### Data Collection Patterns
| Source | Trigger | Frequency | Data Volume | Processing |
|--------|---------|-----------|-------------|------------|
| Ivanti Findings | Schedule + manual | 24h | 100500 findings (all pages) | Extract, upsert, archive detect, BU drift, anomaly |
| Ivanti Workflows | Schedule + manual | 24h | 50 workflow batches | Store as JSON blob |
| Ivanti Closed Findings | During findings sync | 24h | All closed pages | Upsert + closed archive detection |
| Jira Bulk Sync | Manual (admin) | On-demand | All tracked tickets via JQL | Status/summary update per ticket |
| Jira Single Lookup | User action | Real-time | 1 issue | Proxy + display |
| NVD Lookup | User action | Real-time | 1 CVE | Proxy + optional save |
| NVD Bulk Sync | Manual | On-demand | All CVEs in DB | Batch update metadata |
| Atlas Action Plans | Cache refresh | Background | Per-host plan data | Cache in `atlas_action_plans_cache` |
| CARD Operations | User action | Real-time | 1 asset at a time | Proxy (confirm/decline/redirect) |
| Compliance xlsx | Manual upload | Weekly | 1 file → hundreds of rows | Python parse → PG upsert (transactional) |
### What Moves to Collector vs Stays in API
| Operation | Collector | API Server | Rationale |
|-----------|-----------|------------|-----------|
| Ivanti findings sync (all pages) | ✅ | | Heavy, multi-page, post-processing |
| Ivanti workflows sync | ✅ | | Scheduled background task |
| Ivanti closed sweep | ✅ | | Part of findings sync pipeline |
| Archive detection | ✅ | | CPU-intensive comparison |
| BU drift checker | ✅ | | Makes additional API calls |
| Anomaly computation | ✅ | | Depends on sync completion |
| Jira bulk sync-all | ✅ | | Consumes rate budget, multi-issue |
| NVD bulk sync | ✅ | | Multi-CVE, rate-limited |
| Atlas cache refresh | ✅ | | Background, per-host API calls |
| Compliance xlsx parse | ✅ | | Spawns Python, heavy DB writes |
| Single Jira lookup | | ✅ | User-initiated, real-time, 1 call |
| Single NVD lookup | | ✅ | User-initiated, real-time, 1 call |
| CARD operations | | ✅ | User-initiated, real-time |
| All GET /api/* reads | | ✅ | Pure DB queries, user-facing |
| Notes/overrides/queue | | ✅ | Small writes, user-facing |
| File uploads | | ✅ | User-initiated, disk I/O |
### Sync Pipeline Detail (becomes collector's core loop)
```
┌──────────────────────────────────────────────────────────────────┐
│ Collector Sync Pipeline │
│ │
│ ┌────────────────┐ │
│ │ 1. Fetch Open │ ← Ivanti API (paginated, 100/page) │
│ │ Findings │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 2. Extract & │ ← Transform raw API → normalized rows │
│ │ Transform │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 3. Upsert to │ ← Batch INSERT ON CONFLICT (100/batch) │
│ │ PG │ Preserves notes + overrides │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 4. Archive │ ← Compare previous IDs vs current IDs │
│ │ Detection │ Detect disappeared + returned findings │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 5. Fetch Closed│ ← Ivanti API (all closed pages) │
│ │ Findings │ Upsert as state='closed' │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 6. BU Drift │ ← Re-query Ivanti for disappeared IDs │
│ │ Checker │ Classify: BU reassign / severity / decom │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 7. FP Workflow │ ← Sweep closed findings for FP# tickets │
│ │ Counts │ Aggregate by state │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 8. Anomaly │ ← Compute deltas, write to anomaly_log │
│ │ Summary │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 9. Update │ ← sync_state status='success' │
│ │ Sync State │ Notify API server: pg_notify('sync_done') │
│ └────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
---
## Timeline Summary
| Phase | Timeframe | Key Outcome | Required For |
|-------|-----------|-------------|--------------|
| **0** | Weeks 12 | Non-blocking sync, pool increase | Immediate UX fix |
| **1** | Weeks 34 | Collector extracted, fault isolation | Multi-team onboarding |
| **2** | Weeks 58 | Multi-tenancy, rate budgeting, retries | 15 teams / 100+ users |
| **3** | Weeks 912 | New data sources (CS/Qualys/Tanium) | Full vuln coverage |
| **4** | Weeks 13+ | Horizontal scaling, load balancing | 300+ users (if needed) |
Phases 02 are recommended regardless of company-wide rollout. Phase 3 depends on data source priority decisions. Phase 4 is contingent on actual adoption numbers.
---
## Next Steps
1. Review this document and provide input on [Decision Points](#decision-points)
2. Approve Phase 0 for immediate implementation
3. Schedule Phase 1 kickoff once Phase 0 is validated in staging
4. Submit firewall requests for CrowdStrike/Qualys/Tanium access to CT107 (long lead time)

View File

@@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Steam CVE Dashboard - Vulnerability tracking and documentation" content="AEGIS — Advanced Engineering Group Intelligence System"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--
@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>CVE Dashboard</title> <title>AEGIS</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -1,6 +1,6 @@
{ {
"short_name": "SCD", "short_name": "AEGIS",
"name": "Steam CVE Dashboard", "name": "AEGIS — Advanced Engineering Group Intelligence System",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "favicon.ico",

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -17,6 +17,18 @@
} }
} }
/* Toast notification slide-in */
@keyframes toast-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
:root { :root {
/* Base Colors - Modern Slate Foundation */ /* Base Colors - Modern Slate Foundation */
--intel-darkest: #0F172A; --intel-darkest: #0F172A;
@@ -833,3 +845,230 @@ h3.text-intel-accent {
color: #CBD5E1; color: #CBD5E1;
font-style: italic; font-style: italic;
} }
/* ============================================
HOME PAGE COMPONENT CLASSES
============================================ */
/* Panel card — used for right-sidebar panels (Calendar, Tickets, Ivanti) */
.panel-card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
border: 2px solid rgba(14, 165, 233, 0.4);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12);
position: relative;
overflow: hidden;
padding: 1.5rem;
}
.panel-card--accent { border-left: 3px solid #0EA5E9; }
.panel-card--warning { border-left: 3px solid #F59E0B; }
.panel-card--teal { border-left: 3px solid #0D9488; }
/* Section heading — monospace uppercase with glow */
.section-heading {
font-size: 1.125rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-heading--accent {
color: #0EA5E9;
text-shadow: 0 0 12px rgba(14, 165, 233, 0.4);
}
.section-heading--warning {
color: #F59E0B;
text-shadow: 0 0 12px rgba(245, 158, 11, 0.4);
}
.section-heading--teal {
color: #0D9488;
text-shadow: 0 0 12px rgba(13, 148, 136, 0.4);
}
/* Stat card — clickable variant with border color modifiers */
.stat-card--clickable {
cursor: pointer;
}
.stat-card--clickable:active {
transform: scale(0.98);
}
.stat-card--active {
transform: scale(1.03);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.4);
}
.stat-card--warning {
border-color: #F59E0B;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.15);
}
.stat-card--warning::before {
background: linear-gradient(90deg, transparent, #F59E0B, transparent);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.stat-card--danger {
border-color: #EF4444;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(239, 68, 68, 0.15), inset 0 1px 0 rgba(239, 68, 68, 0.15);
}
.stat-card--danger::before {
background: linear-gradient(90deg, transparent, #EF4444, transparent);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
/* Stat card label and value */
.stat-card__label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #CBD5E1;
margin-bottom: 0.25rem;
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.stat-card__value--accent { color: #0EA5E9; text-shadow: 0 0 16px rgba(14, 165, 233, 0.4); }
.stat-card__value--neutral { color: #E2E8F0; }
.stat-card__value--warning { color: #F59E0B; text-shadow: 0 0 16px rgba(245, 158, 11, 0.4); }
.stat-card__value--danger { color: #EF4444; text-shadow: 0 0 16px rgba(239, 68, 68, 0.4); }
.stat-card__value--teal { color: #0D9488; text-shadow: 0 0 16px rgba(13, 148, 136, 0.4); }
/* Glow dot — pulsing indicator */
.glow-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
flex-shrink: 0;
}
.glow-dot--critical { background: #EF4444; box-shadow: 0 0 12px #EF4444, 0 0 6px #EF4444; }
.glow-dot--high { background: #F59E0B; box-shadow: 0 0 12px #F59E0B, 0 0 6px #F59E0B; }
.glow-dot--medium { background: #0EA5E9; box-shadow: 0 0 12px #0EA5E9, 0 0 6px #0EA5E9; }
.glow-dot--low { background: #10B981; box-shadow: 0 0 12px #10B981, 0 0 6px #10B981; }
/* Severity badge — combined style (replaces inline badge objects) */
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.375rem;
padding: 0.375rem 0.875rem;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 2px solid;
}
.severity-badge--critical {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%);
border-color: #EF4444;
color: #FCA5A5;
text-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
}
.severity-badge--high {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.25) 0%, rgba(245, 158, 11, 0.2) 100%);
border-color: #F59E0B;
color: #FCD34D;
text-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
box-shadow: 0 0 16px rgba(245, 158, 11, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
}
.severity-badge--medium {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%);
border-color: #0EA5E9;
color: #7DD3FC;
text-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
box-shadow: 0 0 16px rgba(14, 165, 233, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
}
.severity-badge--low {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%);
border-color: #10B981;
color: #6EE7B7;
text-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4);
}
/* Sidebar ticket item — compact variant */
.sidebar-ticket {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 0.375rem;
padding: 0.5rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
/* Ivanti workflow item — teal accent */
.workflow-item {
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);
border-radius: 0.375rem;
padding: 0.5rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
/* Workflow state badge */
.workflow-state-badge {
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
background: rgba(13, 148, 136, 0.2);
border: 1px solid #0D9488;
color: #0D9488;
white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
}
/* Ticket status badge — small variant */
.ticket-status-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.65rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 2px solid;
}
/* Archive finding item */
.archive-item {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75));
border: 1px solid rgba(100, 116, 139, 0.25);
border-radius: 0.375rem;
padding: 0.5rem;
}
.archive-item--active { border-left: 3px solid #F59E0B; }
.archive-item--resolved { border-left: 3px solid #10B981; }
/* Big counter display — centered stat number */
.big-counter {
font-size: 2rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
text-align: center;
}
.big-counter--warning { color: #F59E0B; text-shadow: 0 0 16px rgba(245, 158, 11, 0.4); }
.big-counter--teal { color: #0D9488; text-shadow: 0 0 16px rgba(13, 148, 136, 0.4); }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -0,0 +1,374 @@
import React, { useState } from 'react';
import { ChevronDown, FileText, Eye, Edit2, Trash2, Plus, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import DocumentDropZone from './DocumentDropZone';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
function getSeverityClass(severity) {
switch (severity?.toLowerCase()) {
case 'critical': return 'critical';
case 'high': return 'high';
case 'medium': return 'medium';
case 'low': return 'low';
default: return 'medium';
}
}
function isClosedStatus(status) {
if (!status) return false;
const lower = status.toLowerCase();
return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
}
function getTicketStatusDotClass(status) {
if (!status) return 'glow-dot--high';
if (isClosedStatus(status)) return 'glow-dot--low';
const lower = status.toLowerCase();
if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return 'glow-dot--high';
return 'glow-dot--medium';
}
// ⚠️ CONVENTION: Uses CSS classes (intel-card, vendor-card, severity-badge, glow-dot, jira-ticket-item, cve-header)
// that are not defined as inline styles or in App.css. Project convention is inline style objects or App.css classes.
// These classes must be added to App.css or converted back to inline style constants.
export default function CVECard({
cveId,
vendorEntries,
jiraTickets,
onEditCVE,
onDeleteEntry,
onDeleteAll,
onEditTicket,
onDeleteTicket,
onAddTicket,
onRequestConfirm,
}) {
const { canWrite, canDelete, isAdmin } = useAuth();
const toast = useToast();
const [expanded, setExpanded] = useState(false);
const [docExpanded, setDocExpanded] = useState(null);
const [documents, setDocuments] = useState({});
const [selectedDocuments, setSelectedDocuments] = useState([]);
const severityOrder = { Critical: 0, High: 1, Medium: 2, Low: 3 };
const highestSeverity = vendorEntries.reduce((highest, entry) => {
const cur = severityOrder[entry.severity] ?? 4;
const hi = severityOrder[highest] ?? 4;
return cur < hi ? entry.severity : highest;
}, vendorEntries[0].severity);
const totalDocCount = vendorEntries.reduce((sum, e) => sum + (e.document_count || 0), 0);
const overallStatuses = [...new Set(vendorEntries.map(e => e.status))];
// ⚠️ CONVENTION: Missing loading state — no visual indicator while documents are being fetched.
// Add a loading flag (e.g. loadingDocs state) and render a spinner/skeleton while the fetch is in flight.
const fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (documents[key]) return;
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch documents');
const data = await response.json();
setDocuments(prev => ({ ...prev, [key]: data }));
} catch (err) {
toast.error(err.message);
}
};
const handleViewDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (docExpanded === key) {
setDocExpanded(null);
} else {
setDocExpanded(key);
await fetchDocuments(cveId, vendor);
}
};
const handleDeleteDocument = (docId, cveId, vendor) => {
onRequestConfirm({
title: 'Delete Document',
message: 'Are you sure you want to delete this document?',
confirmText: 'Delete',
onConfirm: async () => {
try {
const response = await fetch(`${API_BASE}/documents/${docId}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) throw new Error('Failed to delete document');
toast.success('Document deleted');
const key = `${cveId}-${vendor}`;
setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchDocuments(cveId, vendor);
} catch (err) {
toast.error(err.message);
}
},
});
};
const toggleDocSelection = (docId) => {
setSelectedDocuments(prev => prev.includes(docId) ? prev.filter(id => id !== docId) : [...prev, docId]);
};
const highSevClass = getSeverityClass(highestSeverity);
return (
<div className="intel-card rounded-lg">
{/* Clickable CVE Header */}
<div
className="cve-header"
style={{ padding: '1.5rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => setExpanded(prev => !prev)}
role="button"
aria-expanded={expanded}
aria-label={`${cveId} - ${highestSeverity} severity, ${vendorEntries.length} vendors`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<ChevronDown className={`w-5 h-5 text-intel-accent transition-transform duration-200 flex-shrink-0 ${expanded ? 'rotate-0' : '-rotate-90'}`} />
<h3 className="text-2xl font-bold text-intel-accent font-mono tracking-tight">{cveId}</h3>
</div>
{!expanded && (
<div className="ml-8">
<p className="text-sm text-gray-200 truncate mb-2">{vendorEntries[0].description}</p>
<div className="flex items-center gap-3 flex-wrap">
<span className={`severity-badge severity-badge--${highSevClass}`}>
<span className={`glow-dot glow-dot--${highSevClass}`}></span>
{highestSeverity}
</span>
<span className="text-xs text-gray-200 font-mono">
{vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}
</span>
<span className="text-xs text-gray-200 font-mono flex items-center gap-1">
<FileText className="w-3 h-3" />
{totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
</span>
<span className="text-xs text-gray-200 font-mono">
{overallStatuses.join(', ')}
</span>
</div>
</div>
)}
{expanded && (
<div className="ml-8">
<p className="text-white mb-3">{vendorEntries[0].description}</p>
<div className="flex items-center gap-2 text-sm text-gray-300 font-mono">
<span>Published: {vendorEntries[0].published_date}</span>
<span className="text-intel-accent"></span>
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
{isAdmin() && vendorEntries.length >= 2 && (
<button
onClick={(e) => { e.stopPropagation(); onDeleteAll(cveId, vendorEntries.length); }}
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
Delete All
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Expanded vendor entries */}
{expanded && (
<div className="px-6 pb-6">
<div className="space-y-3">
{vendorEntries.map((cve) => {
const key = `${cve.cve_id}-${cve.vendor}`;
const docs = documents[key] || [];
const isDocOpen = docExpanded === key;
const vendorTickets = jiraTickets.filter(t => t.cve_id === cve.cve_id && t.vendor === cve.vendor);
const sevClass = getSeverityClass(cve.severity);
return (
<div key={cve.id} className="vendor-card">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="text-lg font-semibold text-white">{cve.vendor}</h4>
<span className={`severity-badge severity-badge--${sevClass}`}>
<span className={`glow-dot glow-dot--${sevClass}`}></span>
{cve.severity}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-200 font-mono">
<span>Status: <span className="font-medium text-white">{cve.status}</span></span>
<span className="flex items-center gap-1">
<FileText className="w-4 h-4" />
{cve.document_count} doc{cve.document_count !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleViewDocuments(cve.cve_id, cve.vendor)}
className="px-4 py-2 text-intel-accent hover:bg-intel-medium rounded border border-intel-accent/50 transition-all flex items-center gap-2 font-mono text-xs uppercase tracking-wider"
>
<Eye className="w-4 h-4" />
{isDocOpen ? 'Hide' : 'View'}
</button>
{canWrite() && (
<button
onClick={() => onEditCVE(cve)}
className="px-3 py-2 text-intel-warning hover:bg-intel-medium rounded border border-intel-warning/50 transition-all flex items-center gap-1"
title="Edit CVE entry"
>
<Edit2 className="w-4 h-4" />
</button>
)}
{canDelete(cve) && (
<button
onClick={() => onDeleteEntry(cve)}
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
title="Delete this vendor entry"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Documents */}
{isDocOpen && (
<div className="mt-4 pt-4 border-t border-intel-accent/30">
<h5 className="text-sm font-semibold text-white mb-3 flex items-center gap-2 font-mono uppercase tracking-wider">
<FileText className="w-4 h-4 text-intel-accent" />
Documents ({docs.length})
</h5>
{docs.length > 0 ? (
<div className="space-y-2">
{docs.map(doc => (
<div key={doc.id} className="document-item flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={selectedDocuments.includes(doc.id)}
onChange={() => toggleDocSelection(doc.id)}
className="w-4 h-4 text-intel-accent rounded focus:ring-2 focus:ring-intel-accent bg-intel-dark border-intel-accent/50"
aria-label={`Select document ${doc.name}`}
/>
<FileText className="w-5 h-5 text-intel-accent" />
<div className="flex-1">
<p className="text-sm font-medium text-white font-mono">{doc.name}</p>
<p className="text-xs text-gray-300 capitalize font-mono">
{doc.type} <span className="text-intel-accent"></span> {doc.file_size}
{doc.notes && <span> <span className="text-intel-accent"></span> {doc.notes}</span>}
</p>
</div>
</div>
<div className="flex gap-2">
<a
href={`/${doc.file_path}`}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-sm text-intel-accent hover:bg-intel-medium rounded transition-all border border-intel-accent/50 font-mono uppercase tracking-wider"
>
View
</a>
{isAdmin() && (
<button
onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)}
className="px-3 py-1 text-sm text-intel-danger hover:bg-intel-medium rounded transition-all border border-intel-danger/50 flex items-center gap-1 font-mono uppercase tracking-wider"
>
<Trash2 className="w-3 h-3" />
Del
</button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 italic font-mono">No documents attached</p>
)}
{canWrite() && (
<DocumentDropZone
cveId={cve.cve_id}
vendor={cve.vendor}
onUploadComplete={async () => {
const key = `${cve.cve_id}-${cve.vendor}`;
setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchDocuments(cve.cve_id, cve.vendor);
}}
/>
)}
</div>
)}
{/* JIRA Tickets */}
{(vendorTickets.length > 0 || canWrite()) && (
<div className="mt-4 pt-4 border-t border-intel-warning/30">
<div className="flex justify-between items-center mb-3">
<h5 className="text-sm font-semibold text-white flex items-center gap-2 font-mono uppercase tracking-wider">
<AlertCircle className="w-4 h-4 text-intel-warning" />
JIRA Tickets ({vendorTickets.length})
</h5>
{canWrite() && (
<button
onClick={() => onAddTicket(cve.cve_id, cve.vendor)}
className="text-xs px-3 py-1 intel-button intel-button-primary flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Add Ticket
</button>
)}
</div>
{vendorTickets.length > 0 ? (
<div className="space-y-2">
{vendorTickets.map(ticket => (
<div key={ticket.id} className="jira-ticket-item flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<a
href={ticket.url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-semibold text-intel-accent hover:text-intel-warning transition-colors"
>
{ticket.ticket_key}
</a>
{ticket.summary && (
<span className="text-sm text-gray-200 truncate max-w-xs">
{ticket.summary}
</span>
)}
<span className={`severity-badge severity-badge--${isClosedStatus(ticket.status) ? 'low' : 'high'}`}>
<span className={`glow-dot ${getTicketStatusDotClass(ticket.status)}`}></span>
{ticket.status}
</span>
</div>
{canWrite() && (
<div className="flex gap-2">
<button onClick={() => onEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
<Edit2 className="w-4 h-4" />
</button>
{canDelete(ticket) && (
<button onClick={() => onDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 italic font-mono">No JIRA tickets linked</p>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Search, Filter, AlertCircle } from 'lucide-react';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function CVEFilters({ searchQuery, onSearchChange, selectedVendor, onVendorChange, vendors, selectedSeverity, onSeverityChange }) {
return (
<div className="panel-card">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
<Search className="inline w-4 h-4 mr-1" />
Search CVEs
</label>
<input
type="text"
placeholder="CVE ID or description..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="intel-input w-full"
aria-label="Search CVEs by ID or description"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
<Filter className="inline w-4 h-4 mr-1" />
Vendor
</label>
<select
value={selectedVendor}
onChange={(e) => onVendorChange(e.target.value)}
className="intel-input w-full"
aria-label="Filter by vendor"
>
{vendors.map(vendor => (
<option key={vendor} value={vendor}>{vendor}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
<AlertCircle className="inline w-4 h-4 mr-1" />
Severity
</label>
<select
value={selectedSeverity}
onChange={(e) => onSeverityChange(e.target.value)}
className="intel-input w-full"
aria-label="Filter by severity"
>
{severityLevels.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React, { useState, useRef } from 'react';
import { Upload, X } from 'lucide-react';
import { useToast } from '../contexts/ToastContext';
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — avoid hardcoded absolute URL fallback
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const ACCEPTED_EXTENSIONS = '.pdf,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.txt,.csv,.log,.msg,.eml,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.html,.htm,.xml,.json,.yaml,.yml,.zip,.gz,.tar,.7z';
const DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
export default function DocumentDropZone({ cveId, vendor, onUploadComplete }) {
const toast = useToast();
const fileInputRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
const [file, setFile] = useState(null);
const [docType, setDocType] = useState('advisory');
const [notes, setNotes] = useState('');
const [uploading, setUploading] = useState(false);
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) setFile(droppedFile);
};
const handleFileSelect = (e) => {
const selected = e.target.files[0];
if (selected) setFile(selected);
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('cveId', cveId);
formData.append('vendor', vendor);
formData.append('type', docType);
if (notes.trim()) formData.append('notes', notes.trim());
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) throw new Error('Failed to upload document');
toast.success(`Uploaded ${file.name}`);
setFile(null);
setNotes('');
setDocType('advisory');
onUploadComplete();
} catch (err) {
toast.error(err.message);
} finally {
setUploading(false);
}
};
const handleCancel = () => {
setFile(null);
setNotes('');
setDocType('advisory');
};
// No file selected — show drop zone
if (!file) {
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className="mt-3 cursor-pointer transition-all"
style={{
border: `2px dashed ${dragOver ? '#0EA5E9' : 'rgba(100, 116, 139, 0.4)'}`,
borderRadius: '0.5rem',
padding: '1rem',
textAlign: 'center',
background: dragOver ? 'rgba(14, 165, 233, 0.05)' : 'transparent',
}}
role="button"
aria-label="Drop a file here or click to browse"
>
<Upload className="w-5 h-5 mx-auto mb-1" style={{ color: dragOver ? '#0EA5E9' : '#64748B' }} />
<p className="text-xs font-mono" style={{ color: dragOver ? '#0EA5E9' : '#64748B' }}>
Drop file here or click to browse
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTENSIONS}
onChange={handleFileSelect}
className="hidden"
aria-hidden="true"
/>
</div>
);
}
// File selected — show upload form
return (
<div className="mt-3 p-3 rounded" style={{ border: '1px solid rgba(14, 165, 233, 0.3)', background: 'rgba(14, 165, 233, 0.03)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<Upload className="w-4 h-4 text-intel-accent flex-shrink-0" />
<span className="text-xs text-white font-mono truncate">{file.name}</span>
<span className="text-xs text-gray-500 font-mono flex-shrink-0">
({(file.size / 1024).toFixed(0)} KB)
</span>
</div>
<button onClick={handleCancel} className="text-gray-400 hover:text-intel-danger transition-colors flex-shrink-0" aria-label="Cancel upload">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-2 mb-3">
<div>
<label className="block text-xs text-gray-400 mb-1 font-mono">Type</label>
<select
value={docType}
onChange={(e) => setDocType(e.target.value)}
className="intel-input w-full text-xs"
style={{ padding: '0.375rem 0.5rem' }}
>
{DOC_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1 font-mono">Notes</label>
<input
type="text"
placeholder="Optional"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="intel-input w-full text-xs"
style={{ padding: '0.375rem 0.5rem' }}
/>
</div>
</div>
<button
onClick={handleUpload}
disabled={uploading}
className="intel-button intel-button-primary w-full text-xs py-1.5"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { Eye, X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
/**
* ImpersonationBanner — renders a fixed banner at the top of the viewport
* when an Admin is viewing the app as another user. Shows who is being
* impersonated and provides a button to exit.
*/
export default function ImpersonationBanner() {
const { impersonating, user, realUser, stopImpersonation } = useAuth();
if (!impersonating) return null;
const handleStop = async () => {
await stopImpersonation();
// Force page reload to reset all state to admin's view
window.location.reload();
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'linear-gradient(90deg, #D97706 0%, #B45309 100%)',
color: '#FFF',
padding: '0.5rem 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.75rem',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.8rem',
fontWeight: 600,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
}}>
<Eye style={{ width: '16px', height: '16px', flexShrink: 0 }} />
<span>
Viewing as: <strong>{user?.username}</strong> ({user?.group}, teams: {user?.teams?.join(', ') || 'none'})
{realUser && <span style={{ opacity: 0.8 }}> logged in as {realUser.username}</span>}
</span>
<button
onClick={handleStop}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
background: 'rgba(255,255,255,0.2)',
border: '1px solid rgba(255,255,255,0.4)',
borderRadius: '0.25rem',
color: '#FFF',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.35)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.2)'}
>
<X style={{ width: '12px', height: '12px' }} />
Exit
</button>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Activity, RefreshCw, Loader, AlertCircle, CheckCircle, AlertTriangle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import ArchiveSummaryBar from './pages/ArchiveSummaryBar';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function IvantiWorkflowPanel({ embedded = false }) {
const { canWrite, getActiveTeamsParam } = useAuth();
const [total, setTotal] = useState(null);
const [workflows, setWorkflows] = useState([]);
const [syncedAt, setSyncedAt] = useState(null);
const [syncStatus, setSyncStatus] = useState(null);
const [syncError, setSyncError] = useState(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [archiveFilter, setArchiveFilter] = useState(null);
const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
const [archiveList, setArchiveList] = useState([]);
const [archiveListLoading, setArchiveListLoading] = useState(false);
const applyState = (data) => {
setTotal(data.total ?? 0);
setWorkflows(data.workflows || []);
setSyncedAt(data.synced_at || null);
setSyncStatus(data.sync_status || null);
setSyncError(data.error_message || null);
};
const fetchWorkflows = useCallback(async () => {
setLoading(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
const data = await response.json();
if (response.ok) applyState(data);
} catch (err) {
console.error('Error loading Ivanti workflows:', err);
} finally {
setLoading(false);
}
}, []);
const syncWorkflows = async () => {
setSyncing(true);
try {
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, { method: 'POST', credentials: 'include' });
const data = await response.json();
if (response.ok) applyState(data);
} catch (err) {
console.error('Error syncing Ivanti workflows:', err);
} finally {
setSyncing(false);
setArchiveRefreshKey(k => k + 1);
}
};
const handleArchiveStateClick = (state) => {
const newFilter = archiveFilter === state ? null : state;
setArchiveFilter(newFilter);
if (newFilter) {
setArchiveListLoading(true);
const teamsParam = getActiveTeamsParam();
const url = teamsParam
? `${API_BASE}/ivanti/archive?state=${newFilter}&teams=${encodeURIComponent(teamsParam)}`
: `${API_BASE}/ivanti/archive?state=${newFilter}`;
fetch(url, { credentials: 'include' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => setArchiveList(data.archives || []))
.catch(() => setArchiveList([]))
.finally(() => setArchiveListLoading(false));
} else {
setArchiveList([]);
}
};
useEffect(() => { fetchWorkflows(); }, [fetchWorkflows]);
const content = (
<>
<div className="flex justify-between items-center mb-1">
<h2 className="section-heading section-heading--teal">
<Activity className="w-5 h-5" />
Ivanti Workflows
</h2>
{canWrite() && (
<button
onClick={syncWorkflows}
disabled={syncing || loading}
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 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync'}
</button>
)}
</div>
<div className="text-xs text-gray-500 font-mono mb-4">
{syncedAt ? `Synced ${new Date(syncedAt).toLocaleString()}` : 'Never synced'}
</div>
{/* Archive Summary */}
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} teamsParam={getActiveTeamsParam()} />
{/* Archive list */}
{archiveFilter && (
<div style={{ marginBottom: '1rem' }}>
<div className="flex justify-between items-center mb-2">
<span className="font-mono text-xs text-gray-400 uppercase tracking-wider">
{archiveFilter} findings
</span>
<button
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
className="font-mono text-xs text-gray-400 hover:text-white"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
Clear
</button>
</div>
{archiveListLoading ? (
<div className="text-center p-4 text-gray-400 font-mono text-xs">Loading</div>
) : archiveList.length === 0 ? (
<div className="text-center p-4 text-gray-500 font-mono text-xs" style={{ border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
No {archiveFilter.toLowerCase()} findings
</div>
) : (
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{archiveList.map((a) => (
<div key={a.id} className={`archive-item ${a.related_active ? 'archive-item--active' : 'archive-item--resolved'}`}>
<div className="flex justify-between items-start gap-2 mb-1">
<div className="flex items-start gap-1.5 flex-1 min-w-0">
{a.related_active ? (
<AlertTriangle className="w-3 h-3 text-intel-warning flex-shrink-0 mt-0.5" />
) : (
<CheckCircle className="w-3 h-3 text-intel-success flex-shrink-0 mt-0.5" />
)}
<div className="min-w-0">
<span className="font-mono text-xs font-semibold text-gray-200 block">{a.finding_title || a.finding_id}</span>
{a.finding_id && (
<span title={a.finding_id} className="font-mono text-xs text-gray-500 block mt-0.5" style={{ fontSize: '0.6rem' }}>
{a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
</span>
)}
</div>
</div>
<span className="font-mono text-gray-400 whitespace-nowrap" style={{ fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)' }}>
Last seen: {(a.last_severity && Number(a.last_severity) !== 0) ? Number(a.last_severity).toFixed(1) : '—'}
</span>
</div>
<div className="font-mono text-gray-500 ml-5" style={{ fontSize: '0.65rem' }}>
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
</div>
{a.related_active && (
<div className="font-mono text-intel-accent mt-1 ml-5 inline-block" style={{ fontSize: '0.6rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem' }}>
Similar finding active ID: {a.related_active.id} ({a.related_active.severity ? Number(a.related_active.severity).toFixed(1) : '—'})
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Main content */}
{loading ? (
<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>
) : syncStatus === 'error' ? (
<>
<div className="text-center mb-3">
<div className="big-counter big-counter--teal">{total ?? '—'}</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">{syncError}</p>
</div>
</>
) : (
<>
<div className="text-center mb-3">
<div className="big-counter big-counter--teal">
{syncStatus === 'never' ? '—' : (total ?? '—')}
</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">
{workflows.slice(0, 10).map((wf, idx) => (
<div key={wf.uuid ?? idx} className="workflow-item">
<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 className="workflow-state-badge">{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>
))}
{syncStatus !== 'never' && total === 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>
)}
{syncStatus === '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>
</>
)}
</>
);
if (embedded) return content;
return <div className="panel-card panel-card--teal">{content}</div>;
}

View File

@@ -95,6 +95,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
setBulkDefaults({}); setBulkDefaults({});
setEnrichErrors([]); setEnrichErrors([]);
setValidationWarnings([]); setValidationWarnings([]);
setEnriching(false);
}, [isOpen, initialDevices]); }, [isOpen, initialDevices]);
// Auto-select required columns + useful defaults when operation type changes // Auto-select required columns + useful defaults when operation type changes
@@ -417,11 +418,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
{/* Enrich errors */} {/* Enrich errors */}
{enrichErrors.length > 0 && ( {enrichErrors.length > 0 && (
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}> <div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}> <div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem', justifyContent: 'space-between' }}>
<AlertCircle style={{ width: '12px', height: '12px' }} /> <span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all' <AlertCircle style={{ width: '12px', height: '12px' }} />
? enrichErrors[0].error {enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
: `${enrichErrors.length} device(s) not found in CARD`} ? enrichErrors[0].error
: enrichErrors.some(e => e.error && e.error.includes('timed out'))
? `${enrichErrors.length} device(s) timed out — CARD may be slow`
: `${enrichErrors.length} device(s) not found in CARD`}
</span>
<button
onClick={enrichFromCard}
disabled={enriching}
style={{
background: 'rgba(239, 68, 68, 0.15)', border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '0.25rem', padding: '0.2rem 0.5rem',
color: '#F87171', cursor: 'pointer',
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
}}
>
Retry
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -30,11 +30,10 @@ export default function LoginForm() {
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10"> <div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}> {/* ⚠️ CONVENTION: Use lucide-react icons instead of <img> tags for iconography */}
<Lock className="w-8 h-8 text-intel-darkest" /> <img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '64px', height: '64px', borderRadius: '50%', margin: '0 auto 1rem', display: 'block', boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)' }} />
</div> <h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">AEGIS</h1>
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1> <p className="text-gray-400 mt-2 font-sans text-sm">Advanced Engineering Group Intelligence System</p>
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
</div> </div>
{error && ( {error && (

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react'; import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { canAccessPage } from '../config/pageVisibility';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
@@ -16,7 +17,7 @@ const NAV_ITEMS = [
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' }; const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
const { isAdmin } = useAuth(); const { user } = useAuth();
if (!isOpen) return null; if (!isOpen) return null;
@@ -46,11 +47,16 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
{/* Drawer header */} {/* Drawer header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
<div> <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)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
STEAM <img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '28px', height: '28px', borderRadius: '4px' }} />
</div> <div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}> <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)' }}>
Security Dashboard AEGIS
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
Security Dashboard
</div>
</div>
</div> </div>
</div> </div>
<button <button
@@ -65,7 +71,7 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
{/* Nav items */} {/* Nav items */}
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}> <nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => { {NAV_ITEMS.filter(item => canAccessPage(item.id, user?.group)).map(({ id, label, icon: Icon, color, description }) => {
const active = currentPage === id; const active = currentPage === id;
return ( return (
<button <button
@@ -119,8 +125,8 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
); );
})} })}
{/* Admin panel link — visible only to Admin group */} {/* Admin panel link — visible based on page visibility matrix */}
{isAdmin() && (() => { {canAccessPage('admin', user?.group) && (() => {
const { id, label, icon: Icon, color, description } = ADMIN_ITEM; const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
const active = currentPage === id; const active = currentPage === id;
return ( return (

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { AlertCircle, Plus, Edit2, Trash2, CheckCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
function isClosedStatus(status) {
if (!status) return false;
const lower = status.toLowerCase();
return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
}
function getTicketStatusDotClass(status) {
if (!status) return 'glow-dot--high';
if (isClosedStatus(status)) return 'glow-dot--low';
const lower = status.toLowerCase();
if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return 'glow-dot--high';
return 'glow-dot--medium';
}
export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete, embedded = false }) {
const { canWrite, canDelete } = useAuth();
const openTickets = tickets.filter(t => !isClosedStatus(t.status));
const content = (
<>
<div className="flex justify-between items-center mb-4">
<h2 className="section-heading section-heading--warning">
<AlertCircle className="w-5 h-5" />
Open Tickets
</h2>
{canWrite() && (
<button
onClick={onAdd}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
aria-label="Add ticket"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center mb-3">
<div className="big-counter big-counter--warning">{openTickets.length}</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{openTickets.slice(0, 10).map(ticket => (
<div key={ticket.id} className="sidebar-ticket">
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={ticket.url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs font-semibold text-intel-accent hover:text-intel-warning transition-colors"
>
{ticket.ticket_key}
</a>
{canWrite() && (
<div className="flex gap-1">
<button onClick={() => onEdit(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors" aria-label={`Edit ${ticket.ticket_key}`}>
<Edit2 className="w-3 h-3" />
</button>
{canDelete(ticket) && (
<button onClick={() => onDelete(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors" aria-label={`Delete ${ticket.ticket_key}`}>
<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>
{ticket.summary && <div className="text-xs text-gray-300 mt-1 truncate">{ticket.summary}</div>}
<div className="mt-2">
<span className="ticket-status-badge severity-badge--high">
<span className={`glow-dot ${getTicketStatusDotClass(ticket.status)}`} style={{ width: '6px', height: '6px' }}></span>
{ticket.status}
</span>
</div>
</div>
))}
{openTickets.length === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No open tickets</p>
</div>
)}
</div>
</>
);
if (embedded) return content;
return <div className="panel-card panel-card--warning">{content}</div>;
}

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { useToast } from '../contexts/ToastContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function QuickCVELookup() {
const [query, setQuery] = useState('');
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const toast = useToast();
const handleLookup = async () => {
const trimmed = query.trim();
if (!trimmed) return;
setLoading(true);
try {
const response = await fetch(`${API_BASE}/cves/check/${encodeURIComponent(trimmed)}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to check CVE');
const data = await response.json();
setResult(data);
} catch (err) {
toast.error(err.message);
setResult({ error: err.message });
} finally {
setLoading(false);
}
};
return (
<div className="panel-card">
<div className="scan-line"></div>
<h2 className="section-heading section-heading--accent" style={{ marginBottom: '0.75rem' }}>
Quick CVE Lookup
</h2>
<div className="flex gap-3">
<input
type="text"
placeholder="Enter CVE ID (e.g., CVE-2024-1234)"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLookup()}
className="flex-1 intel-input"
aria-label="CVE ID to look up"
/>
<button
onClick={handleLookup}
disabled={loading}
className="intel-button intel-button-primary"
>
{loading ? 'Scanning...' : 'Scan'}
</button>
</div>
{result && (
<div className={`mt-4 p-4 rounded border ${result.exists ? 'bg-intel-success/10 border-intel-success/30' : 'bg-intel-warning/10 border-intel-warning/30'}`}>
{result.error ? (
<div className="flex items-start gap-3">
<XCircle className="w-5 h-5 text-intel-danger mt-0.5" />
<div>
<p className="font-medium text-intel-danger font-mono">Error</p>
<p className="text-sm text-gray-300">{result.error}</p>
</div>
</div>
) : result.exists ? (
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-intel-success mt-0.5" />
<div className="flex-1">
<p className="font-medium text-intel-success font-mono">
CVE Addressed ({result.vendors.length} vendor{result.vendors.length > 1 ? 's' : ''})
</p>
<div className="mt-3 space-y-3">
{result.vendors.map((vendorInfo, idx) => (
<div key={idx} className="p-3 bg-intel-dark/70 rounded border border-intel-accent/30 shadow-lg">
<p className="font-semibold text-white mb-2 font-sans">{vendorInfo.vendor}</p>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-300 mb-2 font-mono">
<p><strong className="text-white">Severity:</strong> {vendorInfo.severity}</p>
<p><strong className="text-white">Status:</strong> {vendorInfo.status}</p>
<p><strong className="text-white">Documents:</strong> {vendorInfo.total_documents} attached</p>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-intel-warning mt-0.5" />
<div>
<p className="font-medium text-intel-warning font-mono">Not Found</p>
<p className="text-sm text-gray-300">This CVE has not been addressed yet. No entry exists in the database.</p>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Activity, RefreshCw } from 'lucide-react';
// ⚠️ CONVENTION: Use relative API path from env var only — avoid hardcoded absolute URL fallback
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const ACTION_LABELS = {
cve_create: 'added CVE',
cve_edit: 'edited CVE',
cve_delete: 'deleted CVE',
cve_update_status: 'updated status',
cve_nvd_sync: 'ran NVD sync',
document_upload: 'uploaded doc',
document_delete: 'deleted doc',
user_create: 'added user',
user_update: 'updated user',
user_delete: 'deleted user',
jira_ticket_create: 'created ticket',
jira_ticket_update: 'updated ticket',
jira_ticket_delete: 'deleted ticket',
archer_ticket_create: 'created Archer ticket',
archer_ticket_update: 'updated Archer ticket',
archer_ticket_delete: 'deleted Archer ticket',
ivanti_sync: 'synced Ivanti',
compliance_upload: 'uploaded compliance',
kb_create: 'created KB article',
kb_update: 'updated KB article',
};
const ACTION_COLORS = {
cve_create: '#0EA5E9',
cve_edit: '#F59E0B',
cve_delete: '#EF4444',
cve_nvd_sync: '#10B981',
document_upload: '#8B5CF6',
document_delete: '#EF4444',
user_create: '#0EA5E9',
user_update: '#F59E0B',
user_delete: '#EF4444',
jira_ticket_create: '#F59E0B',
ivanti_sync: '#0D9488',
compliance_upload: '#10B981',
};
function timeAgo(dateStr) {
const now = new Date();
const then = new Date(dateStr);
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}d ago`;
return then.toLocaleDateString();
}
function formatActivity(activity) {
const label = ACTION_LABELS[activity.action] || activity.action.replace(/_/g, ' ');
const entity = activity.entity_id || '';
let detail = '';
if (activity.details) {
try {
const d = typeof activity.details === 'string' ? JSON.parse(activity.details) : activity.details;
if (d.vendor) detail = `(${d.vendor})`;
else if (d.cve_id) detail = `(${d.cve_id})`;
else if (d.ticket_key) detail = `(${d.ticket_key})`;
else if (d.exc_number) detail = `(${d.exc_number})`;
else if (d.username) detail = `(${d.username})`;
} catch (_e) { /* ignore parse errors */ }
}
return { label, entity, detail };
}
export default function RecentActivityFeed() {
const [activities, setActivities] = useState([]);
const [loading, setLoading] = useState(true);
const fetchActivity = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/recent-activity?limit=8`, { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
setActivities(data.activities || []);
} catch (_err) {
// Silent fail — this is a non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchActivity(); }, [fetchActivity]);
// Auto-refresh every 60 seconds
useEffect(() => {
const interval = setInterval(fetchActivity, 60000);
return () => clearInterval(interval);
}, [fetchActivity]);
return (
<div className="panel-card panel-card--accent">
<div className="flex justify-between items-center mb-3">
<h2 className="section-heading section-heading--accent">
<Activity className="w-4 h-4" />
Recent Activity
</h2>
<button
onClick={fetchActivity}
className="text-gray-500 hover:text-intel-accent transition-colors"
title="Refresh"
aria-label="Refresh activity feed"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{loading ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500 font-mono">Loading...</p>
</div>
) : activities.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500 font-mono">No recent activity</p>
</div>
) : (
<div className="space-y-1.5">
{activities.map((a, idx) => {
const { label, entity, detail } = formatActivity(a);
const color = ACTION_COLORS[a.action] || '#64748B';
return (
<div key={idx} className="flex items-start gap-2 py-1.5" style={{ borderBottom: '1px solid rgba(100, 116, 139, 0.15)' }}>
<span
className="glow-dot flex-shrink-0 mt-1.5"
style={{ width: '6px', height: '6px', background: color, boxShadow: `0 0 6px ${color}` }}
></span>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-300 truncate">
<span className="text-white font-medium">{a.username}</span>
{' '}{label}
{entity && <span className="text-intel-accent font-mono"> {entity}</span>}
{detail && <span className="text-gray-400"> {detail}</span>}
</div>
<div className="text-xs text-gray-500 font-mono" style={{ fontSize: '0.6rem' }}>
{timeAgo(a.created_at)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { AlertCircle, Shield, Activity } from 'lucide-react';
const TAB_CONFIG = [
{ id: 'tickets', label: 'Tickets', icon: AlertCircle, color: '#F59E0B' },
{ id: 'archer', label: 'Archer', icon: Shield, color: '#8B5CF6' },
{ id: 'ivanti', label: 'Ivanti', icon: Activity, color: '#0D9488' },
];
export default function SidebarTabs({ children }) {
const [activeTab, setActiveTab] = useState('tickets');
// children should be an object: { tickets: <Node>, archer: <Node>, ivanti: <Node> }
// Or we accept children as array and map by index
const panels = children;
return (
<div className="panel-card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Tab bar */}
<div className="flex" style={{ borderBottom: '1px solid rgba(100, 116, 139, 0.25)' }}>
{TAB_CONFIG.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 px-2 transition-all font-mono text-xs uppercase tracking-wider"
style={{
color: isActive ? tab.color : '#64748B',
background: isActive ? `rgba(${tab.color === '#F59E0B' ? '245,158,11' : tab.color === '#8B5CF6' ? '139,92,246' : '13,148,136'}, 0.08)` : 'transparent',
borderBottom: isActive ? `2px solid ${tab.color}` : '2px solid transparent',
}}
aria-selected={isActive}
role="tab"
>
<Icon className="w-3.5 h-3.5" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ padding: '1rem' }}>
{activeTab === 'tickets' && panels.tickets}
{activeTab === 'archer' && panels.archer}
{activeTab === 'ivanti' && panels.ivanti}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
function StatCard({ label, value, color = 'accent', variant, onClick, active }) {
const cardClasses = [
'stat-card',
onClick && 'stat-card--clickable',
active && 'stat-card--active',
variant && `stat-card--${variant}`,
].filter(Boolean).join(' ');
const valueClass = `stat-card__value stat-card__value--${color}`;
return (
<div
className={cardClasses}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={`${label}: ${value}`}
>
<div className="stat-card__label">{label}</div>
<div className={valueClass}>{value}</div>
</div>
);
}
export default function StatsBar({ totalCVEs, vendorEntries, openTickets, criticalCount, onFilterSeverity, activeSeverity }) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Total CVEs"
value={totalCVEs}
color="accent"
onClick={() => onFilterSeverity && onFilterSeverity('All Severities')}
active={activeSeverity === 'All Severities'}
/>
<StatCard
label="Vendor Entries"
value={vendorEntries}
color="neutral"
/>
<StatCard
label="Open Tickets"
value={openTickets}
color="warning"
variant="warning"
/>
<StatCard
label="Critical"
value={criticalCount}
color="danger"
variant="danger"
onClick={() => onFilterSeverity && onFilterSeverity(activeSeverity === 'Critical' ? 'All Severities' : 'Critical')}
active={activeSeverity === 'Critical'}
/>
</div>
);
}

View File

@@ -9,7 +9,7 @@
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.) // - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
// - The ConfirmModal integration for delete/group-change confirmations // - The ConfirmModal integration for delete/group-change confirmations
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react'; import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
@@ -170,7 +170,7 @@ const styles = {
export default function UserManagement({ onClose }) { export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth(); const { user: currentUser, startImpersonation } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -665,6 +665,20 @@ export default function UserManagement({ onClose }) {
</td> </td>
<td style={styles.tdRight}> <td style={styles.tdRight}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.25rem' }}>
{currentUser.group === 'Admin' && user.id !== currentUser.id && user.group !== 'Admin' && (
<button
onClick={async () => {
const result = await startImpersonation(user.id);
if (result.success) window.location.reload();
}}
style={styles.actionBtn}
title={`View as ${user.username}`}
onMouseEnter={e => { e.currentTarget.style.color = '#D97706'; e.currentTarget.style.background = 'rgba(217,119,6,0.1)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.background = 'none'; }}
>
<Eye style={{ width: '1rem', height: '1rem' }} />
</button>
)}
<button <button
onClick={() => handleEdit(user)} onClick={() => handleEdit(user)}
style={styles.actionBtn} style={styles.actionBtn}

View File

@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { XCircle, Loader, CheckCircle, AlertCircle } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function AddCVEModal({ onClose, onSuccess }) {
const toast = useToast();
const [form, setForm] = useState({
cve_id: '',
vendor: '',
severity: 'Medium',
description: '',
published_date: new Date().toISOString().split('T')[0],
});
const [nvdLoading, setNvdLoading] = useState(false);
const [nvdError, setNvdError] = useState(null);
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
const lookupNVD = async (cveId) => {
const trimmed = cveId.trim();
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
setNvdLoading(true);
setNvdError(null);
setNvdAutoFilled(false);
try {
const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, { credentials: 'include' });
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'NVD lookup failed');
}
const data = await response.json();
setForm(prev => ({
...prev,
description: prev.description || data.description,
severity: data.severity,
published_date: data.published_date || prev.published_date,
}));
setNvdAutoFilled(true);
} catch (err) {
setNvdError(err.message);
} finally {
setNvdLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await fetch(`${API_BASE}/cves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to add CVE');
}
toast.success(`CVE ${form.cve_id} added for vendor: ${form.vendor}`);
onSuccess();
onClose();
} catch (err) {
toast.error(err.message);
}
};
return (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border-intel-accent">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-intel-accent font-mono">Add CVE Entry</h2>
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="mb-4 p-3 bg-intel-medium border border-intel-accent/30 rounded">
<p className="text-sm text-white">
<strong className="text-intel-accent">Tip:</strong> You can add the same CVE-ID multiple times with different vendors.
Each vendor will have its own documents folder.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<div className="relative">
<input
type="text"
required
placeholder="CVE-2024-1234"
value={form.cve_id}
onChange={(e) => { setForm({ ...form, cve_id: e.target.value.toUpperCase() }); setNvdAutoFilled(false); setNvdError(null); }}
onBlur={(e) => lookupNVD(e.target.value)}
className="intel-input w-full"
/>
{nvdLoading && <Loader className="absolute right-3 top-2.5 w-5 h-5 text-intel-accent animate-spin" />}
</div>
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
{nvdAutoFilled && (
<p className="text-xs text-intel-success mt-1 flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Auto-filled from NVD
</p>
)}
{nvdError && (
<p className="text-xs text-intel-warning mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {nvdError}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
placeholder="Microsoft, Cisco, Oracle, etc."
value={form.vendor}
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
className="intel-input w-full"
/>
<p className="text-xs text-gray-500 mt-1">Must be unique for this CVE-ID</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Severity *</label>
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="intel-input w-full">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Description *</label>
<textarea
required
placeholder="Brief description of the vulnerability"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Published Date *</label>
<input
type="date"
required
value={form.published_date}
onChange={(e) => setForm({ ...form, published_date: e.target.value })}
className="intel-input w-full"
/>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">Add CVE Entry</button>
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { XCircle } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
/**
* Shared modal for adding and editing Archer risk acceptance tickets.
* Props:
* - ticket: existing ticket (edit mode) or null (add mode)
* - context: { cve_id, vendor } when adding from a CVE card
* - onClose: close handler
* - onSuccess: refresh handler
*/
export default function ArcherTicketModal({ ticket, context, onClose, onSuccess }) {
const toast = useToast();
const isEdit = !!ticket;
const [form, setForm] = useState({
exc_number: ticket?.exc_number || '',
archer_url: ticket?.archer_url || '',
status: ticket?.status || 'Draft',
cve_id: ticket?.cve_id || context?.cve_id || '',
vendor: ticket?.vendor || context?.vendor || '',
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (isEdit) {
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
exc_number: form.exc_number,
archer_url: form.archer_url,
status: form.status,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update Archer ticket');
}
toast.success('Archer ticket updated');
} else {
const response = await fetch(`${API_BASE}/archer-tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create Archer ticket');
}
toast.success('Archer ticket added');
}
onSuccess();
onClose();
} catch (err) {
toast.error(err.message);
}
};
return (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-purple-400 font-mono">
{isEdit ? 'Edit Archer Risk Ticket' : 'Add Archer Risk Ticket'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
<XCircle className="w-6 h-6" />
</button>
</div>
{isEdit && (
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
{ticket.cve_id} / {ticket.vendor}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
<input
type="text"
required
placeholder="EXC-5754"
value={form.exc_number}
onChange={(e) => setForm({ ...form, exc_number: e.target.value.toUpperCase() })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
<input
type="url"
placeholder="https://archer.example.com/..."
value={form.archer_url}
onChange={(e) => setForm({ ...form, archer_url: e.target.value })}
className="intel-input w-full"
/>
</div>
{!isEdit && (
<>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={form.cve_id}
onChange={(e) => setForm({ ...form, cve_id: e.target.value.toUpperCase() })}
className="intel-input w-full"
readOnly={!!context}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
placeholder="Vendor name"
value={form.vendor}
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
className="intel-input w-full"
readOnly={!!context}
/>
</div>
</>
)}
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="intel-input w-full"
>
<option value="Draft">Draft</option>
<option value="Open">Open</option>
<option value="Under Review">Under Review</option>
<option value="Accepted">Accepted</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
{isEdit ? 'Save Changes' : 'Create Ticket'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { XCircle, RefreshCw, Loader, CheckCircle, AlertCircle } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function EditCVEModal({ cve, onClose, onSuccess }) {
const toast = useToast();
const [form, setForm] = useState({
cve_id: cve.cve_id,
vendor: cve.vendor,
severity: cve.severity,
description: cve.description || '',
published_date: cve.published_date || '',
status: cve.status || 'Open',
});
const [nvdLoading, setNvdLoading] = useState(false);
const [nvdError, setNvdError] = useState(null);
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
const lookupNVD = async () => {
const trimmed = form.cve_id.trim();
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
setNvdLoading(true);
setNvdError(null);
setNvdAutoFilled(false);
try {
const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, { credentials: 'include' });
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'NVD lookup failed');
}
const data = await response.json();
setForm(prev => ({
...prev,
description: data.description || prev.description,
severity: data.severity || prev.severity,
published_date: data.published_date || prev.published_date,
}));
setNvdAutoFilled(true);
} catch (err) {
setNvdError(err.message);
} finally {
setNvdLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
const body = {};
if (form.cve_id !== cve.cve_id) body.cve_id = form.cve_id;
if (form.vendor !== cve.vendor) body.vendor = form.vendor;
if (form.severity !== cve.severity) body.severity = form.severity;
if (form.description !== (cve.description || '')) body.description = form.description;
if (form.published_date !== (cve.published_date || '')) body.published_date = form.published_date;
if (form.status !== (cve.status || 'Open')) body.status = form.status;
if (Object.keys(body).length === 0) {
toast.info('No changes detected.');
return;
}
try {
const response = await fetch(`${API_BASE}/cves/${cve.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update CVE');
}
toast.success('CVE updated successfully');
onSuccess();
onClose();
} catch (err) {
toast.error(err.message);
}
};
return (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border-intel-accent">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-intel-accent font-mono">Edit CVE Entry</h2>
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="mb-4 p-3 bg-intel-medium border border-intel-warning/30 rounded">
<p className="text-sm text-white">
<strong className="text-intel-warning">Note:</strong> Changing CVE ID or Vendor will move associated documents to the new path.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<div className="relative">
<input
type="text"
required
value={form.cve_id}
onChange={(e) => { setForm({ ...form, cve_id: e.target.value.toUpperCase() }); setNvdAutoFilled(false); setNvdError(null); }}
className="intel-input w-full"
/>
{nvdLoading && <Loader className="absolute right-3 top-2.5 w-5 h-5 text-intel-accent animate-spin" />}
</div>
{nvdAutoFilled && (
<p className="text-xs text-intel-success mt-1 flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Updated from NVD
</p>
)}
{nvdError && (
<p className="text-xs text-intel-warning mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {nvdError}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
value={form.vendor}
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Severity *</label>
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="intel-input w-full">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Description *</label>
<textarea
required
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Published Date *</label>
<input
type="date"
required
value={form.published_date}
onChange={(e) => setForm({ ...form, published_date: e.target.value })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status *</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} className="intel-input w-full">
<option value="Open">Open</option>
<option value="Addressed">Addressed</option>
<option value="In Progress">In Progress</option>
<option value="Resolved">Resolved</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={lookupNVD}
disabled={nvdLoading}
className="intel-button intel-button-success flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${nvdLoading ? 'animate-spin' : ''}`} />
NVD Update
</button>
<button type="submit" className="flex-1 intel-button intel-button-primary">Save Changes</button>
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { XCircle } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
/**
* Shared modal for adding and editing JIRA tickets.
* Props:
* - ticket: existing ticket object (edit mode) or null (add mode)
* - context: { cve_id, vendor } when adding from a CVE card
* - onClose: close handler
* - onSuccess: refresh handler
*/
export default function JiraTicketModal({ ticket, context, onClose, onSuccess }) {
const toast = useToast();
const isEdit = !!ticket;
const [form, setForm] = useState({
cve_id: ticket?.cve_id || context?.cve_id || '',
vendor: ticket?.vendor || context?.vendor || '',
ticket_key: ticket?.ticket_key || '',
url: ticket?.url || '',
summary: ticket?.summary || '',
status: ticket?.status || 'Open',
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (isEdit) {
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
ticket_key: form.ticket_key,
url: form.url,
summary: form.summary,
status: form.status,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update ticket');
}
toast.success('JIRA ticket updated');
} else {
const response = await fetch(`${API_BASE}/jira-tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create ticket');
}
toast.success('JIRA ticket added');
}
onSuccess();
onClose();
} catch (err) {
toast.error(err.message);
}
};
const showCVEFields = !isEdit && !context;
return (
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-intel-warning">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-intel-warning font-mono">
{isEdit ? 'Edit JIRA Ticket' : 'Add JIRA Ticket'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
<XCircle className="w-6 h-6" />
</button>
</div>
{/* Context info */}
{(isEdit || context) && (
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
{isEdit ? `${ticket.cve_id} / ${ticket.vendor}` : `Adding ticket for `}
{context && !isEdit && (
<>
<strong className="text-intel-warning">{context.cve_id}</strong> / <strong className="text-intel-warning">{context.vendor}</strong>
</>
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{showCVEFields && (
<>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={form.cve_id}
onChange={(e) => setForm({ ...form, cve_id: e.target.value.toUpperCase() })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
<input
type="text"
required
placeholder="Cisco"
value={form.vendor}
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
className="intel-input w-full"
/>
</div>
</>
)}
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Ticket Key *</label>
<input
type="text"
required
placeholder="VULN-1234"
value={form.ticket_key}
onChange={(e) => setForm({ ...form, ticket_key: e.target.value.toUpperCase() })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">JIRA URL</label>
<input
type="url"
placeholder="https://jira.company.com/browse/VULN-1234"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Summary</label>
<input
type="text"
placeholder="Brief description"
value={form.summary}
onChange={(e) => setForm({ ...form, summary: e.target.value })}
className="intel-input w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
className="intel-input w-full"
>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
{form.status && !['Open', 'In Progress', 'Closed'].includes(form.status) && (
<option value={form.status}>{form.status}</option>
)}
</select>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="flex-1 intel-button intel-button-primary">
{isEdit ? 'Save Changes' : 'Add Ticket'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info } from 'lucide-react'; import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader, RotateCcw, Info, FileSpreadsheet } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import ComplianceUploadModal from './ComplianceUploadModal'; import ComplianceUploadModal from './ComplianceUploadModal';
import ComplianceDetailPanel from './ComplianceDetailPanel'; import ComplianceDetailPanel from './ComplianceDetailPanel';
import ComplianceChartsPanel from './ComplianceChartsPanel'; import ComplianceChartsPanel from './ComplianceChartsPanel';
import MetricInfoPanel from './MetricInfoPanel'; import MetricInfoPanel from './MetricInfoPanel';
import VCLReportPage from './VCLReportPage'; import VCLReportPage from './VCLReportPage';
import LoaderModal from '../LoaderModal';
import metricDefinitionsRaw from '../../data/metricDefinitions.json'; import metricDefinitionsRaw from '../../data/metricDefinitions.json';
import metricCategoriesConfig from '../../data/complianceCategories.json'; import metricCategoriesConfig from '../../data/complianceCategories.json';
@@ -361,6 +362,10 @@ export default function CompliancePage({ onNavigate }) {
const [rollbackResult, setRollbackResult] = useState(null); const [rollbackResult, setRollbackResult] = useState(null);
const [infoMetric, setInfoMetric] = useState(null); const [infoMetric, setInfoMetric] = useState(null);
const [hoveredMetric, setHoveredMetric] = useState(null); const [hoveredMetric, setHoveredMetric] = useState(null);
const [selectedDevices, setSelectedDevices] = useState(new Set());
const [showLoaderModal, setShowLoaderModal] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const hoverTimeoutRef = useRef(null); const hoverTimeoutRef = useRef(null);
const hoveredCardRef = useRef(null); const hoveredCardRef = useRef(null);
@@ -392,6 +397,8 @@ export default function CompliancePage({ onNavigate }) {
setFilterState(null); setFilterState(null);
setHostSearch(''); setHostSearch('');
setSelectedHost(null); setSelectedHost(null);
setSelectedDevices(new Set());
setCurrentPage(1);
fetchSummary(activeTeam); fetchSummary(activeTeam);
fetchDevices(activeTeam, activeTab); fetchDevices(activeTeam, activeTab);
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -406,14 +413,50 @@ export default function CompliancePage({ onNavigate }) {
useEffect(() => { useEffect(() => {
setFilterState(null); setFilterState(null);
setSelectedDevices(new Set());
setCurrentPage(1);
fetchDevices(activeTeam, activeTab); fetchDevices(activeTeam, activeTab);
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset page when filter changes
useEffect(() => { setCurrentPage(1); }, [filterState]);
const refresh = () => { const refresh = () => {
fetchSummary(activeTeam); fetchSummary(activeTeam);
fetchDevices(activeTeam, activeTab); fetchDevices(activeTeam, activeTab);
}; };
const toggleDeviceSelection = (hostname) => {
setSelectedDevices(prev => {
const next = new Set(prev);
if (next.has(hostname)) next.delete(hostname);
else next.add(hostname);
return next;
});
};
const toggleSelectAll = () => {
if (selectedDevices.size === paginatedDevices.length && paginatedDevices.every(d => selectedDevices.has(d.hostname))) {
setSelectedDevices(new Set());
} else {
setSelectedDevices(new Set(paginatedDevices.map(d => d.hostname)));
}
};
const openGraniteLoader = () => {
setShowLoaderModal(true);
};
const getLoaderDevices = () => {
return filteredDevices
.filter(d => selectedDevices.has(d.hostname))
.map(d => ({
ip_address: d.ip_address || '',
hostname: d.hostname || '',
host_id: null,
}));
};
const handleRollback = async () => { const handleRollback = async () => {
if (!lastUpload) return; if (!lastUpload) return;
setRollbackLoading(true); setRollbackLoading(true);
@@ -446,6 +489,11 @@ export default function CompliancePage({ onNavigate }) {
}) })
.filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase())); .filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
// Pagination
const totalPages = Math.max(1, Math.ceil(filteredDevices.length / pageSize));
const safePage = Math.min(currentPage, totalPages);
const paginatedDevices = filteredDevices.slice((safePage - 1) * pageSize, safePage * pageSize);
const families = groupByMetricFamily(summary.entries, activeTeam); const families = groupByMetricFamily(summary.entries, activeTeam);
const lastUpload = summary.upload; const lastUpload = summary.upload;
@@ -724,7 +772,7 @@ export default function CompliancePage({ onNavigate }) {
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)', padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
}}> }}>
{/* Active / Resolved tabs */} {/* Active / Resolved tabs */}
<div style={{ display: 'flex', gap: '0.25rem' }}> <div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
{['active', 'resolved'].map(tab => { {['active', 'resolved'].map(tab => {
const isActive = activeTab === tab; const isActive = activeTab === tab;
return ( return (
@@ -747,12 +795,36 @@ export default function CompliancePage({ onNavigate }) {
</button> </button>
); );
})} })}
{selectedDevices.size > 0 && canWrite() && (
<button
onClick={openGraniteLoader}
title="Generate Granite Loader Sheet from selected devices"
style={{
marginLeft: '0.75rem',
padding: '0.35rem 0.75rem',
display: 'flex', alignItems: 'center', gap: '0.35rem',
background: 'rgba(124, 58, 237, 0.12)',
border: '1px solid rgba(124, 58, 237, 0.5)',
borderRadius: '0.25rem',
color: '#A78BFA',
cursor: 'pointer',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.04em',
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.8)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.5)'; }}
>
<FileSpreadsheet style={{ width: '13px', height: '13px' }} />
Granite ({selectedDevices.size})
</button>
)}
</div> </div>
{/* Hostname search */} {/* Hostname search */}
<input <input
value={hostSearch} value={hostSearch}
onChange={e => setHostSearch(e.target.value)} onChange={e => { setHostSearch(e.target.value); setCurrentPage(1); }}
placeholder="Search hostname…" placeholder="Search hostname…"
style={{ style={{
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)', background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
@@ -768,12 +840,22 @@ export default function CompliancePage({ onNavigate }) {
{/* Column headers */} {/* Column headers */}
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr', gridTemplateColumns: '0.3fr 2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.05)', borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '0.62rem', color: '#334155', fontSize: '0.62rem', color: '#334155',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
alignItems: 'center',
}}> }}>
<span style={{ display: 'flex', justifyContent: 'center' }}>
<input
type="checkbox"
checked={paginatedDevices.length > 0 && paginatedDevices.every(d => selectedDevices.has(d.hostname))}
onChange={toggleSelectAll}
style={{ cursor: 'pointer', accentColor: TEAL }}
title="Select all on this page"
/>
</span>
<span>Hostname</span> <span>Hostname</span>
<span>IP Address</span> <span>IP Address</span>
<span>Type</span> <span>Type</span>
@@ -798,15 +880,75 @@ export default function CompliancePage({ onNavigate }) {
{lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'} {lastUpload === null ? 'No reports uploaded yet' : filterState ? 'No devices match the selected filter' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
</div> </div>
) : ( ) : (
filteredDevices.map(device => ( paginatedDevices.map(device => (
<DeviceRow <DeviceRow
key={device.hostname} key={device.hostname}
device={device} device={device}
selected={selectedHost === device.hostname} selected={selectedHost === device.hostname}
checked={selectedDevices.has(device.hostname)}
onCheck={() => toggleDeviceSelection(device.hostname)}
onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)} onClick={() => setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
/> />
)) ))
)} )}
{/* Pagination controls */}
{filteredDevices.length > 0 && (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', borderTop: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace' }}>
Showing {((safePage - 1) * pageSize) + 1}{Math.min(safePage * pageSize, filteredDevices.length)} of {filteredDevices.length}
</span>
<select
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); }}
style={{
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
borderRadius: '0.25rem', color: '#94A3B8', fontSize: '0.68rem',
fontFamily: 'monospace', padding: '0.2rem 0.4rem', cursor: 'pointer',
}}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>per page</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={safePage <= 1}
style={{
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
color: safePage <= 1 ? '#1E293B' : '#94A3B8', cursor: safePage <= 1 ? 'default' : 'pointer',
fontFamily: 'monospace', fontSize: '0.72rem',
}}
>
</button>
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', padding: '0 0.5rem' }}>
{safePage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
style={{
padding: '0.3rem 0.6rem', borderRadius: '0.25rem',
border: '1px solid rgba(20,184,166,0.2)', background: 'transparent',
color: safePage >= totalPages ? '#1E293B' : '#94A3B8', cursor: safePage >= totalPages ? 'default' : 'pointer',
fontFamily: 'monospace', fontSize: '0.72rem',
}}
>
</button>
</div>
</div>
)}
</div>} </div>}
{/* ── Detail panel ─────────────────────────────────────────── */} {/* ── Detail panel ─────────────────────────────────────────── */}
@@ -949,11 +1091,18 @@ export default function CompliancePage({ onNavigate }) {
)} )}
</div> </div>
)} )}
{/* ── Granite Loader Modal ─────────────────────────────────── */}
<LoaderModal
isOpen={showLoaderModal}
onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? getLoaderDevices() : null}
/>
</div> </div>
); );
} }
function DeviceRow({ device, selected, onClick }) { function DeviceRow({ device, selected, checked, onCheck, onClick }) {
const truncateText = (text, maxLen = 80) => { const truncateText = (text, maxLen = 80) => {
if (!text) return '—'; if (!text) return '—';
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text; return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
@@ -964,7 +1113,7 @@ function DeviceRow({ device, selected, onClick }) {
onClick={onClick} onClick={onClick}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr', gridTemplateColumns: '0.3fr 2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
padding: '0.625rem 1rem', padding: '0.625rem 1rem',
borderBottom: '1px solid rgba(255,255,255,0.04)', borderBottom: '1px solid rgba(255,255,255,0.04)',
cursor: 'pointer', cursor: 'pointer',
@@ -976,6 +1125,15 @@ function DeviceRow({ device, selected, onClick }) {
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }} onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }} onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
> >
{/* Checkbox */}
<div style={{ display: 'flex', justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={checked}
onChange={onCheck}
style={{ cursor: 'pointer', accentColor: TEAL }}
/>
</div>
{/* Hostname */} {/* Hostname */}
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{device.hostname} {device.hostname}

View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { XCircle, AlertCircle } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext';
import { useDebounce } from '../../hooks/useDebounce';
import StatsBar from '../StatsBar';
import QuickCVELookup from '../QuickCVELookup';
import CVEFilters from '../CVEFilters';
import CVECard from '../CVECard';
import OpenTicketsPanel from '../OpenTicketsPanel';
import IvantiWorkflowPanel from '../IvantiWorkflowPanel';
import CalendarWidget from '../CalendarWidget';
import RecentActivityFeed from '../RecentActivityFeed';
import SidebarTabs from '../SidebarTabs';
import ArcherPage from './ArcherPage';
import ConfirmModal from '../ConfirmModal';
import AddCVEModal from '../modals/AddCVEModal';
import EditCVEModal from '../modals/EditCVEModal';
import JiraTicketModal from '../modals/JiraTicketModal';
import ArcherTicketModal from '../modals/ArcherTicketModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
function isClosedStatus(status) {
if (!status) return false;
const lower = status.toLowerCase();
return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
}
export default function HomePage({ onNavigate, showAddCVE, setShowAddCVE }) {
const { isAuthenticated, canDelete } = useAuth();
const toast = useToast();
// --- CVE data state ---
const [cves, setCves] = useState([]);
const [vendors, setVendors] = useState(['All Vendors']);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// --- Filter state ---
const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
const debouncedSearch = useDebounce(searchQuery, 300);
// --- Pagination ---
const [visibleCount, setVisibleCount] = useState(5);
// --- Tickets ---
const [jiraTickets, setJiraTickets] = useState([]);
const [archerTickets, setArcherTickets] = useState([]);
// --- Modal state ---
const [editingCVE, setEditingCVE] = useState(null);
const [jiraModal, setJiraModal] = useState(null); // { ticket?, context? }
const [archerModal, setArcherModal] = useState(null); // { ticket?, context? }
const [pendingConfirm, setPendingConfirm] = useState(null);
// --- Fetchers ---
const fetchCVEs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (debouncedSearch) params.append('search', debouncedSearch);
if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
const response = await fetch(`${API_BASE}/cves?${params}`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch CVEs');
const data = await response.json();
setCves(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [debouncedSearch, selectedVendor, selectedSeverity]);
const fetchVendors = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/vendors`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch vendors');
const data = await response.json();
setVendors(['All Vendors', ...data]);
} catch (err) {
console.error('Error fetching vendors:', err);
}
}, []);
const fetchJiraTickets = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch JIRA tickets');
const data = await response.json();
setJiraTickets(data);
} catch (err) {
console.error('Error fetching JIRA tickets:', err);
}
}, []);
const fetchArcherTickets = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch Archer tickets');
const data = await response.json();
setArcherTickets(data);
} catch (err) {
console.error('Error fetching Archer tickets:', err);
}
}, []);
// --- Effects ---
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
fetchVendors();
fetchJiraTickets();
fetchArcherTickets();
}
}, [isAuthenticated, fetchCVEs, fetchVendors, fetchJiraTickets, fetchArcherTickets]);
// Reset visible count when filters change
useEffect(() => { setVisibleCount(5); }, [debouncedSearch, selectedVendor, selectedSeverity]);
// --- Memoized data ---
const groupedCVEs = useMemo(() =>
cves.reduce((acc, cve) => {
if (!acc[cve.cve_id]) acc[cve.cve_id] = [];
acc[cve.cve_id].push(cve);
return acc;
}, {}),
[cves]
);
const openTicketCount = useMemo(
() => jiraTickets.filter(t => !isClosedStatus(t.status)).length,
[jiraTickets]
);
const criticalCount = useMemo(
() => cves.filter(c => c.severity === 'Critical').length,
[cves]
);
// --- Handlers ---
const handleDeleteEntry = (cve) => {
setPendingConfirm({
title: 'Delete Vendor Entry',
message: `Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`,
confirmText: 'Delete',
onConfirm: async () => {
setPendingConfirm(null);
try {
const response = await fetch(`${API_BASE}/cves/${cve.id}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) {
const ct = response.headers.get('content-type');
if (ct && ct.includes('application/json')) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete CVE entry');
} else {
throw new Error(`Server returned ${response.status} ${response.statusText}`);
}
}
toast.success(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
fetchCVEs();
fetchVendors();
} catch (err) {
toast.error(err.message);
}
},
});
};
const handleDeleteAll = (cveId, vendorCount) => {
setPendingConfirm({
title: 'Delete Entire CVE',
message: `Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`,
confirmText: 'Delete All',
onConfirm: async () => {
setPendingConfirm(null);
try {
const response = await fetch(`${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) {
const ct = response.headers.get('content-type');
if (ct && ct.includes('application/json')) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete CVE');
} else {
throw new Error(`Server returned ${response.status} ${response.statusText}`);
}
}
toast.success(`Deleted all entries for ${cveId}`);
fetchCVEs();
fetchVendors();
} catch (err) {
toast.error(err.message);
}
},
});
};
const handleDeleteTicket = (ticket) => {
setPendingConfirm({
title: 'Delete Ticket',
message: `Delete ticket ${ticket.ticket_key}?`,
confirmText: 'Delete',
onConfirm: async () => {
setPendingConfirm(null);
try {
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) throw new Error('Failed to delete ticket');
toast.success('Ticket deleted');
fetchJiraTickets();
} catch (err) {
toast.error(err.message);
}
},
});
};
const handleDeleteArcherTicket = (ticket) => {
setPendingConfirm({
title: 'Delete Archer Ticket',
message: `Delete Archer ticket ${ticket.exc_number}?`,
confirmText: 'Delete',
onConfirm: async () => {
setPendingConfirm(null);
try {
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) throw new Error('Failed to delete Archer ticket');
toast.success('Archer ticket deleted');
fetchArcherTickets();
} catch (err) {
toast.error(err.message);
}
},
});
};
const handleFilterSeverity = (severity) => {
setSelectedSeverity(severity);
};
const totalCVEs = Object.keys(groupedCVEs).length;
return (
<>
{/* Stats Bar */}
<StatsBar
totalCVEs={totalCVEs}
vendorEntries={cves.length}
openTickets={openTicketCount}
criticalCount={criticalCount}
onFilterSeverity={handleFilterSeverity}
activeSeverity={selectedSeverity}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-12 gap-6 mt-6">
{/* CENTER PANEL */}
<div className="col-span-12 lg:col-span-9 space-y-4">
<QuickCVELookup />
<CVEFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedVendor={selectedVendor}
onVendorChange={setSelectedVendor}
vendors={vendors}
selectedSeverity={selectedSeverity}
onSeverityChange={setSelectedSeverity}
/>
{/* Results Summary */}
<div className="flex justify-between items-center">
<p className="text-gray-400 font-mono text-sm">
<span className="text-intel-accent font-bold">{totalCVEs}</span> CVE{totalCVEs !== 1 ? 's' : ''}
<span className="text-gray-500 mx-2"></span>
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
</p>
</div>
{/* CVE List */}
{loading ? (
<div className="intel-card rounded-lg p-12 text-center">
<div className="loading-spinner w-12 h-12 mx-auto mb-4"></div>
<p className="text-gray-400 font-mono text-sm uppercase tracking-wider">Scanning Vulnerabilities...</p>
</div>
) : error ? (
<div className="intel-card rounded-lg p-12 text-center border-intel-danger">
<XCircle className="w-12 h-12 text-intel-danger mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-200 mb-2 font-mono">Error Loading CVEs</h3>
<p className="text-gray-400 mb-4">{error}</p>
<button onClick={fetchCVEs} className="intel-button intel-button-primary">Retry</button>
</div>
) : (
<div className="space-y-4">
{Object.entries(groupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => (
<CVECard
key={cveId}
cveId={cveId}
vendorEntries={vendorEntries}
jiraTickets={jiraTickets}
onEditCVE={(cve) => setEditingCVE(cve)}
onDeleteEntry={handleDeleteEntry}
onDeleteAll={handleDeleteAll}
onEditTicket={(ticket) => setJiraModal({ ticket })}
onDeleteTicket={handleDeleteTicket}
onAddTicket={(cve_id, vendor) => setJiraModal({ context: { cve_id, vendor } })}
onRequestConfirm={setPendingConfirm}
/>
))}
{/* Pagination */}
{totalCVEs > visibleCount && (
<div className="flex items-center justify-between pt-2">
<span className="text-gray-500 font-mono text-xs">
Showing {visibleCount} of {totalCVEs} 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(totalCVEs)}
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 && totalCVEs <= visibleCount && totalCVEs > 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>
)}
{totalCVEs === 0 && !loading && (
<div className="intel-card rounded-lg p-12 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2 font-mono">No CVEs Found</h3>
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
</div>
)}
</div>
{/* RIGHT PANEL */}
<div className="col-span-12 lg:col-span-3 space-y-4">
{/* Calendar */}
<div className="panel-card panel-card--accent">
<h2 className="section-heading section-heading--accent" style={{ marginBottom: '1rem' }}>
Calendar
</h2>
<CalendarWidget
onDateClick={(dateStr) => {
onNavigate('triage', { calendarFilter: dateStr });
}}
/>
</div>
{/* Recent Activity */}
<RecentActivityFeed />
{/* Tabbed panels: Tickets / Archer / Ivanti */}
<SidebarTabs>
{{
tickets: (
<OpenTicketsPanel
tickets={jiraTickets}
onAdd={() => setJiraModal({ context: null })}
onEdit={(ticket) => setJiraModal({ ticket })}
onDelete={handleDeleteTicket}
embedded
/>
),
archer: (
<ArcherPage
archerTickets={archerTickets}
onEditTicket={(ticket) => setArcherModal({ ticket })}
onDeleteTicket={handleDeleteArcherTicket}
onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
onAddTicket={() => setArcherModal({ context: null })}
canDeleteTicket={canDelete}
/>
),
ivanti: (
<IvantiWorkflowPanel embedded />
),
}}
</SidebarTabs>
</div>
</div>
{/* Modals */}
{showAddCVE && (
<AddCVEModal
onClose={() => setShowAddCVE(false)}
onSuccess={() => { fetchCVEs(); fetchVendors(); }}
/>
)}
{editingCVE && (
<EditCVEModal
cve={editingCVE}
onClose={() => setEditingCVE(null)}
onSuccess={() => { fetchCVEs(); fetchVendors(); }}
/>
)}
{jiraModal && (
<JiraTicketModal
ticket={jiraModal.ticket || null}
context={jiraModal.context || null}
onClose={() => setJiraModal(null)}
onSuccess={fetchJiraTickets}
/>
)}
{archerModal && (
<ArcherTicketModal
ticket={archerModal.ticket || null}
context={archerModal.context || null}
onClose={() => setArcherModal(null)}
onSuccess={fetchArcherTickets}
/>
)}
{/* Confirmation Modal */}
<ConfirmModal
open={!!pendingConfirm}
title={pendingConfirm?.title}
message={pendingConfirm?.message}
confirmText={pendingConfirm?.confirmText}
variant={pendingConfirm?.variant || 'danger'}
onConfirm={pendingConfirm?.onConfirm}
onCancel={() => setPendingConfirm(null)}
/>
</>
);
}

View File

@@ -5378,6 +5378,7 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) { function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
return ( return (
<div style={{ <div style={{
position: 'sticky', top: 0, zIndex: 20,
display: 'flex', alignItems: 'center', gap: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))', background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',

View File

@@ -0,0 +1,37 @@
// Centralized Page Visibility Matrix
// Controls which pages each user group can access.
// Empty array = visible to all authenticated users.
// Non-empty array = visible only to listed groups.
export const PAGE_VISIBILITY = {
home: [],
triage: ['Admin', 'Standard_User', 'Leadership'],
compliance: ['Admin', 'Standard_User', 'Leadership'],
'ccp-metrics': ['Admin', 'Leadership'],
'knowledge-base': [],
exports: ['Admin', 'Standard_User', 'Leadership'],
jira: ['Admin', 'Standard_User'],
'archer-templates': ['Admin', 'Standard_User'],
admin: ['Admin'],
};
/**
* Check if a user group can access a page.
* @param {string} pageId - Page identifier
* @param {string} userGroup - User's group
* @returns {boolean}
*/
export function canAccessPage(pageId, userGroup) {
const allowed = PAGE_VISIBILITY[pageId];
if (!allowed || allowed.length === 0) return true;
return allowed.includes(userGroup);
}
/**
* Get all page IDs accessible to a given group.
* @param {string} userGroup - User's group
* @returns {string[]}
*/
export function getAccessiblePages(userGroup) {
return Object.keys(PAGE_VISIBILITY).filter(pageId => canAccessPage(pageId, userGroup));
}

View File

@@ -31,6 +31,10 @@ export function AuthProvider({ children }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Impersonation state
const [impersonating, setImpersonating] = useState(false);
const [realUser, setRealUser] = useState(null);
// Admin scope — array of currently selected teams for filtering // Admin scope — array of currently selected teams for filtering
// null = not initialized yet (will default to user's teams after login) // null = not initialized yet (will default to user's teams after login)
const [adminScope, setAdminScope] = useState(loadAdminScope); const [adminScope, setAdminScope] = useState(loadAdminScope);
@@ -45,6 +49,14 @@ export function AuthProvider({ children }) {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setUser(data.user); setUser(data.user);
// Handle impersonation state from backend
if (data.impersonating) {
setImpersonating(true);
setRealUser(data.realUser);
} else {
setImpersonating(false);
setRealUser(null);
}
// Initialize admin scope to user's teams if not yet set // Initialize admin scope to user's teams if not yet set
if (adminScope === null && data.user?.teams?.length > 0) { if (adminScope === null && data.user?.teams?.length > 0) {
const initial = data.user.teams; const initial = data.user.teams;
@@ -53,6 +65,8 @@ export function AuthProvider({ children }) {
} }
} else { } else {
setUser(null); setUser(null);
setImpersonating(false);
setRealUser(null);
} }
} catch (err) { } catch (err) {
console.error('Auth check error:', err); console.error('Auth check error:', err);
@@ -198,6 +212,43 @@ export function AuthProvider({ children }) {
canExport, canExport,
isAdmin, isAdmin,
isAuthenticated: !!user, isAuthenticated: !!user,
// Impersonation
impersonating,
realUser,
startImpersonation: async (userId) => {
try {
const response = await fetch(`${API_BASE}/auth/impersonate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ userId })
});
const data = await response.json();
if (!response.ok) return { success: false, error: data.error };
setUser(data.user);
setImpersonating(true);
setRealUser({ id: user.id, username: user.username, group: user.group });
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
},
stopImpersonation: async () => {
try {
const response = await fetch(`${API_BASE}/auth/stop-impersonate`, {
method: 'POST',
credentials: 'include'
});
const data = await response.json();
if (!response.ok) return { success: false, error: data.error };
setUser(data.user);
setImpersonating(false);
setRealUser(null);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
},
// Multi-BU tenancy // Multi-BU tenancy
hasTeams, hasTeams,
isTeamMember, isTeamMember,

View File

@@ -0,0 +1,117 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
const ToastContext = createContext(null);
let toastId = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message, type = 'info', duration = 4000) => {
const id = ++toastId;
setToasts(prev => [...prev, { id, message, type }]);
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, duration);
}
return id;
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const toast = useCallback((message, type, duration) => addToast(message, type, duration), [addToast]);
toast.success = (msg, duration) => addToast(msg, 'success', duration);
toast.error = (msg, duration) => addToast(msg, 'error', duration ?? 6000);
toast.warning = (msg, duration) => addToast(msg, 'warning', duration);
toast.info = (msg, duration) => addToast(msg, 'info', duration);
return (
<ToastContext.Provider value={toast}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
// --- Toast UI ---
const TOAST_STYLES = {
container: {
position: 'fixed',
top: '1rem',
right: '1rem',
zIndex: 99999,
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
pointerEvents: 'none',
maxWidth: '400px',
},
toast: {
pointerEvents: 'auto',
padding: '0.75rem 1rem',
borderRadius: '0.5rem',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.8rem',
color: '#E2E8F0',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
animation: 'toast-slide-in 0.2s ease-out',
cursor: 'pointer',
},
success: {
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.08))',
border: '1px solid rgba(16, 185, 129, 0.5)',
},
error: {
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.08))',
border: '1px solid rgba(239, 68, 68, 0.5)',
},
warning: {
background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.08))',
border: '1px solid rgba(245, 158, 11, 0.5)',
},
info: {
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.08))',
border: '1px solid rgba(14, 165, 233, 0.5)',
},
};
const TOAST_ICONS = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
};
function ToastContainer({ toasts, onDismiss }) {
if (toasts.length === 0) return null;
return (
<div style={TOAST_STYLES.container}>
{toasts.map(t => (
<div
key={t.id}
style={{ ...TOAST_STYLES.toast, ...TOAST_STYLES[t.type] }}
onClick={() => onDismiss(t.id)}
role="alert"
aria-live="polite"
>
<span style={{ fontSize: '1rem', flexShrink: 0 }}>{TOAST_ICONS[t.type]}</span>
<span>{t.message}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
/**
* Debounces a value by the specified delay.
* Returns the debounced value — updates only after `delay` ms of inactivity.
*/
export function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -4,12 +4,15 @@ import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<AuthProvider> <AuthProvider>
<App /> <ToastProvider>
<App />
</ToastProvider>
</AuthProvider> </AuthProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -25,5 +25,14 @@
"devDependencies": { "devDependencies": {
"fast-check": "^4.8.0", "fast-check": "^4.8.0",
"jest": "^30.3.0" "jest": "^30.3.0"
},
"jest": {
"roots": [
"<rootDir>/backend/__tests__"
],
"testPathIgnorePatterns": [
"/node_modules/",
"integration"
]
} }
} }

BIN
shieldlogo.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB