From 4f960d0866f7b83e240d0ab0745b5a6931e3e89b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Apr 2026 18:44:14 +0000 Subject: [PATCH] Update README and Jira UAT test script --- README.md | 52 ++++++++++++++++- backend/scripts/jira-uat-test.js | 97 ++++++++++++++++++++++++++------ 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1a6fe0d..e555b48 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A self-hosted vulnerability management dashboard for the NTS-AEO-STEAM and NTS-A - [Compliance — AEO Posture](#compliance--aeo-posture) - [Knowledge Base](#knowledge-base) - [Exports](#exports) + - [Jira Tickets](#jira-tickets) - [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets) - [Admin Panel](#admin-panel) - [Scripts](#scripts) @@ -192,6 +193,20 @@ IVANTI_FIRST_NAME= IVANTI_LAST_NAME= # Set to 'true' if your network has SSL inspection / self-signed certs IVANTI_SKIP_TLS=false + +# Jira Data Center REST API (required for Jira Tickets page) +# VPN or Charter Network connection required for all Jira instances. +# Service accounts use Basic Auth (JIRA_API_USER + JIRA_API_TOKEN). +# PATs require ATLSUP approval — set JIRA_AUTH_METHOD=pat to use JIRA_PAT instead. +# Rate limits: 1440 requests/day, burst of 60/minute. +JIRA_BASE_URL=https://jira.charter.com +JIRA_AUTH_METHOD=basic +JIRA_API_USER=your-service-account +JIRA_API_TOKEN=your-api-token +# JIRA_PAT=your-pat-token +JIRA_PROJECT_KEY=VULN +JIRA_ISSUE_TYPE=Task +JIRA_SKIP_TLS=false ``` **`SESSION_SECRET` is required.** The server will exit on startup if it is not set. Generate one with `openssl rand -base64 32`. @@ -472,6 +487,29 @@ Bulk export tools for reports and data extracts. Available to Admin, Standard_Us --- +### Jira Tickets + +A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pairs. Accessible from the navigation drawer. Requires a configured Jira API connection (see [Configuration](#configuration)). + +**Ticket list** +- View all tracked Jira tickets with status, CVE ID, vendor, summary, and Jira key +- Filter by status or search by keyword +- Click a Jira key to open the issue in Jira Data Center + +**Jira API operations (Admin/Standard_User)** +- **Lookup** — search for any Jira issue by key and view its current status, assignee, and summary +- **Create in Jira** — create a new Jira issue directly from the dashboard with project key, issue type, summary, and description; the resulting ticket is automatically linked to a CVE/vendor pair in the local database +- **Sync** — refresh a single ticket's status and summary from Jira, or bulk-sync all tracked tickets via JQL search +- **Create / Edit / Delete** — manage local ticket records linking Jira keys to CVE/vendor pairs + +**Connection test (Admin)** — verify Jira API credentials and connectivity from the page header. + +**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day). + +All Jira API calls are proxied through the backend. Credentials are never exposed to the browser. Rate limits are enforced client-side with inter-request delays (1s for GETs, 2s for writes). See `docs/jira-api-use-cases.md` for the full API compliance summary. + +--- + ### Archer Risk Acceptance Tickets Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs. @@ -578,6 +616,13 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a | POST | `/api/jira-tickets` | Admin, Standard_User | Create a JIRA ticket | | PUT | `/api/jira-tickets/:id` | Admin, Standard_User | Update a JIRA ticket | | DELETE | `/api/jira-tickets/:id` | Admin, Standard_User | Delete a JIRA ticket (ownership + compliance check for Standard_User) | +| GET | `/api/jira-tickets/connection-test` | Admin | Test Jira API connectivity and credentials | +| GET | `/api/jira-tickets/rate-limit` | Admin | Get current burst and daily rate limit usage | +| GET | `/api/jira-tickets/lookup/:issueKey` | Any | Look up a single Jira issue by key | +| POST | `/api/jira-tickets/search` | Any | JQL search for Jira issues | +| POST | `/api/jira-tickets/create-in-jira` | Admin, Standard_User | Create an issue in Jira and link it locally | +| POST | `/api/jira-tickets/sync-all` | Admin | Bulk-sync all tracked tickets via JQL | +| POST | `/api/jira-tickets/:id/sync` | Admin, Standard_User | Sync a single ticket's status from Jira | ### Ivanti — Host Findings @@ -717,13 +762,15 @@ cve-dashboard/ │ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts │ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list │ │ ├── ivantiArchive.js # Finding archive for severity score drift +│ │ ├── jiraTickets.js # Jira ticket CRUD + Jira REST API integration (lookup, sync, create) │ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes │ ├── middleware/ │ │ └── auth.js # requireAuth and requireGroup middleware │ ├── helpers/ │ │ ├── auditLog.js # logAudit helper (fire-and-forget) │ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig() -│ │ └── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST) +│ │ ├── ivantiApi.js # Ivanti API HTTP helpers (multipart, JSON, form POST) +│ │ └── jiraApi.js # Jira Data Center REST API helpers (Basic/PAT auth, rate limiting) │ ├── migrations/ # Sequential migration scripts (run manually with node) │ └── scripts/ │ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets) @@ -740,7 +787,7 @@ cve-dashboard/ │ └── AuthContext.js # Auth state provider (login, logout, group helpers) └── components/ ├── LoginForm.js # Login page - ├── NavDrawer.js # Side navigation drawer (Admin Panel link for Admin group) + ├── NavDrawer.js # Side navigation drawer (pages + Admin Panel link for Admin group) ├── UserMenu.js # User dropdown in header (shows group badge) ├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators ├── UserManagement.js # Admin user management modal (quick-access from UserMenu) @@ -760,6 +807,7 @@ cve-dashboard/ ├── ComplianceChartsPanel.js # Compliance trend charts ├── IvantiCountsChart.js # Ivanti counts history chart ├── ArchiveSummaryBar.js # Finding archive summary + ├── JiraPage.js # Jira ticket management and Jira API integration ├── KnowledgeBasePage.js # Knowledge base page └── ExportsPage.js # Exports page (group-gated) ``` diff --git a/backend/scripts/jira-uat-test.js b/backend/scripts/jira-uat-test.js index 0bf3fe3..e9424af 100644 --- a/backend/scripts/jira-uat-test.js +++ b/backend/scripts/jira-uat-test.js @@ -44,7 +44,9 @@ function log(level, message, data) { console.log(line); if (data) { const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - console.log(' ' + dataStr.split('\n').join('\n ')); + // 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 ')); } } @@ -92,21 +94,81 @@ 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: jiraApi.JIRA_ISSUE_TYPE || 'Task' }, - description: 'Automated UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.' + 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 }); - const result = await jiraApi.createIssue(fields); - assert(result.ok, 'Create issue should succeed. Got: ' + JSON.stringify(result)); + 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 }); + logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self, issueType: issueTypeName }); } // --------------------------------------------------------------------------- @@ -117,7 +179,7 @@ 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: ' + JSON.stringify(result)); + 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'); @@ -142,7 +204,7 @@ async function testUpdateIssue() { const result = await jiraApi.updateIssue(createdIssueKey, { summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}` }); - assert(result.ok, 'Update issue should succeed (204). Got: ' + JSON.stringify(result)); + assert(result.ok, 'Update issue should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); logInfo('Updated issue summary successfully'); } @@ -156,7 +218,7 @@ async function testAddComment() { 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: ' + JSON.stringify(result)); + 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 }); @@ -171,7 +233,7 @@ 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: ' + JSON.stringify(result)); + 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 }))); @@ -197,7 +259,7 @@ async function testTransitionIssue(transitions) { logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`); const result = await jiraApi.transitionIssue(createdIssueKey, transition.id); - assert(result.ok, 'Transition should succeed (204). Got: ' + JSON.stringify(result)); + assert(result.ok, 'Transition should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); logInfo('Transition successful'); } @@ -210,11 +272,12 @@ async function testJqlSearch() { const projectKey = jiraApi.JIRA_PROJECT_KEY; assert(projectKey, 'JIRA_PROJECT_KEY must be set'); - const jql = `project = ${projectKey} AND updated >= -1h ORDER BY updated DESC`; + // Use a broad time window to ensure results even on a quiet project + const jql = `project = ${projectKey} ORDER BY updated DESC`; logInfo('Searching with JQL:', jql); const result = await jiraApi.searchIssues(jql, { maxResults: 10 }); - assert(result.ok, 'Search should succeed. Got: ' + JSON.stringify(result)); + assert(result.ok, 'Search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); const data = result.data; logInfo('Search results:', { @@ -241,7 +304,7 @@ async function testBulkKeySearch() { logInfo('Bulk searching keys:', keys); const result = await jiraApi.searchIssuesByKeys(keys); - assert(result.ok, 'Bulk key search should succeed. Got: ' + JSON.stringify(result)); + assert(result.ok, 'Bulk key search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500))); const found = (result.data.issues || []).map(i => i.key); logInfo('Found issues:', found); @@ -330,7 +393,9 @@ function writeLog() { const lines = results.map(r => { let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`; if (r.data) { - line += '\n ' + (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2)).split('\n').join('\n '); + 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; });