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 multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { requireGroup } = require('../middleware/auth');
|
const { requireGroup } = require('../middleware/auth');
|
||||||
const { ivantiPost, ivantiMultipartPost } = require('../helpers/ivantiApi');
|
const { ivantiFormPost } = require('../helpers/ivantiApi');
|
||||||
const logAudit = require('../helpers/auditLog');
|
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 = {
|
const scopeMap = {
|
||||||
'Authorized': 'AUTHORIZED',
|
'Authorized': 'AUTHORIZED',
|
||||||
'None': 'NONE'
|
'None': 'NONE',
|
||||||
|
'Automated': 'AUTOMATED'
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload = {
|
return [
|
||||||
type: 'FALSE_POSITIVE',
|
{ name: 'name', value: formData.name },
|
||||||
subType: 'FALSE_POSITIVE',
|
{ name: 'reason', value: formData.reason },
|
||||||
name: formData.name,
|
{ name: 'description', value: formData.description || '' },
|
||||||
reason: formData.reason,
|
{ name: 'expirationDate', value: formData.expirationDate },
|
||||||
expirationDate: formData.expirationDate,
|
{ name: 'overrideControl', value: scopeMap[formData.scopeOverride] || 'AUTHORIZED' },
|
||||||
scopeOverrideAuthorization: scopeMap[formData.scopeOverride] || 'AUTHORIZED',
|
{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) },
|
||||||
hostFindingIds: findingIds.map(id => parseInt(id, 10))
|
{ name: 'isEmptyWorkflow', value: findingIds.length === 0 ? 'true' : 'false' }
|
||||||
};
|
];
|
||||||
|
|
||||||
if (formData.description && formData.description.trim().length > 0) {
|
|
||||||
payload.description = formData.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -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' });
|
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
|
// 1. Build form fields and call Ivanti API (multipart/form-data)
|
||||||
const payload = buildIvantiPayload(req.body, findingIds);
|
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`;
|
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
||||||
|
|
||||||
let createResult;
|
let createResult;
|
||||||
try {
|
try {
|
||||||
createResult = await ivantiPost(createUrl, payload, apiKey, skipTls);
|
createResult = await ivantiFormPost(createUrl, formFields, formFiles, apiKey, skipTls);
|
||||||
} catch (networkErr) {
|
} catch (networkErr) {
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id, username: req.user.username,
|
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);
|
return res.status(createResult.status === 429 ? 429 : 502).json(errorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parse workflow batch response
|
// 2. Parse workflow batch response — API returns { id, created }
|
||||||
let workflowBatchId, generatedId;
|
let workflowBatchId;
|
||||||
try {
|
try {
|
||||||
const createData = JSON.parse(createResult.body);
|
const createData = JSON.parse(createResult.body);
|
||||||
workflowBatchId = createData.id;
|
workflowBatchId = createData.id;
|
||||||
generatedId = createData.generatedId;
|
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id, username: req.user.username,
|
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' });
|
return res.status(502).json({ success: false, error: 'Failed to parse Ivanti API response.', step: 'create_workflow' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Upload attachments (if any)
|
// 3. Determine submission status (files sent inline, so success if we got here)
|
||||||
const attachmentResults = [];
|
const status = 'success';
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Determine submission status
|
// 4. Insert submission record
|
||||||
const failedAttachments = attachmentResults.filter(r => !r.success);
|
|
||||||
let status = 'success';
|
|
||||||
if (files.length > 0 && failedAttachments.length > 0) {
|
|
||||||
status = 'partial';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Insert submission record
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
@@ -330,7 +323,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
req.user.id,
|
req.user.id,
|
||||||
req.user.username,
|
req.user.username,
|
||||||
workflowBatchId,
|
workflowBatchId,
|
||||||
generatedId,
|
null, // generatedId not returned by this endpoint
|
||||||
req.body.name,
|
req.body.name,
|
||||||
req.body.reason,
|
req.body.reason,
|
||||||
req.body.description || null,
|
req.body.description || null,
|
||||||
@@ -339,9 +332,9 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
JSON.stringify(findingIds),
|
JSON.stringify(findingIds),
|
||||||
JSON.stringify(queueItemIds),
|
JSON.stringify(queueItemIds),
|
||||||
files.length,
|
files.length,
|
||||||
JSON.stringify(attachmentResults),
|
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
|
||||||
status,
|
status,
|
||||||
failedAttachments.length > 0 ? `${failedAttachments.length} attachment(s) failed` : null
|
null
|
||||||
],
|
],
|
||||||
(err) => { if (err) reject(err); else resolve(); }
|
(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
|
// Don't fail the response — the Ivanti workflow was created
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Log audit entry
|
// 5. Log audit entry
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id, username: req.user.username,
|
userId: req.user.id, username: req.user.username,
|
||||||
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
||||||
entityId: generatedId,
|
entityId: String(workflowBatchId),
|
||||||
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Mark queue items as complete
|
// 6. Mark queue items as complete
|
||||||
let queueItemsUpdated = 0;
|
let queueItemsUpdated = 0;
|
||||||
try {
|
try {
|
||||||
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
|
const queuePlaceholders = queueItemIds.map(() => '?').join(',');
|
||||||
@@ -376,12 +369,10 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
// Don't fail — workflow was created
|
// Don't fail — workflow was created
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Return response
|
// 7. Return response
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
workflowBatchId,
|
workflowBatchId,
|
||||||
generatedId,
|
|
||||||
attachmentResults,
|
|
||||||
queueItemsUpdated,
|
queueItemsUpdated,
|
||||||
status
|
status
|
||||||
});
|
});
|
||||||
@@ -399,5 +390,6 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
|
|
||||||
module.exports = createIvantiFpWorkflowRouter;
|
module.exports = createIvantiFpWorkflowRouter;
|
||||||
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
module.exports.validateFpWorkflowForm = validateFpWorkflowForm;
|
||||||
module.exports.buildIvantiPayload = buildIvantiPayload;
|
module.exports.buildIvantiFormFields = buildIvantiFormFields;
|
||||||
|
module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest;
|
||||||
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
module.exports.isAllowedFileExtension = isAllowedFileExtension;
|
||||||
|
|||||||
Reference in New Issue
Block a user