diff --git a/.gitignore b/.gitignore index 20107c3..9e9a9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,10 +39,6 @@ frontend.pid backend/uploads/temp/ feature_request*.md -# Planning docs -docs/aeo-compliance-ui-plan.md -docs/aeo-compliance-wireframe.md - # AI tooling config .claude/ ai_notes.md @@ -59,28 +55,20 @@ backend/setup.js-backup # Kiro agents (local only) .kiro/agents/ -# Kiro implementation summary (internal only) -docs/kiro-implementation-summary.md - -# Diagnostic scripts (troubleshooting only) -backend/scripts/drift-check.js -backend/scripts/bu-reassignment-check.js -backend/scripts/export-reassigned-findings.js - -# Investigation exports -docs/reassigned-findings-*.xlsx - # Zip files *.zip -# Docs — local/staging files -docs/card-lookup-results.csv -docs/card-prod-archer-firewall-request.md -docs/granite-reassignment-upload.csv -docs/granite-reassignment-upload.xlsx - # Production DB copies cve_database_prod.db cve_database.db.prod cve_database.db.backup database.db + +# Operations — local admin records, UAT logs, firewall requests, data exports +docs/operations/ + +# Data exports — local spreadsheets +docs/data-exports/ + +# Python cache +__pycache__/ diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..c2f4068 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,41 @@ +# Database Migrations + +These migration scripts were used to evolve the database schema during development. **They are NOT needed for fresh deployments** — `setup.js` contains the complete v1.0.0 schema. + +These are retained for reference and for upgrading existing deployments that were set up before v1.0.0. + +## Schema Migrations (run in order for existing deployments) + +| Script | Purpose | +|--------|---------| +| `add_ivanti_sync_table.js` | Creates `ivanti_sync_state` table for tracking Ivanti sync status | +| `add_ivanti_findings_tables.js` | Creates `ivanti_findings_cache`, `ivanti_finding_notes`, `ivanti_counts_cache`, `ivanti_finding_overrides` tables | +| `add_ivanti_counts_history_table.js` | Creates `ivanti_counts_history` table for trend chart data | +| `add_ivanti_todo_queue_table.js` | Creates `ivanti_todo_queue` table for FP/Archer workflow queuing | +| `add_todo_queue_hostname.js` | Adds `hostname` column to `ivanti_todo_queue` | +| `add_todo_queue_ip_address.js` | Adds `ip_address` column to `ivanti_todo_queue` | +| `add_fp_submissions_table.js` | Creates `ivanti_fp_submissions` table for false positive workflow tracking | +| `add_fp_submission_editing.js` | Adds `lifecycle_status`, `ivanti_workflow_batch_uuid`, `updated_at` columns and `ivanti_fp_submission_history` table | +| `add_knowledge_base_table.js` | Creates `knowledge_base` table for KB article storage | +| `add_user_groups.js` | Adds `user_group` column to `users` table with validation triggers | +| `add_created_by_columns.js` | Adds `created_by` column to `compliance_notes` and `knowledge_base` tables | +| `add_compliance_tables.js` | Creates `compliance_uploads`, `compliance_items`, `compliance_notes` tables | +| `add_compliance_notes_group_id.js` | Adds `group_id` column to `compliance_notes` for multi-metric note grouping | +| `add_archer_tickets_table.js` | Creates `archer_tickets` table for Archer exception tracking | +| `add_archer_tickets_timestamps.js` | Adds `created_at` and `updated_at` columns to `archer_tickets` | +| `add_jira_sync_columns.js` | Adds Jira sync-related columns to `jira_tickets` | +| `add_card_workflow_type.js` | Adds `CARD` to `workflow_type` CHECK constraint on `ivanti_todo_queue` | +| `add_granite_workflow_type.js` | Adds `GRANITE` to `workflow_type` CHECK constraint on `ivanti_todo_queue` | +| `add_finding_archive_tables.js` | Creates `ivanti_finding_archives` and `ivanti_archive_transitions` tables | +| `add_closed_gone_state.js` | Adds `CLOSED_GONE` to `current_state` CHECK constraint on `ivanti_finding_archives` | +| `add_sync_anomaly_tables.js` | Creates `ivanti_sync_anomaly_log` and `ivanti_finding_bu_history` tables | +| `add_atlas_action_plans_cache.js` | Creates `atlas_action_plans_cache` table for Atlas API caching | +| `add_return_classification.js` | Adds `return_classification_json` column to `ivanti_sync_anomaly_log` | + +## Data Migrations (one-time backfills) + +| Script | Purpose | +|--------|---------| +| `backfill_anomaly_log.js` | Synthesizes anomaly log entries from existing archive transitions for historical chart data | +| `backfill_return_classification.js` | Populates `return_classification_json` for existing anomaly rows with returned findings. Supports `--force` flag to re-run. | +| `reclassify_bu_roundtrips.js` | Reclassifies archive transitions that were BU reassignment round-trips (archived then returned within 14 days) from the default `severity_score_drift` to `bu_reassignment` | diff --git a/backend/scripts/__pycache__/extract_xlsx_schema.cpython-312.pyc b/backend/scripts/__pycache__/extract_xlsx_schema.cpython-312.pyc deleted file mode 100644 index 5acef26..0000000 Binary files a/backend/scripts/__pycache__/extract_xlsx_schema.cpython-312.pyc and /dev/null differ diff --git a/backend/scripts/card-uat-test.js b/backend/scripts/card-uat-test.js deleted file mode 100644 index e707326..0000000 --- a/backend/scripts/card-uat-test.js +++ /dev/null @@ -1,486 +0,0 @@ -#!/usr/bin/env node -// ========================================================================== -// CARD API UAT Test Script -// ========================================================================== -// Exercises every CARD REST API use case the STEAM Dashboard will run in -// production. Run this against the UAT instance to verify the service -// account has been onboarded and all endpoints are accessible. -// -// Usage: -// cd backend -// node scripts/card-uat-test.js # auto-discovers NTS-AEO-STEAM -// node scripts/card-uat-test.js NTS-ACCESS-ENG # target a specific team -// -// Prerequisites: -// - backend/.env has CARD_API_URL pointing to UAT -// (https://card.caas.stage.charterlab.com) -// - CARD_API_USER / CARD_API_PASS set to service account credentials -// - CARD_SKIP_TLS=true if behind Charter's SSL inspection proxy -// - Service account has been onboarded with the CARD team -// -// The script logs every API call, response status, and timing to both -// console and a log file at backend/scripts/card-uat-test.log. -// ========================================================================== - -require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); - -const fs = require('fs'); -const path = require('path'); -const cardApi = require('../helpers/cardApi'); - -const LOG_FILE = path.join(__dirname, 'card-uat-test.log'); -const results = []; - -// CLI: optional team name override (e.g. node scripts/card-uat-test.js NTS-ACCESS-ENG) -const CLI_TEAM = process.argv[2] || null; - -// State carried between tests -let discoveredTeam = null; -let discoveredAssetId = null; -let discoveredUpdateToken = null; - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- -function log(level, message, data) { - const timestamp = new Date().toISOString(); - const entry = { timestamp, level, message }; - if (data !== undefined) entry.data = data; - results.push(entry); - - const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`; - console.log(line); - if (data) { - const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - const truncated = dataStr.length > 2000 - ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' - : dataStr; - console.log(' ' + truncated.split('\n').join('\n ')); - } -} - -function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); } -function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); } -function logInfo(message, data) { log('info', message, data); } -function logWarn(message, data) { log('warn', message, data); } - -// --------------------------------------------------------------------------- -// Test runner -// --------------------------------------------------------------------------- -async function runTest(name, fn) { - logInfo(`--- Running: ${name} ---`); - const start = Date.now(); - try { - await fn(); - logPass(name, { durationMs: Date.now() - start }); - return true; - } catch (err) { - logFail(name, { error: err.message, durationMs: Date.now() - start }); - return false; - } -} - -function assert(condition, message) { - if (!condition) throw new Error('Assertion failed: ' + message); -} - -// --------------------------------------------------------------------------- -// Use Case 1: Token Acquisition (GET /api/v1/auth/get_token) -// Production use: Automatic — every CARD API call acquires/reuses a token -// --------------------------------------------------------------------------- -async function testTokenAcquisition() { - const result = await cardApi.testConnection(); - assert(result.ok, 'Token acquisition should succeed. Got: ' + JSON.stringify(result)); - logInfo('Token acquired (truncated):', result.token); -} - -// --------------------------------------------------------------------------- -// Use Case 2: List Teams (GET /api/v1/teams) -// Production use: Populate team dropdowns in Confirm/Decline/Redirect forms -// --------------------------------------------------------------------------- -async function testListTeams() { - const result = await cardApi.getTeams(); - assert(result.ok, 'List teams should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - - let teams; - try { - teams = JSON.parse(result.body); - } catch (_) { - teams = result.body; - } - - const teamList = Array.isArray(teams) ? teams : (teams && teams.teams) || []; - logInfo('Teams returned:', { count: teamList.length, sample: teamList.slice(0, 10) }); - - // Extract team name — CARD API uses card_team_name or _id - function extractTeamName(t) { - if (typeof t === 'string') return t; - return t.card_team_name || t._id || t.name || t.teamName || ''; - } - - // If CLI specified a team, use it directly; otherwise auto-discover - if (CLI_TEAM && teamList.length > 0) { - const cliUpper = CLI_TEAM.toUpperCase(); - const match = teamList.find(t => extractTeamName(t).toUpperCase() === cliUpper); - if (match) { - discoveredTeam = extractTeamName(match); - logInfo('Using CLI-specified team:', discoveredTeam); - } else { - // Fuzzy: check if any team contains the CLI string - const fuzzy = teamList.find(t => extractTeamName(t).toUpperCase().includes(cliUpper)); - if (fuzzy) { - discoveredTeam = extractTeamName(fuzzy); - logInfo('CLI team "' + CLI_TEAM + '" not exact — fuzzy matched:', discoveredTeam); - } else { - logWarn('CLI team "' + CLI_TEAM + '" not found in ' + teamList.length + ' teams. Falling back to auto-discover.'); - } - } - } - - // Auto-discover if CLI didn't resolve - if (!discoveredTeam && teamList.length > 0) { - const steamTeam = teamList.find(t => { - const name = extractTeamName(t); - return name.includes('NTS-AEO-STEAM') || name.includes('STEAM'); - }); - discoveredTeam = steamTeam - ? extractTeamName(steamTeam) - : extractTeamName(teamList[0]); - logInfo('Using team for subsequent tests:', discoveredTeam); - } - - assert(teamList.length > 0, 'Should return at least one team'); -} - -// --------------------------------------------------------------------------- -// Use Case 3: List Team Assets (GET /api/v1/team/{teamName}/assets) -// Production use: Asset search UI — find Granite IDs for reassigned assets -// NOTE: CARD API requires a disposition filter — unfiltered calls return 500. -// --------------------------------------------------------------------------- -async function testListTeamAssets() { - assert(discoveredTeam, 'Need a team from previous test'); - - // CARD API requires disposition — use 'confirmed' as the default - const result = await cardApi.getTeamAssets(discoveredTeam, { disposition: 'confirmed', pageSize: 10 }); - assert(result.ok, 'List team assets should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - - let data; - try { - data = JSON.parse(result.body); - } catch (_) { - data = result.body; - } - - const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || []; - const total = data && data.total !== undefined ? data.total : assets.length; - logInfo('Team assets (confirmed):', { team: discoveredTeam, total, returned: assets.length, sample: assets.slice(0, 3) }); - - // Grab first asset ID for owner lookup test - if (assets.length > 0) { - const first = assets[0]; - discoveredAssetId = first.asset_id || first.assetId || first.id || first.ipn || first._id || null; - if (typeof first === 'string') discoveredAssetId = first; - logInfo('Using asset for subsequent tests:', discoveredAssetId); - } -} - -// --------------------------------------------------------------------------- -// Use Case 4: List Team Assets with Disposition Filter -// Production use: Filter assets by confirmed/unconfirmed/declined/candidate -// --------------------------------------------------------------------------- -async function testListTeamAssetsFiltered() { - assert(discoveredTeam, 'Need a team from previous test'); - - const dispositions = ['confirmed', 'unconfirmed', 'declined', 'candidate']; - for (const disposition of dispositions) { - const result = await cardApi.getTeamAssets(discoveredTeam, { disposition, pageSize: 5 }); - let count = '?'; - try { - const data = JSON.parse(result.body); - const assets = Array.isArray(data) ? data : (data && data.assets) || (data && data.results) || []; - count = data && data.total !== undefined ? data.total : assets.length; - } catch (_) { /* ignore parse errors */ } - - logInfo(` ${disposition}: HTTP ${result.status}, count=${count}`); - - // We don't assert success here — some dispositions may return 0 results - // but the endpoint should still respond with 200 - assert( - result.status >= 200 && result.status < 500, - `${disposition} filter should not return server error. Got HTTP ${result.status}` - ); - } -} - -// --------------------------------------------------------------------------- -// Use Case 5: Get Owner Record (GET /api/v1/owner/{assetId}) -// Production use: Retrieve update_token before confirm/decline/redirect -// --------------------------------------------------------------------------- -async function testGetOwner() { - assert(discoveredAssetId, 'Need an asset ID from previous test'); - - const result = await cardApi.getOwner(discoveredAssetId); - assert(result.ok, 'Get owner should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - - let ownerData; - try { - ownerData = JSON.parse(result.body); - } catch (_) { - ownerData = result.body; - } - - logInfo('Owner record:', ownerData); - - // Extract update_token — CARD nests it inside owner object - const updateToken = (ownerData && ownerData.owner && ownerData.owner.update_token) - || (ownerData && ownerData.update_token) - || null; - - if (updateToken) { - discoveredUpdateToken = updateToken; - logInfo('update_token acquired:', discoveredUpdateToken); - } else { - logWarn('No update_token in owner response — mutation tests will be skipped'); - } -} - -// --------------------------------------------------------------------------- -// Use Case 6: Token Reuse (verify caching works) -// Production use: Consecutive API calls should reuse the cached token -// --------------------------------------------------------------------------- -async function testTokenReuse() { - // Make two rapid calls — second should reuse the cached token - const start1 = Date.now(); - const r1 = await cardApi.getTeams(); - const dur1 = Date.now() - start1; - - const start2 = Date.now(); - const r2 = await cardApi.getTeams(); - const dur2 = Date.now() - start2; - - assert(r1.ok, 'First call should succeed'); - assert(r2.ok, 'Second call should succeed'); - - logInfo('Token reuse timing:', { firstCallMs: dur1, secondCallMs: dur2 }); - // Second call should generally be faster (no token acquisition), but we - // don't assert timing — just log it for review -} - -// --------------------------------------------------------------------------- -// Use Case 7: Confirm Asset (POST /api/v2/owner/{assetId}/confirm) -// Production use: User clicks "Confirm" on a CARD queue item -// NOTE: This is a MUTATION — only runs if we have a valid update_token -// and the asset is in a confirmable state. May fail in UAT if the -// asset state doesn't allow confirmation. That's expected. -// --------------------------------------------------------------------------- -async function testConfirmAsset() { - assert(discoveredAssetId, 'Need an asset ID'); - assert(discoveredTeam, 'Need a team name'); - - if (!discoveredUpdateToken) { - logWarn('Skipping confirm test — no update_token available'); - return; - } - - // Re-fetch update_token to ensure it's current - const ownerRes = await cardApi.getOwner(discoveredAssetId); - assert(ownerRes.ok, 'Owner re-fetch should succeed'); - const ownerData = JSON.parse(ownerRes.body); - const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token; - assert(token, 'Should have update_token for confirm'); - - const result = await cardApi.confirmAsset( - discoveredAssetId, - discoveredTeam, - token, - 'STEAM Dashboard UAT test — confirm' - ); - - logInfo('Confirm result:', { status: result.status, body: (result.body || '').substring(0, 500) }); - - // Accept 200-299 as success, but also accept 400/409 (asset may already - // be confirmed or in a state that doesn't allow confirmation in UAT) - if (result.ok) { - logInfo('Confirm succeeded'); - } else if (result.status === 400 || result.status === 409 || result.status === 422) { - logWarn('Confirm returned ' + result.status + ' — asset may already be in confirmed state (expected in UAT)'); - } else { - assert(false, 'Confirm returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - } -} - -// --------------------------------------------------------------------------- -// Use Case 8: Decline Asset (POST /api/v2/owner/{assetId}/decline) -// Production use: User clicks "Decline" on a CARD queue item -// --------------------------------------------------------------------------- -async function testDeclineAsset() { - assert(discoveredAssetId, 'Need an asset ID'); - assert(discoveredTeam, 'Need a team name'); - - if (!discoveredUpdateToken) { - logWarn('Skipping decline test — no update_token available'); - return; - } - - // Re-fetch update_token - const ownerRes = await cardApi.getOwner(discoveredAssetId); - assert(ownerRes.ok, 'Owner re-fetch should succeed'); - const ownerData = JSON.parse(ownerRes.body); - const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token; - assert(token, 'Should have update_token for decline'); - - const result = await cardApi.declineAsset( - discoveredAssetId, - discoveredTeam, - token, - 'STEAM Dashboard UAT test — decline' - ); - - logInfo('Decline result:', { status: result.status, body: (result.body || '').substring(0, 500) }); - - if (result.ok) { - logInfo('Decline succeeded'); - } else if (result.status === 400 || result.status === 409 || result.status === 422) { - logWarn('Decline returned ' + result.status + ' — asset may not be in a declinable state (expected in UAT)'); - } else { - assert(false, 'Decline returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - } -} - -// --------------------------------------------------------------------------- -// Use Case 9: Redirect Asset (POST /api/v2/owner/{assetId}/{from}/redirect) -// Production use: User clicks "Redirect" on a CARD queue item -// NOTE: Requires two different teams. We'll attempt it but expect it may -// fail in UAT if only one team is available. -// --------------------------------------------------------------------------- -async function testRedirectAsset() { - assert(discoveredAssetId, 'Need an asset ID'); - assert(discoveredTeam, 'Need a team name'); - - if (!discoveredUpdateToken) { - logWarn('Skipping redirect test — no update_token available'); - return; - } - - // We need a second team for redirect. Try to find one from the teams list. - const teamsRes = await cardApi.getTeams(); - let teams = []; - try { - const parsed = JSON.parse(teamsRes.body); - teams = Array.isArray(parsed) ? parsed : (parsed.teams || []); - } catch (_) { /* ignore */ } - - const teamNames = teams.map(t => typeof t === 'string' ? t : (t.card_team_name || t._id || t.name || t.teamName || '')); - const otherTeam = teamNames.find(t => t && t !== discoveredTeam); - - if (!otherTeam) { - logWarn('Only one team available — cannot test redirect (requires from and to teams)'); - return; - } - - logInfo('Redirect test:', { from: discoveredTeam, to: otherTeam }); - - // Re-fetch update_token - const ownerRes = await cardApi.getOwner(discoveredAssetId); - assert(ownerRes.ok, 'Owner re-fetch should succeed'); - const ownerData = JSON.parse(ownerRes.body); - const token = (ownerData.owner && ownerData.owner.update_token) || ownerData.update_token; - assert(token, 'Should have update_token for redirect'); - - const result = await cardApi.redirectAsset( - discoveredAssetId, - discoveredTeam, - otherTeam, - token - ); - - logInfo('Redirect result:', { status: result.status, body: (result.body || '').substring(0, 500) }); - - if (result.ok) { - logInfo('Redirect succeeded'); - } else if (result.status === 400 || result.status === 409 || result.status === 422) { - logWarn('Redirect returned ' + result.status + ' — asset may not be in a redirectable state (expected in UAT)'); - } else { - assert(false, 'Redirect returned unexpected HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - } -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -async function main() { - logInfo('=== STEAM Dashboard — CARD API UAT Test Run ==='); - logInfo('Timestamp: ' + new Date().toISOString()); - logInfo('CARD_API_URL: ' + (process.env.CARD_API_URL || '(not set)')); - logInfo('CARD_API_USER: ' + (process.env.CARD_API_USER || '(not set)')); - logInfo('CARD_SKIP_TLS: ' + (process.env.CARD_SKIP_TLS || 'false')); - logInfo('isConfigured: ' + cardApi.isConfigured); - logInfo(''); - - if (!cardApi.isConfigured) { - logFail('Pre-flight check', { - error: 'CARD API is not configured. Set CARD_API_URL, CARD_API_USER, and CARD_API_PASS in backend/.env', - missing: cardApi.missingVars, - }); - writeLog(); - process.exit(1); - } - - let passed = 0; - let failed = 0; - - // Read-only tests first (safe to run in any environment) - if (await runTest('1. Token Acquisition (GET /auth/get_token)', testTokenAcquisition)) passed++; else failed++; - if (await runTest('2. List Teams (GET /teams)', testListTeams)) passed++; else failed++; - if (await runTest('3. List Team Assets (GET /team/{name}/assets)', testListTeamAssets)) passed++; else failed++; - if (await runTest('4. List Team Assets — Disposition Filters', testListTeamAssetsFiltered)) passed++; else failed++; - if (await runTest('5. Get Owner Record (GET /owner/{assetId})', testGetOwner)) passed++; else failed++; - if (await runTest('6. Token Reuse (caching verification)', testTokenReuse)) passed++; else failed++; - - // Mutation tests — these modify asset state in CARD - logInfo(''); - logInfo('=== Mutation Tests (modify asset state) ==='); - logInfo('These tests exercise confirm/decline/redirect. They may return'); - logInfo('4xx if the asset is not in the correct state — that is expected.'); - logInfo(''); - - if (await runTest('7. Confirm Asset (POST /owner/{id}/confirm)', testConfirmAsset)) passed++; else failed++; - if (await runTest('8. Decline Asset (POST /owner/{id}/decline)', testDeclineAsset)) passed++; else failed++; - if (await runTest('9. Redirect Asset (POST /owner/{id}/{from}/redirect)', testRedirectAsset)) passed++; else failed++; - - logInfo(''); - logInfo('=== Summary ==='); - logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`); - if (discoveredTeam) logInfo('Team used: ' + discoveredTeam); - if (discoveredAssetId) logInfo('Asset used: ' + discoveredAssetId); - - writeLog(); - - if (failed > 0) { - console.log('\nSome tests failed. Review the log above and card-uat-test.log for details.'); - process.exit(1); - } else { - console.log('\nAll tests passed. Log saved to backend/scripts/card-uat-test.log'); - process.exit(0); - } -} - -function writeLog() { - const lines = results.map(r => { - let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`; - if (r.data) { - const dataStr = typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2); - const truncated = dataStr.length > 2000 - ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' - : dataStr; - line += '\n ' + truncated.split('\n').join('\n '); - } - return line; - }); - fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8'); -} - -main().catch(err => { - console.error('Unhandled error:', err); - process.exit(1); -}); diff --git a/backend/scripts/jira-uat-test.js b/backend/scripts/jira-uat-test.js deleted file mode 100644 index 62f92bb..0000000 --- a/backend/scripts/jira-uat-test.js +++ /dev/null @@ -1,410 +0,0 @@ -#!/usr/bin/env node -// ========================================================================== -// Jira UAT Test Script -// ========================================================================== -// Exercises every Jira REST API use case the STEAM Dashboard will run in -// production. Run this against the UAT instance before submitting the -// ATLSUP Rest API Approval ticket. -// -// Usage: -// cd backend -// node scripts/jira-uat-test.js -// -// Prerequisites: -// - backend/.env has JIRA_BASE_URL pointing to UAT -// - JIRA_API_USER / JIRA_API_TOKEN set to service account credentials -// - JIRA_PROJECT_KEY set to a UAT project your service account can access -// - Service account has been granted access to the target space by space owners -// -// The script logs every API call, response status, and timing to both -// console and a log file at backend/scripts/jira-uat-test.log for the -// ATLSUP reviewers. -// ========================================================================== - -require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); - -const fs = require('fs'); -const path = require('path'); -const jiraApi = require('../helpers/jiraApi'); - -const LOG_FILE = path.join(__dirname, 'jira-uat-test.log'); -const results = []; -let createdIssueKey = null; - -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- -function log(level, message, data) { - const timestamp = new Date().toISOString(); - const entry = { timestamp, level, message }; - if (data !== undefined) entry.data = data; - results.push(entry); - - const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`; - console.log(line); - if (data) { - const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - // Truncate long data to keep logs readable (HTML error pages can be 50KB+) - const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr; - console.log(' ' + truncated.split('\n').join('\n ')); - } -} - -function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); } -function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); } -function logInfo(message, data) { log('info', message, data); } -function logWarn(message, data) { log('warn', message, data); } - -// --------------------------------------------------------------------------- -// Test runner -// --------------------------------------------------------------------------- -async function runTest(name, fn) { - logInfo(`--- Running: ${name} ---`); - const start = Date.now(); - try { - await fn(); - logPass(name, { durationMs: Date.now() - start }); - return true; - } catch (err) { - logFail(name, { error: err.message, durationMs: Date.now() - start }); - return false; - } -} - -function assert(condition, message) { - if (!condition) throw new Error('Assertion failed: ' + message); -} - -// --------------------------------------------------------------------------- -// Use Case 1: Connection Test (GET /rest/api/2/myself) -// Production use: Admin clicks "Test Connection" button on Jira settings panel -// --------------------------------------------------------------------------- -async function testConnection() { - const result = await jiraApi.testConnection(); - assert(result.ok, 'Connection test should succeed. Got: ' + JSON.stringify(result)); - assert(result.user && result.user.name, 'Should return authenticated user name'); - logInfo('Authenticated as:', result.user); -} - -// --------------------------------------------------------------------------- -// Use Case 2: Create Issue (POST /rest/api/2/issue) -// Production use: User clicks "Create in Jira" from CVE detail panel -// --------------------------------------------------------------------------- -async function testCreateIssue() { - const projectKey = jiraApi.JIRA_PROJECT_KEY; - assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env'); - - // Discover available issue types for this project - const projRes = await jiraApi.jiraGet('/rest/api/2/project/' + encodeURIComponent(projectKey)); - assert(projRes.status === 200, 'Should be able to fetch project metadata. Got HTTP ' + projRes.status + ': ' + (projRes.body || '').substring(0, 300)); - - const projData = JSON.parse(projRes.body); - const availableTypes = (projData.issueTypes || []).filter(t => !t.subtask); - logInfo('Available issue types:', availableTypes.map(t => t.name)); - - // Determine which issue type to use: configured type first, then fallback order - const configuredType = jiraApi.JIRA_ISSUE_TYPE || 'Task'; - const fallbackOrder = [configuredType, 'Story', 'Task', 'Bug']; - let issueTypeName = null; - - for (const candidate of fallbackOrder) { - if (availableTypes.some(t => t.name === candidate)) { - issueTypeName = candidate; - break; - } - } - - // If none of the preferred types exist, use the first available non-subtask type - if (!issueTypeName && availableTypes.length > 0) { - issueTypeName = availableTypes[0].name; - } - - assert(issueTypeName, 'No usable issue type found in project ' + projectKey); - - if (issueTypeName !== configuredType) { - logWarn('Configured JIRA_ISSUE_TYPE "' + configuredType + '" not available — falling back to "' + issueTypeName + '"'); - } - - const fields = { - project: { key: projectKey }, - summary: '[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ' + new Date().toISOString(), - issuetype: { name: issueTypeName }, - description: 'UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.' - }; - - // Epic type requires an Epic Name field — add it if creating an Epic - if (issueTypeName === 'Epic') { - fields.customfield_10004 = fields.summary; // Epic Name (standard Jira field ID) - } - - logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype }); - - let result = await jiraApi.createIssue(fields); - - // If the first attempt fails with 400, try without description (some screens don't have it) - if (!result.ok && result.status === 400) { - const errBody = (result.body || '').substring(0, 500); - logWarn('Create failed with 400, retrying without description. Error: ' + errBody); - - const retryFields = { ...fields }; - delete retryFields.description; - result = await jiraApi.createIssue(retryFields); - } - - // If still failing with 400 and we used Epic, try without the customfield_10004 - // (Epic Name field ID varies across Jira instances) - if (!result.ok && result.status === 400 && issueTypeName === 'Epic') { - const errBody = (result.body || '').substring(0, 500); - logWarn('Epic create failed, retrying with alternate Epic Name field. Error: ' + errBody); - - const retryFields = { ...fields }; - delete retryFields.customfield_10004; - // Try common alternate Epic Name field IDs - retryFields.customfield_10011 = fields.summary; - result = await jiraApi.createIssue(retryFields); - } - - assert(result.ok, 'Create issue should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500)); - assert(result.data && result.data.key, 'Should return issue key'); - - createdIssueKey = result.data.key; - logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self, issueType: issueTypeName }); -} - -// --------------------------------------------------------------------------- -// Use Case 3: Get Single Issue (GET /rest/api/2/issue/{key}?fields=...) -// Production use: User clicks "Sync" on a single Jira ticket row -// --------------------------------------------------------------------------- -async function testGetIssue() { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - const result = await jiraApi.getIssue(createdIssueKey); - assert(result.ok, 'Get issue should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - - const issue = result.data; - assert(issue.key === createdIssueKey, 'Returned key should match'); - assert(issue.fields && issue.fields.summary, 'Should have summary field'); - assert(issue.fields.status, 'Should have status field'); - - logInfo('Fetched issue:', { - key: issue.key, - summary: issue.fields.summary, - status: issue.fields.status.name, - issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null - }); -} - -// --------------------------------------------------------------------------- -// Use Case 4: Update Issue (PUT /rest/api/2/issue/{key}) -// Production use: Local ticket edits synced back to Jira (future feature) -// --------------------------------------------------------------------------- -async function testUpdateIssue() { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - const result = await jiraApi.updateIssue(createdIssueKey, { - summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}` - }); - assert(result.ok, 'Update issue should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - logInfo('Updated issue summary successfully'); -} - -// --------------------------------------------------------------------------- -// Use Case 5: Add Comment (POST /rest/api/2/issue/{key}/comment) -// Production use: Dashboard adds audit trail comments to linked Jira tickets -// --------------------------------------------------------------------------- -async function testAddComment() { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - const commentBody = `STEAM Dashboard UAT test comment.\nTimestamp: ${new Date().toISOString()}\nThis comment was created by the automated test script.`; - - const result = await jiraApi.addComment(createdIssueKey, commentBody); - assert(result.ok, 'Add comment should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - assert(result.data && result.data.id, 'Should return comment ID'); - - logInfo('Added comment:', { commentId: result.data.id }); -} - -// --------------------------------------------------------------------------- -// Use Case 6: Get Transitions (GET /rest/api/2/issue/{key}/transitions) -// Production use: Dashboard checks available workflow transitions before -// attempting to move a ticket to a new status -// --------------------------------------------------------------------------- -async function testGetTransitions() { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - const result = await jiraApi.getTransitions(createdIssueKey); - assert(result.ok, 'Get transitions should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - - const transitions = result.data.transitions || []; - logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null }))); - - // Store for the transition test - return transitions; -} - -// --------------------------------------------------------------------------- -// Use Case 7: Transition Issue (POST /rest/api/2/issue/{key}/transitions) -// Production use: Dashboard moves ticket status (e.g., Open → In Progress) -// --------------------------------------------------------------------------- -async function testTransitionIssue(transitions) { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - if (!transitions || transitions.length === 0) { - logWarn('No transitions available — skipping transition test. This may be expected depending on the project workflow.'); - return; - } - - // Pick the first available transition - const transition = transitions[0]; - logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`); - - const result = await jiraApi.transitionIssue(createdIssueKey, transition.id); - assert(result.ok, 'Transition should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - logInfo('Transition successful'); -} - -// --------------------------------------------------------------------------- -// Use Case 8: JQL Search (POST /rest/api/2/search) -// Production use: Bulk sync — fetches all tracked tickets in one request -// instead of one GET per ticket (Charter-compliant) -// --------------------------------------------------------------------------- -async function testJqlSearch() { - const projectKey = jiraApi.JIRA_PROJECT_KEY; - assert(projectKey, 'JIRA_PROJECT_KEY must be set'); - - // Use a broad time window to ensure results even on a quiet project - const jql = `project = ${projectKey} AND updated >= -24h ORDER BY updated DESC`; - logInfo('Searching with JQL:', jql); - - const result = await jiraApi.searchIssues(jql, { maxResults: 10 }); - assert(result.ok, 'Search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - - const data = result.data; - logInfo('Search results:', { - total: data.total, - returned: (data.issues || []).length, - issues: (data.issues || []).slice(0, 5).map(i => ({ - key: i.key, - summary: i.fields.summary, - status: i.fields.status ? i.fields.status.name : null - })) - }); -} - -// --------------------------------------------------------------------------- -// Use Case 9: Bulk Key Search (searchIssuesByKeys) -// Production use: sync-all endpoint — fetches multiple tickets by key -// in a single JQL query -// --------------------------------------------------------------------------- -async function testBulkKeySearch() { - assert(createdIssueKey, 'Need a created issue key from previous test'); - - // Search for the issue we created plus a fake key to test partial results - const keys = [createdIssueKey, 'FAKE-99999']; - logInfo('Bulk searching keys:', keys); - - const result = await jiraApi.searchIssuesByKeys(keys); - assert(result.ok, 'Bulk key search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); - - logInfo('Bulk search uses project-scoped JQL with project = ' + jiraApi.JIRA_PROJECT_KEY); - - const found = (result.data.issues || []).map(i => i.key); - logInfo('Found issues:', found); - assert(found.includes(createdIssueKey), 'Should find the created issue'); -} - -// --------------------------------------------------------------------------- -// Use Case 10: Rate Limit Status Check -// Production use: Admin views rate limit usage on the Jira settings panel -// --------------------------------------------------------------------------- -async function testRateLimitStatus() { - const status = jiraApi.getRateLimitStatus(); - assert(status.daily && typeof status.daily.used === 'number', 'Should have daily usage'); - assert(status.burst && typeof status.burst.used === 'number', 'Should have burst usage'); - logInfo('Rate limit status after all tests:', status); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -async function main() { - logInfo('=== STEAM Dashboard — Jira UAT Test Run ==='); - logInfo('Timestamp: ' + new Date().toISOString()); - logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)')); - logInfo('JIRA_AUTH_METHOD: ' + (process.env.JIRA_AUTH_METHOD || 'basic')); - logInfo('JIRA_API_USER: ' + (process.env.JIRA_API_USER || '(not set)')); - logInfo('JIRA_PROJECT_KEY: ' + (jiraApi.JIRA_PROJECT_KEY || '(not set)')); - logInfo('JIRA_ISSUE_TYPE: ' + (jiraApi.JIRA_ISSUE_TYPE || 'Task')); - logInfo('JIRA_SKIP_TLS: ' + (process.env.JIRA_SKIP_TLS || 'false')); - logInfo('isConfigured: ' + jiraApi.isConfigured); - logInfo(''); - - if (!jiraApi.isConfigured) { - logFail('Pre-flight check', 'Jira API is not configured. Set JIRA_BASE_URL, JIRA_API_USER, and JIRA_API_TOKEN in backend/.env'); - writeLog(); - process.exit(1); - } - - let passed = 0; - let failed = 0; - let transitions = []; - - // Run tests in order — later tests depend on the created issue - if (await runTest('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++; - if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++; - if (await runTest('3. Get Single Issue (JQL search)', testGetIssue)) passed++; else failed++; - if (await runTest('4. Update Issue (PUT /issue/{key})', testUpdateIssue)) passed++; else failed++; - if (await runTest('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++; - - if (await runTest('6. Get Transitions (GET /issue/{key}/transitions)', async () => { - transitions = await testGetTransitions(); - })) passed++; else failed++; - - if (await runTest('7. Transition Issue (POST /issue/{key}/transitions)', async () => { - await testTransitionIssue(transitions); - })) passed++; else failed++; - - if (await runTest('8. JQL Search (GET /search)', testJqlSearch)) passed++; else failed++; - if (await runTest('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++; - if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++; - - logInfo(''); - logInfo('=== Summary ==='); - logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`); - if (createdIssueKey) { - logInfo(`Test issue created: ${createdIssueKey} — delete manually after ATLSUP review if desired.`); - } - logInfo('Rate limit usage:', jiraApi.getRateLimitStatus()); - - writeLog(); - - if (failed > 0) { - console.log('\nSome tests failed. Review the log above and jira-uat-test.log for details.'); - process.exit(1); - } else { - console.log('\nAll tests passed. Log saved to backend/scripts/jira-uat-test.log'); - console.log('Next steps:'); - console.log(' 1. Submit an ATLSUP Rest API Approval ticket'); - console.log(' 2. Attach or reference jira-uat-test.log in the ticket'); - console.log(' 3. Click "Script ran - Review Logs" on the ATLSUP ticket'); - process.exit(0); - } -} - -function writeLog() { - const lines = results.map(r => { - let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`; - if (r.data) { - const dataStr = (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2)); - const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr; - line += '\n ' + truncated.split('\n').join('\n '); - } - return line; - }); - fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8'); -} - -main().catch(err => { - console.error('Unhandled error:', err); - process.exit(1); -}); diff --git a/docs/TeamDeviceLoader_matched_findings.xlsx b/docs/TeamDeviceLoader_matched_findings.xlsx deleted file mode 100644 index 6d08213..0000000 Binary files a/docs/TeamDeviceLoader_matched_findings.xlsx and /dev/null differ diff --git a/docs/Team_Device Loader.xlsx b/docs/Team_Device Loader.xlsx deleted file mode 100644 index c0ab813..0000000 Binary files a/docs/Team_Device Loader.xlsx and /dev/null differ diff --git a/docs/atlasinfosec-api-spec.json b/docs/api/atlasinfosec-api-spec.json similarity index 100% rename from docs/atlasinfosec-api-spec.json rename to docs/api/atlasinfosec-api-spec.json diff --git a/ivantiAPI.py b/docs/api/ivanti-api-python-wrapper.py similarity index 100% rename from ivantiAPI.py rename to docs/api/ivanti-api-python-wrapper.py diff --git a/docs/ivanti-api-reference.md b/docs/api/ivanti-api-reference.md similarity index 100% rename from docs/ivanti-api-reference.md rename to docs/api/ivanti-api-reference.md diff --git a/Ivanti_config_template.ini b/docs/api/ivanti-config-template.ini similarity index 100% rename from Ivanti_config_template.ini rename to docs/api/ivanti-config-template.ini diff --git a/swagger.json b/docs/api/ivanti-neurons-swagger.json similarity index 100% rename from swagger.json rename to docs/api/ivanti-neurons-swagger.json diff --git a/docs/jira-api-use-cases.md b/docs/api/jira-api-use-cases.md similarity index 100% rename from docs/jira-api-use-cases.md rename to docs/api/jira-api-use-cases.md diff --git a/docs/MOP-workflow-color-codes.md b/docs/design/MOP-workflow-color-codes.md similarity index 100% rename from docs/MOP-workflow-color-codes.md rename to docs/design/MOP-workflow-color-codes.md diff --git a/DESIGN_SYSTEM.md b/docs/design/design-system.md similarity index 100% rename from DESIGN_SYSTEM.md rename to docs/design/design-system.md diff --git a/docs/graniteexport.xlsx b/docs/graniteexport.xlsx deleted file mode 100644 index 4eb5dbd..0000000 Binary files a/docs/graniteexport.xlsx and /dev/null differ diff --git a/docs/kb-compliance-guide.md b/docs/guides/kb-compliance-guide.md similarity index 100% rename from docs/kb-compliance-guide.md rename to docs/guides/kb-compliance-guide.md diff --git a/docs/kb-cve-tracking-guide.md b/docs/guides/kb-cve-tracking-guide.md similarity index 100% rename from docs/kb-cve-tracking-guide.md rename to docs/guides/kb-cve-tracking-guide.md diff --git a/docs/kb-fp-submission-editing-guide.md b/docs/guides/kb-fp-submission-editing-guide.md similarity index 100% rename from docs/kb-fp-submission-editing-guide.md rename to docs/guides/kb-fp-submission-editing-guide.md diff --git a/docs/kb-ivanti-queue-guide.md b/docs/guides/kb-ivanti-queue-guide.md similarity index 100% rename from docs/kb-ivanti-queue-guide.md rename to docs/guides/kb-ivanti-queue-guide.md diff --git a/docs/kb-reporting-page-guide.md b/docs/guides/kb-reporting-page-guide.md similarity index 100% rename from docs/kb-reporting-page-guide.md rename to docs/guides/kb-reporting-page-guide.md diff --git a/docs/kb-user-management-guide.md b/docs/guides/kb-user-management-guide.md similarity index 100% rename from docs/kb-user-management-guide.md rename to docs/guides/kb-user-management-guide.md diff --git a/docs/python-venv-setup.md b/docs/guides/python-venv-setup.md similarity index 100% rename from docs/python-venv-setup.md rename to docs/guides/python-venv-setup.md diff --git a/docs/team-training-agenda.md b/docs/guides/team-training-agenda.md similarity index 100% rename from docs/team-training-agenda.md rename to docs/guides/team-training-agenda.md diff --git a/docs/time-based-reporting-recommendations.md b/docs/guides/time-based-reporting-recommendations.md similarity index 100% rename from docs/time-based-reporting-recommendations.md rename to docs/guides/time-based-reporting-recommendations.md diff --git a/docs/security-audit-2026-04-01.md b/docs/security/security-audit-2026-04-01.md similarity index 100% rename from docs/security-audit-2026-04-01.md rename to docs/security/security-audit-2026-04-01.md diff --git a/docs/security-audit-tracker.md b/docs/security/security-audit-tracker.md similarity index 100% rename from docs/security-audit-tracker.md rename to docs/security/security-audit-tracker.md diff --git a/docs/security-posture-workflow-diagrams.md b/docs/security/security-posture-workflow-diagrams.md similarity index 100% rename from docs/security-posture-workflow-diagrams.md rename to docs/security/security-posture-workflow-diagrams.md diff --git a/docs/security-posture-workflow-lucidchart.md b/docs/security/security-posture-workflow-lucidchart.md similarity index 100% rename from docs/security-posture-workflow-lucidchart.md rename to docs/security/security-posture-workflow-lucidchart.md diff --git a/docs/security-posture-workflow.md b/docs/security/security-posture-workflow.md similarity index 100% rename from docs/security-posture-workflow.md rename to docs/security/security-posture-workflow.md diff --git a/docs/security-remediation-plan.md b/docs/security/security-remediation-plan.md similarity index 100% rename from docs/security-remediation-plan.md rename to docs/security/security-remediation-plan.md diff --git a/run_audit_tests.sh b/docs/testing/run-audit-tests.sh similarity index 100% rename from run_audit_tests.sh rename to docs/testing/run-audit-tests.sh diff --git a/test_cases_auth.md b/docs/testing/test-cases-auth.md similarity index 100% rename from test_cases_auth.md rename to docs/testing/test-cases-auth.md diff --git a/TEST_PLAN_AUDIT_LOG.md b/docs/testing/test-plan-audit-log.md similarity index 100% rename from TEST_PLAN_AUDIT_LOG.md rename to docs/testing/test-plan-audit-log.md diff --git a/docs/troubleshooting/bu-reassignment-check.js b/docs/troubleshooting/bu-reassignment-check.js new file mode 100644 index 0000000..99e5273 --- /dev/null +++ b/docs/troubleshooting/bu-reassignment-check.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node +// bu-reassignment-check.js — Check if disappeared findings were reassigned to a different BU +// +// Queries Ivanti for the specific finding IDs that are completely gone from our +// BU-filtered results, using NO filters at all (just the finding IDs). +// If they come back with a different BU, that confirms BU reassignment. +// +// Usage: node backend/scripts/bu-reassignment-check.js + +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); + +const path = require('path'); +const sqlite3 = require('sqlite3').verbose(); +const { ivantiPost } = require('../helpers/ivantiApi'); + +const DB_PATH = path.join(__dirname, '..', 'cve_database.db'); + +function dbAll(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); +} + +async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) { + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + const allResults = []; + + // Ivanti's IN filter can handle batches — but let's chunk to be safe + const chunkSize = 50; + for (let i = 0; i < findingIds.length; i += chunkSize) { + const chunk = findingIds.slice(i, i + chunkSize); + const idList = chunk.join(','); + + // Query with ONLY the finding ID filter — no BU, no severity, no state + 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 + }; + + try { + const result = await ivantiPost(urlPath, body, apiKey, skipTls); + if (result.status !== 200) { + console.error(` API returned status ${result.status} for chunk starting at ${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'; + allResults.push({ + id: String(f.id), + severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, + title: f.title || '', + hostName: f.host?.hostName || '', + ipAddress: f.host?.ipAddress || '', + state: f.status || f.generic_state || '', + bu, + // Check for FP workflow + fpWorkflow: extractFP(f) + }); + } + + console.error(` Chunk ${Math.floor(i/chunkSize)+1}: page ${page+1}/${totalPages}, ${findings.length} results`); + page++; + } catch (err) { + console.error(` Error querying chunk at ${i}:`, err.message); + break; + } + } while (page < totalPages); + } + + return allResults; +} + +function extractFP(f) { + const wfDist = f.workflowDistribution || {}; + const fpBuckets = [ + ...(wfDist.approvedWorkflows || []), + ...(wfDist.actionableWorkflows || []), + ...(wfDist.requestedWorkflows || []), + ...(wfDist.reworkedWorkflows || []), + ...(wfDist.rejectedWorkflows || []), + ...(wfDist.expiredWorkflows || []), + ].filter(w => (w.generatedId || '').startsWith('FP#')); + const entry = fpBuckets[0]; + if (!entry) return null; + return { id: entry.generatedId, state: entry.state }; +} + +async function main() { + 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) { + console.error('IVANTI_API_KEY not set'); + process.exit(1); + } + + const db = new sqlite3.Database(DB_PATH); + + // Get the 124 finding IDs that were completely gone from BU-filtered results + const goneFindings = await dbAll(db, + `SELECT finding_id, last_severity, finding_title, current_state + FROM ivanti_finding_archives + WHERE current_state IN ('ARCHIVED', 'CLOSED')` + ); + + const goneIds = goneFindings.map(f => f.finding_id); + console.error(`\n=== BU Reassignment Check ===`); + console.error(`Querying Ivanti for ${goneIds.length} disappeared finding IDs (no BU/severity/state filter)...\n`); + + const results = await queryByFindingIds(goneIds, apiKey, clientId, skipTls); + + const foundMap = new Map(results.map(r => [r.id, r])); + + // Categorize + const reassigned = []; // Found with different BU + const sameBU = []; // Found with same BU (STEAM or ACCESS-ENG) + const notFound = []; // Still not found even without filters + const withFP = []; // Has an FP workflow (any state) + + for (const arch of goneFindings) { + const found = foundMap.get(arch.finding_id); + if (!found) { + notFound.push(arch); + } else if (found.bu !== 'NTS-AEO-ACCESS-ENG' && found.bu !== 'NTS-AEO-STEAM') { + reassigned.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow }); + if (found.fpWorkflow) withFP.push({ ...arch, ...found }); + } else { + sameBU.push({ ...arch, currentBU: found.bu, currentSeverity: found.severity, currentState: found.state, fp: found.fpWorkflow }); + if (found.fpWorkflow) withFP.push({ ...arch, ...found }); + } + } + + console.log(''); + console.log('='.repeat(130)); + console.log('BU REASSIGNMENT CHECK RESULTS'); + console.log('='.repeat(130)); + + console.log(`\nREASSIGNED TO DIFFERENT BU: ${reassigned.length} findings`); + console.log('-'.repeat(130)); + if (reassigned.length > 0) { + console.log( + 'Finding ID'.padEnd(14) + + 'Archive State'.padEnd(15) + + 'Last Sev'.padEnd(10) + + 'Current Sev'.padEnd(13) + + 'Current BU'.padEnd(30) + + 'FP Workflow'.padEnd(25) + + 'Title' + ); + console.log('-'.repeat(130)); + for (const f of reassigned) { + const fpStr = f.fp ? `${f.fp.id} (${f.fp.state})` : '-'; + console.log( + f.finding_id.padEnd(14) + + f.current_state.padEnd(15) + + f.last_severity.toFixed(2).padEnd(10) + + f.currentSeverity.toFixed(2).padEnd(13) + + f.currentBU.padEnd(30) + + fpStr.padEnd(25) + + f.finding_title.substring(0, 40) + ); + } + } + + console.log(`\nSTILL SAME BU (but missing from filtered results): ${sameBU.length} findings`); + console.log('-'.repeat(130)); + if (sameBU.length > 0) { + console.log( + 'Finding ID'.padEnd(14) + + 'Archive State'.padEnd(15) + + 'Last Sev'.padEnd(10) + + 'Current Sev'.padEnd(13) + + 'Current BU'.padEnd(30) + + 'Current State'.padEnd(15) + + 'Title' + ); + console.log('-'.repeat(130)); + for (const f of sameBU) { + console.log( + f.finding_id.padEnd(14) + + f.current_state.padEnd(15) + + f.last_severity.toFixed(2).padEnd(10) + + f.currentSeverity.toFixed(2).padEnd(13) + + f.currentBU.padEnd(30) + + f.currentState.padEnd(15) + + f.finding_title.substring(0, 40) + ); + } + } + + console.log(`\nCOMPLETELY GONE (not found even without any filters): ${notFound.length} findings`); + if (notFound.length > 0 && notFound.length <= 20) { + console.log('-'.repeat(130)); + for (const f of notFound) { + console.log(` ${f.finding_id} ${f.last_severity.toFixed(2)} ${f.finding_title.substring(0, 60)}`); + } + } + + if (withFP.length > 0) { + console.log(`\nFINDINGS WITH FP WORKFLOWS: ${withFP.length}`); + console.log('-'.repeat(130)); + for (const f of withFP) { + const fpStr = f.fpWorkflow ? `${f.fpWorkflow.id} (${f.fpWorkflow.state})` : f.fp ? `${f.fp.id} (${f.fp.state})` : '-'; + console.log(` ${f.finding_id || f.id} ${fpStr} ${f.bu || f.currentBU} ${(f.finding_title || f.title || '').substring(0, 50)}`); + } + } + + // Summary + console.log(''); + console.log('='.repeat(130)); + console.log('SUMMARY'); + console.log('='.repeat(130)); + console.log(` Total disappeared findings checked: ${goneFindings.length}`); + console.log(` Reassigned to different BU: ${reassigned.length}`); + console.log(` Still same BU (unexpected): ${sameBU.length}`); + console.log(` Completely gone from platform: ${notFound.length}`); + console.log(` Have FP workflows: ${withFP.length}`); + + if (reassigned.length > 0) { + const buCounts = {}; + reassigned.forEach(f => { buCounts[f.currentBU] = (buCounts[f.currentBU] || 0) + 1; }); + console.log('\n BU reassignment breakdown:'); + for (const [bu, cnt] of Object.entries(buCounts).sort((a, b) => b[1] - a[1])) { + console.log(` ${bu}: ${cnt} findings`); + } + } + + if (reassigned.length > goneFindings.length * 0.5) { + console.log('\n VERDICT: BU REASSIGNMENT CONFIRMED.'); + } else if (notFound.length > goneFindings.length * 0.5) { + console.log('\n VERDICT: Findings removed from platform entirely (decommission or data purge).'); + } else { + console.log('\n VERDICT: Mixed causes — review individual categories above.'); + } + + db.close(); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/backend/scripts/diagnose-chart-alignment.js b/docs/troubleshooting/diagnose-chart-alignment.js similarity index 100% rename from backend/scripts/diagnose-chart-alignment.js rename to docs/troubleshooting/diagnose-chart-alignment.js diff --git a/docs/troubleshooting/drift-check.js b/docs/troubleshooting/drift-check.js new file mode 100644 index 0000000..9302db8 --- /dev/null +++ b/docs/troubleshooting/drift-check.js @@ -0,0 +1,275 @@ +#!/usr/bin/env node +// drift-check.js — One-time diagnostic to confirm host-level VRR score drift +// +// Queries Ivanti WITHOUT the severity filter for the same BUs, then cross- +// references the results against our archived finding IDs to see if they +// still exist at lower severity scores. +// +// Usage: node backend/scripts/drift-check.js +// +// Output: prints a comparison table and summary. Does NOT modify cve_database.db +// permanently — uses a temporary in-memory table for the comparison. + +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); + +const path = require('path'); +const sqlite3 = require('sqlite3').verbose(); +const { ivantiPost } = require('../helpers/ivantiApi'); + +const DB_PATH = path.join(__dirname, '..', 'cve_database.db'); + +// Same BU filter, NO severity filter, NO state filter — get everything +const ALL_FINDINGS_FILTERS = [ + { + field: 'assetCustomAttributes.1550_host_1.value', + exclusive: false, + operator: 'IN', + orWithPrevious: false, + implicitFilters: [], + value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM', + caseSensitive: false + } +]; + +function dbAll(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); +} + +function dbRun(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function (err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +async function fetchAllFindings(apiKey, clientId, skipTls, state) { + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + const filters = [ + ...ALL_FINDINGS_FILTERS, + { + field: 'generic_state', + exclusive: false, + operator: 'EXACT', + orWithPrevious: false, + implicitFilters: [], + value: state, + caseSensitive: false + } + ]; + + let allFindings = []; + 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(` API returned status ${result.status} on page ${page}`); + break; + } + + const data = JSON.parse(result.body); + totalPages = data.page?.totalPages || 1; + const findings = data._embedded?.hostFindings || []; + + for (const f of findings) { + allFindings.push({ + id: String(f.id), + severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, + title: f.title || '', + hostName: f.host?.hostName || '', + state + }); + } + + console.error(` ${state} page ${page + 1}/${totalPages} — ${allFindings.length} findings so far`); + page++; + } while (page < totalPages); + + return allFindings; +} + +async function main() { + 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) { + console.error('IVANTI_API_KEY not set in backend/.env'); + process.exit(1); + } + + console.error('=== Drift Check: Querying Ivanti WITHOUT severity filter ===\n'); + + // Fetch all Open findings (no severity filter) + console.error('Fetching ALL Open findings (no severity filter)...'); + const openFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Open'); + console.error(` Total Open (all severities): ${openFindings.length}\n`); + + // Fetch all Closed findings (no severity filter) + console.error('Fetching ALL Closed findings (no severity filter)...'); + const closedFindings = await fetchAllFindings(apiKey, clientId, skipTls, 'Closed'); + console.error(` Total Closed (all severities): ${closedFindings.length}\n`); + + const allFindings = [...openFindings, ...closedFindings]; + const findingMap = new Map(allFindings.map(f => [f.id, f])); + + console.error(`Total findings across both states: ${allFindings.length}\n`); + + // Open the database and get archived finding IDs + const db = new sqlite3.Database(DB_PATH); + + const archived = await dbAll(db, + `SELECT finding_id, last_severity, finding_title, host_name, current_state + FROM ivanti_finding_archives + WHERE current_state IN ('ARCHIVED', 'CLOSED') + ORDER BY current_state, last_severity DESC` + ); + + console.log(''); + console.log('='.repeat(120)); + console.log('DRIFT CHECK RESULTS'); + console.log('='.repeat(120)); + console.log(''); + + // Categorize results + const drifted = []; // Found in API at lower severity (below 8.5) + const stillHigh = []; // Found in API, severity still >= 8.5 + const gone = []; // Not found in API at all (any severity) + const stateChanged = []; // Found but in different state + + for (const arch of archived) { + const current = findingMap.get(arch.finding_id); + if (!current) { + gone.push(arch); + } else if (current.severity < 8.5) { + drifted.push({ ...arch, currentSeverity: current.severity, currentState: current.state }); + } else { + stillHigh.push({ ...arch, currentSeverity: current.severity, currentState: current.state }); + } + } + + // Print drifted findings + console.log(`CONFIRMED SCORE DRIFT (now below 8.5): ${drifted.length} findings`); + console.log('-'.repeat(120)); + if (drifted.length > 0) { + console.log( + 'Finding ID'.padEnd(14) + + 'Archive State'.padEnd(15) + + 'Last Severity'.padEnd(15) + + 'Current Severity'.padEnd(18) + + 'Delta'.padEnd(10) + + 'Current State'.padEnd(15) + + 'Title' + ); + console.log('-'.repeat(120)); + for (const f of drifted) { + const delta = (f.currentSeverity - f.last_severity).toFixed(2); + console.log( + f.finding_id.padEnd(14) + + f.current_state.padEnd(15) + + f.last_severity.toFixed(2).padEnd(15) + + f.currentSeverity.toFixed(2).padEnd(18) + + delta.padEnd(10) + + f.currentState.padEnd(15) + + f.finding_title.substring(0, 50) + ); + } + } + + console.log(''); + console.log(`STILL HIGH SEVERITY (>= 8.5, should be in filtered results): ${stillHigh.length} findings`); + console.log('-'.repeat(120)); + if (stillHigh.length > 0) { + console.log( + 'Finding ID'.padEnd(14) + + 'Archive State'.padEnd(15) + + 'Last Severity'.padEnd(15) + + 'Current Severity'.padEnd(18) + + 'Current State'.padEnd(15) + + 'Title' + ); + console.log('-'.repeat(120)); + for (const f of stillHigh) { + console.log( + f.finding_id.padEnd(14) + + f.current_state.padEnd(15) + + f.last_severity.toFixed(2).padEnd(15) + + f.currentSeverity.toFixed(2).padEnd(18) + + f.currentState.padEnd(15) + + f.finding_title.substring(0, 50) + ); + } + } + + console.log(''); + console.log(`COMPLETELY GONE (not in API at any severity): ${gone.length} findings`); + console.log('-'.repeat(120)); + if (gone.length > 0) { + console.log( + 'Finding ID'.padEnd(14) + + 'Archive State'.padEnd(15) + + 'Last Severity'.padEnd(15) + + 'Title' + ); + console.log('-'.repeat(120)); + for (const f of gone) { + console.log( + f.finding_id.padEnd(14) + + f.current_state.padEnd(15) + + f.last_severity.toFixed(2).padEnd(15) + + f.finding_title.substring(0, 50) + ); + } + } + + // Summary + console.log(''); + console.log('='.repeat(120)); + console.log('SUMMARY'); + console.log('='.repeat(120)); + console.log(` Archived/Closed findings checked: ${archived.length}`); + console.log(` Confirmed score drift (< 8.5): ${drifted.length}`); + console.log(` Still high severity (>= 8.5): ${stillHigh.length}`); + console.log(` Completely gone from API: ${gone.length}`); + console.log(''); + + if (drifted.length > 0) { + const avgDelta = drifted.reduce((sum, f) => sum + (f.currentSeverity - f.last_severity), 0) / drifted.length; + const minNew = Math.min(...drifted.map(f => f.currentSeverity)); + const maxNew = Math.max(...drifted.map(f => f.currentSeverity)); + console.log(` Score drift range: ${minNew.toFixed(2)} – ${maxNew.toFixed(2)} (avg delta: ${avgDelta.toFixed(2)})`); + } + + if (drifted.length > archived.length * 0.5) { + console.log('\n VERDICT: Host-level VRR score drift CONFIRMED.'); + console.log(' The majority of disappeared findings still exist in Ivanti but at lower severity scores.'); + } else if (drifted.length > 0) { + console.log('\n VERDICT: Partial score drift detected. Some findings drifted, others may have been removed.'); + } else if (gone.length > archived.length * 0.5) { + console.log('\n VERDICT: Score drift NOT confirmed. Most findings are completely gone from the API.'); + console.log(' This suggests BU reassignment, host decommission, or a platform-side data issue.'); + } + + db.close(); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/docs/troubleshooting/export-reassigned-findings.js b/docs/troubleshooting/export-reassigned-findings.js new file mode 100644 index 0000000..9c53667 --- /dev/null +++ b/docs/troubleshooting/export-reassigned-findings.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// export-reassigned-findings.js — Generate an xlsx with findings reassigned to SDIT-CSD-ITLS-PIES +// +// Pulls data from the archive database and the BU reassignment check results. +// Outputs to docs/reassigned-findings-2026-04-24.xlsx +// +// Usage: node backend/scripts/export-reassigned-findings.js + +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); + +const path = require('path'); +const sqlite3 = require('sqlite3').verbose(); +const XLSX = require('xlsx'); +const { ivantiPost } = require('../helpers/ivantiApi'); + +const DB_PATH = path.join(__dirname, '..', 'cve_database.db'); +const OUTPUT_PATH = path.join(__dirname, '..', '..', 'docs', 'reassigned-findings-2026-04-24.xlsx'); + +function dbAll(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); +} + +async function queryByFindingIds(findingIds, apiKey, clientId, skipTls) { + const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`; + const results = new Map(); + const chunkSize = 50; + + for (let i = 0; i < findingIds.length; i += chunkSize) { + const chunk = findingIds.slice(i, i + chunkSize); + const idList = chunk.join(','); + const filters = [{ + field: 'id', exclusive: false, operator: 'IN', + orWithPrevious: false, implicitFilters: [], + value: idList, caseSensitive: false + }]; + + let page = 0; + let totalPages = 1; + do { + try { + 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) 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 wfDist = f.workflowDistribution || {}; + const fpBuckets = [ + ...(wfDist.approvedWorkflows || []), ...(wfDist.actionableWorkflows || []), + ...(wfDist.requestedWorkflows || []), ...(wfDist.reworkedWorkflows || []), + ...(wfDist.rejectedWorkflows || []), ...(wfDist.expiredWorkflows || []), + ].filter(w => (w.generatedId || '').startsWith('FP#')); + const fp = fpBuckets[0] || null; + results.set(String(f.id), { + bu, + severity: typeof f.severity === 'number' ? f.severity : parseFloat(f.severity) || 0, + state: f.status || '', + fpId: fp ? fp.generatedId : '', + fpState: fp ? fp.state : '', + hostName: f.host?.hostName || '', + ipAddress: f.host?.ipAddress || '', + title: f.title || '', + }); + } + page++; + } catch (err) { + console.error(` Error on batch at ${i}:`, err.message); + break; + } + } while (page < totalPages); + } + return results; +} + +async function main() { + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + const db = new sqlite3.Database(DB_PATH); + + // Get all archived/closed findings from the archive + const archived = await dbAll(db, + `SELECT finding_id, last_severity, finding_title, host_name, ip_address, current_state, + DATE(first_archived_at) as archived_date, DATE(last_transition_at) as last_transition_date + FROM ivanti_finding_archives + WHERE current_state IN ('ARCHIVED', 'CLOSED') + ORDER BY current_state, last_severity DESC` + ); + + const ids = archived.map(a => a.finding_id); + console.log(`Querying Ivanti for ${ids.length} findings...`); + const currentData = await queryByFindingIds(ids, apiKey, clientId, skipTls); + + // Build rows for each sheet + const reassignedRows = []; + const goneRows = []; + const sameBuRows = []; + + for (const arch of archived) { + const current = currentData.get(arch.finding_id); + + if (!current) { + goneRows.push({ + 'Finding ID': arch.finding_id, + 'Title': arch.finding_title, + 'Last Severity': arch.last_severity, + 'Host': arch.host_name, + 'IP Address': arch.ip_address, + 'Archive State': arch.current_state, + 'Archived Date': arch.archived_date, + 'Status': 'Gone from platform', + }); + } else if (current.bu !== 'NTS-AEO-ACCESS-ENG' && current.bu !== 'NTS-AEO-STEAM') { + reassignedRows.push({ + 'Finding ID': arch.finding_id, + 'Title': current.title || arch.finding_title, + 'Last Severity (STEAM)': arch.last_severity, + 'Current Severity': current.severity, + 'Host': current.hostName || arch.host_name, + 'IP Address': current.ipAddress || arch.ip_address, + 'Previous BU': 'NTS-AEO-STEAM / ACCESS-ENG', + 'Current BU': current.bu, + 'Current State': current.state, + 'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '', + 'Archive State': arch.current_state, + 'Archived Date': arch.archived_date, + }); + } else { + sameBuRows.push({ + 'Finding ID': arch.finding_id, + 'Title': current.title || arch.finding_title, + 'Severity': current.severity, + 'Host': current.hostName || arch.host_name, + 'IP Address': current.ipAddress || arch.ip_address, + 'BU': current.bu, + 'Current State': current.state, + 'FP Workflow': current.fpId ? `${current.fpId} (${current.fpState})` : '', + 'Archive State': arch.current_state, + }); + } + } + + // Create workbook + const wb = XLSX.utils.book_new(); + + // Sheet 1: Reassigned findings + const ws1 = XLSX.utils.json_to_sheet(reassignedRows); + // Set column widths + ws1['!cols'] = [ + { wch: 14 }, { wch: 55 }, { wch: 18 }, { wch: 16 }, + { wch: 30 }, { wch: 16 }, { wch: 28 }, { wch: 24 }, + { wch: 14 }, { wch: 24 }, { wch: 14 }, { wch: 14 }, + ]; + XLSX.utils.book_append_sheet(wb, ws1, 'Reassigned to SDIT-PIES'); + + // Sheet 2: Gone from platform + if (goneRows.length > 0) { + const ws2 = XLSX.utils.json_to_sheet(goneRows); + ws2['!cols'] = [ + { wch: 14 }, { wch: 55 }, { wch: 14 }, { wch: 30 }, + { wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 20 }, + ]; + XLSX.utils.book_append_sheet(wb, ws2, 'Gone from Platform'); + } + + // Sheet 3: Still same BU + if (sameBuRows.length > 0) { + const ws3 = XLSX.utils.json_to_sheet(sameBuRows); + ws3['!cols'] = [ + { wch: 14 }, { wch: 55 }, { wch: 10 }, { wch: 30 }, + { wch: 16 }, { wch: 24 }, { wch: 14 }, { wch: 24 }, { wch: 14 }, + ]; + XLSX.utils.book_append_sheet(wb, ws3, 'Still Same BU'); + } + + // Write file + XLSX.writeFile(wb, OUTPUT_PATH); + console.log(`\nExported to: ${OUTPUT_PATH}`); + console.log(` Reassigned: ${reassignedRows.length}`); + console.log(` Gone: ${goneRows.length}`); + console.log(` Same BU: ${sameBuRows.length}`); + + db.close(); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/docs/findings-count-investigation-2026-04-24.md b/docs/troubleshooting/findings-count-investigation-2026-04-24.md similarity index 100% rename from docs/findings-count-investigation-2026-04-24.md rename to docs/troubleshooting/findings-count-investigation-2026-04-24.md