fix: rewrite FP workflow to use Ivanti multipart/form-data API

The /workflowBatch/falsePositive/request endpoint expects
multipart/form-data with text fields (name, reason, description,
expirationDate, overrideControl, subjectFilterRequest, isEmptyWorkflow)
and inline file uploads — not a JSON body with separate attachment calls.

- Add ivantiFormPost() helper for mixed form fields + files
- Replace buildIvantiPayload with buildIvantiFormFields + buildSubjectFilterRequest
- Remove separate attachment upload loop (files sent inline)
- Update response handling for { id, created } shape
This commit is contained in:
jramos
2026-04-08 10:18:45 -06:00
parent ee9403ab47
commit 03e60c9daf
2 changed files with 114 additions and 59 deletions

View File

@@ -88,4 +88,67 @@ function ivantiMultipartPost(urlPath, fileBuffer, fileName, apiKey, skipTls) {
});
}
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost };
// ---------------------------------------------------------------------------
// Multipart form POST — used for endpoints that accept mixed form fields + files.
// fields: array of { name, value } for text form fields
// files: array of { name, buffer, filename } for file uploads
// ---------------------------------------------------------------------------
function ivantiFormPost(urlPath, fields, files, apiKey, skipTls) {
const boundary = '----IvantiForm' + Date.now().toString(36) + Math.random().toString(36).slice(2);
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
const parts = [];
// Text fields
for (const { name, value } of fields) {
parts.push(Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
`${value}\r\n`
));
}
// File fields
for (const { name, buffer, filename } of files) {
parts.push(Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`
));
parts.push(buffer);
parts.push(Buffer.from('\r\n'));
}
parts.push(Buffer.from(`--${boundary}--\r\n`));
const bodyBuffer = Buffer.concat(parts);
return new Promise((resolve, reject) => {
const options = {
hostname: fullUrl.hostname,
path: fullUrl.pathname + fullUrl.search,
method: 'POST',
headers: {
'accept': '*/*',
'content-type': `multipart/form-data; boundary=${boundary}`,
'x-api-key': apiKey,
'x-http-client-type': 'browser',
'content-length': bodyBuffer.length
},
rejectUnauthorized: !skipTls,
timeout: 60000
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('timeout', () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
req.write(bodyBuffer);
req.end();
});
}
module.exports = { IVANTI_URL_BASE, ivantiPost, ivantiMultipartPost, ivantiFormPost };

View File

@@ -3,7 +3,7 @@ const express = require('express');
const multer = require('multer');
const path = require('path');
const { requireGroup } = require('../middleware/auth');
const { ivantiPost, ivantiMultipartPost } = require('../helpers/ivantiApi');
const { ivantiFormPost } = require('../helpers/ivantiApi');
const logAudit = require('../helpers/auditLog');
// ---------------------------------------------------------------------------
@@ -74,29 +74,42 @@ function validateFpWorkflowForm(body) {
}
/**
* Constructs the Ivanti API request body for an FP workflow batch.
* Builds the subjectFilterRequest JSON for the Ivanti FP workflow endpoint.
* This is a stringified filter that tells Ivanti which host findings to include.
*/
function buildIvantiPayload(formData, findingIds) {
const scopeMap = {
'Authorized': 'AUTHORIZED',
'None': 'NONE'
};
const payload = {
type: 'FALSE_POSITIVE',
subType: 'FALSE_POSITIVE',
name: formData.name,
reason: formData.reason,
expirationDate: formData.expirationDate,
scopeOverrideAuthorization: scopeMap[formData.scopeOverride] || 'AUTHORIZED',
hostFindingIds: findingIds.map(id => parseInt(id, 10))
};
if (formData.description && formData.description.trim().length > 0) {
payload.description = formData.description;
function buildSubjectFilterRequest(findingIds) {
return JSON.stringify({
filters: [{
field: 'id',
exclusive: false,
operator: 'IN',
orWithPrevious: false,
implicitFilters: [],
value: findingIds.map(id => String(id)).join(',')
}],
subject: 'hostFinding'
});
}
return payload;
/**
* 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' }
];
}
// ---------------------------------------------------------------------------
@@ -241,13 +254,14 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.', step: 'create_workflow' });
}
// 1. Build payload and call Ivanti API to create workflow batch
const payload = buildIvantiPayload(req.body, findingIds);
// 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 ivantiPost(createUrl, payload, apiKey, skipTls);
createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls);
} catch (networkErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
@@ -281,12 +295,11 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse);
}
// 2. Parse workflow batch response
let workflowBatchId, generatedId;
// 2. Parse workflow batch response — API returns { id, created }
let workflowBatchId;
try {
const createData = JSON.parse(createResult.body);
workflowBatchId = createData.id;
generatedId = createData.generatedId;
} catch (parseErr) {
logAudit(db, {
userId: req.user.id, username: req.user.username,
@@ -297,30 +310,10 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' });
}
// 3. Upload attachments (if any)
const attachmentResults = [];
for (const file of files) {
try {
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(workflowBatchId)}/attach`;
const attachResult = await ivantiMultipartPost(attachUrl, file.buffer, file.originalname, apiKey, skipTls);
if (attachResult.status === 200 || attachResult.status === 201) {
attachmentResults.push({ filename: file.originalname, success: true });
} else {
attachmentResults.push({ filename: file.originalname, success: false, error: `Status ${attachResult.status}` });
}
} catch (attachErr) {
attachmentResults.push({ filename: file.originalname, success: false, error: attachErr.message });
}
}
// 3. Determine submission status (files sent inline, so success if we got here)
const status = 'success';
// 4. Determine submission status
const failedAttachments = attachmentResults.filter(r => !r.success);
let status = 'success';
if (files.length > 0 && failedAttachments.length > 0) {
status = 'partial';
}
// 5. Insert submission record
// 4. Insert submission record
try {
await new Promise((resolve, reject) => {
db.run(
@@ -330,7 +323,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
req.user.id,
req.user.username,
workflowBatchId,
generatedId,
null, // generatedId not returned by this endpoint
req.body.name,
req.body.reason,
req.body.description || null,
@@ -339,9 +332,9 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
JSON.stringify(findingIds),
JSON.stringify(queueItemIds),
files.length,
JSON.stringify(attachmentResults),
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
status,
failedAttachments.length > 0 ? `${failedAttachments.length} attachment(s) failed` : null
null
],
(err) => { if (err) reject(err); else resolve(); }
);
@@ -351,16 +344,16 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
// Don't fail the response — the Ivanti workflow was created
}
// 6. Log audit entry
// 5. Log audit entry
logAudit(db, {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
entityId: generatedId,
entityId: String(workflowBatchId),
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
ipAddress: req.ip
});
// 7. Mark queue items as complete
// 6. Mark queue items as complete
let queueItemsUpdated = 0;
try {
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
@@ -376,12 +369,10 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
// Don't fail — workflow was created
}
// 8. Return response
// 7. Return response
res.json({
success: true,
workflowBatchId,
generatedId,
attachmentResults,
queueItemsUpdated,
status
});
@@ -399,5 +390,6 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
module.exports = createIvantiFpWorkflowRouter;
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
module.exports.buildIvantiPayload = buildIvantiPayload;
module.exports.buildIvantiFormFields = buildIvantiFormFields;
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
module.exports.isAllowedFileExtension = isAllowedFileExtension;