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:
@@ -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
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user