Compare commits
3 Commits
b1069b1a05
...
fix/jira-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a179f19a1 | ||
|
|
4f960d0866 | ||
|
|
caa1d539cc |
@@ -125,7 +125,7 @@ Add a tab system to the Metric Graphs panel on the ReportingPage, with an "Ivant
|
|||||||
- Verify `getStatusColor` returns `#10B981` for "active", `#EF4444` for "expired", `#0EA5E9` for "completed", `#64748B` for any other string
|
- Verify `getStatusColor` returns `#10B981` for "active", `#EF4444` for "expired", `#0EA5E9` for "completed", `#64748B` for any other string
|
||||||
- **Validates: Requirements 5.2**
|
- **Validates: Requirements 5.2**
|
||||||
|
|
||||||
- [~]* 5.8 Write unit tests for Atlas donut components
|
- [ ]* 5.8 Write unit tests for Atlas donut components
|
||||||
- Test Coverage donut empty state message when totalHosts is 0
|
- Test Coverage donut empty state message when totalHosts is 0
|
||||||
- Test Plan type donut empty state message when totalPlans is 0
|
- Test Plan type donut empty state message when totalPlans is 0
|
||||||
- Test Plan status donut empty state message when totalPlans is 0
|
- Test Plan status donut empty state message when totalPlans is 0
|
||||||
|
|||||||
1
.kiro/specs/card-api-integration/.config.kiro
Normal file
1
.kiro/specs/card-api-integration/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "0334e0b6-7ae7-4284-95a0-caed55c59af1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
163
.kiro/specs/card-api-integration/requirements.md
Normal file
163
.kiro/specs/card-api-integration/requirements.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This feature integrates the CARD API into the STEAM Security Dashboard so that CARD workflow items in the Ivanti Queue can trigger real actions — confirm, decline, redirect, and search — via the CARD API. The integration covers OAuth token management, a backend helper module with automatic update_token handling, specific proxy routes for each CARD operation, and frontend UI updates that let users execute CARD actions directly from the queue. A standalone asset search capability supports Granite ID lookups when assets are reassigned.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: The STEAM Security Dashboard — the self-hosted vulnerability management application this feature extends.
|
||||||
|
- **CARD_API**: The external CARD REST API hosted at `card.charter.com` (production) or `card.caas.stage.charterlab.com` (UAT), authenticated via OAuth Bearer tokens. Read endpoints use the `/api/v1/` path prefix; mutation endpoints use the `/api/v2/` path prefix.
|
||||||
|
- **CARD_Helper**: The new `backend/helpers/cardApi.js` module responsible for CARD API authentication, token management, and HTTP request execution.
|
||||||
|
- **Token_Manager**: The component within CARD_Helper that handles OAuth token acquisition via Basic Auth, in-memory caching, and automatic refresh before expiry. Tokens have a one-hour TTL.
|
||||||
|
- **Queue_Item**: A row in the `ivanti_todo_queue` table with `workflow_type = 'CARD'`, representing a finding staged for CARD action.
|
||||||
|
- **CARD_Route**: The new Express route module at `backend/routes/cardApi.js` that exposes CARD API operations to the frontend through the backend.
|
||||||
|
- **Audit_Logger**: The existing `logAudit(db, {...})` helper that records state-changing actions to the `audit_logs` table.
|
||||||
|
- **Auth_Middleware**: The existing `requireAuth(db)` and `requireGroup(...)` middleware that enforces session validation and role-based access.
|
||||||
|
- **Asset_ID**: A CARD asset identifier in IPN format (e.g., `98.8.142.56-NATL`). Used as the path parameter in owner lookup and mutation endpoints.
|
||||||
|
- **Update_Token**: A server-generated token returned by the GET owner endpoint. The update_token is mandatory for all mutation calls (confirm, decline, redirect) and ensures optimistic concurrency control.
|
||||||
|
- **Disposition**: The ownership state of an asset in CARD. Valid values are `confirmed`, `unconfirmed`, `declined`, and `candidate`.
|
||||||
|
- **Team**: A CARD team name (e.g., `NTS-AEO-STEAM`). Teams are the organizational unit for asset ownership in CARD.
|
||||||
|
- **Owner_Record**: The JSON object returned by the GET owner endpoint, containing the asset ownership details, disposition states with team names, scores, timestamps, and the update_token field.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: CARD API Helper Module
|
||||||
|
|
||||||
|
**User Story:** As a backend developer, I want a dedicated CARD API helper module that follows the existing atlasApi.js pattern, so that all CARD API communication is centralized and consistent with the codebase.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE CARD_Helper SHALL export an `isConfigured` boolean that is `true` only when all required environment variables (`CARD_API_URL`, `CARD_API_USER`, `CARD_API_PASS`) are present and non-empty.
|
||||||
|
2. WHEN `isConfigured` is `false`, THE CARD_Helper SHALL log a warning at module load listing the missing environment variables with the prefix `[card-api]`.
|
||||||
|
3. THE CARD_Helper SHALL use the Node.js built-in `https` module for all HTTP requests to the CARD_API.
|
||||||
|
4. THE CARD_Helper SHALL export convenience wrapper functions for GET and POST HTTP methods, each accepting a URL path, optional request body, and optional options object.
|
||||||
|
5. THE CARD_Helper SHALL set `rejectUnauthorized` to `false` on HTTPS requests when the `CARD_SKIP_TLS` environment variable is set to `'true'`.
|
||||||
|
6. THE CARD_Helper SHALL apply a configurable request timeout defaulting to 15000 milliseconds.
|
||||||
|
7. THE CARD_Helper SHALL return a Promise that resolves with an object containing `status` (HTTP status code) and `body` (response body string) for each request.
|
||||||
|
8. THE CARD_Helper SHALL route read requests (GET) through the `/api/v1/` path prefix and mutation requests (POST) through the `/api/v2/` path prefix, matching the CARD_API versioning scheme.
|
||||||
|
|
||||||
|
### Requirement 2: OAuth Token Management
|
||||||
|
|
||||||
|
**User Story:** As a backend developer, I want the CARD helper to manage OAuth Bearer tokens automatically, so that downstream code does not need to handle authentication directly.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a CARD API request is made and no cached token exists, THE Token_Manager SHALL acquire a new token by sending a request to the CARD_API `/api/v1/auth/get_token` endpoint with a Basic Auth header containing the base64-encoded `CARD_API_USER:CARD_API_PASS` credentials.
|
||||||
|
2. WHEN a valid token is received, THE Token_Manager SHALL cache the token in memory along with its expiry timestamp (one-hour TTL from acquisition time).
|
||||||
|
3. WHEN a cached token exists and its expiry timestamp is more than 60 seconds in the future, THE Token_Manager SHALL reuse the cached token for subsequent requests.
|
||||||
|
4. WHEN a cached token exists and its expiry timestamp is 60 seconds or less in the future, THE Token_Manager SHALL acquire a new token before making the API request.
|
||||||
|
5. THE Token_Manager SHALL include the cached Bearer token in the `Authorization` header of all non-authentication CARD API requests.
|
||||||
|
6. IF the CARD_API returns an HTTP 401 response on a non-authentication request, THEN THE Token_Manager SHALL invalidate the cached token, acquire a new token, and retry the original request exactly once.
|
||||||
|
7. IF the token acquisition request fails or returns a non-success HTTP status, THEN THE Token_Manager SHALL reject the Promise with a descriptive error message including the HTTP status code and the response body.
|
||||||
|
|
||||||
|
### Requirement 3: Environment Variable Configuration
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want CARD API credentials and settings stored in environment variables following the existing pattern, so that configuration is consistent and secrets are not committed to source control.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL read the following environment variables for CARD API configuration: `CARD_API_URL` (base URL), `CARD_API_USER` (service account username), `CARD_API_PASS` (service account password), and `CARD_SKIP_TLS` (TLS verification toggle).
|
||||||
|
2. THE Dashboard SHALL document all CARD environment variables in `backend/.env.example` with descriptive comments matching the existing documentation style.
|
||||||
|
3. WHEN any of `CARD_API_URL`, `CARD_API_USER`, or `CARD_API_PASS` is missing or empty, THE CARD_Helper SHALL treat the integration as unconfigured and report `isConfigured` as `false`.
|
||||||
|
4. THE Dashboard SHALL treat `CARD_SKIP_TLS` as optional, defaulting to `false` when not set.
|
||||||
|
|
||||||
|
### Requirement 4: CARD API Proxy Routes
|
||||||
|
|
||||||
|
**User Story:** As a dashboard user, I want backend routes that proxy specific CARD API operations, so that the frontend can trigger CARD actions without exposing API credentials to the browser.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE CARD_Route SHALL export a factory function `createCardApiRouter(db, requireAuth)` that returns an Express Router, following the existing route module pattern.
|
||||||
|
2. THE CARD_Route SHALL protect all endpoints with `requireAuth(db)` for session validation and `requireGroup('Admin', 'Standard_User')` for role-based access.
|
||||||
|
3. THE CARD_Route SHALL expose a `GET /api/card/status` endpoint that returns `{ configured: boolean }` indicating whether the CARD API integration is configured.
|
||||||
|
4. THE CARD_Route SHALL expose a `GET /api/card/teams` endpoint that proxies the CARD_API `GET /api/v1/teams` endpoint and returns the list of CARD teams to the client.
|
||||||
|
5. THE CARD_Route SHALL expose a `GET /api/card/teams/:teamName/assets` endpoint that proxies the CARD_API `GET /api/v1/team/{teamName}/assets` endpoint, accepting `disposition`, `page`, and `page_size` query parameters.
|
||||||
|
6. WHEN the `page_size` query parameter is not provided on the assets endpoint, THE CARD_Route SHALL default to a page size of 50.
|
||||||
|
7. THE CARD_Route SHALL expose a `GET /api/card/owner/:assetId` endpoint that proxies the CARD_API `GET /api/v1/owner/{assetId}` endpoint and returns the Owner_Record including disposition states and the update_token.
|
||||||
|
8. IF `isConfigured` is `false` when a CARD API proxy endpoint is called, THEN THE CARD_Route SHALL return HTTP 503 with `{ error: 'CARD API is not configured.' }`.
|
||||||
|
9. IF the CARD_API returns an error response, THEN THE CARD_Route SHALL return the CARD_API HTTP status code and a JSON error body containing the upstream error message.
|
||||||
|
10. THE CARD_Route SHALL be mounted at the `/api/card` path prefix in `server.js`.
|
||||||
|
|
||||||
|
### Requirement 5: CARD Asset Mutation Actions
|
||||||
|
|
||||||
|
**User Story:** As a dashboard user, I want to confirm, decline, or redirect CARD assets directly from the queue, so that I can process CARD workflow findings without leaving the dashboard.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/confirm` endpoint that confirms an asset to a specified team via the CARD_API.
|
||||||
|
2. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/decline` endpoint that declines an asset from a specified team via the CARD_API.
|
||||||
|
3. THE CARD_Route SHALL expose a `POST /api/card/queue/:queueItemId/redirect` endpoint that redirects an asset from one team to another team via the CARD_API.
|
||||||
|
4. WHEN any mutation endpoint is called, THE CARD_Route SHALL verify that the queue item exists, belongs to the requesting user, has `workflow_type = 'CARD'`, and has `status = 'pending'`.
|
||||||
|
5. IF the queue item does not exist, does not belong to the user, or is not a CARD workflow item, THEN THE CARD_Route SHALL return HTTP 404 with `{ error: 'Queue item not found.' }`.
|
||||||
|
6. IF the queue item status is not `'pending'`, THEN THE CARD_Route SHALL return HTTP 400 with `{ error: 'Only pending queue items can be executed.' }`.
|
||||||
|
7. WHEN a mutation endpoint is called, THE CARD_Route SHALL first call `GET /api/v1/owner/{assetId}` to retrieve the current update_token, then use that update_token in the subsequent mutation call, making the two-step flow transparent to the frontend.
|
||||||
|
8. WHEN the confirm endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/confirm?update_token={token}&comment={comment}` with body `{ "name": "TEAM-NAME" }` to the CARD_API.
|
||||||
|
9. WHEN the decline endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/decline?update_token={token}&comment={comment}` with body `{ "name": "TEAM-NAME" }` to the CARD_API.
|
||||||
|
10. WHEN the redirect endpoint is called, THE CARD_Route SHALL send `POST /api/v2/owner/{assetId}/{fromTeam}/redirect?update_token={token}` with body `{ "name": "TO-TEAM-NAME" }` to the CARD_API, where `fromTeam` is a path parameter and the destination team is in the request body.
|
||||||
|
11. THE confirm endpoint SHALL accept a request body containing `teamName` (string, required), `comment` (string, optional), and `assetId` (string, required).
|
||||||
|
12. THE decline endpoint SHALL accept a request body containing `teamName` (string, required), `comment` (string, optional), and `assetId` (string, required).
|
||||||
|
13. THE redirect endpoint SHALL accept a request body containing `fromTeam` (string, required), `toTeam` (string, required), and `assetId` (string, required).
|
||||||
|
14. WHEN the CARD_API mutation call succeeds, THE CARD_Route SHALL update the queue item status to `'complete'` and return the CARD_API response to the client.
|
||||||
|
15. IF the CARD_API mutation call fails, THEN THE CARD_Route SHALL leave the queue item status as `'pending'` and return the error to the client.
|
||||||
|
|
||||||
|
### Requirement 6: Frontend CARD Action UI
|
||||||
|
|
||||||
|
**User Story:** As a dashboard user, I want specific Confirm, Decline, and Redirect action buttons on CARD queue items, so that I can perform the correct CARD operation for each finding.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a CARD Queue_Item is displayed in the Ivanti Queue panel, THE Dashboard SHALL render three action buttons labeled "Confirm", "Decline", and "Redirect" on pending CARD items.
|
||||||
|
2. WHEN the user clicks the "Confirm" button, THE Dashboard SHALL display a form with a team selection dropdown (populated from the `/api/card/teams` endpoint) and an optional comment text field, then send a `POST` request to `/api/card/queue/:queueItemId/confirm` with the selected team name, comment, and asset ID.
|
||||||
|
3. WHEN the user clicks the "Decline" button, THE Dashboard SHALL display a form with a team selection dropdown and an optional comment text field, then send a `POST` request to `/api/card/queue/:queueItemId/decline` with the selected team name, comment, and asset ID.
|
||||||
|
4. WHEN the user clicks the "Redirect" button, THE Dashboard SHALL display a form with a "From Team" dropdown and a "To Team" dropdown (both populated from the `/api/card/teams` endpoint), then send a `POST` request to `/api/card/queue/:queueItemId/redirect` with the from team, to team, and asset ID.
|
||||||
|
5. WHILE a CARD action request is in flight, THE Dashboard SHALL disable the action buttons and display a loading indicator on the affected queue item.
|
||||||
|
6. WHEN the CARD action request succeeds, THE Dashboard SHALL update the queue item status to `'complete'` in the local UI state without requiring a full queue refresh.
|
||||||
|
7. IF the CARD action request fails, THEN THE Dashboard SHALL display the error message returned by the backend in an inline error indicator on the affected queue item.
|
||||||
|
8. WHEN the CARD API is not configured (status endpoint returns `configured: false`), THE Dashboard SHALL disable CARD action buttons and display a tooltip indicating the integration is not configured.
|
||||||
|
9. THE Dashboard SHALL cache the teams list from `/api/card/teams` for the duration of the browser session to avoid redundant API calls.
|
||||||
|
|
||||||
|
### Requirement 7: Asset Search UI
|
||||||
|
|
||||||
|
**User Story:** As a dashboard user, I want to search CARD for assets by team and disposition, so that I can find Granite IDs when assets get reassigned.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL provide an asset search interface accessible from the Ivanti Queue page.
|
||||||
|
2. THE asset search interface SHALL include a team selection dropdown (populated from the `/api/card/teams` endpoint) and a disposition filter dropdown with options: `confirmed`, `unconfirmed`, `declined`, `candidate`.
|
||||||
|
3. WHEN the user initiates a search, THE Dashboard SHALL send a `GET` request to `/api/card/teams/:teamName/assets` with the selected disposition and `page_size=50`.
|
||||||
|
4. WHEN the first page of results is returned, THE Dashboard SHALL display the total asset count and render the first page of results in a table.
|
||||||
|
5. WHEN the total asset count exceeds the page size, THE Dashboard SHALL provide pagination controls to navigate through additional pages by sending subsequent requests with incremented `page` parameters.
|
||||||
|
6. THE asset search results table SHALL display the Asset_ID and any other identifying fields returned by the CARD_API that help the user locate the correct Granite ID.
|
||||||
|
7. IF the asset search request fails, THEN THE Dashboard SHALL display the error message returned by the backend in the search results area.
|
||||||
|
|
||||||
|
### Requirement 8: Error Handling and Resilience
|
||||||
|
|
||||||
|
**User Story:** As a dashboard user, I want clear error feedback when CARD API operations fail, so that I can understand what went wrong and take corrective action.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. IF the CARD_API is unreachable or the request times out, THEN THE CARD_Helper SHALL reject the Promise with an error message that includes the HTTP method, URL path, and failure reason.
|
||||||
|
2. IF the token acquisition endpoint returns invalid or unparseable JSON, THEN THE Token_Manager SHALL reject the Promise with a descriptive error message indicating a token parse failure.
|
||||||
|
3. IF the token acquisition endpoint returns HTTP 403, THEN THE CARD_Route SHALL return HTTP 403 with `{ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' }`.
|
||||||
|
4. IF the token acquisition endpoint returns HTTP 401, THEN THE CARD_Route SHALL return HTTP 401 with `{ error: 'CARD authorization failed. Check service account credentials.' }`.
|
||||||
|
5. IF the token acquisition endpoint returns HTTP 525, THEN THE CARD_Route SHALL return HTTP 502 with `{ error: 'CARD LDAP error. The service account may not be provisioned correctly.' }`.
|
||||||
|
6. IF a CARD_API call returns HTTP 401, THEN THE CARD_Route SHALL return HTTP 401 with `{ error: 'CARD token expired or invalid. The request has been retried once automatically.' }`.
|
||||||
|
7. IF a CARD_API call returns HTTP 403, THEN THE CARD_Route SHALL return HTTP 403 with `{ error: 'Insufficient CARD permissions for this operation.' }`.
|
||||||
|
8. THE CARD_Route SHALL catch all unhandled errors from CARD_Helper calls and return HTTP 502 with `{ error: 'CARD API request failed.', details: <error message> }`.
|
||||||
|
9. THE CARD_Route SHALL log all CARD API errors to the server console with the prefix `[card-api]` for consistent log filtering.
|
||||||
|
10. IF the CARD_Helper is not configured and a proxy endpoint is called, THEN THE CARD_Route SHALL return HTTP 503 with a message indicating which environment variables are missing.
|
||||||
|
|
||||||
|
### Requirement 9: Audit Logging for CARD Actions
|
||||||
|
|
||||||
|
**User Story:** As an administrator, I want all CARD API actions logged in the audit trail, so that I can review what CARD operations were performed and by whom.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a CARD confirm action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_confirm'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, team name, comment, and CARD_API response status.
|
||||||
|
2. WHEN a CARD decline action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_decline'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, team name, comment, and CARD_API response status.
|
||||||
|
3. WHEN a CARD redirect action is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_redirect'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the asset ID, from team, to team, and CARD_API response status.
|
||||||
|
4. WHEN a CARD asset search is executed through the dashboard, THE Audit_Logger SHALL record an entry with `action: 'card_search'`, `entityType: 'card_asset'`, `entityId` set to the team name, and `details` containing the disposition filter and result count.
|
||||||
|
5. WHEN a CARD API action fails, THE Audit_Logger SHALL record an entry with `action: 'card_action_failed'`, `entityType: 'ivanti_todo_queue'`, the queue item ID as `entityId`, and `details` containing the action type, asset ID, error message, and CARD_API response status.
|
||||||
|
6. THE Audit_Logger SHALL record the requesting user's `userId`, `username`, and `ipAddress` on all CARD audit entries.
|
||||||
|
7. THE Audit_Logger SHALL use fire-and-forget semantics for CARD audit entries, matching the existing audit logging pattern.
|
||||||
52
README.md
52
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)
|
- [Compliance — AEO Posture](#compliance--aeo-posture)
|
||||||
- [Knowledge Base](#knowledge-base)
|
- [Knowledge Base](#knowledge-base)
|
||||||
- [Exports](#exports)
|
- [Exports](#exports)
|
||||||
|
- [Jira Tickets](#jira-tickets)
|
||||||
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
- [Archer Risk Acceptance Tickets](#archer-risk-acceptance-tickets)
|
||||||
- [Admin Panel](#admin-panel)
|
- [Admin Panel](#admin-panel)
|
||||||
- [Scripts](#scripts)
|
- [Scripts](#scripts)
|
||||||
@@ -192,6 +193,20 @@ IVANTI_FIRST_NAME=
|
|||||||
IVANTI_LAST_NAME=
|
IVANTI_LAST_NAME=
|
||||||
# Set to 'true' if your network has SSL inspection / self-signed certs
|
# Set to 'true' if your network has SSL inspection / self-signed certs
|
||||||
IVANTI_SKIP_TLS=false
|
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`.
|
**`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
|
### Archer Risk Acceptance Tickets
|
||||||
|
|
||||||
Track Archer exception tickets (EXC numbers) linked to specific CVE/vendor pairs.
|
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 |
|
| POST | `/api/jira-tickets` | Admin, Standard_User | Create a JIRA ticket |
|
||||||
| PUT | `/api/jira-tickets/:id` | Admin, Standard_User | Update 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) |
|
| 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
|
### Ivanti — Host Findings
|
||||||
|
|
||||||
@@ -717,13 +762,15 @@ cve-dashboard/
|
|||||||
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
│ │ ├── ivantiFindings.js # Ivanti host findings sync, notes, overrides, FP counts
|
||||||
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
│ │ ├── ivantiTodoQueue.js # Ivanti Queue — personal FP/Archer/CARD staging list
|
||||||
│ │ ├── ivantiArchive.js # Finding archive for severity score drift
|
│ │ ├── 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
|
│ │ └── compliance.js # AEO compliance upload, diff, device tracking, notes
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── auth.js # requireAuth and requireGroup middleware
|
│ │ └── auth.js # requireAuth and requireGroup middleware
|
||||||
│ ├── helpers/
|
│ ├── helpers/
|
||||||
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
│ │ ├── auditLog.js # logAudit helper (fire-and-forget)
|
||||||
│ │ ├── driftChecker.js # Schema drift detection: compareSchemaToDrift(), loadConfig(), reconcileConfig()
|
│ │ ├── 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)
|
│ ├── migrations/ # Sequential migration scripts (run manually with node)
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ ├── compliance_config.json # Shared parser config (metric_categories, core_cols, skip_sheets)
|
│ ├── 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)
|
│ └── AuthContext.js # Auth state provider (login, logout, group helpers)
|
||||||
└── components/
|
└── components/
|
||||||
├── LoginForm.js # Login page
|
├── 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)
|
├── UserMenu.js # User dropdown in header (shows group badge)
|
||||||
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
├── CalendarWidget.js # Due-date calendar with Ivanti finding indicators
|
||||||
├── UserManagement.js # Admin user management modal (quick-access from UserMenu)
|
├── UserManagement.js # Admin user management modal (quick-access from UserMenu)
|
||||||
@@ -760,6 +807,7 @@ cve-dashboard/
|
|||||||
├── ComplianceChartsPanel.js # Compliance trend charts
|
├── ComplianceChartsPanel.js # Compliance trend charts
|
||||||
├── IvantiCountsChart.js # Ivanti counts history chart
|
├── IvantiCountsChart.js # Ivanti counts history chart
|
||||||
├── ArchiveSummaryBar.js # Finding archive summary
|
├── ArchiveSummaryBar.js # Finding archive summary
|
||||||
|
├── JiraPage.js # Jira ticket management and Jira API integration
|
||||||
├── KnowledgeBasePage.js # Knowledge base page
|
├── KnowledgeBasePage.js # Knowledge base page
|
||||||
└── ExportsPage.js # Exports page (group-gated)
|
└── ExportsPage.js # Exports page (group-gated)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -276,14 +276,15 @@ function jiraDelete(urlPath, options) {
|
|||||||
* @param {string[]} [fields] - Jira field names to return
|
* @param {string[]} [fields] - Jira field names to return
|
||||||
*/
|
*/
|
||||||
async function getIssue(issueKey, fields) {
|
async function getIssue(issueKey, fields) {
|
||||||
const fieldList = (fields || DEFAULT_FIELDS).join(',');
|
const jql = `key = "${issueKey}" AND project = ${JIRA_PROJECT_KEY}`;
|
||||||
const res = await jiraGet(
|
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||||
`/rest/api/2/issue/${encodeURIComponent(issueKey)}?fields=${encodeURIComponent(fieldList)}`
|
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||||
);
|
return { ok: true, data: result.data.issues[0] };
|
||||||
if (res.status === 200) {
|
|
||||||
return { ok: true, data: JSON.parse(res.body) };
|
|
||||||
}
|
}
|
||||||
return { ok: false, status: res.status, body: res.body, rateLimited: res.rateLimited };
|
if (result.ok && (!result.data.issues || result.data.issues.length === 0)) {
|
||||||
|
return { ok: false, status: 404, body: 'Issue not found' };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,7 +304,7 @@ async function searchIssuesByKeys(issueKeys, opts) {
|
|||||||
// or similar, but key-based search is inherently scoped. We add updated
|
// or similar, but key-based search is inherently scoped. We add updated
|
||||||
// clause for compliance.
|
// clause for compliance.
|
||||||
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
const keyList = issueKeys.map(k => `"${k}"`).join(', ');
|
||||||
const jql = `key in (${keyList}) AND updated >= -24h`;
|
const jql = `key in (${keyList}) AND updated >= -24h AND project = ${JIRA_PROJECT_KEY}`;
|
||||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
|
|
||||||
@@ -327,8 +328,10 @@ async function searchIssues(jql, opts) {
|
|||||||
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
const maxResults = Math.min((opts && opts.maxResults) || 1000, 1000);
|
||||||
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
const fields = (opts && opts.fields) || DEFAULT_FIELDS;
|
||||||
|
|
||||||
const body = { jql, startAt, maxResults, fields };
|
const fieldList = encodeURIComponent(fields.join(','));
|
||||||
const res = await jiraPost('/rest/api/2/search', body);
|
const encodedJql = encodeURIComponent(jql);
|
||||||
|
const queryString = `?jql=${encodedJql}&fields=${fieldList}&maxResults=${maxResults}&startAt=${startAt}`;
|
||||||
|
const res = await jiraGet('/rest/api/2/search' + queryString);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
return { ok: true, data: JSON.parse(res.body) };
|
return { ok: true, data: JSON.parse(res.body) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,6 +554,9 @@ function createAtlasRouter(db, requireAuth) {
|
|||||||
try {
|
try {
|
||||||
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
|
const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 });
|
||||||
|
|
||||||
|
console.log('[Atlas] POST /ivanti-vulnerabilities-by-host status:', result.status, 'body length:', result.body?.length);
|
||||||
|
console.log('[Atlas] Response preview:', result.body?.substring(0, 500));
|
||||||
|
|
||||||
if (result.status >= 200 && result.status < 300) {
|
if (result.status >= 200 && result.status < 300) {
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ function log(level, message, data) {
|
|||||||
console.log(line);
|
console.log(line);
|
||||||
if (data) {
|
if (data) {
|
||||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
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;
|
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
||||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env');
|
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 = {
|
const fields = {
|
||||||
project: { key: projectKey },
|
project: { key: projectKey },
|
||||||
summary: `[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ${new Date().toISOString()}`,
|
summary: '[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ' + new Date().toISOString(),
|
||||||
issuetype: { name: jiraApi.JIRA_ISSUE_TYPE || 'Task' },
|
issuetype: { name: issueTypeName },
|
||||||
description: 'Automated UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
|
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 });
|
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
|
||||||
|
|
||||||
const result = await jiraApi.createIssue(fields);
|
let result = await jiraApi.createIssue(fields);
|
||||||
assert(result.ok, 'Create issue should succeed. Got: ' + JSON.stringify(result));
|
|
||||||
|
// 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');
|
assert(result.data && result.data.key, 'Should return issue key');
|
||||||
|
|
||||||
createdIssueKey = result.data.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');
|
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||||
|
|
||||||
const result = await jiraApi.getIssue(createdIssueKey);
|
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;
|
const issue = result.data;
|
||||||
assert(issue.key === createdIssueKey, 'Returned key should match');
|
assert(issue.key === createdIssueKey, 'Returned key should match');
|
||||||
@@ -142,7 +204,7 @@ async function testUpdateIssue() {
|
|||||||
const result = await jiraApi.updateIssue(createdIssueKey, {
|
const result = await jiraApi.updateIssue(createdIssueKey, {
|
||||||
summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}`
|
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');
|
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 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);
|
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');
|
assert(result.data && result.data.id, 'Should return comment ID');
|
||||||
|
|
||||||
logInfo('Added comment:', { commentId: result.data.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');
|
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||||
|
|
||||||
const result = await jiraApi.getTransitions(createdIssueKey);
|
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 || [];
|
const transitions = result.data.transitions || [];
|
||||||
logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null })));
|
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})`);
|
logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`);
|
||||||
|
|
||||||
const result = await jiraApi.transitionIssue(createdIssueKey, 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');
|
logInfo('Transition successful');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,11 +272,12 @@ async function testJqlSearch() {
|
|||||||
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
||||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set');
|
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} AND updated >= -24h ORDER BY updated DESC`;
|
||||||
logInfo('Searching with JQL:', jql);
|
logInfo('Searching with JQL:', jql);
|
||||||
|
|
||||||
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
|
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;
|
const data = result.data;
|
||||||
logInfo('Search results:', {
|
logInfo('Search results:', {
|
||||||
@@ -241,7 +304,9 @@ async function testBulkKeySearch() {
|
|||||||
logInfo('Bulk searching keys:', keys);
|
logInfo('Bulk searching keys:', keys);
|
||||||
|
|
||||||
const result = await jiraApi.searchIssuesByKeys(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)));
|
||||||
|
|
||||||
|
logInfo('Bulk search uses project-scoped JQL with project = ' + jiraApi.JIRA_PROJECT_KEY);
|
||||||
|
|
||||||
const found = (result.data.issues || []).map(i => i.key);
|
const found = (result.data.issues || []).map(i => i.key);
|
||||||
logInfo('Found issues:', found);
|
logInfo('Found issues:', found);
|
||||||
@@ -287,7 +352,7 @@ async function main() {
|
|||||||
// Run tests in order — later tests depend on the created issue
|
// 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('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++;
|
||||||
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
|
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
|
||||||
if (await runTest('3. Get Single Issue (GET /issue/{key})', testGetIssue)) 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('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('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++;
|
||||||
|
|
||||||
@@ -299,7 +364,7 @@ async function main() {
|
|||||||
await testTransitionIssue(transitions);
|
await testTransitionIssue(transitions);
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (await runTest('8. JQL Search (POST /search)', testJqlSearch)) 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('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++;
|
||||||
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
|
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
|
||||||
|
|
||||||
@@ -330,7 +395,9 @@ function writeLog() {
|
|||||||
const lines = results.map(r => {
|
const lines = results.map(r => {
|
||||||
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
|
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
|
||||||
if (r.data) {
|
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;
|
return line;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
|||||||
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
||||||
const createComplianceRouter = require('./routes/compliance');
|
const createComplianceRouter = require('./routes/compliance');
|
||||||
const createAtlasRouter = require('./routes/atlas');
|
const createAtlasRouter = require('./routes/atlas');
|
||||||
|
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -238,6 +239,9 @@ app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requi
|
|||||||
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
||||||
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
||||||
|
|
||||||
|
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
||||||
|
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
@@ -1185,234 +1189,6 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== JIRA TICKET ENDPOINTS ==========
|
|
||||||
|
|
||||||
// Get all JIRA tickets (with optional filters)
|
|
||||||
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
|
||||||
const { cve_id, vendor, status } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (cve_id) {
|
|
||||||
query += ' AND cve_id = ?';
|
|
||||||
params.push(cve_id);
|
|
||||||
}
|
|
||||||
if (vendor) {
|
|
||||||
query += ' AND vendor = ?';
|
|
||||||
params.push(vendor);
|
|
||||||
}
|
|
||||||
if (status) {
|
|
||||||
query += ' AND status = ?';
|
|
||||||
params.push(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY created_at DESC';
|
|
||||||
|
|
||||||
db.all(query, params, (err, rows) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error fetching JIRA tickets:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
res.json(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create JIRA ticket
|
|
||||||
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
||||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!cve_id || !isValidCveId(cve_id)) {
|
|
||||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
|
||||||
}
|
|
||||||
if (!vendor || !isValidVendor(vendor)) {
|
|
||||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
|
||||||
}
|
|
||||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
|
||||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
|
||||||
}
|
|
||||||
if (url && (typeof url !== 'string' || url.length > 500)) {
|
|
||||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
|
||||||
}
|
|
||||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
|
||||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
|
||||||
}
|
|
||||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
|
||||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketStatus = status || 'Open';
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error creating JIRA ticket:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'jira_ticket_create',
|
|
||||||
entityType: 'jira_ticket',
|
|
||||||
entityId: this.lastID.toString(),
|
|
||||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
id: this.lastID,
|
|
||||||
message: 'JIRA ticket created successfully'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update JIRA ticket
|
|
||||||
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { ticket_key, url, summary, status } = req.body;
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
|
||||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
|
||||||
}
|
|
||||||
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
|
||||||
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
|
||||||
}
|
|
||||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
|
||||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
|
||||||
}
|
|
||||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
|
||||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dynamic update
|
|
||||||
const fields = [];
|
|
||||||
const values = [];
|
|
||||||
|
|
||||||
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
|
||||||
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
|
||||||
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
|
||||||
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'No fields to update.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
||||||
values.push(id);
|
|
||||||
|
|
||||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
if (!existing) {
|
|
||||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
|
||||||
if (updateErr) {
|
|
||||||
console.error('Error updating JIRA ticket:', updateErr);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'jira_ticket_update',
|
|
||||||
entityType: 'jira_ticket',
|
|
||||||
entityId: id,
|
|
||||||
details: { before: existing, changes: req.body },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete JIRA ticket
|
|
||||||
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
if (!ticket) {
|
|
||||||
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin bypasses all delete restrictions
|
|
||||||
if (req.user.group === 'Admin') {
|
|
||||||
return performJiraDelete();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard_User: ownership check
|
|
||||||
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'You can only delete resources you created' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard_User: compliance linkage check
|
|
||||||
const ticketKey = ticket.ticket_key;
|
|
||||||
db.all(
|
|
||||||
`SELECT ci.id, ci.extra_json
|
|
||||||
FROM compliance_items ci
|
|
||||||
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
|
||||||
WHERE ci.status = 'active' AND ci.extra_json LIKE ?`,
|
|
||||||
[`%${ticketKey}%`],
|
|
||||||
(compErr, compLinks) => {
|
|
||||||
// If compliance_items table doesn't exist yet, treat as no linkage
|
|
||||||
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
|
||||||
compLinks = [];
|
|
||||||
} else if (compErr) {
|
|
||||||
console.error(compErr);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLinked = (compLinks || []).some(cl => {
|
|
||||||
const json = cl.extra_json || '';
|
|
||||||
return json.includes(ticketKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLinked) {
|
|
||||||
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return performJiraDelete();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function performJiraDelete() {
|
|
||||||
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
|
||||||
if (deleteErr) {
|
|
||||||
console.error('Error deleting JIRA ticket:', deleteErr);
|
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit(db, {
|
|
||||||
userId: req.user.id,
|
|
||||||
username: req.user.username,
|
|
||||||
action: 'jira_ticket_delete',
|
|
||||||
entityType: 'jira_ticket',
|
|
||||||
entityId: id,
|
|
||||||
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
|
||||||
ipAddress: req.ip
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'JIRA ticket deleted successfully' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||||
|
|||||||
BIN
docs/TeamDeviceLoader_matched_findings.xlsx
Normal file
BIN
docs/TeamDeviceLoader_matched_findings.xlsx
Normal file
Binary file not shown.
BIN
docs/Team_Device Loader.xlsx
Normal file
BIN
docs/Team_Device Loader.xlsx
Normal file
Binary file not shown.
BIN
docs/graniteexport.xlsx
Normal file
BIN
docs/graniteexport.xlsx
Normal file
Binary file not shown.
@@ -19,8 +19,9 @@ All API calls are made from a single Node.js backend process. The integration us
|
|||||||
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
|
| Inter-request delay — writes | 2 seconds minimum between PUT/POST/DELETE requests |
|
||||||
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
|
| Explicit field lists | Every GET includes `?fields=` parameter; `/rest/api/2/field` is blocked |
|
||||||
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
|
| No bulk updates | Issues are updated one at a time; `/rest/api/2/issue/bulk` is blocked |
|
||||||
| Bulk reads via JQL | Multi-ticket sync uses a single `POST /rest/api/2/search` with JQL, not per-issue GETs |
|
| Bulk reads via JQL | Multi-ticket sync uses a single `GET /rest/api/2/search` with JQL query parameters, not per-issue GETs |
|
||||||
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause |
|
| Single-issue fetch via JQL | `GET /rest/api/2/search?jql=key="KEY" AND project=<KEY>&fields=...&maxResults=1` |
|
||||||
|
| JQL scoping | All recurring JQL queries include `updated >= -Xh` clause and `project = <KEY>` scoping |
|
||||||
| `maxResults` cap | Search queries capped at 1 000 results per page |
|
| `maxResults` cap | Search queries capped at 1 000 results per page |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -52,11 +53,11 @@ All API calls are made from a single Node.js backend process. The integration us
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution` |
|
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=summary,status,assignee,created,updated,priority,issuetype,project,resolution&maxResults=1` |
|
||||||
| **Trigger** | User clicks "Sync" on a single Jira ticket row |
|
| **Trigger** | User clicks "Sync" on a single Jira ticket row |
|
||||||
| **Frequency** | Manual, estimated 10–30 per day |
|
| **Frequency** | Manual, estimated 10–30 per day |
|
||||||
| **Purpose** | Refresh a single ticket's status and summary from Jira |
|
| **Purpose** | Refresh a single ticket's status and summary from Jira via JQL search |
|
||||||
| **Notes** | Fields are always specified explicitly per Charter requirement |
|
| **Notes** | Uses JQL-based lookup instead of single-issue GET per Charter compliance. Fields are always specified explicitly. |
|
||||||
|
|
||||||
### 4. Update Issue
|
### 4. Update Issue
|
||||||
|
|
||||||
@@ -99,23 +100,23 @@ All API calls are made from a single Node.js backend process. The integration us
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Endpoint** | `POST /rest/api/2/search` |
|
| **Endpoint** | `GET /rest/api/2/search?jql=...&fields=...&maxResults=...&startAt=...` |
|
||||||
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
|
| **Trigger** | Admin clicks "Sync All" on the Jira tickets panel |
|
||||||
| **Frequency** | Manual, estimated 1–3 times per day |
|
| **Frequency** | Manual, estimated 1–3 times per day |
|
||||||
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
|
| **Purpose** | Bulk-refresh all tracked tickets in a single request instead of per-issue GETs |
|
||||||
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h` |
|
| **JQL pattern** | `key in ("KEY-1", "KEY-2", ...) AND updated >= -24h AND project = <KEY>` |
|
||||||
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
|
| **Fields requested** | `summary, status, assignee, created, updated, priority, issuetype, project, resolution` |
|
||||||
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
|
| **Batch size** | 100 keys per JQL query; multiple batches if needed |
|
||||||
| **Notes** | Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
|
| **Notes** | Uses GET with URL-encoded query parameters per Charter compliance. Stops early if rate limit budget is running low (burst remaining <= 5 or daily remaining <= 10) |
|
||||||
|
|
||||||
### 9. Issue Lookup
|
### 9. Issue Lookup
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Endpoint** | `GET /rest/api/2/issue/{issueKey}?fields=...` |
|
| **Endpoint** | `GET /rest/api/2/search?jql=key="ISSUE-KEY" AND project=<KEY>&fields=...&maxResults=1` |
|
||||||
| **Trigger** | User looks up a Jira issue by key from the dashboard search |
|
| **Trigger** | User looks up a Jira issue by key from the dashboard search |
|
||||||
| **Frequency** | Manual, estimated 5–15 per day |
|
| **Frequency** | Manual, estimated 5–15 per day |
|
||||||
| **Purpose** | Quick lookup of any Jira issue to view its current state |
|
| **Purpose** | Quick lookup of any Jira issue to view its current state via JQL search |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ All API calls are made from a single Node.js backend process. The integration us
|
|||||||
| Add comment | 5–15 | POST | 2s |
|
| Add comment | 5–15 | POST | 2s |
|
||||||
| Get transitions | 5–10 | GET | 1s |
|
| Get transitions | 5–10 | GET | 1s |
|
||||||
| Transition issue | 5–10 | POST | 2s |
|
| Transition issue | 5–10 | POST | 2s |
|
||||||
| JQL search (sync) | 1–5 | POST | 2s |
|
| JQL search (sync) | 1–5 | GET | 1s |
|
||||||
| Issue lookup | 5–15 | GET | 1s |
|
| Issue lookup | 5–15 | GET | 1s |
|
||||||
| **Total estimated** | **43–120** | | |
|
| **Total estimated** | **43–120** | | |
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
|||||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||||
import ExportsPage from './components/pages/ExportsPage';
|
import ExportsPage from './components/pages/ExportsPage';
|
||||||
import CompliancePage from './components/pages/CompliancePage';
|
import CompliancePage from './components/pages/CompliancePage';
|
||||||
|
import JiraPage from './components/pages/JiraPage';
|
||||||
import AdminPage from './components/pages/AdminPage';
|
import AdminPage from './components/pages/AdminPage';
|
||||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -1043,6 +1044,7 @@ export default function App() {
|
|||||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
{currentPage === 'jira' && <JiraPage />}
|
||||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings } from 'lucide-react';
|
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -8,6 +8,7 @@ const NAV_ITEMS = [
|
|||||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||||
|
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||||
|
|||||||
Reference in New Issue
Block a user