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.
396 lines
19 KiB
JavaScript
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;
|