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:
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the multipart form fields array for the Ivanti FP workflow request.
|
||||
*/
|
||||
function buildIvantiFormFields(formData, findingIds) {
|
||||
const scopeMap = {
|
||||
'Authorized': 'AUTHORIZED',
|
||||
'None': 'NONE'
|
||||
'None': 'NONE',
|
||||
'Automated': 'AUTOMATED'
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return payload;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user