Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// Ivanti / RiskSense Host Findings Routes
|
2026-05-06 12:12:34 -06:00
|
|
|
// Stores individual finding rows in PostgreSQL `ivanti_findings` table.
|
|
|
|
|
// Notes and overrides are columns on the same table (no separate tables needed).
|
|
|
|
|
// Daily auto-sync fetches from Ivanti API and upserts rows.
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-04-06 16:18:07 -06:00
|
|
|
const { requireGroup } = require('../middleware/auth');
|
2026-04-07 16:20:24 -06:00
|
|
|
const { ivantiPost } = require('../helpers/ivantiApi');
|
2026-05-06 12:12:34 -06:00
|
|
|
const pool = require('../db');
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
2026-05-22 13:13:54 -06:00
|
|
|
// PostgreSQL DATE columns return JS Date objects — normalize to 'YYYY-MM-DD' strings
|
|
|
|
|
function formatDate(val) {
|
|
|
|
|
if (!val) return null;
|
|
|
|
|
if (val instanceof Date) {
|
|
|
|
|
const y = val.getFullYear();
|
|
|
|
|
const m = String(val.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const d = String(val.getDate()).padStart(2, '0');
|
|
|
|
|
return `${y}-${m}-${d}`;
|
|
|
|
|
}
|
|
|
|
|
// Already a string — strip any time portion (e.g. "2025-05-22T00:00:00.000Z")
|
|
|
|
|
return String(val).slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 11:04:53 -06:00
|
|
|
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
|
|
|
|
// Users see only their assigned teams' findings (filtered at query time).
|
|
|
|
|
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const FINDINGS_FILTERS = [
|
2026-03-13 12:23:05 -06:00
|
|
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
{
|
|
|
|
|
field: 'assetCustomAttributes.1550_host_1.value',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
2026-05-05 11:04:53 -06:00
|
|
|
value: BU_FILTER_VALUE,
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'severity',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'RANGE',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: '8.5,9.9',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'generic_state',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'EXACT',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'Open',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
|
|
|
|
const CLOSED_COUNT_FILTERS = [
|
|
|
|
|
{
|
|
|
|
|
field: 'assetCustomAttributes.1550_host_1.value',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
2026-05-05 11:04:53 -06:00
|
|
|
value: BU_FILTER_VALUE,
|
2026-03-13 12:23:05 -06:00
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'severity',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'RANGE',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: '8.5,9.9',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: 'generic_state',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'EXACT',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: 'Closed',
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-09 13:29:43 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Extract Qualys IPv6 address from hostAdditionalDetails
|
|
|
|
|
// Looks for "IPv6 Address" (string) or "IPv6 Addresses" (array) fields
|
|
|
|
|
// in the scanner-specific details from Qualys.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function extractQualysIpv6(f) {
|
|
|
|
|
const details = f.hostAdditionalDetails || [];
|
|
|
|
|
for (const entry of details) {
|
|
|
|
|
if (entry['IPv6 Address']) return entry['IPv6 Address'];
|
|
|
|
|
if (Array.isArray(entry['IPv6 Addresses']) && entry['IPv6 Addresses'].length > 0) {
|
|
|
|
|
return entry['IPv6 Addresses'][0];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
// Extract only the fields we need from a raw finding object
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
function extractFinding(f) {
|
|
|
|
|
// statusEmbedded.dueDate = "2026-03-06T00:00:00" — strip to date part
|
|
|
|
|
const rawDueDate = f.statusEmbedded?.dueDate || '';
|
|
|
|
|
const dueDate = rawDueDate ? rawDueDate.split('T')[0] : '';
|
|
|
|
|
|
|
|
|
|
// BU ownership: assetCustomAttributes['1550_host_1'] is an array like ["NTS-AEO-STEAM"]
|
|
|
|
|
const buOwnership = f.assetCustomAttributes?.['1550_host_1']?.[0] || '';
|
|
|
|
|
|
|
|
|
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
|
|
|
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
|
|
|
|
// system workflows and not actionable for our purposes.
|
|
|
|
|
const wfDist = f.workflowDistribution || {};
|
|
|
|
|
const fpBuckets = [
|
|
|
|
|
...(wfDist.actionableWorkflows || []),
|
|
|
|
|
...(wfDist.requestedWorkflows || []),
|
|
|
|
|
...(wfDist.reworkedWorkflows || []),
|
|
|
|
|
...(wfDist.rejectedWorkflows || []),
|
|
|
|
|
...(wfDist.expiredWorkflows || []),
|
|
|
|
|
...(wfDist.approvedWorkflows || []),
|
|
|
|
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
|
|
|
|
|
|
|
|
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
|
|
|
|
const fpEntry = fpBuckets[0] || null;
|
|
|
|
|
|
|
|
|
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
|
|
|
|
const generatedNames = f.workflowGeneratedNames || [];
|
|
|
|
|
const fpFromNames = !fpEntry
|
|
|
|
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const workflow = fpEntry ? {
|
|
|
|
|
id: fpEntry.generatedId || '',
|
|
|
|
|
state: fpEntry.state || '',
|
|
|
|
|
type: 'FP',
|
|
|
|
|
} : fpFromNames ? {
|
|
|
|
|
id: fpFromNames,
|
|
|
|
|
state: '',
|
|
|
|
|
type: 'FP',
|
|
|
|
|
} : null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: String(f.id),
|
|
|
|
|
hostId: f.host?.hostId || null,
|
|
|
|
|
title: f.title || '',
|
|
|
|
|
severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0,
|
|
|
|
|
vrrGroup: f.vrrGroup || f.severityGroup || '',
|
|
|
|
|
hostName: f.host?.hostName || '',
|
|
|
|
|
ipAddress: f.host?.ipAddress || '',
|
|
|
|
|
dns: f.dns || f.host?.fqdn || '',
|
|
|
|
|
status: f.status || '',
|
|
|
|
|
slaStatus: f.slaStatus || '',
|
|
|
|
|
dueDate,
|
|
|
|
|
lastFoundOn: f.lastFoundOn || '',
|
|
|
|
|
buOwnership,
|
|
|
|
|
cves,
|
2026-06-09 13:29:43 -06:00
|
|
|
workflow,
|
|
|
|
|
// IPv6 fallbacks for findings with no IPv4
|
|
|
|
|
qualysIpv6: extractQualysIpv6(f),
|
|
|
|
|
primaryIpv6: f.assetCustomAttributes?.['1550_host_6']?.[0] || '',
|
2026-05-06 12:12:34 -06:00
|
|
|
};
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
|
|
|
|
// Returns { id, state } or null if no FP# workflow present.
|
2026-04-03 15:20:04 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
function extractFPWorkflow(f) {
|
|
|
|
|
const wfDist = f.workflowDistribution || {};
|
|
|
|
|
const fpBuckets = [
|
|
|
|
|
...(wfDist.actionableWorkflows || []),
|
|
|
|
|
...(wfDist.requestedWorkflows || []),
|
|
|
|
|
...(wfDist.reworkedWorkflows || []),
|
|
|
|
|
...(wfDist.rejectedWorkflows || []),
|
|
|
|
|
...(wfDist.expiredWorkflows || []),
|
|
|
|
|
...(wfDist.approvedWorkflows || []),
|
|
|
|
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
|
|
|
|
const fpEntry = fpBuckets[0] || null;
|
|
|
|
|
if (!fpEntry) return null;
|
|
|
|
|
return { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Batch upsert findings into ivanti_findings table
|
|
|
|
|
// Preserves note and override_* columns (user data) during upsert.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function upsertFindingsBatch(findings, state) {
|
|
|
|
|
if (findings.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const BATCH_SIZE = 100;
|
|
|
|
|
for (let i = 0; i < findings.length; i += BATCH_SIZE) {
|
|
|
|
|
const batch = findings.slice(i, i + BATCH_SIZE);
|
|
|
|
|
const values = [];
|
|
|
|
|
const placeholders = [];
|
|
|
|
|
|
|
|
|
|
batch.forEach((f, idx) => {
|
2026-06-09 13:29:43 -06:00
|
|
|
const offset = idx * 20;
|
2026-05-06 12:12:34 -06:00
|
|
|
values.push(
|
|
|
|
|
f.id,
|
|
|
|
|
f.hostId,
|
|
|
|
|
f.title || '',
|
|
|
|
|
f.severity || 0,
|
|
|
|
|
f.vrrGroup || '',
|
|
|
|
|
f.hostName || '',
|
|
|
|
|
f.ipAddress || '',
|
|
|
|
|
f.dns || '',
|
|
|
|
|
f.status || '',
|
|
|
|
|
f.slaStatus || '',
|
|
|
|
|
f.dueDate || null,
|
|
|
|
|
f.lastFoundOn || null,
|
|
|
|
|
f.buOwnership || '',
|
|
|
|
|
f.cves || [],
|
|
|
|
|
f.workflow ? f.workflow.id : null,
|
|
|
|
|
f.workflow ? f.workflow.state : null,
|
|
|
|
|
f.workflow ? f.workflow.type : null,
|
2026-06-09 13:29:43 -06:00
|
|
|
state,
|
|
|
|
|
f.qualysIpv6 || null,
|
|
|
|
|
f.primaryIpv6 || null
|
2026-05-06 12:12:34 -06:00
|
|
|
);
|
|
|
|
|
placeholders.push(
|
|
|
|
|
`($${offset+1}, $${offset+2}, $${offset+3}, $${offset+4}, $${offset+5}, ` +
|
|
|
|
|
`$${offset+6}, $${offset+7}, $${offset+8}, $${offset+9}, $${offset+10}, ` +
|
|
|
|
|
`$${offset+11}, $${offset+12}, $${offset+13}, $${offset+14}, $${offset+15}, ` +
|
2026-06-09 13:29:43 -06:00
|
|
|
`$${offset+16}, $${offset+17}, $${offset+18}, $${offset+19}, $${offset+20})`
|
2026-05-06 12:12:34 -06:00
|
|
|
);
|
2026-04-03 15:20:04 -06:00
|
|
|
});
|
2026-05-06 12:12:34 -06:00
|
|
|
|
|
|
|
|
await pool.query(`
|
|
|
|
|
INSERT INTO ivanti_findings (
|
|
|
|
|
id, host_id, title, severity, vrr_group,
|
|
|
|
|
host_name, ip_address, dns, status, sla_status,
|
|
|
|
|
due_date, last_found_on, bu_ownership, cves,
|
2026-06-09 13:29:43 -06:00
|
|
|
workflow_id, workflow_state, workflow_type, state,
|
|
|
|
|
qualys_ipv6, primary_ipv6
|
2026-05-06 12:12:34 -06:00
|
|
|
)
|
|
|
|
|
VALUES ${placeholders.join(', ')}
|
|
|
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
|
|
|
host_id = EXCLUDED.host_id,
|
|
|
|
|
title = EXCLUDED.title,
|
|
|
|
|
severity = EXCLUDED.severity,
|
|
|
|
|
vrr_group = EXCLUDED.vrr_group,
|
|
|
|
|
host_name = EXCLUDED.host_name,
|
|
|
|
|
ip_address = EXCLUDED.ip_address,
|
|
|
|
|
dns = EXCLUDED.dns,
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
sla_status = EXCLUDED.sla_status,
|
|
|
|
|
due_date = EXCLUDED.due_date,
|
|
|
|
|
last_found_on = EXCLUDED.last_found_on,
|
|
|
|
|
bu_ownership = EXCLUDED.bu_ownership,
|
|
|
|
|
cves = EXCLUDED.cves,
|
|
|
|
|
workflow_id = EXCLUDED.workflow_id,
|
|
|
|
|
workflow_state = EXCLUDED.workflow_state,
|
|
|
|
|
workflow_type = EXCLUDED.workflow_type,
|
|
|
|
|
state = EXCLUDED.state,
|
2026-06-09 13:29:43 -06:00
|
|
|
qualys_ipv6 = EXCLUDED.qualys_ipv6,
|
|
|
|
|
primary_ipv6 = EXCLUDED.primary_ipv6,
|
2026-05-06 12:12:34 -06:00
|
|
|
synced_at = NOW()
|
|
|
|
|
`, values);
|
|
|
|
|
}
|
2026-04-03 15:20:04 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Archive detection — compare previous vs current findings to detect state changes
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function detectArchiveChanges(previousFindings, currentFindings) {
|
2026-04-03 15:20:04 -06:00
|
|
|
const previousIds = new Set(previousFindings.map(f => String(f.id)));
|
|
|
|
|
const currentIds = new Set(currentFindings.map(f => String(f.id)));
|
|
|
|
|
|
|
|
|
|
// Build lookup maps for metadata
|
|
|
|
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
|
|
|
|
const currentMap = new Map(currentFindings.map(f => [String(f.id), f]));
|
|
|
|
|
|
|
|
|
|
// 1. Disappeared findings: in previous but not in current → ARCHIVED
|
|
|
|
|
const disappearedIds = [...previousIds].filter(id => !currentIds.has(id));
|
|
|
|
|
|
|
|
|
|
for (const id of disappearedIds) {
|
|
|
|
|
const finding = previousMap.get(id);
|
|
|
|
|
const title = finding.title || '';
|
2026-05-06 12:12:34 -06:00
|
|
|
const hostName = finding.hostName || finding.host_name || '';
|
|
|
|
|
const ipAddress = finding.ipAddress || finding.ip_address || '';
|
2026-04-03 15:20:04 -06:00
|
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT id, current_state FROM ivanti_finding_archives WHERE finding_id = $1`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[id]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const existing = rows[0];
|
2026-04-03 15:20:04 -06:00
|
|
|
|
|
|
|
|
if (existing && existing.current_state === 'RETURNED') {
|
|
|
|
|
// Re-disappeared: RETURNED → ARCHIVED
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`UPDATE ivanti_finding_archives
|
2026-05-06 12:12:34 -06:00
|
|
|
SET current_state = 'ARCHIVED', last_severity = $1, last_transition_at = NOW()
|
|
|
|
|
WHERE id = $2`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[severity, existing.id]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES ($1, 'RETURNED', 'ARCHIVED', $2, 'severity_score_drift', NOW())`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[existing.id, severity]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${id} re-archived (RETURNED → ARCHIVED)`);
|
|
|
|
|
} else if (!existing) {
|
|
|
|
|
// First disappearance: NONE → ARCHIVED
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows: insertRows } = await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`INSERT INTO ivanti_finding_archives (finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES ($1, $2, $3, $4, 'ARCHIVED', $5, NOW(), NOW()) RETURNING id`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[id, title, hostName, ipAddress, severity]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const archiveId = insertRows[0].id;
|
|
|
|
|
await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES ($1, 'NONE', 'ARCHIVED', $2, 'severity_score_drift', NOW())`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[archiveId, severity]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${id} archived (NONE → ARCHIVED)`);
|
|
|
|
|
}
|
|
|
|
|
// If existing state is ARCHIVED or CLOSED, no action needed
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error processing disappeared finding ${id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Returned findings: in current AND has existing ARCHIVED record → RETURNED
|
2026-05-06 12:12:34 -06:00
|
|
|
const returnedArchiveIds = [];
|
|
|
|
|
try {
|
|
|
|
|
const { rows: archivedRecords } = await pool.query(
|
|
|
|
|
`SELECT id, finding_id FROM ivanti_finding_archives WHERE current_state = 'ARCHIVED'`
|
|
|
|
|
);
|
2026-04-03 15:20:04 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
for (const record of archivedRecords) {
|
|
|
|
|
if (currentIds.has(record.finding_id)) {
|
|
|
|
|
const finding = currentMap.get(record.finding_id);
|
|
|
|
|
const severity = typeof finding.severity === 'number' ? finding.severity : 0;
|
|
|
|
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_finding_archives
|
|
|
|
|
SET current_state = 'RETURNED', last_severity = $1, last_transition_at = NOW()
|
|
|
|
|
WHERE id = $2`,
|
|
|
|
|
[severity, record.id]
|
|
|
|
|
);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
|
|
|
|
VALUES ($1, 'ARCHIVED', 'RETURNED', $2, 'reappeared_in_sync', NOW())`,
|
|
|
|
|
[record.id, severity]
|
|
|
|
|
);
|
|
|
|
|
returnedArchiveIds.push(record.id);
|
|
|
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} returned (ARCHIVED → RETURNED)`);
|
2026-04-03 15:20:04 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-06 12:12:34 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error processing returned findings:', err.message);
|
2026-04-03 15:20:04 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// Count returned findings for anomaly summary
|
2026-05-01 17:15:41 +00:00
|
|
|
let returnedCount = returnedArchiveIds.length;
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Classify returned findings
|
2026-05-01 17:15:41 +00:00
|
|
|
const returnClassification = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
for (const archiveId of returnedArchiveIds) {
|
2026-04-24 20:34:34 +00:00
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
2026-05-01 17:15:41 +00:00
|
|
|
`SELECT reason FROM ivanti_archive_transitions
|
2026-05-06 12:12:34 -06:00
|
|
|
WHERE archive_id = $1 AND to_state = 'ARCHIVED'
|
|
|
|
|
AND transitioned_at <= NOW()
|
2026-05-01 17:15:41 +00:00
|
|
|
ORDER BY transitioned_at DESC LIMIT 1`,
|
|
|
|
|
[archiveId]
|
2026-04-24 20:34:34 +00:00
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const transition = rows[0];
|
2026-05-01 17:15:41 +00:00
|
|
|
if (transition && transition.reason) {
|
|
|
|
|
const reasonKey = transition.reason.split(':')[0];
|
|
|
|
|
if (reasonKey in returnClassification) {
|
|
|
|
|
returnClassification[reasonKey]++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
} catch (err) {
|
2026-05-01 17:15:41 +00:00
|
|
|
// Non-fatal — skip this finding's classification
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
console.log(`[Archive Detection] Processed ${disappearedIds.length} disappeared, ${returnedCount} returned`);
|
2026-05-01 17:15:41 +00:00
|
|
|
if (returnedCount > 0) {
|
|
|
|
|
console.log(`[Archive Detection] Return classification:`, returnClassification);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
return { disappearedIds, returnedCount, returnClassification };
|
2026-04-03 15:20:04 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Closed finding detection — check archived/returned findings against Ivanti closed set
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function detectClosedFindings(closedFindingIds) {
|
2026-04-03 15:20:04 -06:00
|
|
|
if (!closedFindingIds || closedFindingIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const closedSet = new Set(closedFindingIds.map(String));
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows: records } = await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`SELECT id, finding_id, current_state, last_severity FROM ivanti_finding_archives WHERE current_state IN ('ARCHIVED', 'RETURNED')`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let closedCount = 0;
|
|
|
|
|
for (const record of records) {
|
|
|
|
|
if (!closedSet.has(record.finding_id)) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`UPDATE ivanti_finding_archives
|
2026-05-06 12:12:34 -06:00
|
|
|
SET current_state = 'CLOSED', last_transition_at = NOW()
|
|
|
|
|
WHERE id = $1`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[record.id]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-03 15:20:04 -06:00
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES ($1, $2, 'CLOSED', $3, 'remediated_in_ivanti', NOW())`,
|
2026-04-03 15:20:04 -06:00
|
|
|
[record.id, record.current_state, record.last_severity || 0]
|
|
|
|
|
);
|
|
|
|
|
closedCount++;
|
|
|
|
|
console.log(`[Archive Detection] Finding ${record.finding_id} closed (${record.current_state} → CLOSED)`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error closing finding ${record.finding_id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[Archive Detection] Closed ${closedCount} findings as remediated`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error querying archive records for closed detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Closed-gone detection — find archive CLOSED findings that vanished from the
|
2026-05-06 12:12:34 -06:00
|
|
|
// Ivanti closed API set.
|
2026-04-24 20:34:34 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function detectClosedGoneFindings(closedFindingIds) {
|
2026-04-24 20:34:34 +00:00
|
|
|
if (!closedFindingIds) return;
|
|
|
|
|
|
|
|
|
|
const closedSet = new Set(closedFindingIds.map(String));
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows: records } = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT id, finding_id, last_severity FROM ivanti_finding_archives WHERE current_state = 'CLOSED'`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let goneCount = 0;
|
|
|
|
|
for (const record of records) {
|
|
|
|
|
if (closedSet.has(record.finding_id)) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`UPDATE ivanti_finding_archives
|
2026-05-06 12:12:34 -06:00
|
|
|
SET current_state = 'CLOSED_GONE', last_transition_at = NOW()
|
|
|
|
|
WHERE id = $1`,
|
2026-04-24 20:34:34 +00:00
|
|
|
[record.id]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`INSERT INTO ivanti_archive_transitions (archive_id, from_state, to_state, severity_at_transition, reason, transitioned_at)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES ($1, 'CLOSED', 'CLOSED_GONE', $2, 'disappeared_from_closed_set', NOW())`,
|
2026-04-24 20:34:34 +00:00
|
|
|
[record.id, record.last_severity || 0]
|
|
|
|
|
);
|
|
|
|
|
goneCount++;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Archive Detection] Error marking finding ${record.finding_id} as CLOSED_GONE:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (goneCount > 0) {
|
|
|
|
|
console.warn(`[Archive Detection] ${goneCount} previously-closed findings disappeared from the Ivanti closed set (CLOSED → CLOSED_GONE)`);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Archive Detection] Error in closed-gone detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
// Fetch closed findings from Ivanti and upsert + update counts
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function syncClosedCount(openCount, apiKey, clientId, skipTls) {
|
2026-03-13 12:23:05 -06:00
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
try {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page: 0,
|
2026-04-03 15:20:04 -06:00
|
|
|
size: 100
|
2026-03-13 12:23:05 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
2026-04-03 15:20:04 -06:00
|
|
|
const totalPages = data.page?.totalPages || 1;
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Collect closed findings for upsert and archive detection
|
|
|
|
|
const closedFindings = [];
|
2026-04-03 15:20:04 -06:00
|
|
|
const closedFindingIds = [];
|
|
|
|
|
const firstPageFindings = data._embedded?.hostFindings || [];
|
2026-05-06 12:12:34 -06:00
|
|
|
firstPageFindings.forEach(f => {
|
|
|
|
|
if (f.id) closedFindingIds.push(String(f.id));
|
|
|
|
|
closedFindings.push(extractFinding(f));
|
|
|
|
|
});
|
2026-04-03 15:20:04 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Fetch remaining pages to collect all closed findings
|
2026-04-03 15:20:04 -06:00
|
|
|
for (let pg = 1; pg < totalPages; pg++) {
|
|
|
|
|
try {
|
|
|
|
|
const pageBody = { ...body, page: pg };
|
|
|
|
|
const pageResult = await ivantiPost(urlPath, pageBody, apiKey, skipTls);
|
|
|
|
|
if (pageResult.status !== 200) break;
|
|
|
|
|
const pageData = JSON.parse(pageResult.body);
|
|
|
|
|
const pageFindings = pageData._embedded?.hostFindings || [];
|
2026-05-06 12:12:34 -06:00
|
|
|
pageFindings.forEach(f => {
|
|
|
|
|
if (f.id) closedFindingIds.push(String(f.id));
|
|
|
|
|
closedFindings.push(extractFinding(f));
|
|
|
|
|
});
|
2026-04-03 15:20:04 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[Ivanti Findings] Failed to fetch closed findings page ${pg}:`, err.message);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-13 12:23:05 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Upsert closed findings as individual rows with state='closed'
|
|
|
|
|
await upsertFindingsBatch(closedFindings, 'closed');
|
|
|
|
|
|
|
|
|
|
// Update counts cache
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_counts_cache SET open_count=$1, closed_count=$2, synced_at=NOW() WHERE id=1`,
|
2026-03-13 12:23:05 -06:00
|
|
|
[openCount, closedCount]
|
|
|
|
|
);
|
2026-04-02 10:12:04 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Drift guard — if the new total drops by more than 50% compared to the
|
|
|
|
|
// most recent history snapshot, skip writing to history.
|
2026-04-24 20:34:34 +00:00
|
|
|
const newTotal = openCount + closedCount;
|
|
|
|
|
let skipHistory = false;
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_history ORDER BY recorded_at DESC LIMIT 1`
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const prev = rows[0];
|
2026-04-24 20:34:34 +00:00
|
|
|
if (prev) {
|
|
|
|
|
const prevTotal = (prev.open_count || 0) + (prev.closed_count || 0);
|
|
|
|
|
if (prevTotal > 0 && newTotal < prevTotal * 0.5) {
|
|
|
|
|
console.warn(`[Ivanti Findings] Drift guard triggered — new total ${newTotal} is <50% of previous ${prevTotal}. Skipping history write.`);
|
|
|
|
|
skipHistory = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Drift guard check failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!skipHistory) {
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES ($1, $2)`,
|
2026-04-24 20:34:34 +00:00
|
|
|
[openCount, closedCount]
|
|
|
|
|
);
|
2026-05-06 13:38:38 -06:00
|
|
|
|
|
|
|
|
// Per-BU history snapshot — enables scoped trend lines
|
|
|
|
|
try {
|
|
|
|
|
await pool.query(`
|
|
|
|
|
INSERT INTO ivanti_counts_history_by_bu (bu_ownership, state, count)
|
|
|
|
|
SELECT bu_ownership, state, COUNT(*)
|
|
|
|
|
FROM ivanti_findings
|
|
|
|
|
WHERE bu_ownership != ''
|
|
|
|
|
GROUP BY bu_ownership, state
|
|
|
|
|
`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Per-BU history snapshot failed (non-fatal):', err.message);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
2026-04-02 10:12:04 -06:00
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}${skipHistory ? ' (history write skipped — drift guard)' : ''}`);
|
2026-04-03 15:20:04 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Detect closed findings in the archive
|
2026-04-03 15:20:04 -06:00
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
await detectClosedFindings(closedFindingIds);
|
2026-04-03 15:20:04 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Closed finding archive detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
|
|
|
|
|
// Detect findings that vanished from the closed set — CLOSED → CLOSED_GONE
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
await detectClosedGoneFindings(closedFindingIds);
|
2026-04-24 20:34:34 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Closed-gone detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
2026-03-13 12:23:05 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
|
|
|
|
// Still update open count so it stays in sync; leave closed_count as-is
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_counts_cache SET open_count=$1, synced_at=NOW() WHERE id=1`,
|
2026-03-13 12:23:05 -06:00
|
|
|
[openCount]
|
|
|
|
|
).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 11:43:57 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-16 12:13:13 -06:00
|
|
|
// Sync FP stats across ALL findings (open + closed).
|
2026-03-16 11:43:57 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function syncFPWorkflowCounts(openFindings, apiKey, clientId, skipTls) {
|
2026-03-16 12:13:13 -06:00
|
|
|
const findingCounts = {}; // state → # findings
|
|
|
|
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
|
|
|
|
|
|
|
|
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
2026-03-16 11:43:57 -06:00
|
|
|
openFindings.forEach(f => {
|
|
|
|
|
if (!f.workflow) return;
|
2026-05-06 12:12:34 -06:00
|
|
|
const state = f.workflow.state || 'Unknown';
|
|
|
|
|
const id = f.workflow.id || '';
|
|
|
|
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
|
|
|
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
});
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
try {
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: CLOSED_COUNT_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) {
|
|
|
|
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
|
|
|
|
break;
|
2026-03-13 12:23:05 -06:00
|
|
|
}
|
2026-05-06 12:12:34 -06:00
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
findings.forEach(f => {
|
|
|
|
|
const wf = extractFPWorkflow(f);
|
|
|
|
|
if (!wf) return;
|
|
|
|
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
|
|
|
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
2026-03-13 15:39:37 -06:00
|
|
|
});
|
2026-05-06 12:12:34 -06:00
|
|
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Aggregate unique FP# IDs by state
|
|
|
|
|
const idCounts = {};
|
|
|
|
|
Object.values(fpIdMap).forEach(state => {
|
|
|
|
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
2026-03-13 15:39:37 -06:00
|
|
|
});
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=$1, fp_id_counts_json=$2 WHERE id=1`,
|
|
|
|
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
|
|
|
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
|
|
|
|
|
|
|
|
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
|
|
|
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// BU Drift Checker — post-sync classification of newly archived findings
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-12 15:27:58 -06:00
|
|
|
// Managed BUs for drift classification — derived from IVANTI_MANAGED_BUS env var.
|
|
|
|
|
// Findings leaving these BUs are classified as bu_reassignment.
|
|
|
|
|
// Each tenant deployment sets this to their own managed teams.
|
|
|
|
|
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));
|
2026-04-24 20:34:34 +00:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
async function runBUDriftChecker(newlyArchivedIds, apiKey, clientId, skipTls) {
|
2026-04-24 20:34:34 +00:00
|
|
|
const summary = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
|
|
|
|
|
if (!newlyArchivedIds || newlyArchivedIds.length === 0) return summary;
|
|
|
|
|
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
const chunkSize = 50;
|
|
|
|
|
|
|
|
|
|
const foundMap = new Map();
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < newlyArchivedIds.length; i += chunkSize) {
|
|
|
|
|
const chunk = newlyArchivedIds.slice(i, i + chunkSize);
|
|
|
|
|
const idList = chunk.join(',');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const filters = [
|
|
|
|
|
{
|
|
|
|
|
field: 'id',
|
|
|
|
|
exclusive: false,
|
|
|
|
|
operator: 'IN',
|
|
|
|
|
orWithPrevious: false,
|
|
|
|
|
implicitFilters: [],
|
|
|
|
|
value: idList,
|
|
|
|
|
caseSensitive: false
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
if (result.status !== 200) {
|
|
|
|
|
console.error(`[BU Drift Checker] API returned status ${result.status} for batch starting at index ${i}`);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
|
|
|
|
|
for (const f of findings) {
|
|
|
|
|
const bu = f.assetCustomAttributes?.['1550_host_1']?.[0] || 'UNKNOWN';
|
|
|
|
|
const severity = typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0;
|
|
|
|
|
const state = f.status || f.generic_state || '';
|
2026-06-15 09:29:46 -06:00
|
|
|
const title = f.title || '';
|
|
|
|
|
const hostName = f.host?.hostName || f.hostName || '';
|
|
|
|
|
foundMap.set(String(f.id), { bu, severity, state, title, hostName });
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
|
|
|
|
|
console.log(`[BU Drift Checker] Batch ${Math.floor(i / chunkSize) + 1}: queried ${chunk.length} IDs, found ${foundMap.size} so far`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Drift Checker] Error querying batch at index ${i}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Classify each archived finding and update the archive transition reason
|
|
|
|
|
for (const id of newlyArchivedIds) {
|
|
|
|
|
const found = foundMap.get(id);
|
|
|
|
|
let classification;
|
|
|
|
|
let reason;
|
|
|
|
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
classification = 'decommissioned';
|
|
|
|
|
reason = 'decommissioned';
|
|
|
|
|
} else if (!EXPECTED_BUS.has(found.bu)) {
|
|
|
|
|
classification = 'bu_reassignment';
|
|
|
|
|
reason = `bu_reassignment:${found.bu}`;
|
|
|
|
|
} else if (found.severity < 8.5) {
|
|
|
|
|
classification = 'severity_drift';
|
|
|
|
|
reason = `severity_drift:${found.severity}`;
|
|
|
|
|
} else if (found.state === 'Closed') {
|
|
|
|
|
classification = 'closed_on_platform';
|
|
|
|
|
reason = 'closed_on_platform';
|
|
|
|
|
} else {
|
|
|
|
|
classification = 'decommissioned';
|
|
|
|
|
reason = 'decommissioned';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary[classification] = (summary[classification] || 0) + 1;
|
|
|
|
|
|
|
|
|
|
// Update the most recent archive transition reason for this finding
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT id FROM ivanti_finding_archives WHERE finding_id = $1`,
|
2026-04-24 20:34:34 +00:00
|
|
|
[id]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const archive = rows[0];
|
2026-04-24 20:34:34 +00:00
|
|
|
if (archive) {
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_archive_transitions SET reason = $1
|
|
|
|
|
WHERE archive_id = $2 AND id = (
|
2026-04-24 20:34:34 +00:00
|
|
|
SELECT id FROM ivanti_archive_transitions
|
2026-05-06 12:12:34 -06:00
|
|
|
WHERE archive_id = $3 ORDER BY transitioned_at DESC LIMIT 1
|
2026-04-24 20:34:34 +00:00
|
|
|
)`,
|
|
|
|
|
[reason, archive.id, archive.id]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Drift Checker] Error updating transition reason for finding ${id}:`, err.message);
|
|
|
|
|
}
|
2026-06-15 09:29:46 -06:00
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[BU Drift Checker] Classification complete:`, summary);
|
|
|
|
|
return summary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Anomaly Summary — compute and store post-sync anomaly report
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 12:12:34 -06:00
|
|
|
async function computeAnomalySummary(openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationBreakdown, returnClassificationBreakdown) {
|
2026-04-24 20:34:34 +00:00
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const isSignificant = newlyArchivedCount > 5;
|
2026-04-24 20:34:34 +00:00
|
|
|
const classificationJson = JSON.stringify(classificationBreakdown || {});
|
2026-05-01 17:15:41 +00:00
|
|
|
const returnClassificationJson = JSON.stringify(returnClassificationBreakdown || {});
|
2026-04-24 20:34:34 +00:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`INSERT INTO ivanti_sync_anomaly_log
|
2026-05-01 17:15:41 +00:00
|
|
|
(sync_timestamp, open_count_delta, closed_count_delta, newly_archived_count, returned_count, classification_json, return_classification_json, is_significant)
|
2026-05-06 12:12:34 -06:00
|
|
|
VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7)`,
|
2026-05-01 17:15:41 +00:00
|
|
|
[openCountDelta, closedCountDelta, newlyArchivedCount, returnedCount, classificationJson, returnClassificationJson, isSignificant]
|
2026-04-24 20:34:34 +00:00
|
|
|
);
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
console.log(`[Anomaly Summary] Sync anomaly logged — open_delta: ${openCountDelta}, closed_delta: ${closedCountDelta}, archived: ${newlyArchivedCount}, returned: ${returnedCount}, significant: ${isSignificant}`);
|
2026-04-24 20:34:34 +00:00
|
|
|
console.log(`[Anomaly Summary] Classification:`, classificationBreakdown);
|
2026-05-01 17:15:41 +00:00
|
|
|
if (returnedCount > 0) {
|
|
|
|
|
console.log(`[Anomaly Summary] Return classification:`, returnClassificationBreakdown);
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Anomaly Summary] Failed to write anomaly summary (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Core sync — fetches ALL pages, upserts individual rows into ivanti_findings
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function syncFindings() {
|
|
|
|
|
const apiKey = process.env.IVANTI_API_KEY;
|
|
|
|
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
|
|
|
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
|
|
|
|
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
|
|
|
|
console.warn('[Ivanti Findings]', errMsg);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
|
|
|
[errMsg]
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[Ivanti Findings] Starting sync...');
|
|
|
|
|
|
|
|
|
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
|
|
|
|
let allFindings = [];
|
|
|
|
|
let page = 0;
|
|
|
|
|
let totalPages = 1;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
do {
|
|
|
|
|
const body = {
|
|
|
|
|
filters: FINDINGS_FILTERS,
|
|
|
|
|
projection: 'internal',
|
|
|
|
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
|
|
|
|
page,
|
|
|
|
|
size: 100
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
|
|
|
|
|
|
|
|
|
if (result.status === 401) throw new Error('Invalid or missing API key (401)');
|
|
|
|
|
if (result.status === 419) throw new Error('Insufficient privileges (419) — API key lacks hostFinding access');
|
|
|
|
|
if (result.status === 429) throw new Error('Rate limited (429) — try again later');
|
|
|
|
|
if (result.status !== 200) throw new Error(`Ivanti API returned status ${result.status}`);
|
|
|
|
|
|
|
|
|
|
const data = JSON.parse(result.body);
|
|
|
|
|
totalPages = data.page?.totalPages || 1;
|
|
|
|
|
const findings = data._embedded?.hostFindings || [];
|
|
|
|
|
allFindings = allFindings.concat(findings.map(extractFinding));
|
|
|
|
|
|
|
|
|
|
console.log(`[Ivanti Findings] Page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`);
|
|
|
|
|
page++;
|
|
|
|
|
} while (page < totalPages);
|
|
|
|
|
|
|
|
|
|
// Read previous open findings from DB for archive detection
|
|
|
|
|
let previousFindings = [];
|
|
|
|
|
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;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to read previous findings for archive detection:', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Per-finding BU comparison — detect BU changes across syncs
|
|
|
|
|
try {
|
|
|
|
|
const previousMap = new Map(previousFindings.map(f => [String(f.id), f]));
|
|
|
|
|
for (const finding of allFindings) {
|
|
|
|
|
try {
|
|
|
|
|
const prev = previousMap.get(String(finding.id));
|
|
|
|
|
if (prev && prev.buOwnership && finding.buOwnership && prev.buOwnership !== finding.buOwnership) {
|
|
|
|
|
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())`,
|
|
|
|
|
[String(finding.id), finding.title || '', finding.hostName || '', prev.buOwnership, finding.buOwnership]
|
|
|
|
|
);
|
|
|
|
|
console.log(`[BU Tracking] Finding ${finding.id} BU changed: ${prev.buOwnership} → ${finding.buOwnership}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[BU Tracking] Error recording BU change for finding ${finding.id}:`, err.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[BU Tracking] BU comparison failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert all open findings as individual rows
|
|
|
|
|
await upsertFindingsBatch(allFindings, 'open');
|
|
|
|
|
|
|
|
|
|
// Mark findings that disappeared from the open set:
|
|
|
|
|
// Any finding that was 'open' in DB but NOT in the current sync set
|
|
|
|
|
// should NOT be automatically closed here — archive detection handles that.
|
|
|
|
|
// However, we track the current sync IDs for reference.
|
|
|
|
|
const currentIds = allFindings.map(f => f.id);
|
|
|
|
|
|
|
|
|
|
// Update sync metadata
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_sync_state SET total=$1, synced_at=NOW(), sync_status='success', error_message=NULL WHERE id=1`,
|
|
|
|
|
[allFindings.length]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
|
|
|
|
|
|
|
|
|
// Archive detection — compare previous vs current to detect disappeared/returned findings
|
|
|
|
|
let archiveResult = { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
|
|
|
|
try {
|
|
|
|
|
archiveResult = await detectArchiveChanges(previousFindings, allFindings) || { disappearedIds: [], returnedCount: 0, returnClassification: {} };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Archive detection failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read previous counts BEFORE syncClosedCount updates them — needed for anomaly deltas
|
|
|
|
|
let previousOpenCount = 0;
|
|
|
|
|
let previousClosedCount = 0;
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
|
|
|
|
);
|
|
|
|
|
if (rows[0]) {
|
|
|
|
|
previousOpenCount = rows[0].open_count || 0;
|
|
|
|
|
previousClosedCount = rows[0].closed_count || 0;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Failed to read previous counts for anomaly summary (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await syncClosedCount(allFindings.length, apiKey, clientId, skipTls);
|
|
|
|
|
await syncFPWorkflowCounts(allFindings, apiKey, clientId, skipTls);
|
|
|
|
|
|
|
|
|
|
// Post-sync: BU drift checker for newly archived findings
|
|
|
|
|
let classificationBreakdown = { bu_reassignment: 0, severity_drift: 0, closed_on_platform: 0, decommissioned: 0 };
|
|
|
|
|
try {
|
|
|
|
|
classificationBreakdown = await runBUDriftChecker(archiveResult.disappearedIds, apiKey, clientId, skipTls);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] BU drift checker failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Post-sync: Compute and store anomaly summary
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT open_count, closed_count FROM ivanti_counts_cache WHERE id = 1`
|
|
|
|
|
);
|
|
|
|
|
const currentOpenCount = rows[0]?.open_count || 0;
|
|
|
|
|
const currentClosedCount = rows[0]?.closed_count || 0;
|
|
|
|
|
const openCountDelta = currentOpenCount - previousOpenCount;
|
|
|
|
|
const closedCountDelta = currentClosedCount - previousClosedCount;
|
|
|
|
|
|
|
|
|
|
await computeAnomalySummary(
|
|
|
|
|
openCountDelta,
|
|
|
|
|
closedCountDelta,
|
|
|
|
|
archiveResult.disappearedIds.length,
|
|
|
|
|
archiveResult.returnedCount,
|
|
|
|
|
classificationBreakdown,
|
|
|
|
|
archiveResult.returnClassification || {}
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Anomaly summary failed (non-fatal):', err.message);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err.message || 'Unknown error';
|
|
|
|
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=$1, synced_at=NOW() WHERE id=1`,
|
|
|
|
|
[msg]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Scheduler
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
async function scheduleSync() {
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT synced_at FROM ivanti_sync_state WHERE id = 1');
|
|
|
|
|
const row = rows[0];
|
|
|
|
|
if (!row || !row.synced_at) {
|
|
|
|
|
syncFindings();
|
|
|
|
|
} else {
|
|
|
|
|
const lastSync = new Date(row.synced_at);
|
|
|
|
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
|
|
|
|
if (hoursSince >= 24) {
|
|
|
|
|
syncFindings();
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`[Ivanti Findings] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${(24 - hoursSince).toFixed(1)}h`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] Schedule sync check failed, triggering sync:', err.message);
|
|
|
|
|
syncFindings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInterval(() => syncFindings(), SYNC_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function createIvantiFindingsRouter(db, requireAuth) {
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Initialize sync schedule (no table init needed — schema handled by db-schema.sql)
|
|
|
|
|
scheduleSync();
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
router.use(requireAuth());
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* Return findings from ivanti_findings table (state='open') with notes and overrides.
|
2026-05-05 11:04:53 -06:00
|
|
|
* Accepts optional `teams` query parameter (comma-separated) to filter
|
2026-05-06 12:12:34 -06:00
|
|
|
* findings by buOwnership. If omitted, returns all open findings.
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
2026-05-05 11:04:53 -06:00
|
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
2026-05-06 12:12:34 -06:00
|
|
|
* @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message }
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
router.get('/', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-05 11:04:53 -06:00
|
|
|
const teamsParam = req.query.teams;
|
2026-05-06 12:12:34 -06:00
|
|
|
let query = `SELECT * FROM ivanti_findings WHERE state = 'open'`;
|
|
|
|
|
const params = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
2026-05-05 11:04:53 -06:00
|
|
|
if (teamsParam) {
|
2026-05-06 12:12:34 -06:00
|
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
2026-05-05 11:04:53 -06:00
|
|
|
if (teams.length > 0) {
|
2026-05-06 12:12:34 -06:00
|
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
|
|
|
query += ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
|
|
|
|
|
params.push(patterns);
|
2026-05-05 11:04:53 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
query += ' ORDER BY severity DESC';
|
|
|
|
|
|
|
|
|
|
const { rows } = await pool.query(query, params);
|
|
|
|
|
|
|
|
|
|
// Transform rows to match existing API response shape
|
|
|
|
|
const findings = rows.map(row => ({
|
|
|
|
|
id: row.id,
|
|
|
|
|
hostId: row.host_id,
|
|
|
|
|
title: row.title,
|
|
|
|
|
severity: parseFloat(row.severity),
|
|
|
|
|
vrrGroup: row.vrr_group,
|
|
|
|
|
hostName: row.host_name,
|
|
|
|
|
ipAddress: row.ip_address,
|
|
|
|
|
dns: row.dns,
|
|
|
|
|
status: row.status,
|
|
|
|
|
slaStatus: row.sla_status,
|
2026-05-22 13:13:54 -06:00
|
|
|
dueDate: formatDate(row.due_date),
|
|
|
|
|
lastFoundOn: formatDate(row.last_found_on),
|
2026-05-06 12:12:34 -06:00
|
|
|
buOwnership: row.bu_ownership,
|
|
|
|
|
cves: row.cves || [],
|
|
|
|
|
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
|
|
|
|
note: row.note || '',
|
2026-06-09 13:29:43 -06:00
|
|
|
qualysIpv6: row.qualys_ipv6 || null,
|
|
|
|
|
primaryIpv6: row.primary_ipv6 || null,
|
2026-05-06 12:12:34 -06:00
|
|
|
overrides: {
|
|
|
|
|
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
|
|
|
|
...(row.override_dns ? { dns: row.override_dns } : {})
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Get sync metadata
|
|
|
|
|
const metaResult = await pool.query('SELECT synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1');
|
|
|
|
|
const meta = metaResult.rows[0] || {};
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
findings,
|
|
|
|
|
total: findings.length,
|
|
|
|
|
synced_at: meta.synced_at || null,
|
|
|
|
|
sync_status: meta.sync_status || 'never',
|
|
|
|
|
error_message: meta.error_message || null
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET / error:', err.message);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
res.status(500).json({ error: 'Database error reading findings' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* POST /api/ivanti/findings/sync
|
|
|
|
|
*
|
|
|
|
|
* Trigger an immediate Ivanti findings sync and return the fresh state.
|
|
|
|
|
* Requires Admin or Standard_User group.
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* @returns {Object} 200 - { findings, total, synced_at, sync_status, error_message }
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 500 - { error: string } if sync ran but state could not be read
|
|
|
|
|
*/
|
2026-04-07 09:52:26 -06:00
|
|
|
router.post('/sync', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-06 12:12:34 -06:00
|
|
|
await syncFindings();
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
// Return fresh state after sync
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT * FROM ivanti_findings WHERE state = 'open' ORDER BY severity DESC`
|
|
|
|
|
);
|
|
|
|
|
const findings = rows.map(row => ({
|
|
|
|
|
id: row.id,
|
|
|
|
|
hostId: row.host_id,
|
|
|
|
|
title: row.title,
|
|
|
|
|
severity: parseFloat(row.severity),
|
|
|
|
|
vrrGroup: row.vrr_group,
|
|
|
|
|
hostName: row.host_name,
|
|
|
|
|
ipAddress: row.ip_address,
|
|
|
|
|
dns: row.dns,
|
|
|
|
|
status: row.status,
|
|
|
|
|
slaStatus: row.sla_status,
|
2026-05-22 13:13:54 -06:00
|
|
|
dueDate: formatDate(row.due_date),
|
|
|
|
|
lastFoundOn: formatDate(row.last_found_on),
|
2026-05-06 12:12:34 -06:00
|
|
|
buOwnership: row.bu_ownership,
|
|
|
|
|
cves: row.cves || [],
|
|
|
|
|
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
|
|
|
|
note: row.note || '',
|
2026-06-09 13:29:43 -06:00
|
|
|
qualysIpv6: row.qualys_ipv6 || null,
|
|
|
|
|
primaryIpv6: row.primary_ipv6 || null,
|
2026-05-06 12:12:34 -06:00
|
|
|
overrides: {
|
|
|
|
|
...(row.override_host_name ? { hostName: row.override_host_name } : {}),
|
|
|
|
|
...(row.override_dns ? { dns: row.override_dns } : {})
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const metaResult = await pool.query('SELECT synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1');
|
|
|
|
|
const meta = metaResult.rows[0] || {};
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
findings,
|
|
|
|
|
total: findings.length,
|
|
|
|
|
synced_at: meta.synced_at || null,
|
|
|
|
|
sync_status: meta.sync_status || 'never',
|
|
|
|
|
error_message: meta.error_message || null
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] POST /sync read error:', err.message);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/counts
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* Return open vs closed finding totals.
|
|
|
|
|
* Accepts optional `teams` query parameter to scope counts to specific BUs.
|
|
|
|
|
* With Postgres, both open AND closed counts are per-BU when filtered.
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
2026-05-05 11:04:53 -06:00
|
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
|
|
|
|
* @returns {Object} 200 - { open: number, closed: number, filtered: boolean }
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-03-13 12:23:05 -06:00
|
|
|
router.get('/counts', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-05 11:04:53 -06:00
|
|
|
const teamsParam = req.query.teams;
|
2026-05-06 12:12:34 -06:00
|
|
|
let whereExtra = '';
|
|
|
|
|
const params = [];
|
|
|
|
|
let paramIndex = 1;
|
2026-05-05 11:04:53 -06:00
|
|
|
|
|
|
|
|
if (teamsParam) {
|
2026-05-06 12:12:34 -06:00
|
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
2026-05-05 11:04:53 -06:00
|
|
|
if (teams.length > 0) {
|
2026-05-06 12:12:34 -06:00
|
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
|
|
|
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
|
|
|
|
|
params.push(patterns);
|
2026-05-05 11:04:53 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT state, COUNT(*) as count FROM ivanti_findings WHERE 1=1 ${whereExtra} GROUP BY state`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const counts = { open: 0, closed: 0 };
|
|
|
|
|
rows.forEach(r => { counts[r.state] = parseInt(r.count); });
|
|
|
|
|
|
|
|
|
|
res.json({ ...counts, filtered: !!teamsParam });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /counts error:', err.message);
|
2026-03-13 12:23:05 -06:00
|
|
|
res.status(500).json({ error: 'Database error reading counts' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/counts/history
|
|
|
|
|
*
|
|
|
|
|
* Return the last snapshot per day (ascending) for the trend chart.
|
2026-05-06 13:38:38 -06:00
|
|
|
* Accepts optional `teams` query parameter to scope the trend to specific BUs.
|
|
|
|
|
* When teams is provided, uses the per-BU history table.
|
|
|
|
|
* When no teams, returns the global aggregate history.
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
2026-05-06 13:38:38 -06:00
|
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 200 - { history: Array<{ date: string, open_count: number, closed_count: number }> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-04-02 10:12:04 -06:00
|
|
|
router.get('/counts/history', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-06 13:38:38 -06:00
|
|
|
const teamsParam = req.query.teams;
|
|
|
|
|
|
|
|
|
|
if (teamsParam) {
|
|
|
|
|
// Per-BU history — filter and aggregate by selected teams
|
|
|
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
|
|
|
if (teams.length > 0) {
|
|
|
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT date,
|
|
|
|
|
SUM(CASE WHEN state = 'open' THEN count ELSE 0 END)::int AS open_count,
|
|
|
|
|
SUM(CASE WHEN state = 'closed' THEN count ELSE 0 END)::int AS closed_count
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT recorded_at::date AS date, bu_ownership, state, count,
|
|
|
|
|
ROW_NUMBER() OVER (
|
|
|
|
|
PARTITION BY recorded_at::date, bu_ownership, state
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
) AS rn
|
|
|
|
|
FROM ivanti_counts_history_by_bu
|
|
|
|
|
WHERE bu_ownership ILIKE ANY($1::text[])
|
|
|
|
|
) sub WHERE rn = 1
|
|
|
|
|
GROUP BY date
|
|
|
|
|
ORDER BY date ASC`,
|
|
|
|
|
[patterns]
|
|
|
|
|
);
|
|
|
|
|
return res.json({ history: rows });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Global history (no filter)
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT date, open_count, closed_count FROM (
|
|
|
|
|
SELECT recorded_at::date AS date,
|
|
|
|
|
open_count, closed_count,
|
|
|
|
|
ROW_NUMBER() OVER (
|
|
|
|
|
PARTITION BY recorded_at::date
|
|
|
|
|
ORDER BY recorded_at DESC
|
|
|
|
|
) AS rn
|
|
|
|
|
FROM ivanti_counts_history
|
|
|
|
|
) sub WHERE rn = 1
|
|
|
|
|
ORDER BY date ASC`
|
|
|
|
|
);
|
2026-04-02 10:12:04 -06:00
|
|
|
res.json({ history: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/fp-workflow-counts
|
|
|
|
|
*
|
|
|
|
|
* Return FP finding counts and unique workflow ID counts (open + closed),
|
|
|
|
|
* broken down by workflow status.
|
2026-05-06 15:19:34 -06:00
|
|
|
* Accepts optional `teams` query parameter to scope to specific BUs.
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
2026-05-06 15:19:34 -06:00
|
|
|
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 200 - { findingCounts: Object, findingTotal: number, idCounts: Object, idTotal: number }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-03-16 11:43:57 -06:00
|
|
|
router.get('/fp-workflow-counts', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-06 15:19:34 -06:00
|
|
|
const teamsParam = req.query.teams;
|
|
|
|
|
let whereExtra = '';
|
|
|
|
|
const params = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (teamsParam) {
|
|
|
|
|
const teams = teamsParam.split(',').map(t => t.trim()).filter(Boolean);
|
|
|
|
|
if (teams.length > 0) {
|
|
|
|
|
const patterns = teams.map(t => `%${t}%`);
|
|
|
|
|
whereExtra = ` AND bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
|
|
|
|
|
params.push(patterns);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Finding counts: number of findings per workflow state
|
|
|
|
|
const findingResult = await pool.query(
|
|
|
|
|
`SELECT workflow_state, COUNT(*) as count
|
|
|
|
|
FROM ivanti_findings
|
|
|
|
|
WHERE workflow_id IS NOT NULL ${whereExtra}
|
|
|
|
|
GROUP BY workflow_state`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
const findingCounts = {};
|
|
|
|
|
findingResult.rows.forEach(r => {
|
|
|
|
|
const state = r.workflow_state || 'Unknown';
|
|
|
|
|
findingCounts[state] = parseInt(r.count);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ID counts: number of unique workflow IDs per state
|
|
|
|
|
const idResult = await pool.query(
|
|
|
|
|
`SELECT workflow_state, COUNT(DISTINCT workflow_id) as count
|
|
|
|
|
FROM ivanti_findings
|
|
|
|
|
WHERE workflow_id IS NOT NULL ${whereExtra}
|
|
|
|
|
GROUP BY workflow_state`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
const idCounts = {};
|
|
|
|
|
idResult.rows.forEach(r => {
|
|
|
|
|
const state = r.workflow_state || 'Unknown';
|
|
|
|
|
idCounts[state] = parseInt(r.count);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-16 12:13:13 -06:00
|
|
|
res.json({
|
|
|
|
|
findingCounts,
|
|
|
|
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
|
|
|
|
idCounts,
|
|
|
|
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
|
|
|
|
});
|
2026-05-06 12:12:34 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /fp-workflow-counts error:', err.message);
|
2026-03-16 11:43:57 -06:00
|
|
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 20:34:34 +00:00
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/anomaly/latest
|
|
|
|
|
*
|
|
|
|
|
* Return the most recent anomaly summary row from ivanti_sync_anomaly_log.
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { anomaly: Object|null }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/anomaly/latest', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT id, sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
|
|
|
|
ORDER BY sync_timestamp DESC LIMIT 1`
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
const row = rows[0];
|
2026-04-24 20:34:34 +00:00
|
|
|
if (!row) return res.json({ anomaly: null });
|
|
|
|
|
let classification = {};
|
|
|
|
|
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
2026-05-01 17:15:41 +00:00
|
|
|
let return_classification = {};
|
|
|
|
|
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
2026-04-24 20:34:34 +00:00
|
|
|
res.json({
|
|
|
|
|
anomaly: {
|
|
|
|
|
id: row.id,
|
|
|
|
|
sync_timestamp: row.sync_timestamp,
|
|
|
|
|
open_count_delta: row.open_count_delta,
|
|
|
|
|
closed_count_delta: row.closed_count_delta,
|
|
|
|
|
newly_archived_count: row.newly_archived_count,
|
|
|
|
|
returned_count: row.returned_count,
|
|
|
|
|
classification,
|
2026-05-01 17:15:41 +00:00
|
|
|
return_classification,
|
2026-04-24 20:34:34 +00:00
|
|
|
is_significant: !!row.is_significant
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /anomaly/latest error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading latest anomaly' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/anomaly/history
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* Return anomaly history. Accepts optional `from` and `to` query parameters.
|
2026-04-24 20:34:34 +00:00
|
|
|
*
|
|
|
|
|
* @query {string} [from] - Inclusive start date (ISO string)
|
|
|
|
|
* @query {string} [to] - Inclusive end date (ISO string)
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { history: Array<Object> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/anomaly/history', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { from, to } = req.query;
|
|
|
|
|
let rows;
|
|
|
|
|
|
|
|
|
|
if (from && to) {
|
2026-05-06 12:12:34 -06:00
|
|
|
const result = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
2026-05-06 12:12:34 -06:00
|
|
|
WHERE sync_timestamp >= $1 AND sync_timestamp <= $2
|
2026-04-24 20:34:34 +00:00
|
|
|
ORDER BY sync_timestamp DESC`,
|
|
|
|
|
[from, to]
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
rows = result.rows;
|
2026-04-24 20:34:34 +00:00
|
|
|
} else {
|
2026-05-06 12:12:34 -06:00
|
|
|
const result = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT sync_timestamp, open_count_delta, closed_count_delta,
|
2026-05-01 17:15:41 +00:00
|
|
|
newly_archived_count, returned_count, classification_json, return_classification_json, is_significant
|
2026-04-24 20:34:34 +00:00
|
|
|
FROM ivanti_sync_anomaly_log
|
|
|
|
|
ORDER BY sync_timestamp DESC LIMIT 30`
|
|
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
rows = result.rows;
|
2026-04-24 20:34:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const history = rows.map(row => {
|
|
|
|
|
let classification = {};
|
|
|
|
|
try { classification = JSON.parse(row.classification_json || '{}'); } catch (_) {}
|
2026-05-01 17:15:41 +00:00
|
|
|
let return_classification = {};
|
|
|
|
|
try { return_classification = JSON.parse(row.return_classification_json || '{}'); } catch (_) {}
|
2026-04-24 20:34:34 +00:00
|
|
|
return {
|
|
|
|
|
sync_timestamp: row.sync_timestamp,
|
|
|
|
|
open_count_delta: row.open_count_delta,
|
|
|
|
|
closed_count_delta: row.closed_count_delta,
|
|
|
|
|
newly_archived_count: row.newly_archived_count,
|
|
|
|
|
returned_count: row.returned_count,
|
|
|
|
|
classification,
|
2026-05-01 17:15:41 +00:00
|
|
|
return_classification,
|
2026-04-24 20:34:34 +00:00
|
|
|
is_significant: !!row.is_significant
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ history });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /anomaly/history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading anomaly history' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/bu-changes
|
|
|
|
|
*
|
2026-06-12 12:12:59 -06:00
|
|
|
* Return BU change events from ivanti_finding_bu_history.
|
|
|
|
|
* Accepts optional `since` to filter by date, or `limit` to cap the result count.
|
|
|
|
|
* If `since` is provided, returns all changes on or after that timestamp.
|
|
|
|
|
* If neither is provided, returns the most recent 200 rows (max 500).
|
2026-04-24 20:34:34 +00:00
|
|
|
*
|
2026-06-12 12:12:59 -06:00
|
|
|
* @query {string} [since] - ISO timestamp; return changes where detected_at >= this value
|
|
|
|
|
* @query {string} [limit] - Maximum number of rows to return (default 200, max 500); ignored when `since` is provided
|
|
|
|
|
* @returns {Object} 200 - { changes: Array<{ id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at }> }
|
2026-04-24 20:34:34 +00:00
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/bu-changes', async (req, res) => {
|
|
|
|
|
try {
|
2026-06-12 12:12:59 -06:00
|
|
|
const { since, limit } = req.query;
|
|
|
|
|
let rows;
|
|
|
|
|
if (since) {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
|
|
|
|
FROM ivanti_finding_bu_history
|
|
|
|
|
WHERE detected_at >= $1
|
|
|
|
|
ORDER BY detected_at DESC`,
|
|
|
|
|
[since]
|
|
|
|
|
);
|
|
|
|
|
rows = result.rows;
|
|
|
|
|
} else {
|
|
|
|
|
const maxRows = Math.min(parseInt(limit) || 200, 500);
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`SELECT id, finding_id, finding_title, host_name, previous_bu, new_bu, detected_at
|
|
|
|
|
FROM ivanti_finding_bu_history
|
|
|
|
|
ORDER BY detected_at DESC
|
|
|
|
|
LIMIT $1`,
|
|
|
|
|
[maxRows]
|
|
|
|
|
);
|
|
|
|
|
rows = result.rows;
|
|
|
|
|
}
|
2026-04-24 20:34:34 +00:00
|
|
|
res.json({ changes: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /bu-changes error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading BU changes' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/ivanti/findings/:findingId/bu-history
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* Return BU change history for a specific finding.
|
2026-04-24 20:34:34 +00:00
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
|
|
|
|
* @returns {Object} 200 - { finding_id: string, history: Array<Object> }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
|
|
|
|
router.get('/:findingId/bu-history', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { findingId } = req.params;
|
2026-05-06 12:12:34 -06:00
|
|
|
const { rows } = await pool.query(
|
2026-04-24 20:34:34 +00:00
|
|
|
`SELECT previous_bu, new_bu, detected_at
|
|
|
|
|
FROM ivanti_finding_bu_history
|
2026-05-06 12:12:34 -06:00
|
|
|
WHERE finding_id = $1
|
2026-04-24 20:34:34 +00:00
|
|
|
ORDER BY detected_at DESC`,
|
|
|
|
|
[findingId]
|
|
|
|
|
);
|
|
|
|
|
res.json({ finding_id: findingId, history: rows });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] GET /:findingId/bu-history error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Database error reading finding BU history' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* PUT /api/ivanti/findings/:findingId/override
|
|
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* Save or clear field overrides for a finding. Requires Admin or Standard_User group.
|
|
|
|
|
* Accepts hostName and/or dns in the body. Empty/null values clear the override.
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
2026-05-06 12:12:34 -06:00
|
|
|
* @body {string} [hostName] - Override for host name; empty/null to clear
|
|
|
|
|
* @body {string} [dns] - Override for DNS; empty/null to clear
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
*
|
2026-05-06 12:12:34 -06:00
|
|
|
* @returns {Object} 200 - { finding_id, overrides: { hostName, dns } }
|
|
|
|
|
* @returns {Object} 404 - { error: string } when finding not found
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-05-06 12:12:34 -06:00
|
|
|
router.put('/:findingId/override', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { findingId } = req.params;
|
|
|
|
|
const { hostName, dns, field, value } = req.body;
|
2026-03-13 15:39:37 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
// Support legacy single-field format: { field: 'hostName', value: 'x' }
|
|
|
|
|
if (field !== undefined) {
|
|
|
|
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
|
|
|
|
if (!OVERRIDE_ALLOWED.includes(field)) {
|
|
|
|
|
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
|
|
|
|
}
|
|
|
|
|
const val = String(value ?? '').trim() || null;
|
|
|
|
|
const col = field === 'hostName' ? 'override_host_name' : 'override_dns';
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_findings SET ${col} = $1 WHERE id = $2`,
|
|
|
|
|
[val, findingId]
|
|
|
|
|
);
|
|
|
|
|
return res.json({ finding_id: findingId, field, value: val });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New multi-field format: { hostName: 'x', dns: 'y' }
|
|
|
|
|
const overrideHostName = hostName !== undefined ? (String(hostName).trim() || null) : undefined;
|
|
|
|
|
const overrideDns = dns !== undefined ? (String(dns).trim() || null) : undefined;
|
2026-03-13 15:39:37 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
if (overrideHostName !== undefined || overrideDns !== undefined) {
|
|
|
|
|
const sets = [];
|
|
|
|
|
const params = [];
|
|
|
|
|
let idx = 1;
|
2026-03-13 15:39:37 -06:00
|
|
|
|
2026-05-06 12:12:34 -06:00
|
|
|
if (overrideHostName !== undefined) {
|
|
|
|
|
sets.push(`override_host_name = $${idx++}`);
|
|
|
|
|
params.push(overrideHostName);
|
2026-03-13 15:39:37 -06:00
|
|
|
}
|
2026-05-06 12:12:34 -06:00
|
|
|
if (overrideDns !== undefined) {
|
|
|
|
|
sets.push(`override_dns = $${idx++}`);
|
|
|
|
|
params.push(overrideDns);
|
2026-03-13 15:39:37 -06:00
|
|
|
}
|
2026-05-06 12:12:34 -06:00
|
|
|
|
|
|
|
|
params.push(findingId);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE ivanti_findings SET ${sets.join(', ')} WHERE id = $${idx}`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return current override state
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT override_host_name, override_dns FROM ivanti_findings WHERE id = $1`,
|
|
|
|
|
[findingId]
|
2026-03-13 15:39:37 -06:00
|
|
|
);
|
2026-05-06 12:12:34 -06:00
|
|
|
if (rows.length === 0) {
|
|
|
|
|
return res.status(404).json({ error: 'Finding not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
finding_id: findingId,
|
|
|
|
|
overrides: {
|
|
|
|
|
...(rows[0].override_host_name ? { hostName: rows[0].override_host_name } : {}),
|
|
|
|
|
...(rows[0].override_dns ? { dns: rows[0].override_dns } : {})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] PUT /:findingId/override error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to save override' });
|
2026-03-13 15:39:37 -06:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Atlas InfoSec action plans integration
Integrate Atlas InfoSec API to manage compliance action plans directly from
the ReportingPage. Users can view, create, and update action plans for host
findings without switching to the Atlas web tool.
Backend:
- Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST
- Add atlas_action_plans_cache migration for SQLite cache table
- Add atlas.js router with sync, status, and proxy CRUD endpoints
- Mount Atlas router at /api/atlas in server.js
- Extract hostId from Ivanti host findings during sync
Frontend:
- Add AtlasBadge component (amber=needs plan, green=has plan)
- Add AtlasSlideOutPanel with plan list, create form, edit capability
- Separate active plans from inactive history in collapsible section
- Custom dark-themed plan type dropdown
- Optimistic local state shows pending plans immediately after creation
- Atlas sync button on ReportingPage toolbar
- Prepopulate finding ID in create form from clicked row
Environment:
- Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
2026-04-23 21:52:53 +00:00
|
|
|
/**
|
|
|
|
|
* PUT /api/ivanti/findings/:findingId/note
|
|
|
|
|
*
|
|
|
|
|
* Save or update a note for a finding (max 255 characters).
|
|
|
|
|
* Requires Admin or Standard_User group.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} findingId - The finding identifier (URL param)
|
|
|
|
|
* @body {string} [note] - The note text (truncated to 255 chars)
|
|
|
|
|
*
|
|
|
|
|
* @returns {Object} 200 - { finding_id: string, note: string }
|
|
|
|
|
* @returns {Object} 500 - { error: string } on database error
|
|
|
|
|
*/
|
2026-05-06 12:12:34 -06:00
|
|
|
router.put('/:findingId/note', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { findingId } = req.params;
|
|
|
|
|
const note = String(req.body.note || '').slice(0, 255);
|
|
|
|
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
'UPDATE ivanti_findings SET note = $1 WHERE id = $2',
|
|
|
|
|
[note, findingId]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
res.json({ finding_id: findingId, note });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Ivanti Findings] PUT /:findingId/note error:', err.message);
|
|
|
|
|
res.status(500).json({ error: 'Failed to save note' });
|
|
|
|
|
}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = createIvantiFindingsRouter;
|
2026-04-03 15:20:04 -06:00
|
|
|
module.exports.detectArchiveChanges = detectArchiveChanges;
|
|
|
|
|
module.exports.detectClosedFindings = detectClosedFindings;
|
2026-04-24 20:34:34 +00:00
|
|
|
module.exports.runBUDriftChecker = runBUDriftChecker;
|
|
|
|
|
module.exports.computeAnomalySummary = computeAnomalySummary;
|
|
|
|
|
module.exports.extractFinding = extractFinding;
|
2026-05-06 12:12:34 -06:00
|
|
|
module.exports.upsertFindingsBatch = upsertFindingsBatch;
|