Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
306950e360
|
||
|
|
4a0adfb574
|
||
|
|
223b6f22b8
|
||
|
|
55795710d9
|
||
|
|
e9d6038636
|
||
|
|
c7274be66d
|
||
|
|
ba6e67c639
|
||
|
|
f257cfad88
|
||
|
|
a95fd03f5e
|
||
|
|
479c61b88f
|
||
|
|
2fed9221f1
|
||
|
|
8b985a21f8
|
||
|
|
55a4d299ef
|
||
|
|
28714eed47
|
||
|
|
c0e3139503
|
||
|
|
09db1c2ae9
|
||
|
|
c1a266f4f7
|
@@ -100,7 +100,7 @@ test-backend:
|
||||
policy: pull
|
||||
script:
|
||||
- test -d node_modules || npm ci
|
||||
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||
- ./node_modules/.bin/jest --ci --forceExit
|
||||
timeout: 5 minutes
|
||||
needs:
|
||||
- install-backend
|
||||
@@ -118,7 +118,7 @@ test-frontend:
|
||||
- node_modules/
|
||||
policy: pull
|
||||
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
|
||||
needs:
|
||||
- install-frontend
|
||||
|
||||
@@ -75,11 +75,36 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
||||
- `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
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
### Unused Variables
|
||||
|
||||
@@ -80,3 +80,11 @@ GITLAB_PAT=
|
||||
# Generate with: openssl rand -hex 20
|
||||
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
3
backend/.gitignore
vendored
@@ -3,3 +3,6 @@
|
||||
backend/fix_multivendor_constraint.js
|
||||
backend/migrate_multivendor.js
|
||||
backend/add_vendor_to_documents.js
|
||||
|
||||
# TLS certificates (self-signed or CA-issued)
|
||||
certs/
|
||||
|
||||
@@ -312,6 +312,23 @@ async function searchByIvantiHostId(ivantiHostId, options) {
|
||||
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.
|
||||
* 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) {
|
||||
const quick = options && options.quick;
|
||||
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();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
@@ -384,4 +401,5 @@ module.exports = {
|
||||
invalidateToken,
|
||||
resolveAssetId,
|
||||
searchByIvantiHostId,
|
||||
searchByAssetId,
|
||||
};
|
||||
|
||||
@@ -251,6 +251,19 @@ function createAtlasRouter() {
|
||||
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 withPlans = 0;
|
||||
let failed = 0;
|
||||
@@ -273,30 +286,40 @@ function createAtlasRouter() {
|
||||
}
|
||||
|
||||
const { hostId, result } = settled.value;
|
||||
const isManagedHost = managedHostIds.has(hostId);
|
||||
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let allPlans = [];
|
||||
let activePlans = [];
|
||||
let atlasRecognizesHost = false;
|
||||
try {
|
||||
const parsed = JSON.parse(result.body);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
// Check for "not found" error responses that come back as 200
|
||||
if (parsed.error || parsed.message?.includes('not found')) {
|
||||
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)) {
|
||||
atlasRecognizesHost = true;
|
||||
allPlans = parsed;
|
||||
activePlans = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
allPlans = [];
|
||||
activePlans = [];
|
||||
atlasRecognizesHost = false;
|
||||
}
|
||||
|
||||
const planCount = activePlans.length;
|
||||
const hasActionPlan = planCount > 0;
|
||||
// Atlas "knows" this host if it returned any plans (active or inactive).
|
||||
// Hosts with completely empty responses are not tracked by Atlas.
|
||||
const atlasKnown = allPlans.length > 0;
|
||||
// Atlas knows this host if it returned a valid structured response
|
||||
// (not "not found" or error). This determines whether the badge renders.
|
||||
const atlasKnown = atlasRecognizesHost;
|
||||
|
||||
try {
|
||||
if (!hasActionPlan) {
|
||||
@@ -449,6 +472,45 @@ function createAtlasRouter() {
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
let 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);
|
||||
} else {
|
||||
let errorBody;
|
||||
@@ -580,7 +642,27 @@ function createAtlasRouter() {
|
||||
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 newCount = updatedPlans.length;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
redirectAsset,
|
||||
resolveAssetId,
|
||||
searchByIvantiHostId,
|
||||
searchByAssetId,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1025,16 +1026,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
|
||||
// The team assets endpoint returns the full enriched record with ncim_discovery,
|
||||
// card_flags, netops_granite_allips, etc.
|
||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
|
||||
// Skip if all unresolved IPs already timed out (heavier calls will also timeout)
|
||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS'];
|
||||
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
|
||||
const stillUnresolved = [...targetIps].filter(ip => !resultMap[ip]);
|
||||
|
||||
for (const teamName of teams) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
if (stillUnresolved.length === 0 || foundCount >= targetIps.size) break;
|
||||
|
||||
for (const disposition of dispositions) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
@@ -1097,8 +1148,13 @@ function createCardApiRouter() {
|
||||
}
|
||||
|
||||
if (resultMap[trimmedIp]) {
|
||||
if (resultMap[trimmedIp]._timeout) {
|
||||
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 {
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
||||
notFoundCount++;
|
||||
|
||||
@@ -681,7 +681,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 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 };
|
||||
|
||||
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
||||
@@ -797,17 +797,18 @@ async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
||||
// Record BU reassignment in ivanti_finding_bu_history for detail view
|
||||
if (classification === 'bu_reassignment' && found) {
|
||||
try {
|
||||
// Determine previous BU — look up from the cached finding record
|
||||
const { rows: prevRows } = await pool.query(
|
||||
`SELECT bu_ownership FROM ivanti_findings WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const previousBu = prevRows[0]?.bu_ownership || 'UNKNOWN';
|
||||
// Determine previous BU from the pre-sync snapshot (passed in from syncFindings)
|
||||
const previousBu = (previousBuMap && previousBuMap.get(id)) || '';
|
||||
|
||||
// Only record if we have a known previous BU — "UNKNOWN → X" entries
|
||||
// provide no actionable insight for asset movement tracking.
|
||||
if (previousBu && EXPECTED_BUS.has(previousBu)) {
|
||||
await pool.query(
|
||||
`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())`,
|
||||
[id, found.title || '', found.hostName || '', previousBu, found.bu]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BU Drift Checker] Error recording BU change for finding ${id}:`, err.message);
|
||||
}
|
||||
@@ -897,12 +898,14 @@ async function syncFindings() {
|
||||
|
||||
// Read previous open findings from DB for archive detection
|
||||
let previousFindings = [];
|
||||
let previousBuMap = new Map(); // id → bu_ownership snapshot BEFORE upsert
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, title, host_name AS "hostName", ip_address AS "ipAddress", severity, bu_ownership AS "buOwnership"
|
||||
FROM ivanti_findings WHERE state = 'open'`
|
||||
);
|
||||
previousFindings = rows;
|
||||
previousBuMap = new Map(rows.map(f => [String(f.id), f.buOwnership || '']));
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
||||
}
|
||||
@@ -1004,7 +1007,7 @@ async function syncFindings() {
|
||||
console.log(`[BU Drift Checker] ${idsToCheck.length} disappeared total, ${newlyArchivedOnly.length} genuinely new (${alreadyArchivedSet.size} already archived, skipped)`);
|
||||
idsToCheck = newlyArchivedOnly;
|
||||
}
|
||||
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls);
|
||||
classificationBreakdown = await runBUDriftChecker(idsToCheck, apiKey, clientId, skipTls, previousBuMap);
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
||||
}
|
||||
|
||||
@@ -186,8 +186,9 @@ function isSafeTempPath(filePath) {
|
||||
function createVCLMultiVerticalRouter(upload) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
// All routes require authentication + Leadership or Admin group
|
||||
router.use(requireAuth());
|
||||
router.use(requireGroup('Admin', 'Leadership'));
|
||||
|
||||
/**
|
||||
* POST /preview
|
||||
|
||||
34
backend/scripts/check-host-fields.js
Normal file
34
backend/scripts/check-host-fields.js
Normal 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); });
|
||||
@@ -196,6 +196,13 @@ const upload = multer({
|
||||
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)
|
||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
||||
|
||||
@@ -223,10 +230,10 @@ app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
||||
|
||||
// VCL multi-vertical routes — cross-organizational compliance reporting
|
||||
// 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
|
||||
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
|
||||
app.use('/api/atlas', createAtlasRouter());
|
||||
@@ -1196,8 +1203,30 @@ if (fs.existsSync(frontendBuild)) {
|
||||
});
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||
// Start server — use HTTPS if TLS cert/key are available, otherwise plain HTTP
|
||||
const TLS_CERT = process.env.TLS_CERT || path.join(__dirname, 'certs', 'cert.pem');
|
||||
const TLS_KEY = process.env.TLS_KEY || path.join(__dirname, 'certs', 'key.pem');
|
||||
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(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
354
docs/architecture/ad-saml-integration.md
Normal file
354
docs/architecture/ad-saml-integration.md
Normal 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)
|
||||
547
docs/architecture/split-architecture-proposal.md
Normal file
547
docs/architecture/split-architecture-proposal.md
Normal 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 | 5–15 | 100–300 |
|
||||
| Teams tracked | 2 | 15+ |
|
||||
| Ivanti findings (open) | ~200–500 | 2,000–10,000+ |
|
||||
| Ivanti sync frequency | 24h | 1–4h 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 2–5 minutes. At 10,000 findings across 15 teams, this could take 15–30 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 1–2)
|
||||
**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 3–4)
|
||||
**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 5–8)
|
||||
**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 9–12)
|
||||
**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 1–3) 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 | 100–500 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 1–2 | Non-blocking sync, pool increase | Immediate UX fix |
|
||||
| **1** | Weeks 3–4 | Collector extracted, fault isolation | Multi-team onboarding |
|
||||
| **2** | Weeks 5–8 | Multi-tenancy, rate budgeting, retries | 15 teams / 100+ users |
|
||||
| **3** | Weeks 9–12 | New data sources (CS/Qualys/Tanium) | Full vuln coverage |
|
||||
| **4** | Weeks 13+ | Horizontal scaling, load balancing | 300+ users (if needed) |
|
||||
|
||||
Phases 0–2 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)
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
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" />
|
||||
<!--
|
||||
@@ -24,7 +24,7 @@
|
||||
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`.
|
||||
-->
|
||||
<title>CVE Dashboard</title>
|
||||
<title>AEGIS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "SCD",
|
||||
"name": "Steam CVE Dashboard",
|
||||
"short_name": "AEGIS",
|
||||
"name": "AEGIS — Advanced Engineering Group Intelligence System",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
BIN
frontend/public/shieldlogo.jpeg
Normal file
BIN
frontend/public/shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
@@ -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 {
|
||||
/* Base Colors - Modern Slate Foundation */
|
||||
--intel-darkest: #0F172A;
|
||||
@@ -833,3 +845,230 @@ h3.text-intel-accent {
|
||||
color: #CBD5E1;
|
||||
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); }
|
||||
|
||||
2347
frontend/src/App.js
2347
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
BIN
frontend/src/assets/shieldlogo.jpeg
Normal file
BIN
frontend/src/assets/shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
412
frontend/src/components/CVECard.js
Normal file
412
frontend/src/components/CVECard.js
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, FileText, Eye, Edit2, Trash2, Upload, Plus, AlertCircle } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
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 [uploadingFile, setUploadingFile] = useState(false);
|
||||
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 handleFileUpload = async (cveId, vendor) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.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';
|
||||
|
||||
fileInput.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const docType = prompt('Document type (advisory, email, screenshot, patch, other):', 'advisory');
|
||||
if (!docType) return;
|
||||
const notes = prompt('Notes (optional):');
|
||||
|
||||
setUploadingFile(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('cveId', cveId);
|
||||
formData.append('vendor', vendor);
|
||||
formData.append('type', docType);
|
||||
if (notes) formData.append('notes', notes);
|
||||
|
||||
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('Document uploaded successfully');
|
||||
const key = `${cveId}-${vendor}`;
|
||||
setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
await fetchDocuments(cveId, vendor);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
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() && (
|
||||
<button
|
||||
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
|
||||
disabled={uploadingFile}
|
||||
className="mt-3 px-4 py-2 text-sm text-gray-400 hover:text-intel-accent hover:bg-intel-medium rounded transition-all flex items-center gap-2 disabled:opacity-50 border border-gray-600 font-mono uppercase tracking-wider"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingFile ? 'Uploading...' : 'Upload Doc'}
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/CVEFilters.js
Normal file
63
frontend/src/components/CVEFilters.js
Normal 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>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/IvantiWorkflowPanel.js
Normal file
224
frontend/src/components/IvantiWorkflowPanel.js
Normal file
@@ -0,0 +1,224 @@
|
||||
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() {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className="panel-card panel-card--teal">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
setBulkDefaults({});
|
||||
setEnrichErrors([]);
|
||||
setValidationWarnings([]);
|
||||
setEnriching(false);
|
||||
}, [isOpen, initialDevices]);
|
||||
|
||||
// Auto-select required columns + useful defaults when operation type changes
|
||||
@@ -417,11 +418,27 @@ export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
{/* Enrich errors */}
|
||||
{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={{ 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' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||||
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
||||
? 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>
|
||||
)}
|
||||
|
||||
@@ -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="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)'}}>
|
||||
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||
</div>
|
||||
<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">Threat Intelligence Access Portal</p>
|
||||
{/* ⚠️ CONVENTION: Use lucide-react icons instead of <img> tags for iconography */}
|
||||
<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)' }} />
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">AEGIS</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Advanced Engineering Group Intelligence System</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -6,7 +6,7 @@ const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting' },
|
||||
{ id: 'ccp-metrics', label: 'CCP Metrics', icon: Building2, color: '#A78BFA', description: 'Cross-vertical VCL reporting', requiredGroups: ['Admin', 'Leadership'] },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, isInGroup } = useAuth();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -45,14 +45,19 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<img src="/shieldlogo.jpeg" alt="AEGIS" style={{ width: '28px', height: '28px', borderRadius: '4px' }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
STEAM
|
||||
AEGIS
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||
@@ -65,7 +70,7 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
{NAV_ITEMS.filter(({ requiredGroups }) => !requiredGroups || isInGroup(...requiredGroups)).map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
|
||||
91
frontend/src/components/OpenTicketsPanel.js
Normal file
91
frontend/src/components/OpenTicketsPanel.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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 }) {
|
||||
const { canWrite, canDelete } = useAuth();
|
||||
const openTickets = tickets.filter(t => !isClosedStatus(t.status));
|
||||
|
||||
return (
|
||||
<div className="panel-card panel-card--warning">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/QuickCVELookup.js
Normal file
102
frontend/src/components/QuickCVELookup.js
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/StatsBar.js
Normal file
58
frontend/src/components/StatsBar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/src/components/modals/AddCVEModal.js
Normal file
173
frontend/src/components/modals/AddCVEModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/components/modals/ArcherTicketModal.js
Normal file
165
frontend/src/components/modals/ArcherTicketModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/modals/EditCVEModal.js
Normal file
200
frontend/src/components/modals/EditCVEModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/modals/JiraTicketModal.js
Normal file
186
frontend/src/components/modals/JiraTicketModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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 ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
import MetricInfoPanel from './MetricInfoPanel';
|
||||
import VCLReportPage from './VCLReportPage';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
import metricCategoriesConfig from '../../data/complianceCategories.json';
|
||||
|
||||
@@ -361,6 +362,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const [rollbackResult, setRollbackResult] = useState(null);
|
||||
const [infoMetric, setInfoMetric] = 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 hoveredCardRef = useRef(null);
|
||||
|
||||
@@ -392,6 +397,8 @@ export default function CompliancePage({ onNavigate }) {
|
||||
setFilterState(null);
|
||||
setHostSearch('');
|
||||
setSelectedHost(null);
|
||||
setSelectedDevices(new Set());
|
||||
setCurrentPage(1);
|
||||
fetchSummary(activeTeam);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -406,14 +413,50 @@ export default function CompliancePage({ onNavigate }) {
|
||||
|
||||
useEffect(() => {
|
||||
setFilterState(null);
|
||||
setSelectedDevices(new Set());
|
||||
setCurrentPage(1);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset page when filter changes
|
||||
useEffect(() => { setCurrentPage(1); }, [filterState]);
|
||||
|
||||
const refresh = () => {
|
||||
fetchSummary(activeTeam);
|
||||
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 () => {
|
||||
if (!lastUpload) return;
|
||||
setRollbackLoading(true);
|
||||
@@ -446,6 +489,11 @@ export default function CompliancePage({ onNavigate }) {
|
||||
})
|
||||
.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 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)',
|
||||
}}>
|
||||
{/* Active / Resolved tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
|
||||
{['active', 'resolved'].map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
@@ -747,12 +795,36 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</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>
|
||||
|
||||
{/* Hostname search */}
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={e => setHostSearch(e.target.value)}
|
||||
onChange={e => { setHostSearch(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="Search hostname…"
|
||||
style={{
|
||||
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 */}
|
||||
<div style={{
|
||||
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',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
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>IP Address</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'}
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map(device => (
|
||||
paginatedDevices.map(device => (
|
||||
<DeviceRow
|
||||
key={device.hostname}
|
||||
device={device}
|
||||
selected={selectedHost === device.hostname}
|
||||
checked={selectedDevices.has(device.hostname)}
|
||||
onCheck={() => toggleDeviceSelection(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>}
|
||||
|
||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||
@@ -949,11 +1091,18 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Granite Loader Modal ─────────────────────────────────── */}
|
||||
<LoaderModal
|
||||
isOpen={showLoaderModal}
|
||||
onClose={() => setShowLoaderModal(false)}
|
||||
initialDevices={showLoaderModal ? getLoaderDevices() : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({ device, selected, onClick }) {
|
||||
function DeviceRow({ device, selected, checked, onCheck, onClick }) {
|
||||
const truncateText = (text, maxLen = 80) => {
|
||||
if (!text) return '—';
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||
@@ -964,7 +1113,7 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
onClick={onClick}
|
||||
style={{
|
||||
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',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
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)'; }}
|
||||
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 */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{device.hostname}
|
||||
|
||||
442
frontend/src/components/pages/HomePage.js
Normal file
442
frontend/src/components/pages/HomePage.js
Normal file
@@ -0,0 +1,442 @@
|
||||
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 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>
|
||||
|
||||
{/* Open Tickets */}
|
||||
<OpenTicketsPanel
|
||||
tickets={jiraTickets}
|
||||
onAdd={() => setJiraModal({ context: null })}
|
||||
onEdit={(ticket) => setJiraModal({ ticket })}
|
||||
onDelete={handleDeleteTicket}
|
||||
/>
|
||||
|
||||
{/* Archer Tickets */}
|
||||
<ArcherPage
|
||||
archerTickets={archerTickets}
|
||||
onEditTicket={(ticket) => setArcherModal({ ticket })}
|
||||
onDeleteTicket={handleDeleteArcherTicket}
|
||||
onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
|
||||
onAddTicket={() => setArcherModal({ context: null })}
|
||||
canDeleteTicket={canDelete}
|
||||
/>
|
||||
|
||||
{/* Ivanti Workflows */}
|
||||
<IvantiWorkflowPanel />
|
||||
</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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5378,6 +5378,7 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
|
||||
function BulkHideToolbar({ count, onHide, onClear, onAtlasBulk, canWrite }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 20,
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'linear-gradient(135deg, rgba(15,23,42,0.95), rgba(30,41,59,0.95))',
|
||||
|
||||
117
frontend/src/contexts/ToastContext.js
Normal file
117
frontend/src/contexts/ToastContext.js
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/hooks/useDebounce.js
Normal file
16
frontend/src/hooks/useDebounce.js
Normal 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;
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -25,5 +25,14 @@
|
||||
"devDependencies": {
|
||||
"fast-check": "^4.8.0",
|
||||
"jest": "^30.3.0"
|
||||
},
|
||||
"jest": {
|
||||
"roots": [
|
||||
"<rootDir>/backend/__tests__"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"integration"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
shieldlogo.jpeg
Normal file
BIN
shieldlogo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Reference in New Issue
Block a user