diff --git a/backend/helpers/ivantiApi.js b/backend/helpers/ivantiApi.js index 55fc68f..e101423 100644 --- a/backend/helpers/ivantiApi.js +++ b/backend/helpers/ivantiApi.js @@ -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 }; diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index 984a03a..fac0fd6 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -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;