Compare commits
3 Commits
b111273e5a
...
5102a2c5b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 |
@@ -175,6 +175,15 @@ function initTables(db) {
|
|||||||
db.run(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
ON ivanti_finding_overrides(finding_id)
|
ON ivanti_finding_overrides(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
@@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|||||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
[openCount, closedCount]
|
[openCount, closedCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Append a snapshot to history — every sync is stored; the history
|
||||||
|
// endpoint aggregates to last-per-day at query time (Option B).
|
||||||
|
await dbRun(db,
|
||||||
|
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
@@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||||
|
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||||
|
router.get('/counts/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT date, open_count, closed_count FROM (
|
||||||
|
SELECT DATE(recorded_at) AS date,
|
||||||
|
open_count, closed_count,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY DATE(recorded_at)
|
||||||
|
ORDER BY recorded_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ivanti_counts_history
|
||||||
|
) WHERE rn = 1
|
||||||
|
ORDER BY date ASC`,
|
||||||
|
[],
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.json({ history: rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
router.get('/fp-workflow-counts', async (req, res) => {
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Renders natively in GitHub, GitLab, and most modern documentation tools.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
START([Open Reporting Page]) --> SYNC
|
START([Open Vulnerability Triage Page]) --> SYNC
|
||||||
|
|
||||||
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||||
SYNC --> DUE{Overdue<br/>findings?}
|
SYNC --> DUE{Overdue<br/>findings?}
|
||||||
@@ -94,11 +94,11 @@ flowchart TD
|
|||||||
|
|
||||||
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
||||||
|
|
||||||
What to do when a finding already has a workflow badge in the Reporting page.
|
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A([Finding in<br/>Reporting Page]) --> B{"Check<br/>Workflow column"}
|
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
|
||||||
|
|
||||||
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||||
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
||||||
|
|||||||
158
docs/team-training-agenda.md
Normal file
158
docs/team-training-agenda.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# STEAM Security Dashboard — Team Training Agenda
|
||||||
|
|
||||||
|
**Session length:** 30–40 minutes
|
||||||
|
**Format:** Live walkthrough (share your screen on the dashboard)
|
||||||
|
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-meeting prep
|
||||||
|
|
||||||
|
- Have the dashboard open and logged in before the meeting starts
|
||||||
|
- Sync Vulnerability Triage page so data is fresh when you get there
|
||||||
|
- Print or share `security-posture-workflow.md` as a take-home reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 1 — Why this tool exists (3 min)
|
||||||
|
|
||||||
|
**Talking points:**
|
||||||
|
- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for
|
||||||
|
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
|
||||||
|
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
|
||||||
|
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 2 — Dashboard orientation (4 min)
|
||||||
|
|
||||||
|
**Show on screen:** Navigate through each page in the nav drawer
|
||||||
|
|
||||||
|
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
|
||||||
|
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
|
||||||
|
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
|
||||||
|
- **Knowledge Base** — internal docs, runbooks, advisories
|
||||||
|
- **Exports** — bulk data extracts when needed
|
||||||
|
|
||||||
|
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 3 — The three things you can do with a finding (5 min)
|
||||||
|
|
||||||
|
**Talking points — before showing the table, set context:**
|
||||||
|
|
||||||
|
Every finding in our range gets one of three designations:
|
||||||
|
|
||||||
|
1. **Remediation** — you fix the root cause
|
||||||
|
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
|
||||||
|
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
|
||||||
|
|
||||||
|
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
|
||||||
|
- Requires an FP workflow opened in Ivanti
|
||||||
|
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
|
||||||
|
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
|
||||||
|
|
||||||
|
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
|
||||||
|
- Vendor hasn't released a patch yet
|
||||||
|
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
|
||||||
|
- Business constraint — needs justification and compensating controls
|
||||||
|
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
|
||||||
|
|
||||||
|
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
|
||||||
|
|
||||||
|
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
|
||||||
|
|
||||||
|
### Step 1 — Sync and sort (1 min)
|
||||||
|
- Click **Sync** top-right, wait for timestamp to update
|
||||||
|
- Click **Due Date** column to sort ascending — reds first, then ambers
|
||||||
|
- Red = overdue, Amber = due within 30 days — work these first
|
||||||
|
|
||||||
|
### Step 2 — Identify the host (3 min)
|
||||||
|
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
|
||||||
|
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
|
||||||
|
- An amber dot appears on overridden cells; original value is preserved and can be restored
|
||||||
|
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
|
||||||
|
|
||||||
|
### Step 3 — Check who owns the asset (2 min)
|
||||||
|
- Look at the **BU column**
|
||||||
|
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
|
||||||
|
- Anything else (or blank) → not ours → **CARD queue**
|
||||||
|
- Check the row checkbox, select CARD, click Add to Queue
|
||||||
|
- IP address is captured automatically for the CARD search
|
||||||
|
- Process CARD items in a separate session
|
||||||
|
|
||||||
|
### Step 4 — Look up the CVEs (4 min)
|
||||||
|
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
|
||||||
|
- Go to Home page, search for the CVE ID
|
||||||
|
- If it exists → review existing notes, docs, and any EXC numbers already linked
|
||||||
|
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
|
||||||
|
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
|
||||||
|
|
||||||
|
### Step 5 — Take action (5 min)
|
||||||
|
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
|
||||||
|
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
|
||||||
|
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
|
||||||
|
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 5 — The Ivanti Queue (5 min)
|
||||||
|
|
||||||
|
**Show on screen:** Click the Queue button, show the panel
|
||||||
|
|
||||||
|
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
|
||||||
|
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
|
||||||
|
- CARD items show the IP address so you can search directly in CARD
|
||||||
|
- Check the green checkbox on an item when the Ivanti/Archer action is done
|
||||||
|
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
|
||||||
|
- Queue is **personal to your login** — each person has their own; it persists across sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 6 — Workflow badge colours (3 min)
|
||||||
|
|
||||||
|
**Show on screen:** Workflow column on the Vulnerability Triage table
|
||||||
|
|
||||||
|
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
|
||||||
|
|
||||||
|
| Badge | What it means | What to do |
|
||||||
|
|---|---|---|
|
||||||
|
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
|
||||||
|
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
|
||||||
|
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
|
||||||
|
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
|
||||||
|
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
|
||||||
|
| No badge | Never been triaged | Run it through the 5-step workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 7 — Quick tips (2 min)
|
||||||
|
|
||||||
|
Quick features worth pointing out before Q&A:
|
||||||
|
|
||||||
|
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
|
||||||
|
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
|
||||||
|
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
|
||||||
|
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segment 8 — Q&A (remaining time)
|
||||||
|
|
||||||
|
Suggested prompts to open discussion if no questions come up:
|
||||||
|
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
|
||||||
|
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
|
||||||
|
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Takeaway for the team
|
||||||
|
|
||||||
|
Point them to:
|
||||||
|
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
|
||||||
|
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners
|
||||||
@@ -10,7 +10,7 @@ import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
|||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import CalendarWidget from './components/CalendarWidget';
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
import ReportingPage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
import CompliancePage from './components/pages/CompliancePage';
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
@@ -966,14 +966,14 @@ export default function App() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
// Clear contextual filters when navigating directly via the nav drawer
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
|
|
||||||
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex justify-between items-start mb-6">
|
||||||
@@ -1043,7 +1043,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
@@ -2231,7 +2231,7 @@ export default function App() {
|
|||||||
<CalendarWidget
|
<CalendarWidget
|
||||||
onDateClick={(dateStr) => {
|
onDateClick={(dateStr) => {
|
||||||
setCalendarFilter(dateStr);
|
setCalendarFilter(dateStr);
|
||||||
setCurrentPage('reporting');
|
setCurrentPage('triage');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2337,7 +2337,7 @@ export default function App() {
|
|||||||
</a>
|
</a>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
|
||||||
title="View findings referencing this ticket"
|
title="View findings referencing this ticket"
|
||||||
className="text-gray-400 hover:text-sky-400 transition-colors"
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-reac
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||||
{ id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' },
|
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
|||||||
</div>
|
</div>
|
||||||
{onNavigate && (
|
{onNavigate && (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onNavigate('reporting'); }}
|
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0, marginLeft: '0.5rem',
|
flexShrink: 0, marginLeft: '0.5rem',
|
||||||
background: 'rgba(14,165,233,0.1)',
|
background: 'rgba(14,165,233,0.1)',
|
||||||
@@ -348,7 +348,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
|||||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||||
>
|
>
|
||||||
View in Reporting →
|
View in Triage →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// IvantiCountsChart.js
|
||||||
|
// Collapsible trend panel for the Vulnerability Triage page.
|
||||||
|
// Shows open vs closed Ivanti finding counts over time (last sync per day).
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend, ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const AMBER = '#F59E0B';
|
||||||
|
const SKY = '#0EA5E9';
|
||||||
|
const GREEN = '#10B981';
|
||||||
|
const RED = '#EF4444';
|
||||||
|
|
||||||
|
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||||
|
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||||
|
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom dark tooltip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function DarkTooltip({ active, payload, label }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
|
||||||
|
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(10,17,32,0.97)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
minWidth: '160px',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{payload.map(p => (
|
||||||
|
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||||
|
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{openVal != null && closedVal != null && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<span>total</span>
|
||||||
|
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shorten YYYY-MM-DD to MM/DD/YY
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const p = d.split('-');
|
||||||
|
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function IvantiCountsChart() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
||||||
|
if (res.ok && !cancelled) {
|
||||||
|
const d = await res.json();
|
||||||
|
setHistory(d.history || []);
|
||||||
|
}
|
||||||
|
} catch { /* silent — chart shows no-data state */ }
|
||||||
|
finally { if (!cancelled) setLoading(false); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
||||||
|
[history]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute a simple delta label for the latest vs previous point
|
||||||
|
const deltaLabel = useMemo(() => {
|
||||||
|
if (chartData.length < 2) return null;
|
||||||
|
const latest = chartData[chartData.length - 1];
|
||||||
|
const prev = chartData[chartData.length - 2];
|
||||||
|
const delta = latest.open_count - prev.open_count;
|
||||||
|
if (delta === 0) return { text: 'no change in open', color: '#475569' };
|
||||||
|
if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
|
||||||
|
return { text: `▲ ${delta} open since ${prev.date}`, color: RED };
|
||||||
|
}, [chartData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '0 0 0.625rem 0',
|
||||||
|
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
|
||||||
|
marginBottom: collapsed ? 0 : '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
Findings Trend
|
||||||
|
</span>
|
||||||
|
{loading && (
|
||||||
|
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{!loading && deltaLabel && (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
||||||
|
{deltaLabel.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed
|
||||||
|
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem 1.25rem 0.875rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
Open vs Closed — end-of-day snapshot per sync day
|
||||||
|
</div>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||||
|
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartData.length < 2 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '160px', color: '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
{chartData.length === 0
|
||||||
|
? 'Trend data begins accumulating after the first sync — check back tomorrow'
|
||||||
|
: 'Need at least 2 days of syncs to display a trend'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||||
|
<CartesianGrid {...GRID_STYLE} />
|
||||||
|
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||||
|
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||||
|
<Tooltip content={<DarkTooltip />} />
|
||||||
|
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="open_count" name="Open"
|
||||||
|
stroke={AMBER} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone" dataKey="closed_count" name="Closed"
|
||||||
|
stroke={SKY} strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import IvantiCountsChart from './IvantiCountsChart';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
@@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage({ filterDate, filterEXC }) {
|
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||||
const { canWrite } = useAuth();
|
const { canWrite } = useAuth();
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
@@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ----------------------------------------------------------------
|
||||||
|
Panel 1.5 — Open vs Closed trend over time
|
||||||
|
---------------------------------------------------------------- */}
|
||||||
|
<IvantiCountsChart />
|
||||||
|
|
||||||
{/* ----------------------------------------------------------------
|
{/* ----------------------------------------------------------------
|
||||||
Panel 2 — Findings table
|
Panel 2 — Findings table
|
||||||
---------------------------------------------------------------- */}
|
---------------------------------------------------------------- */}
|
||||||
|
|||||||
Reference in New Issue
Block a user