chore: reorganize docs, document migrations, gitignore operational files for v1.0.0 release
This commit is contained in:
30
.gitignore
vendored
30
.gitignore
vendored
@@ -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__/
|
||||
|
||||
41
backend/migrations/README.md
Normal file
41
backend/migrations/README.md
Normal file
@@ -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` |
|
||||
Binary file not shown.
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
270
docs/troubleshooting/bu-reassignment-check.js
Normal file
@@ -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);
|
||||
});
|
||||
275
docs/troubleshooting/drift-check.js
Normal file
275
docs/troubleshooting/drift-check.js
Normal file
@@ -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);
|
||||
});
|
||||
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
197
docs/troubleshooting/export-reassigned-findings.js
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user