Files
cve-dashboard/backend/routes/ivantiTodoQueue.js
jramos 0fe8e94d51 feat: add GRANITE as fourth workflow type in Ivanti queue
- Add GRANITE to VALID_WORKFLOW_TYPES in backend (no vendor required, same as CARD)
- Update vendor validation and error messages across all endpoints (single add, batch, PUT, redirect)
- Add GRANITE option to RedirectModal with warm slate color (#A1887F)
- Rename QueuePanel CARD section to Inventory, group CARD + GRANITE with sub-divider
- Add GRANITE to AddToQueuePopover and SelectionToolbar
- Update spec docs (requirements, design, tasks)
2026-04-14 15:38:22 -06:00

570 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// routes/ivantiTodoQueue.js
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE'];
const VALID_STATUSES = ['pending', 'complete'];
function isValidVendor(vendor) {
if (typeof vendor !== 'string') return false;
const trimmed = vendor.trim();
return trimmed.length > 0 && trimmed.length <= 200;
}
function createIvantiTodoQueueRouter(db, requireAuth) {
const router = express.Router();
/**
* GET /api/ivanti/todo-queue
*
* Fetch the current user's queue items, ordered by vendor then created_at.
*
* @returns {Array<Object>} 200 - Array of queue items, each with:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/', requireAuth(db), (req, res) => {
db.all(
`SELECT q.*,
o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.user_id = ?
ORDER BY q.vendor ASC, q.created_at ASC`,
[req.user.id],
(err, rows) => {
if (err) {
console.error('Error fetching todo queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Parse cves_json back to array; prefer overridden hostname
const parsed = rows.map((r) => ({
...r,
hostname: r.override_hostname || r.hostname,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
// Clean up the extra column from the response
parsed.forEach((r) => delete r.override_hostname);
res.json(parsed);
}
);
});
/**
* POST /api/ivanti/todo-queue/batch
*
* Add multiple findings to the current user's queue in a single transaction.
*
* @body {Object[]} findings - Required array of 1200 finding objects
* @body {string} findings[].finding_id - Required, non-empty finding identifier
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
*
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
*/
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findings, workflow_type, vendor } = req.body;
// --- Validation ---
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
}
for (let i = 0; i < findings.length; i++) {
const f = findings[i];
if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) {
return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' });
}
}
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
}
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
}
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
const userId = req.user.id;
// --- Transactional batch insert ---
// Prepare all row values upfront
const rows = findings.map((f) => {
const findingId = f.finding_id.trim();
const title = f.finding_title && typeof f.finding_title === 'string'
? f.finding_title.slice(0, 500)
: null;
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
const ipVal = f.ip_address && typeof f.ip_address === 'string'
? f.ip_address.trim().slice(0, 64)
: null;
const hostVal = f.hostname && typeof f.hostname === 'string'
? f.hostname.trim().slice(0, 255)
: null;
return [userId, findingId, title, cvesJson, ipVal, hostVal, vendorVal, workflow_type];
});
const insertedIds = [];
let insertError = null;
let remaining = rows.length;
db.serialize(() => {
db.run('BEGIN TRANSACTION');
rows.forEach((params) => {
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
params,
function (err) {
if (err && !insertError) {
insertError = err;
} else if (!err) {
insertedIds.push(this.lastID);
}
remaining--;
// After all insert callbacks have fired, commit or rollback
if (remaining === 0) {
if (insertError) {
db.run('ROLLBACK', () => {
console.error('Batch insert error:', insertError);
return res.status(500).json({ error: 'Internal server error.' });
});
} else {
db.run('COMMIT', (commitErr) => {
if (commitErr) {
console.error('Batch commit error:', commitErr);
db.run('ROLLBACK', () => {});
return res.status(500).json({ error: 'Internal server error.' });
}
// Fetch all inserted rows
const placeholders = insertedIds.map(() => '?').join(',');
db.all(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id IN (${placeholders})`,
insertedIds,
(fetchErr, fetchedRows) => {
if (fetchErr) {
console.error('Error fetching inserted batch rows:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const items = (fetchedRows || []).map((r) => {
const item = {
...r,
hostname: r.override_hostname || r.hostname,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
};
delete item.override_hostname;
return item;
});
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'batch_add_to_queue',
entityType: 'ivanti_todo_queue',
entityId: null,
details: {
count: insertedIds.length,
workflow_type: workflow_type,
finding_ids: findings.map((f) => f.finding_id.trim()),
},
ipAddress: req.ip,
});
return res.status(201).json({ items });
}
);
});
}
}
}
);
});
});
});
/**
* POST /api/ivanti/todo-queue
*
* Add a single finding to the current user's queue.
*
* @body {string} finding_id - Required, non-empty finding identifier
* @body {string} [finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [cves] - Optional array of CVE identifiers
* @body {string} [ip_address] - Optional IP address (max 64 chars)
* @body {string} [hostname] - Optional hostname (max 255 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
*
* @returns {Object} 201 - Created queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, hostname, vendor, workflow_type } = req.body;
if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) {
return res.status(400).json({ error: 'finding_id is required.' });
}
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
}
// Vendor is required for FP and Archer, optional for CARD/GRANITE
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null;
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
const title = finding_title && typeof finding_title === 'string'
? finding_title.slice(0, 500)
: null;
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, finding_id.trim(), title, cvesJson, ipVal, hostVal, vendorVal, workflow_type],
function (err) {
if (err) {
console.error('Error adding to queue:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id = ?`,
[this.lastID],
(err2, row) => {
if (err2 || !row) {
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
}
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
res.status(201).json(result);
}
);
}
);
});
/**
* PUT /api/ivanti/todo-queue/:id
*
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
* @body {string} [vendor] - New vendor string (max 200 chars)
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE'
* @body {string} [status] - One of 'pending', 'complete'
*
* @returns {Object} 200 - Updated queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { vendor, workflow_type, status } = req.body;
if (vendor !== undefined && !isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
}
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
}
if (status !== undefined && !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: 'status must be pending or complete.' });
}
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.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: 'Queue item not found.' });
}
const updates = [];
const params = [];
if (vendor !== undefined) {
updates.push('vendor = ?');
params.push(vendor.trim());
}
if (workflow_type !== undefined) {
updates.push('workflow_type = ?');
params.push(workflow_type);
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update.' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(id, req.user.id);
db.run(
`UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params,
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
db.get(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id = ?`,
[id],
(err3, row) => {
if (err3 || !row) {
return res.json({ message: 'Queue item updated.' });
}
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
res.json(result);
}
);
}
);
}
);
});
/**
* POST /api/ivanti/todo-queue/:id/redirect
*
* Redirect a completed queue item to a different workflow type.
* Creates a new pending item copying finding data from the original.
*
* @param {string} id - Original queue item ID (URL parameter)
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE'
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE
*
* @returns {Object} 201 - Newly created queue item with parsed cves array
* @returns {Object} 400 - { error: string } on validation failure or item not complete
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { workflow_type, vendor } = req.body;
// --- Validation ---
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
}
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
}
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
// --- Fetch original item scoped to current user ---
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, original) => {
if (err) {
console.error('Error fetching queue item for redirect:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!original) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (original.status !== 'complete') {
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
}
// --- INSERT new row copying finding data from original ---
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
function (insertErr) {
if (insertErr) {
console.error('Error inserting redirected queue item:', insertErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const newId = this.lastID;
// --- Fetch the inserted row ---
db.get(
`SELECT q.*, o.value AS override_hostname
FROM ivanti_todo_queue q
LEFT JOIN ivanti_finding_overrides o
ON o.finding_id = q.finding_id AND o.field = 'hostName'
WHERE q.id = ?`,
[newId],
(fetchErr, row) => {
if (fetchErr || !row) {
console.error('Error fetching redirected queue item:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'queue_item_redirected',
entityType: 'ivanti_todo_queue',
entityId: String(original.id),
details: {
original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type,
new_item_id: newId,
vendor: vendorVal,
},
ipAddress: req.ip,
});
const result = {
...row,
hostname: row.override_hostname || row.hostname,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
};
delete result.override_hostname;
return res.status(201).json(result);
}
);
}
);
}
);
});
/**
* DELETE /api/ivanti/todo-queue/completed
*
* Bulk-delete all completed items for the current user.
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
*
* @returns {Object} 200 - { message: string, deleted: number }
* @returns {Object} 500 - { error: string } on database error
*/
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
[req.user.id],
function (err) {
if (err) {
console.error('Error clearing completed queue items:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Completed items cleared.', deleted: this.changes });
}
);
});
/**
* DELETE /api/ivanti/todo-queue/:id
*
* Delete a single queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
*
* @returns {Object} 200 - { message: string }
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
db.get(
'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, row) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!row) {
return res.status(404).json({ error: 'Queue item not found.' });
}
db.run(
'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
function (err2) {
if (err2) {
console.error(err2);
return res.status(500).json({ error: 'Internal server error.' });
}
res.json({ message: 'Queue item deleted.' });
}
);
}
);
});
return router;
}
module.exports = createIvantiTodoQueueRouter;