feat: add FP attachment library — attach existing CVE documents to FP submissions

- Add GET /api/ivanti/fp-workflow/documents/search endpoint for querying the document library
- Update POST /api/ivanti/fp-workflow to accept libraryDocIds for attaching library documents on create
- Update POST .../submissions/:id/attachments to accept libraryDocIds on edit
- Add AttachmentSourcePicker component with local upload and library search modes
- Integrate picker into FpWorkflowModal (create) and FpEditModal (edit)
- Track attachment source (local/library) in attachment_results_json for traceability
This commit is contained in:
jramos
2026-04-15 15:27:21 -06:00
parent ed48522932
commit e1b0236874
6 changed files with 1224 additions and 119 deletions

View File

@@ -2,6 +2,7 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { requireGroup } = require('../middleware/auth');
const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi');
const logAudit = require('../helpers/auditLog');
@@ -246,6 +247,45 @@ const fpUpload = multer({
function createIvantiFpWorkflowRouter(db, requireAuth) {
const router = express.Router();
/**
* GET /api/ivanti/fp-workflow/documents/search
*
* Searches the CVE document library for existing documents that can be
* attached to FP workflow submissions.
*
* @param {string} [req.query.q] - Optional search term matched against name, cve_id, vendor
* @returns {Array<object>} 200 - Array of matching document records
* @returns {object} 500 - Database error
*/
router.get('/documents/search', requireAuth(db), (req, res) => {
const q = (req.query.q || '').trim();
let sql, params;
if (q) {
const like = `%${q}%`;
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
FROM documents
WHERE name LIKE ? OR cve_id LIKE ? OR vendor LIKE ?
ORDER BY uploaded_at DESC
LIMIT 50`;
params = [like, like, like];
} else {
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
FROM documents
ORDER BY uploaded_at DESC
LIMIT 50`;
params = [];
}
db.all(sql, params, (err, rows) => {
if (err) {
console.error('Error searching documents:', err);
return res.status(500).json({ error: 'Database error.' });
}
res.json(rows || []);
});
});
/**
* POST /api/ivanti/fp-workflow
*
@@ -306,6 +346,23 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
return res.status(400).json({ error: 'At least one queue item ID is required.' });
}
// --- Parse and validate libraryDocIds ---
let libraryDocIds;
try {
libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]');
} catch (e) {
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
}
if (!Array.isArray(libraryDocIds)) {
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
}
// Validate each ID is a positive integer
for (const id of libraryDocIds) {
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` });
}
}
// --- Validate form fields ---
const validationErrors = validateFpWorkflowForm(req.body);
if (Object.keys(validationErrors).length > 0) {
@@ -365,7 +422,44 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
// 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 }));
// --- Look up library documents and read from disk ---
let libraryDocs = [];
const libraryAttachmentResults = [];
if (libraryDocIds.length > 0) {
const docPlaceholders = libraryDocIds.map(() => '?').join(',');
libraryDocs = await new Promise((resolve, reject) => {
db.all(
`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`,
libraryDocIds,
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
);
});
// Validate all IDs were found
const foundIds = new Set(libraryDocs.map(d => d.id));
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
if (missingIds.length > 0) {
return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
}
}
// Build library file buffers (read from disk)
const libraryFormFiles = [];
for (const doc of libraryDocs) {
try {
const buffer = fs.readFileSync(doc.file_path);
libraryFormFiles.push({ name: 'files', buffer, filename: doc.name });
libraryAttachmentResults.push({ filename: doc.name, success: true, source: 'library', documentId: doc.id });
} catch (readErr) {
console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`);
libraryAttachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name });
}
}
// Combine local file buffers and library file buffers
const localFormFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
const formFiles = [...localFormFiles, ...libraryFormFiles];
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
let createResult;
@@ -431,6 +525,9 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}
// 4. Insert submission record
const localAttachmentResults = files.map(f => ({ filename: f.originalname, success: true, source: 'local' }));
const allAttachmentResults = [...localAttachmentResults, ...libraryAttachmentResults];
const totalAttachmentCount = files.length + libraryDocIds.length;
try {
await new Promise((resolve, reject) => {
db.run(
@@ -449,8 +546,8 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
req.body.scopeOverride || 'Authorized',
JSON.stringify(findingIds),
JSON.stringify(queueItemIds),
files.length,
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
totalAttachmentCount,
JSON.stringify(allAttachmentResults),
status,
null
],
@@ -467,7 +564,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
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 },
details: { workflowName: req.body.name, findingIds, attachmentCount: totalAttachmentCount, libraryDocCount: libraryDocIds.length, status },
ipAddress: req.ip
});
@@ -997,20 +1094,25 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
* POST /api/ivanti/fp-submissions/:id/attachments
*
* Uploads additional file attachments to an existing FP workflow batch
* via the Ivanti attach endpoint. Updates the local submission record's
* via the Ivanti attach endpoint. Supports both local file uploads and
* library document references. Updates the local submission record's
* attachment_count and attachment_results_json, and records the change
* in submission history and audit log.
*
* Content-Type: multipart/form-data
*
* @param {string} req.params.id - Local FP submission ID
* @param {File[]} req.files - One or more file attachments (field name "attachments",
* @param {File[]} req.files - Zero or more file attachments (field name "attachments",
* max 10 files, max 10 MB each); allowed extensions:
* .pdf .png .jpg .jpeg .gif .doc .docx .xlsx .csv .txt .zip
* @param {string} [req.body.libraryDocIds] - JSON-encoded array of document IDs from the
* documents table to attach from the library (optional)
*
* At least one local file or library document ID is required.
*
* @returns {object} 200 - Success (or partial success)
* { success: true,
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
* attachmentResults: Array<{ filename: string, success: boolean, source: string, documentId?: number, error?: string }>,
* status: 'success' | 'partial' }
* @returns {object} 400 - Validation error, lifecycle guard, file constraint, or UUID resolution failure
* { error: string } or { success: false, error: string }
@@ -1031,8 +1133,27 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
}
const files = req.files || [];
if (files.length === 0) {
return res.status(400).json({ error: 'At least one file is required.' });
// --- Parse and validate libraryDocIds ---
let libraryDocIds;
try {
libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]');
} catch (e) {
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
}
if (!Array.isArray(libraryDocIds)) {
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
}
// Validate each ID is a positive integer
for (const id of libraryDocIds) {
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` });
}
}
// Require at least one file (local or library)
if (files.length === 0 && libraryDocIds.length === 0) {
return res.status(400).json({ error: 'At least one file or library document is required.' });
}
// Validate extensions (belt-and-suspenders)
@@ -1066,6 +1187,27 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
}
// 2.5. Look up library documents and read from disk
let libraryDocs = [];
const libraryAttachmentResults = [];
if (libraryDocIds.length > 0) {
const docPlaceholders = libraryDocIds.map(() => '?').join(',');
libraryDocs = await new Promise((resolve, reject) => {
db.all(
`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`,
libraryDocIds,
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
);
});
// Validate all IDs were found
const foundIds = new Set(libraryDocs.map(d => d.id));
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
if (missingIds.length > 0) {
return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
}
}
// 3. Upload each file to Ivanti
const apiKey = process.env.IVANTI_API_KEY;
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
@@ -1083,14 +1225,35 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`;
const attachmentResults = [];
// Upload local files
for (const f of files) {
try {
const formFiles = [{ name: 'file', buffer: f.buffer, filename: f.originalname, contentType: f.mimetype || 'application/octet-stream' }];
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
const success = result.status === 200 || result.status === 201 || result.status === 202;
attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
attachmentResults.push({ filename: f.originalname, success, source: 'local', ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
} catch (uploadErr) {
attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message });
attachmentResults.push({ filename: f.originalname, success: false, source: 'local', error: uploadErr.message });
}
}
// Upload library files
for (const doc of libraryDocs) {
let buffer;
try {
buffer = fs.readFileSync(doc.file_path);
} catch (readErr) {
console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`);
attachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name });
continue;
}
try {
const formFiles = [{ name: 'file', buffer, filename: doc.name, contentType: doc.mime_type || 'application/octet-stream' }];
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
const success = result.status === 200 || result.status === 201 || result.status === 202;
attachmentResults.push({ filename: doc.name, success, source: 'library', documentId: doc.id, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
} catch (uploadErr) {
attachmentResults.push({ filename: doc.name, success: false, source: 'library', documentId: doc.id, error: uploadErr.message });
}
}
@@ -1136,7 +1299,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
userId: req.user.id, username: req.user.username,
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
entityId: String(submission.ivanti_workflow_batch_id),
details: { submissionId, attachmentResults },
details: { submissionId, attachmentResults, libraryDocCount: libraryDocIds.length },
ipAddress: req.ip
});