Files
cve-dashboard/backend/routes/ivantiFpWorkflow.js
jramos 5d417edf82 fix: align subjectFilterRequest with Ivanti search filter schema
Remove extra fields (orWithPrevious, implicitFilters, subject) that
aren't in the Swagger filter schema. Add projection and sort fields
to match the search endpoint format.
2026-04-08 12:08:08 -06:00

396 lines
19 KiB
JavaScript

// routes/ivantiFpWorkflow.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const { requireGroup } = require('../middleware/auth');
const { ivantiFormPost } = require('../helpers/ivantiApi');
const logAudit = require('../helpers/auditLog');
// ---------------------------------------------------------------------------
// Pure helpers (exported for testing)
// ---------------------------------------------------------------------------
const ALLOWED_EXTENSIONS = new Set([
'.pdf', '.png', '.jpg', '.jpeg', '.gif',
'.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'
]);
/**
* Returns true if the filename has an allowed extension (case-insensitive).
*/
function isAllowedFileExtension(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
return ALLOWED_EXTENSIONS.has(ext);
}
/**
* Validates the FP workflow form body.
* Returns {} if valid, or { fieldName: 'error message' } for each invalid field.
*/
function validateFpWorkflowForm(body) {
const errors = {};
// name: required, non-empty, max 255
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
errors.name = 'Workflow name is required.';
} else if (body.name.trim().length > 255) {
errors.name = 'Workflow name must be 255 characters or fewer.';
}
// reason: required, non-empty
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
errors.reason = 'Reason is required.';
}
// description: optional, max 2000 if provided
if (body.description !== undefined && body.description !== null && body.description !== '') {
if (typeof body.description !== 'string') {
errors.description = 'Description must be a string.';
} else if (body.description.length > 2000) {
errors.description = 'Description must be 2000 characters or fewer.';
}
}
// expirationDate: required, valid date, strictly after today
if (!body.expirationDate || typeof body.expirationDate !== 'string' || body.expirationDate.trim().length === 0) {
errors.expirationDate = 'Expiration date is required.';
} else {
const parsed = new Date(body.expirationDate);
if (isNaN(parsed.getTime())) {
errors.expirationDate = 'Expiration date must be a valid date.';
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expDay = new Date(parsed);
expDay.setHours(0, 0, 0, 0);
if (expDay <= today) {
errors.expirationDate = 'Expiration date must be in the future.';
}
}
}
return errors;
}
/**
* Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint.
* This is a stringified filter that tells Ivanti which host findings to include.
* Format matches the search endpoint filter schema: { filters, projection, sort }
*/
function buildSubjectFilterRequest(findingIds) {
return JSON.stringify({
filters: [{
field: 'id',
exclusive: false,
operator: 'IN',
value: findingIds.map(id => String(id)).join(',')
}],
projection: 'internal',
sort: [{ field: 'id', direction: 'ASC' }]
});
}
/**
* Builds the multipart form fields array for the Ivanti FP workflow request.
*/
function buildIvantiFormFields(formData, findingIds) {
const scopeMap = {
'Authorized': 'AUTHORIZED',
'None': 'NONE',
'Automated': 'AUTOMATED'
};
return [
{ name: 'name', value: formData.name },
{ name: 'reason', value: formData.reason },
{ name: 'description', value: formData.description || '' },
{ name: 'expirationDate', value: formData.expirationDate },
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
];
}
// ---------------------------------------------------------------------------
// Multer configuration
// ---------------------------------------------------------------------------
const uploadStorage = multer.memoryStorage();
const fpUpload = multer({
storage: uploadStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file
fileFilter: (req, file, cb) => {
if (isAllowedFileExtension(file.originalname)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${path.extname(file.originalname)}`));
}
}
}).array('attachments', 10); // up to 10 files
// ---------------------------------------------------------------------------
// Router factory
// ---------------------------------------------------------------------------
function createIvantiFpWorkflowRouter(db, requireAuth) {
const router = express.Router();
/**
* POST /api/ivanti/fp-workflow
*
* Creates a False Positive workflow batch in the Ivanti/RiskSense API,
* optionally uploads file attachments, records the submission locally,
* and marks the associated queue items as complete.
*
* Content-Type: multipart/form-data
*
* @param {string} req.body.name - Workflow name (required, max 255 chars)
* @param {string} req.body.reason - Reason for the FP determination (required)
* @param {string} [req.body.description] - Additional description (optional, max 2000 chars)
* @param {string} req.body.expirationDate - ISO date string, must be a future date (required)
* @param {string} [req.body.scopeOverride] - "Authorized" (default) or "None"
* @param {string} req.body.findingIds - JSON-encoded array of Ivanti finding IDs
* @param {string} req.body.queueItemIds - JSON-encoded array of local queue item IDs
* @param {File[]} [req.files] - Up to 10 file attachments (max 10 MB each);
* allowed extensions: .pdf .png .jpg .jpeg .gif
* .doc .docx .xlsx .csv .txt .zip
*
* @returns {object} 200 - Success
* { success: true, workflowBatchId: number, generatedId: string,
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
* queueItemsUpdated: number, status: 'success' | 'partial' }
* @returns {object} 400 - Validation error
* { error: string } or { success: false, errors: { [field]: string } }
* @returns {object} 403 - Queue item ownership violation
* { error: string }
* @returns {object} 429 - Ivanti rate limit
* { success: false, error: string, step: 'create_workflow' }
* @returns {object} 500 - Server configuration error
* { success: false, error: string, step: 'create_workflow' }
* @returns {object} 502 - Ivanti API error
* { success: false, error: string, step: 'create_workflow', details?: string }
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
fpUpload(req, res, (multerErr) => {
if (multerErr) {
if (multerErr.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' });
}
return res.status(400).json({ error: multerErr.message });
}
// --- Parse JSON-encoded arrays from the multipart body ---
let findingIds, queueItemIds;
try {
findingIds = JSON.parse(req.body.findingIds || '[]');
queueItemIds = JSON.parse(req.body.queueItemIds || '[]');
} catch (e) {
return res.status(400).json({ error: 'findingIds and queueItemIds must be valid JSON arrays.' });
}
if (!Array.isArray(findingIds) || findingIds.length === 0) {
return res.status(400).json({ error: 'At least one finding ID is required.' });
}
if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) {
return res.status(400).json({ error: 'At least one queue item ID is required.' });
}
// --- Validate form fields ---
const validationErrors = validateFpWorkflowForm(req.body);
if (Object.keys(validationErrors).length > 0) {
return res.status(400).json({ success: false, errors: validationErrors });
}
// --- Validate file extensions (belt-and-suspenders with Multer filter) ---
const files = req.files || [];
for (const file of files) {
if (!isAllowedFileExtension(file.originalname)) {
return res.status(400).json({
error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`
});
}
}
// --- Verify queue items belong to user, are FP type, and pending ---
const placeholders = queueItemIds.map(() => '?').join(',');
db.all(
`SELECT id, workflow_type, status, user_id
FROM ivanti_todo_queue
WHERE id IN (${placeholders})`,
queueItemIds,
(err, rows) => {
if (err) {
console.error('Error verifying queue items:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
// Check all items were found
if (!rows || rows.length !== queueItemIds.length) {
return res.status(400).json({ error: 'One or more queue items not found.' });
}
// Check ownership, type, and status
for (const row of rows) {
if (row.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only submit your own queue items.' });
}
if (row.workflow_type !== 'FP') {
return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` });
}
if (row.status !== 'pending') {
return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` });
}
}
// --- Validation passed — submit to Ivanti API ---
(async () => {
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
if (!apiKey) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' });
}
// 1. Build form fields and call Ivanti API (multipart/form-data)
const formFields = buildIvantiFormFields(req.body, findingIds);
const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
let createResult;
try {
createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls);
} catch (networkErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: networkErr.message, findingIds },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', step: 'create_workflow', details: networkErr.message });
}
// Handle error responses from Ivanti
if (createResult.status !== 200 && createResult.status !== 201) {
const errorMap = {
401: 'Ivanti API key is invalid or missing.',
419: 'API key lacks workflow creation permissions.',
429: 'Ivanti API rate limit reached. Please try again in a few minutes.'
};
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
const errorResponse = { success: false, error: errorMsg, step: 'create_workflow' };
if (!errorMap[createResult.status]) {
errorResponse.details = createResult.body;
}
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: errorMsg, status: createResult.status, findingIds },
ipAddress: req.ip
});
return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse);
}
// 2. Parse workflow batch response — API returns { id, created }
let workflowBatchId;
try {
const createData = JSON.parse(createResult.body);
workflowBatchId = createData.id;
} catch (parseErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow',
details: { error: 'Failed to parse Ivanti response', responseBody: createResult.body },
ipAddress: req.ip
});
return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' });
}
// 3. Determine submission status (files sent inline, so success if we got here)
const status = 'success';
// 4. Insert submission record
try {
await new Promise((resolve, reject) => {
db.run(
`INSERT INTO ivanti_fp_submissions (user_id, username, ivanti_workflow_batch_id, ivanti_generated_id, workflow_name, reason, description, expiration_date, scope_override, finding_ids_json, queue_item_ids_json, attachment_count, attachment_results_json, status, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
req.user.id,
req.user.username,
workflowBatchId,
null, // generatedId not returned by this endpoint
req.body.name,
req.body.reason,
req.body.description || null,
req.body.expirationDate,
req.body.scopeOverride || 'Authorized',
JSON.stringify(findingIds),
JSON.stringify(queueItemIds),
files.length,
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
status,
null
],
(err) => { if (err) reject(err); else resolve(); }
);
});
} catch (dbErr) {
console.error('Failed to insert submission record:', dbErr);
// Don't fail the response — the Ivanti workflow was created
}
// 5. Log audit entry
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
entityId: String(workflowBatchId),
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
ipAddress: req.ip
});
// 6. Mark queue items as complete
let queueItemsUpdated = 0;
try {
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
queueItemsUpdated = await new Promise((resolve, reject) => {
db.run(
`UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`,
[...queueItemIds, req.user.id],
function (err) { if (err) reject(err); else resolve(this.changes); }
);
});
} catch (queueErr) {
console.error('Failed to update queue items:', queueErr);
// Don't fail — workflow was created
}
// 7. Return response
res.json({
success: true,
workflowBatchId,
queueItemsUpdated,
status
});
})().catch((unexpectedErr) => {
console.error('Unexpected error in FP workflow submission:', unexpectedErr);
res.status(500).json({ success: false, error: 'Internal server error.' });
});
}
);
});
});
return router;
}
module.exports = createIvantiFpWorkflowRouter;
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
module.exports.buildIvantiFormFields = buildIvantiFormFields;
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
module.exports.isAllowedFileExtension = isAllowedFileExtension;